Skip to content

Commit 5f43f88

Browse files
os-zhuangclaude
andauthored
fix(analytics): scope /analytics/query + /sql to the caller (RLS) (#2852) (#2854)
handleAnalytics dropped the request's execution context — analyticsService.query(body) was called with no context, so the analytics read-scope provider (getReadScope → security getReadFilter) received undefined and applied NO tenant/RLS filter. An authenticated caller could query analytics datasets and receive rows their row-level security would otherwise hide. Thread context.executionContext into query() and generateSql() (both already accept it) so each object is scoped by its per-object read filter. The analytics getReadFilter does not yet apply the ADR-0090 D10 agent delegator intersection — latent (analytics isn't reachable over /mcp), tracked in #2852. Found by the D10 adversarial security review. Test: dispatcher asserts the execution context now reaches query/generateSql. runtime http-dispatcher 165 green. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 01274eb commit 5f43f88

3 files changed

Lines changed: 41 additions & 4 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
**Security fix (#2852): `/analytics/query` and `/analytics/sql` now run scoped to the caller.**
6+
7+
`handleAnalytics` dropped the request's execution context — `analyticsService.query(body)` was called with no context, so the analytics service's per-object read-scope provider (`getReadScope` → security `getReadFilter`) received `undefined` and applied **no tenant/RLS filter**. An authenticated caller could query analytics datasets and receive rows their row-level security would otherwise hide.
8+
9+
Fix: thread `context.executionContext` into `analyticsService.query(…)` and `generateSql(…)` (both already accept it), so each object in the query is scoped by its per-object read filter.
10+
11+
Note: the analytics read-scope provider (`getReadFilter`) does not yet apply the ADR-0090 D10 agent delegator intersection — latent today because analytics is not reachable over the OAuth `/mcp` surface; tracked in #2852 for when it is.

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,26 @@ describe('HttpDispatcher', () => {
383383
expect(mockAnalytics.query).toHaveBeenCalled();
384384
});
385385

386+
// [#2852] The execution context must reach the analytics service so
387+
// it scopes each object by its per-object read filter (tenant + RLS).
388+
// Previously it was dropped and the query ran UNSCOPED.
389+
it('threads the execution context into analytics.query and generateSql (RLS scoping)', async () => {
390+
const mockAnalytics = {
391+
query: vi.fn().mockResolvedValue({ rows: [], total: 0 }),
392+
generateSql: vi.fn().mockResolvedValue({ sql: 'SELECT 1', params: [] }),
393+
};
394+
(kernel as any).getService = vi.fn().mockImplementation((name: string) =>
395+
name === 'analytics' ? mockAnalytics : null,
396+
);
397+
const ec = { userId: 'u1', positions: [], permissions: [], tenantId: 'org-1' };
398+
399+
await dispatcher.handleAnalytics('query', 'POST', { cube: 'leads' }, { request: {}, executionContext: ec } as any);
400+
expect(mockAnalytics.query).toHaveBeenCalledWith({ cube: 'leads' }, ec);
401+
402+
await dispatcher.handleAnalytics('sql', 'POST', { cube: 'leads' }, { request: {}, executionContext: ec } as any);
403+
expect(mockAnalytics.generateSql).toHaveBeenCalledWith({ cube: 'leads' }, ec);
404+
});
405+
386406
it('should handle POST /analytics/sql with async service', async () => {
387407
const mockAnalytics = {
388408
generateSql: vi.fn().mockResolvedValue({ sql: 'SELECT * FROM t' }),

packages/runtime/src/http-dispatcher.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2040,7 +2040,7 @@ export class HttpDispatcher {
20402040
* Handles Analytics requests
20412041
* path: sub-path after /analytics/
20422042
*/
2043-
async handleAnalytics(path: string, method: string, body: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
2043+
async handleAnalytics(path: string, method: string, body: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
20442044
const analyticsService = await this.getService(CoreServiceName.enum.analytics);
20452045
if (!analyticsService) return { handled: false }; // 404 handled by caller if unhandled
20462046

@@ -2049,7 +2049,12 @@ export class HttpDispatcher {
20492049

20502050
// POST /analytics/query
20512051
if (subPath === 'query' && m === 'POST') {
2052-
const result = await analyticsService.query(body);
2052+
// [#2852] Pass the request's execution context so the analytics
2053+
// service scopes each object by its per-object read filter (tenant +
2054+
// RLS). Without it, `getReadScope(object, undefined)` returned no
2055+
// filter and the query ran UNSCOPED — an authenticated caller saw
2056+
// rows RLS would otherwise hide.
2057+
const result = await analyticsService.query(body, context?.executionContext);
20532058
return { handled: true, response: this.success(result) };
20542059
}
20552060

@@ -2061,8 +2066,9 @@ export class HttpDispatcher {
20612066

20622067
// POST /analytics/sql (Dry-run or debug)
20632068
if (subPath === 'sql' && m === 'POST') {
2064-
// Assuming service has generateSql method
2065-
const result = await analyticsService.generateSql(body);
2069+
// [#2852] Scope the generated SQL to the caller too, so a preview
2070+
// reflects the same per-object read filter the real query applies.
2071+
const result = await analyticsService.generateSql(body, context?.executionContext);
20662072
return { handled: true, response: this.success(result) };
20672073
}
20682074

0 commit comments

Comments
 (0)