Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/analytics-query-rls-context.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
Expand Down
14 changes: 10 additions & 4 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HttpDispatcherResult> {
async handleAnalytics(path: string, method: string, body: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
const analyticsService = await this.getService(CoreServiceName.enum.analytics);
if (!analyticsService) return { handled: false }; // 404 handled by caller if unhandled

Expand All @@ -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) };
}

Expand All @@ -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) };
}

Expand Down