Skip to content

Commit 87b47f4

Browse files
os-zhuangclaude
andcommitted
fix(analytics): fail closed on cross-object aggregation the ObjectQL path cannot join (#3654)
engine.aggregate() has no join — it never expands a lookup and the SQL driver's aggregate emits no JOIN. A dotted dimension/measure like account.region reaching ObjectQLStrategy (the fallback NativeSQL declines on: date-granularity bucketing, in-memory driver, federated objects) failed SILENTLY: the in-memory path bucketed every row under one (null) group and summed the whole table into it — a plausible number that is actually a mislabelled full-table total; the native path errored on the unresolved column. ObjectQLStrategy now rejects any cross-object reference outright, with a clear message, before the query reaches the engine. This generalizes the #3597 guard (assertJoinedScopesEnforceable), which only rejected when the joined object carried a read scope AND early-returned when no read-scope provider was configured — so the silent (null) bucket still shipped on unsecured / in-memory setups. The new guard is unconditional and subsumes #3597: a rejected query never loads the joined object, so nothing is left unscoped. Cross-object datasets are unaffected on NativeSQLStrategy, which hand-compiles the LEFT JOINs (and scopes each). This only changes the fallback path — a silent wrong answer becomes a loud, actionable error. Full lookup-traversal in the aggregate path is left as follow-up (#3654). Tests: three cross-object cases (joined-object scope / base-only scope / NO provider — the #3654 silent-(null) case) now reject; reverting the guard turns all three red. The engine-level in-memory repro is in #3654. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 415254c commit 87b47f4

3 files changed

Lines changed: 101 additions & 49 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(analytics): fail closed on cross-object aggregation the ObjectQL path cannot join (#3654)
6+
7+
`engine.aggregate()` has no join — it never expands a lookup and the SQL driver's
8+
aggregate emits no `JOIN`. So a dotted dimension/measure like `account.region`
9+
reaching `ObjectQLStrategy` (the fallback NativeSQL declines: date-granularity
10+
bucketing, in-memory driver, federated objects) failed SILENTLY: the in-memory
11+
path bucketed every row under one `(null)` group and summed the whole table into
12+
it (a plausible number that is actually a mislabelled full-table total), and the
13+
native path errored on the unresolved column.
14+
15+
`ObjectQLStrategy` now rejects any cross-object reference outright, with a clear
16+
message, before the query reaches the engine. This generalizes the #3597 guard
17+
(which only rejected when the joined object carried a read scope, and skipped the
18+
check entirely when no read-scope provider was configured — so the silent
19+
`(null)` bucket still shipped on unsecured/in-memory setups) into an
20+
unconditional one, and subsumes it: a rejected query never loads the joined
21+
object, so there is nothing left unscoped.
22+
23+
Cross-object datasets are unaffected on `NativeSQLStrategy`, which hand-compiles
24+
the LEFT JOINs (and scopes each). This only changes the fallback path, turning a
25+
silent wrong answer into a loud, actionable error. Full lookup-traversal support
26+
in the aggregate path is left as follow-up (see #3654).

packages/services/service-analytics/src/__tests__/objectql-read-scope.test.ts

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ describe('ObjectQLStrategy — read scope (ADR-0021 D-C, #3597)', () => {
218218
});
219219
});
220220

221-
describe('ObjectQLStrategy — joined-object scope is fail-closed (#3597)', () => {
221+
describe('ObjectQLStrategy — cross-object references are fail-closed (#3654, subsumes #3597)', () => {
222222
const joined = DatasetSchema.parse({
223223
name: 'sales_by_account',
224224
label: 'Sales by account',
@@ -228,32 +228,42 @@ describe('ObjectQLStrategy — joined-object scope is fail-closed (#3597)', () =
228228
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
229229
});
230230

231-
function makeJoinedService(scopeFor: (o: string) => FilterCondition | undefined) {
231+
function makeJoinedService(scopeFor?: (o: string) => FilterCondition | undefined) {
232232
const compiled = compileDataset(joined);
233-
return new AnalyticsService({
233+
// A stub that would happily "succeed" with a garbage single-(null) bucket if
234+
// the guard didn't fire — so a passing test proves the REJECTION, not luck.
235+
let called = false;
236+
const svc = new AnalyticsService({
234237
cubes: [compiled.cube],
235238
queryCapabilities: objectqlOnly,
236-
executeAggregate: async () => [],
237-
getReadScope: (o: string) => scopeFor(o),
239+
executeAggregate: async () => { called = true; return [{ 'account.region': null, revenue: 999 }]; },
240+
getReadScope: scopeFor ? (o: string) => scopeFor(o) : undefined,
238241
getAllowedRelationships: () => compiled.allowedRelationships,
239242
});
243+
return { svc, wasExecuted: () => called };
240244
}
241245

242-
it('denies the query when a referenced joined object carries a scope', async () => {
243-
const service = makeJoinedService(() => ({ organization_id: 'org_A' }));
246+
const q = { cube: 'sales_by_account', dimensions: ['region'], measures: ['revenue'] };
244247

245-
await expect(
246-
service.query({ cube: 'sales_by_account', dimensions: ['region'], measures: ['revenue'] }, ctxA),
247-
).rejects.toThrow(/cannot enforce the read scope of joined object\(s\) "account"/);
248+
it('rejects a cross-object grouping the engine cannot join — even with a joined-object scope', async () => {
249+
const { svc, wasExecuted } = makeJoinedService(() => ({ organization_id: 'org_A' }));
250+
await expect(svc.query(q, ctxA)).rejects.toThrow(
251+
/cannot group or aggregate across a relationship .*"account\.region"/,
252+
);
253+
expect(wasExecuted()).toBe(false); // never reached the engine
248254
});
249255

250-
it('allows the query when only the base object carries a scope', async () => {
251-
const service = makeJoinedService((o) =>
252-
o === 'opportunity' ? { organization_id: 'org_A' } : undefined,
253-
);
256+
it('rejects when only the base object carries a scope (the query is still unjoinable)', async () => {
257+
const { svc } = makeJoinedService((o) => (o === 'opportunity' ? { organization_id: 'org_A' } : undefined));
258+
await expect(svc.query(q, ctxA)).rejects.toThrow(/cannot group or aggregate across a relationship/);
259+
});
254260

255-
await expect(
256-
service.query({ cube: 'sales_by_account', dimensions: ['region'], measures: ['revenue'] }, ctxA),
257-
).resolves.toBeDefined();
261+
it('rejects with NO read-scope provider at all — the #3654 silent-(null) case (security off)', async () => {
262+
// Before #3654 this path ran unguarded (the guard early-returned without a
263+
// getReadScope), and the member got a single mislabelled `(null)` bucket
264+
// summing the whole table. Now it fails loud.
265+
const { svc, wasExecuted } = makeJoinedService(/* no provider */);
266+
await expect(svc.query(q)).rejects.toThrow(/cannot group or aggregate across a relationship/);
267+
expect(wasExecuted()).toBe(false);
258268
});
259269
});

packages/services/service-analytics/src/strategies/objectql-strategy.ts

Lines changed: 48 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,17 @@ export class ObjectQLStrategy implements AnalyticsStrategy {
7979
}
8080
}
8181

82-
// ADR-0021 D-C — the read scope (tenant + RLS) MUST be ANDed in before the
83-
// query leaves the strategy. Rejects the query outright when a joined object
84-
// carries a scope this path cannot express (#3597).
85-
this.assertJoinedScopesEnforceable(cube, query, groupBy, filter, ctx);
82+
// Reject cross-object grouping/aggregation this path cannot perform — the
83+
// engine has no join, so it would silently mis-bucket or error (#3654). This
84+
// runs BEFORE scope injection and subsumes the #3597 joined-scope concern:
85+
// a rejected query never loads the joined object, so nothing is left
86+
// unscoped. Independent of read scope, so it fires with security off too.
87+
this.assertNoCrossObjectReferences(cube, query, groupBy, filter);
88+
89+
// ADR-0021 D-C — the base object's read scope (tenant + RLS) MUST be ANDed
90+
// in before the query leaves the strategy (#3597).
91+
// (`assertNoCrossObjectReferences` above guarantees `objectName` is the only
92+
// object in play, so a single base-object scope is sufficient here.)
8693

8794
const rows = await ctx.executeAggregate!(objectName, {
8895
// Structured groupBy items ({field, dateGranularity}) pass through the
@@ -185,58 +192,67 @@ export class ObjectQLStrategy implements AnalyticsStrategy {
185192
}
186193

187194
/**
188-
* Fail-closed guard for cross-object queries (#3597).
195+
* Fail-closed guard for cross-object references on the ObjectQL path
196+
* (#3654, subsuming #3597).
189197
*
190-
* `engine.aggregate`'s `where` addresses the BASE object. A dotted member
191-
* (`account.region`) is traversed by the engine through the lookup field, but
192-
* there is no place in this call shape to hang a predicate on the JOINED
193-
* object — so a joined object's read scope cannot be enforced here.
198+
* `engine.aggregate()` has NO join: it never expands a lookup, and the SQL
199+
* driver's aggregate emits no `JOIN`. A dotted member like `account.region`
200+
* therefore reaches the engine as a bare column that no table in the query
201+
* provides. The failure is SILENT and wrong, not loud:
202+
* - native SQL path → "column account.region does not exist" (a hard error);
203+
* - in-memory path → `row['account.region']` is always `undefined`, so every
204+
* row collapses into ONE `(null)` bucket and the measure is summed across
205+
* the whole table — a plausible-looking number that is actually a
206+
* full-table total mislabelled `(null)`.
194207
*
195-
* `NativeSQLStrategy` can express it (alias-qualified WHERE per join) and does.
196-
* When this path cannot, we reject rather than run a partially-scoped query:
197-
* the same posture as `resolveReadScopes` (throws rather than emit unscoped
198-
* SQL) and `compileScopedFilterToSql` (throws rather than drop a predicate).
208+
* So we reject any cross-object reference OUTRIGHT — regardless of read scope.
209+
* This also subsumes the #3597 concern: because the joined object is never
210+
* loaded on this path, there is nothing to leave unscoped. Cross-object
211+
* datasets are served by `NativeSQLStrategy`, which hand-compiles the LEFT
212+
* JOINs (and scopes each one); this path is the fallback NativeSQL declines
213+
* (date-granularity bucketing, in-memory driver, federated objects), and it
214+
* cannot join, so "loud rejection" beats "silent wrong answer".
199215
*
200-
* Only joins the query ACTUALLY references are considered — the scope map is
201-
* a deliberate superset of what gets scanned, so keying off the map alone
202-
* would reject queries that never touch the joined table.
216+
* Detection is on the RESOLVED field names (post-`resolveFieldName`), so a
217+
* dotted dimension the cube flattens to a real column is NOT flagged — only
218+
* genuinely-unresolved relationship traversals are.
203219
*/
204-
private assertJoinedScopesEnforceable(
220+
private assertNoCrossObjectReferences(
205221
cube: Cube,
206222
query: AnalyticsQuery,
207223
groupBy: Array<string | { field: string; dateGranularity: string }>,
208224
filter: Record<string, unknown>,
209-
ctx: StrategyContext,
210225
): void {
211-
if (typeof ctx.getReadScope !== 'function') return;
212-
213-
// Field names as they will reach the engine. A dotted name is a relationship
214-
// traversal; its first segment is the join alias.
226+
// Field names as they will reach the engine. A dotted name whose first
227+
// segment names a DIFFERENT object is a relationship traversal the engine
228+
// cannot perform in an aggregate.
215229
const referenced = [
216230
...groupBy.map((g) => (typeof g === 'string' ? g : g.field)),
217231
...Object.keys(filter),
218232
...(query.measures ?? []).map((m) => this.resolveMeasureAggregation(cube, m).field),
219233
];
220234

235+
const baseObject = this.extractObjectName(cube);
221236
const offending = new Set<string>();
222237
for (const fieldName of referenced) {
223238
if (!fieldName.includes('.')) continue;
224239
const alias = fieldName.split('.')[0];
225240
const joinedObject = cube.joins?.[alias]?.name ?? alias;
226-
if (joinedObject === this.extractObjectName(cube)) continue;
227-
const scope = ctx.getReadScope(joinedObject);
228-
if (scope !== undefined && scope !== null) offending.add(joinedObject);
241+
if (joinedObject === baseObject) continue;
242+
offending.add(fieldName);
229243
}
230244
if (offending.size === 0) return;
231245

232246
throw new Error(
233-
`[Analytics] ObjectQLStrategy cannot enforce the read scope of joined ` +
234-
`object(s) ${[...offending].map((o) => `"${o}"`).join(', ')} — denying the ` +
235-
`query (fail-closed, ADR-0021 D-C). This path reaches the joined table ` +
236-
`through the base object's lookup, where a per-join security predicate ` +
237-
`cannot be expressed. Run this query on a driver that supports native SQL ` +
238-
`(NativeSQLStrategy scopes each join), or drop the cross-object ` +
239-
`dimension/measure from the query.`,
247+
`[Analytics] ObjectQLStrategy cannot group or aggregate across a ` +
248+
`relationship (cross-object reference(s): ` +
249+
`${[...offending].map((f) => `"${f}"`).join(', ')}). ` +
250+
`engine.aggregate() does not join, so the referenced object is never ` +
251+
`loaded — this path would silently bucket every row under a single ` +
252+
`"(null)" group (or error outright on a SQL driver), and could not scope ` +
253+
`the joined object's rows. Run this query where NativeSQLStrategy handles ` +
254+
`it (a raw-SQL/JOIN-capable driver with no date-granularity bucketing), or ` +
255+
`drop the cross-object dimension/measure from the query.`,
240256
);
241257
}
242258

0 commit comments

Comments
 (0)