Skip to content

Commit b48c42e

Browse files
committed
fix(service-analytics)!: an object read through a relationship path joins the one admitted and scoped object set (#20933)
The analytics door admits and row-scopes one object set, and both strategies read their scopes from it. That set held the cube's base object and its declared joins, but not an object reached through a relationship path the cube does not declare, although both strategies read that object. It is now in the set: each hop's object, resolved as the field gate resolves it, is admitted and scoped exactly as a declared join is. The set is derived once per call and handed to the admission, the scope resolution and the native strategy's cross-field decline, so a read scope that strategy cannot compile routes the query to the engine path for a related object as it does for a declared join. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9c12bad commit b48c42e

3 files changed

Lines changed: 76 additions & 26 deletions

File tree

‎packages/services/service-analytics/src/analytics-service.ts‎

Lines changed: 52 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1443,7 +1443,10 @@ export class AnalyticsService implements IAnalyticsService {
14431443
// read"). `callCtx` is the ONE thing `query()` and `generateSql()` share,
14441444
// so gating it covers the direct `/analytics/query` door, the `/analytics/sql`
14451445
// echo door and — through `DatasetExecutor` — every dataset door.
1446-
await this.assertReadAdmitted(this.queryObjects(query, scope), context);
1446+
// [#20933] The set is derived ONCE here and handed to both questions, so
1447+
// the objects admitted and the objects scoped are one value, not two calls.
1448+
const objects = this.queryObjects(query, scope);
1449+
await this.assertReadAdmitted(objects, context);
14471450
// [#20917] …and the FIELD-level gate, right behind it and for the same
14481451
// reason: every member the query names is judged here, once, so every
14491452
// strategy — and the SQL echo — inherits the verdict by construction. The
@@ -1461,17 +1464,21 @@ export class AnalyticsService implements IAnalyticsService {
14611464
// a deployment with no `getReadScope` provider is exactly the one that most
14621465
// needs the engine to scope for it.
14631466
if (!this.readScopeProvider) return { ...this.baseCtx, ...reads, context, getDatasetScope };
1464-
// Pre-resolve the read scope for every object the strategy will scan (base
1465-
// + all declared joins) BEFORE the synchronous SQL builder runs, since the
1467+
// Pre-resolve the read scope for every object the strategy will scan (base,
1468+
// declared joins, relationship paths — `queryObjects`, the set the admission
1469+
// above read) BEFORE the synchronous SQL builder runs, since the
14661470
// provider may be async (the production `security.getReadFilter` bridge).
14671471
// The strategy then reads each object's filter synchronously from the map.
1468-
const scopes = await this.resolveReadScopes(query, context, scope);
1472+
const scopes = await this.resolveReadScopes(objects, context);
14691473
return {
14701474
...this.baseCtx,
14711475
...reads,
14721476
context,
14731477
getDatasetScope,
14741478
getReadScope: (objectName: string) => scopes.get(objectName) ?? null,
1479+
// [#20933] …and the set itself, for the strategy that looks at the scopes
1480+
// before it compiles (`NativeSQLStrategy`'s cross-field decline).
1481+
readScopedObjects: [...objects],
14751482
};
14761483
}
14771484

@@ -1530,15 +1537,30 @@ export class AnalyticsService implements IAnalyticsService {
15301537
}
15311538

15321539
/**
1533-
* Every object this query will READ — the cube's base object plus every
1534-
* joined object.
1540+
* Every object this query will READ — the cube's base object, every join the
1541+
* cube declares, and every object a member the query names reaches through a
1542+
* relationship path.
15351543
*
15361544
* ONE derivation, two consumers: {@link resolveReadScopes} scopes exactly
15371545
* this set and {@link assertReadAdmitted} admits exactly this set, so the set
15381546
* that is row-scoped and the set that is admitted are provably the same set
15391547
* rather than two lists that agree today. It is a SUPERSET of what a strategy
1540-
* actually scans (a strategy only joins along declared relationships), which
1541-
* is the safe direction: no scanned object is ever left ungated.
1548+
* actually scans (a declared join the query never uses is in it), which is
1549+
* the safe direction: no scanned object is ever left ungated.
1550+
*
1551+
* [#20933] A relationship path is read whether or not the cube declares it.
1552+
* A dotted member of an inferred cube, an authored member whose `sql` walks a
1553+
* relationship the cube's `joins` never lists, a dotted member the query
1554+
* names itself: `NativeSQLStrategy` joins the object at each hop
1555+
* (`qualifyAndRegisterJoin`) and `ObjectQLStrategy` reads it to resolve the
1556+
* related value. Those objects are in this set, so each is admitted and its
1557+
* read scope reaches the strategy exactly as a declared join's does — the
1558+
* strategies apply the scope of every object they read from this set, and
1559+
* carry no rule of their own. Each hop's object is the one the field gate
1560+
* attributes the hop's fields to ({@link namedQueryFields}: the cube's join
1561+
* keyed by the path with its dots as `__`, falling back to the alias itself),
1562+
* reused rather than re-derived, so the field gate and this set can never
1563+
* name different objects for the same hop.
15421564
*
15431565
* An unregistered cube yields the empty set — the query fails its own
15441566
* cube-existence gate downstream, and inventing an object name here would
@@ -1553,14 +1575,21 @@ export class AnalyticsService implements IAnalyticsService {
15531575
private queryObjects(query: AnalyticsQuery, scope: CubeScope): Set<string> {
15541576
if (!query.cube) return new Set<string>();
15551577
const cube = scope.getCube(query.cube);
1556-
return cube ? this.cubeObjects(cube) : new Set<string>();
1578+
if (!cube) return new Set<string>();
1579+
const objects = this.cubeObjects(cube);
1580+
for (const { object } of namedQueryFields(query, cube, this.cubeReads(scope).getDatasetScope(query.cube))) {
1581+
objects.add(object);
1582+
}
1583+
return objects;
15571584
}
15581585

15591586
/**
1560-
* {@link queryObjects} for a cube already in hand — the draft-preview branch
1561-
* holds the COMPILED dataset rather than a query naming it, and reaching for
1562-
* the registry there would make the gate depend on a registration side
1563-
* effect. One derivation, two entry points.
1587+
* The cube's own part of {@link queryObjects}: its base object and every
1588+
* join it declares. The draft-preview branch asks this part alone — it holds
1589+
* the COMPILED dataset rather than a query naming it (reaching for the
1590+
* registry there would make the gate depend on a registration side effect),
1591+
* and it evaluates the executor's queries over the base object's drafted seed
1592+
* rows in memory, so it reads no object through a relationship path.
15641593
*/
15651594
private cubeObjects(cube: Cube): Set<string> {
15661595
const objects = new Set<string>();
@@ -1631,13 +1660,14 @@ export class AnalyticsService implements IAnalyticsService {
16311660

16321661
/**
16331662
* Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
1634-
* AND every joined object of the query's cube, keyed by object name. This is
1635-
* the async pre-pass that lets the synchronous strategy enforce scoping even
1636-
* when the provider (security `getReadFilter`) resolves asynchronously.
1663+
* AND every object the query reads through a join or a relationship path,
1664+
* keyed by object name. This is the async pre-pass that lets the synchronous
1665+
* strategy enforce scoping even when the provider (security `getReadFilter`)
1666+
* resolves asynchronously.
16371667
*
1638-
* The object set is `cube.sql` (base) plus every `cube.joins[*].name` — a
1639-
* SUPERSET of what the strategy actually scans (the strategy only joins along
1640-
* declared relationships), so no scanned object is ever left unscoped.
1668+
* The object set is {@link queryObjects} — the one the admission reads, a
1669+
* SUPERSET of what the strategy actually scans, so no scanned object is ever
1670+
* left unscoped.
16411671
*
16421672
* Fail-closed: if the provider throws for an object, the whole query is
16431673
* rejected rather than emitting SQL with that object unscoped.
@@ -1651,15 +1681,14 @@ export class AnalyticsService implements IAnalyticsService {
16511681
* enveloped, it is re-thrown before the wording is ever read.
16521682
*/
16531683
private async resolveReadScopes(
1654-
query: AnalyticsQuery,
1684+
objects: Iterable<string>,
16551685
context: ExecutionContext | undefined,
1656-
scope: CubeScope,
16571686
): Promise<Map<string, FilterCondition>> {
16581687
const map = new Map<string, FilterCondition>();
16591688
const provider = this.readScopeProvider;
1660-
if (!provider || !query.cube) return map;
1689+
if (!provider) return map;
16611690

1662-
for (const object of this.queryObjects(query, scope)) {
1691+
for (const object of objects) {
16631692
let filter: FilterCondition | null | undefined;
16641693
try {
16651694
filter = await provider(object, context);

‎packages/services/service-analytics/src/strategies/native-sql-strategy.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -332,9 +332,17 @@ export class NativeSQLStrategy implements AnalyticsStrategy {
332332
if (typeof ctx.getReadScope !== 'function') return null;
333333
const cube = query.cube ? ctx.getCube(query.cube) : undefined;
334334
if (!cube) return null;
335-
const objects = [this.extractObjectName(cube)];
336-
for (const alias of Object.keys(cube.joins ?? {})) {
337-
objects.push(cube.joins?.[alias]?.name ?? alias);
335+
// [#20933] The scopes the door resolved, over the set it resolved them for:
336+
// an object read through a relationship path carries its scope too, and a
337+
// reference in it declines the query exactly as one in a declared join's
338+
// does. The cube's own objects stand in only for a context built without
339+
// that set.
340+
const scoped = (ctx as DatasetScopedStrategyContext).readScopedObjects;
341+
const objects = scoped ? [...scoped] : [this.extractObjectName(cube)];
342+
if (!scoped) {
343+
for (const alias of Object.keys(cube.joins ?? {})) {
344+
objects.push(cube.joins?.[alias]?.name ?? alias);
345+
}
338346
}
339347
for (const objectName of objects) {
340348
const scope = ctx.getReadScope(objectName);

‎packages/services/service-analytics/src/strategies/types.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,19 @@ export interface DatasetScope {
7070
/** A {@link StrategyContext} that can answer for a compiled dataset (#10298). */
7171
export interface DatasetScopedStrategyContext extends StrategyContext {
7272
getDatasetScope?(cubeName: string): DatasetScope | undefined;
73+
/**
74+
* [#20933] Every object whose read scope `getReadScope` answers for this
75+
* query — `AnalyticsService.queryObjects`, the one set the object-level
76+
* admission read: the base object, every join the cube declares, and every
77+
* object a member reaches through a relationship path.
78+
*
79+
* For a strategy that must look at the scopes BEFORE it compiles, so it reads
80+
* them over the set the door scoped instead of re-deriving which objects the
81+
* query reads. `undefined` when the context carries no read scope at all.
82+
* Declared HERE rather than on the spec's {@link StrategyContext} for the
83+
* reason `getDatasetScope` is: nothing about it is an authorable surface.
84+
*/
85+
readScopedObjects?: readonly string[];
7386
/**
7487
* [#14079] The DECLARED type of `field` on `objectName` — `'number'`,
7588
* `'boolean'`, `'text'`, … — or `undefined` when the host cannot answer (no

0 commit comments

Comments
 (0)