Skip to content

Commit 26db346

Browse files
os-zhuangclaude
andcommitted
fix(analytics): enforce read scope on the ObjectQL aggregate path (#3597)
ObjectQLStrategy never consumed `getReadScope`, so every analytics query served by that path ran with no RLS or tenant predicate — an authenticated caller received aggregates computed over every tenant's rows. Both belts were off at once. The strategy dropped the pre-resolved read scope, and the engine could not compensate: the `executeAggregate` bridge passes no ExecutionContext, so plugin-security's principal-less fall-open (security-plugin.ts:775) skipped its own RLS injection. Only NativeSQLStrategy was ever wired for ADR-0021 D-C. Exposure was not limited to exotic drivers. NativeSQLStrategy declines — routing to this path — on any date-bucketed query (the most common dashboard shape, on Postgres and SQLite too), on RAW_SQL_UNSUPPORTED, and on federated objects. The scope is composed with `$and`, never by key merge, so a caller filter naming the same field cannot displace the security predicate. A query referencing a joined object that carries its own scope is now rejected rather than run partially-scoped: `engine.aggregate`'s `where` addresses the base object, so a per-join predicate cannot be expressed there. Failing closed matches resolveReadScopes and compileScopedFilterToSql. The gap survived because every case in dataset-rls-integration.test.ts pins `objectqlAggregate: false` — the RLS suite only ever exercised NativeSQLStrategy. The new tests cover the ObjectQL path directly; 7 of the 9 fail without this fix (the other 2 assert unchanged behaviour). Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d44dbfa commit 26db346

3 files changed

Lines changed: 384 additions & 1 deletion

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(analytics): ObjectQLStrategy now enforces the read scope (RLS + tenant) (#3597)
6+
7+
`ObjectQLStrategy` never consumed `getReadScope`, so any analytics query served by
8+
that path ran with **no RLS or tenant predicate** — an authenticated caller
9+
received aggregates computed over every tenant's rows.
10+
11+
Both belts were off at once. The strategy dropped the pre-resolved read scope, and
12+
the engine could not compensate: the `executeAggregate` bridge passes no
13+
`ExecutionContext`, so plugin-security's principal-less fall-open skipped its own
14+
RLS injection. Only `NativeSQLStrategy` was ever wired for ADR-0021 D-C.
15+
16+
The exposure was **not** limited to exotic drivers. `NativeSQLStrategy` declines —
17+
handing the query to this path — on any date-bucketed query
18+
(`timeDimensions[].granularity`, the most common dashboard shape, on Postgres and
19+
SQLite too), on `RAW_SQL_UNSUPPORTED` (in-memory driver), and on federated objects.
20+
21+
The scope is composed with `$and`, never by key merge, so a caller filter naming
22+
the same field (e.g. `organization_id`) cannot displace the security predicate.
23+
24+
**Behaviour change to be aware of:** a query that references a **joined** object
25+
carrying its own read scope is now REJECTED on this path rather than run
26+
partially-scoped. `engine.aggregate`'s `where` addresses the base object, so a
27+
per-join predicate cannot be expressed there; failing closed matches the posture
28+
already taken by `resolveReadScopes` and `compileScopedFilterToSql`. Such a query
29+
previously returned results that omitted the joined object's tenant predicate.
30+
Run it on a native-SQL driver (`NativeSQLStrategy` scopes each join), or drop the
31+
cross-object dimension/measure.
32+
33+
Deployments with no read-scope provider configured are unaffected — that path
34+
stays unscoped by documented contract.
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0021 D-C on the ObjectQL path (#3597).
5+
*
6+
* `dataset-rls-integration.test.ts` proves tenant scoping end-to-end, but every
7+
* case there pins `objectqlAggregate: false` — so it only ever exercises
8+
* NativeSQLStrategy. That blind spot is exactly why ObjectQLStrategy shipped
9+
* without consuming `getReadScope` at all: an authenticated caller received
10+
* aggregates computed over every tenant's rows.
11+
*
12+
* These cases run the same pipeline with the ObjectQL aggregate path selected —
13+
* which is what the runtime picks whenever NativeSQL declines (date-granularity
14+
* bucketing, `RAW_SQL_UNSUPPORTED`, federated objects).
15+
*/
16+
17+
import { describe, it, expect } from 'vitest';
18+
import { DatasetSchema } from '@objectstack/spec/ui';
19+
import type { ExecutionContext } from '@objectstack/spec/kernel';
20+
import type { FilterCondition } from '@objectstack/spec/data';
21+
import { AnalyticsService } from '../analytics-service.js';
22+
import { compileDataset } from '../dataset-compiler.js';
23+
24+
const dataset = DatasetSchema.parse({
25+
name: 'sales',
26+
label: 'Sales',
27+
object: 'opportunity',
28+
dimensions: [{ name: 'region', field: 'region', type: 'string' }],
29+
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
30+
});
31+
32+
/** Two tenants' rows in one physical table. */
33+
const TABLE = [
34+
{ id: 1, organization_id: 'org_A', region: 'West', amount: 100 },
35+
{ id: 2, organization_id: 'org_B', region: 'East', amount: 900 },
36+
];
37+
38+
/** The production-shaped provider: tenant predicate derived from the request. */
39+
const readScope = (_o: string, context?: ExecutionContext): FilterCondition | undefined =>
40+
context?.tenantId ? { organization_id: context.tenantId } : undefined;
41+
42+
type AggOpts = {
43+
groupBy?: string[];
44+
aggregations?: Array<{ field: string; method: string; alias: string }>;
45+
filter?: Record<string, unknown>;
46+
};
47+
48+
/** Match a row against the subset of FilterCondition these tests emit. */
49+
function matches(row: Record<string, unknown>, filter: Record<string, unknown>): boolean {
50+
return Object.entries(filter).every(([k, v]) => {
51+
if (k === '$and') return (v as Record<string, unknown>[]).every((sub) => matches(row, sub));
52+
return row[k] === v;
53+
});
54+
}
55+
56+
/**
57+
* An HONEST aggregate bridge: it applies whatever `filter` it is handed. So a
58+
* missing tenant predicate produces a real cross-tenant leak rather than an
59+
* artifact of a permissive stub.
60+
*/
61+
function makeAggregate(seen: AggOpts[]) {
62+
return async (_objectName: string, opts: AggOpts) => {
63+
seen.push(opts);
64+
const rows = TABLE.filter((r) => matches(r as Record<string, unknown>, opts.filter ?? {}));
65+
const buckets = new Map<string, Record<string, unknown>>();
66+
for (const r of rows) {
67+
const row = r as Record<string, unknown>;
68+
const key = (opts.groupBy ?? []).map((g) => String(row[g as string])).join('|');
69+
const b = buckets.get(key)
70+
?? Object.fromEntries((opts.groupBy ?? []).map((g) => [g, row[g as string]]));
71+
for (const a of opts.aggregations ?? []) {
72+
if (a.method === 'sum') b[a.alias] = Number(b[a.alias] ?? 0) + Number(row[a.field] ?? 0);
73+
}
74+
buckets.set(key, b);
75+
}
76+
return [...buckets.values()];
77+
};
78+
}
79+
80+
const ctxA = { tenantId: 'org_A', userId: 'u_a' } as ExecutionContext;
81+
82+
/** ObjectQL-only capabilities — NativeSQL unavailable. */
83+
const objectqlOnly = () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false });
84+
85+
function makeService(seen: AggOpts[], overrides: Record<string, unknown> = {}) {
86+
const compiled = compileDataset(dataset);
87+
return new AnalyticsService({
88+
cubes: [compiled.cube],
89+
queryCapabilities: objectqlOnly,
90+
executeAggregate: makeAggregate(seen),
91+
getReadScope: readScope,
92+
...overrides,
93+
});
94+
}
95+
96+
describe('ObjectQLStrategy — read scope (ADR-0021 D-C, #3597)', () => {
97+
it('scopes a plain aggregate query to the caller tenant', async () => {
98+
const seen: AggOpts[] = [];
99+
const result = await makeService(seen).query(
100+
{ cube: 'sales', dimensions: ['region'], measures: ['revenue'] },
101+
ctxA,
102+
);
103+
104+
expect(seen[0].filter).toEqual({ organization_id: 'org_A' });
105+
expect(result.rows).toEqual([{ region: 'West', revenue: 100 }]);
106+
});
107+
108+
it('scopes when NativeSQL declines at runtime (RAW_SQL_UNSUPPORTED fallback)', async () => {
109+
const seen: AggOpts[] = [];
110+
const service = makeService(seen, {
111+
// Both advertised — exactly what the plugin auto-bridge produces.
112+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }),
113+
executeRawSql: async () => {
114+
const err = new Error('driver cannot run SQL') as Error & { code: string };
115+
err.code = 'RAW_SQL_UNSUPPORTED';
116+
throw err;
117+
},
118+
});
119+
120+
const result = await service.query(
121+
{ cube: 'sales', dimensions: ['region'], measures: ['revenue'] },
122+
ctxA,
123+
);
124+
125+
expect(seen[0].filter).toEqual({ organization_id: 'org_A' });
126+
expect(result.rows).toEqual([{ region: 'West', revenue: 100 }]);
127+
});
128+
129+
it('scopes a date-bucketed query (NativeSQL declines on granularity, even on SQL drivers)', async () => {
130+
const compiled = compileDataset(
131+
DatasetSchema.parse({
132+
name: 'sales_t',
133+
label: 'Sales',
134+
object: 'opportunity',
135+
dimensions: [{ name: 'created', field: 'created_at', type: 'date' }],
136+
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
137+
}),
138+
);
139+
const seen: AggOpts[] = [];
140+
const service = new AnalyticsService({
141+
cubes: [compiled.cube],
142+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }),
143+
executeRawSql: async () => { throw new Error('NativeSQL must decline on granularity'); },
144+
executeAggregate: makeAggregate(seen),
145+
getReadScope: readScope,
146+
});
147+
148+
await service.query(
149+
{
150+
cube: 'sales_t',
151+
measures: ['revenue'],
152+
timeDimensions: [{ dimension: 'created', granularity: 'month' }],
153+
},
154+
ctxA,
155+
);
156+
157+
expect(seen[0].filter).toEqual({ organization_id: 'org_A' });
158+
});
159+
160+
it('ANDs the scope with the query filter instead of key-merging it', async () => {
161+
const seen: AggOpts[] = [];
162+
await makeService(seen).query(
163+
{
164+
cube: 'sales',
165+
dimensions: ['region'],
166+
measures: ['revenue'],
167+
where: { region: 'West' },
168+
},
169+
ctxA,
170+
);
171+
172+
expect(seen[0].filter).toEqual({
173+
$and: [{ region: 'West' }, { organization_id: 'org_A' }],
174+
});
175+
});
176+
177+
it('a caller filter on the SAME field cannot displace the security predicate', async () => {
178+
const seen: AggOpts[] = [];
179+
// The caller tries to widen their scope by naming the tenant column itself.
180+
const result = await makeService(seen).query(
181+
{
182+
cube: 'sales',
183+
dimensions: ['region'],
184+
measures: ['revenue'],
185+
where: { organization_id: 'org_B' },
186+
},
187+
ctxA,
188+
);
189+
190+
// Both predicates survive — and being contradictory, they yield nothing.
191+
expect(seen[0].filter).toEqual({
192+
$and: [{ organization_id: 'org_B' }, { organization_id: 'org_A' }],
193+
});
194+
expect(result.rows).toEqual([]);
195+
});
196+
197+
it('two tenants stay isolated on one service instance (singleton-safe)', async () => {
198+
const seen: AggOpts[] = [];
199+
const service = makeService(seen);
200+
const q = { cube: 'sales', dimensions: ['region'], measures: ['revenue'] };
201+
202+
const a = await service.query(q, ctxA);
203+
const b = await service.query(q, { tenantId: 'org_B', userId: 'u_b' } as ExecutionContext);
204+
205+
expect(a.rows).toEqual([{ region: 'West', revenue: 100 }]);
206+
expect(b.rows).toEqual([{ region: 'East', revenue: 900 }]);
207+
});
208+
209+
it('runs unscoped when no provider is configured (documented contract, unchanged)', async () => {
210+
const seen: AggOpts[] = [];
211+
const result = await makeService(seen, { getReadScope: undefined }).query(
212+
{ cube: 'sales', dimensions: ['region'], measures: ['revenue'] },
213+
ctxA,
214+
);
215+
216+
expect(seen[0].filter).toBeUndefined();
217+
expect(result.rows).toHaveLength(2);
218+
});
219+
});
220+
221+
describe('ObjectQLStrategy — joined-object scope is fail-closed (#3597)', () => {
222+
const joined = DatasetSchema.parse({
223+
name: 'sales_by_account',
224+
label: 'Sales by account',
225+
object: 'opportunity',
226+
include: ['account'],
227+
dimensions: [{ name: 'region', field: 'account.region', type: 'string' }],
228+
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
229+
});
230+
231+
function makeJoinedService(scopeFor: (o: string) => FilterCondition | undefined) {
232+
const compiled = compileDataset(joined);
233+
return new AnalyticsService({
234+
cubes: [compiled.cube],
235+
queryCapabilities: objectqlOnly,
236+
executeAggregate: async () => [],
237+
getReadScope: (o: string) => scopeFor(o),
238+
getAllowedRelationships: () => compiled.allowedRelationships,
239+
});
240+
}
241+
242+
it('denies the query when a referenced joined object carries a scope', async () => {
243+
const service = makeJoinedService(() => ({ organization_id: 'org_A' }));
244+
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+
});
249+
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+
);
254+
255+
await expect(
256+
service.query({ cube: 'sales_by_account', dimensions: ['region'], measures: ['revenue'] }, ctxA),
257+
).resolves.toBeDefined();
258+
});
259+
});

0 commit comments

Comments
 (0)