diff --git a/.changeset/analytics-query-rls-context.md b/.changeset/analytics-query-rls-context.md new file mode 100644 index 0000000000..6a5c69ddf9 --- /dev/null +++ b/.changeset/analytics-query-rls-context.md @@ -0,0 +1,11 @@ +--- +'@objectstack/runtime': patch +--- + +**Security fix (#2852): `/analytics/query` and `/analytics/sql` now run scoped to the caller.** + +`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. + +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. + +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. diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 5189e4f504..cca5e5ec2c 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -383,6 +383,26 @@ describe('HttpDispatcher', () => { expect(mockAnalytics.query).toHaveBeenCalled(); }); + // [#2852] The execution context must reach the analytics service so + // it scopes each object by its per-object read filter (tenant + RLS). + // Previously it was dropped and the query ran UNSCOPED. + it('threads the execution context into analytics.query and generateSql (RLS scoping)', async () => { + const mockAnalytics = { + query: vi.fn().mockResolvedValue({ rows: [], total: 0 }), + generateSql: vi.fn().mockResolvedValue({ sql: 'SELECT 1', params: [] }), + }; + (kernel as any).getService = vi.fn().mockImplementation((name: string) => + name === 'analytics' ? mockAnalytics : null, + ); + const ec = { userId: 'u1', positions: [], permissions: [], tenantId: 'org-1' }; + + await dispatcher.handleAnalytics('query', 'POST', { cube: 'leads' }, { request: {}, executionContext: ec } as any); + expect(mockAnalytics.query).toHaveBeenCalledWith({ cube: 'leads' }, ec); + + await dispatcher.handleAnalytics('sql', 'POST', { cube: 'leads' }, { request: {}, executionContext: ec } as any); + expect(mockAnalytics.generateSql).toHaveBeenCalledWith({ cube: 'leads' }, ec); + }); + it('should handle POST /analytics/sql with async service', async () => { const mockAnalytics = { generateSql: vi.fn().mockResolvedValue({ sql: 'SELECT * FROM t' }), diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index e142776e0a..61ed2137b6 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -2040,7 +2040,7 @@ export class HttpDispatcher { * Handles Analytics requests * path: sub-path after /analytics/ */ - async handleAnalytics(path: string, method: string, body: any, _context: HttpProtocolContext): Promise { + async handleAnalytics(path: string, method: string, body: any, context: HttpProtocolContext): Promise { const analyticsService = await this.getService(CoreServiceName.enum.analytics); if (!analyticsService) return { handled: false }; // 404 handled by caller if unhandled @@ -2049,7 +2049,12 @@ export class HttpDispatcher { // POST /analytics/query if (subPath === 'query' && m === 'POST') { - const result = await analyticsService.query(body); + // [#2852] Pass the request's execution context so the analytics + // service scopes each object by its per-object read filter (tenant + + // RLS). Without it, `getReadScope(object, undefined)` returned no + // filter and the query ran UNSCOPED — an authenticated caller saw + // rows RLS would otherwise hide. + const result = await analyticsService.query(body, context?.executionContext); return { handled: true, response: this.success(result) }; } @@ -2061,8 +2066,9 @@ export class HttpDispatcher { // POST /analytics/sql (Dry-run or debug) if (subPath === 'sql' && m === 'POST') { - // Assuming service has generateSql method - const result = await analyticsService.generateSql(body); + // [#2852] Scope the generated SQL to the caller too, so a preview + // reflects the same per-object read filter the real query applies. + const result = await analyticsService.generateSql(body, context?.executionContext); return { handled: true, response: this.success(result) }; }