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
47 changes: 47 additions & 0 deletions .changeset/analytics-execute-aggregate-execution-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
"@objectstack/spec": minor
"@objectstack/service-analytics": minor
"@objectstack/verify": minor
---

feat(analytics): the executeAggregate bridge carries ExecutionContext — ADR-0021 D-C second belt

The analytics→engine bridge now forwards the request's `ExecutionContext` to
`engine.aggregate`, so the engine's own middleware chain scopes analytics reads
independently of the analytics layer's `getReadScope`.

**Why.** `BaseEngineOptions.context` has always been `.optional()`, so nothing
forced the bridge to pass it — and it did not. An authenticated aggregate
reached the engine with no principal, plugin-security's principal-less fall-open
skipped its RLS injection, and the only thing left scoping the query was the
strategy remembering to call `getReadScope`. #3597 was a strategy that did not,
and both belts were off at once.

`getReadScope` stays: the two resolve scope through different paths (engine
middleware vs `security.getReadFilter`), and a deployment without
plugin-security has only the analytics layer. This is depth, not a replacement.

- `StrategyContext` gains `context?: ExecutionContext`, bound per call by
`AnalyticsService` from `query()` / `generateSql()` / `queryDataset()`.
- `StrategyContext.executeAggregate` and the `AnalyticsServicePlugin` /
`AnalyticsService` `executeAggregate` config options gain `context?:
ExecutionContext`. **Custom bridges should forward it** to their engine; the
built-in auto-bridge does. Purely additive — an existing bridge that ignores
it keeps working exactly as before.
- `DimensionLabelDeps.fetchRecordLabels` and `resolveDimensionLabels` each gain
an optional trailing `context`, beside the `scope` / `resolveScope` that
#3639 added — the same two-belt split as the aggregate path.
- `BootOptions.analytics` (`@objectstack/verify`) overrides the
AnalyticsServicePlugin instance, so a gate can boot with the analytics belt
off and assert the engine-side belt alone still scopes.

**Also fixed on the same seam:**

- `fetchRecordLabels` — the dimension display-label lookup — is row-granular
(one row per record, real display names). #3639 gave it the analytics-layer
belt (the referenced object's own read scope); it now also carries the
context, so the engine scopes the same read independently.
- `ObjectQLStrategy.generateSql` emitted no `WHERE` at all, so the
`/analytics/sql` preview read as an unscoped table scan while the real
aggregate was scoped. It now renders the caller's filters and the read scope.
The preview never executed, so this was misleading output rather than a leak.
1 change: 1 addition & 0 deletions packages/qa/dogfood/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@objectstack/plugin-auth": "workspace:*",
"@objectstack/plugin-security": "workspace:*",
"@objectstack/plugin-webhooks": "workspace:*",
"@objectstack/service-analytics": "workspace:*",
"@objectstack/service-messaging": "workspace:*",
"@objectstack/service-storage": "workspace:*",
"@objectstack/spec": "workspace:*",
Expand Down
72 changes: 72 additions & 0 deletions packages/qa/dogfood/test/analytics-rls.dogfood.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { bootStack, type VerifyStack } from '@objectstack/verify';
import { AnalyticsServicePlugin } from '@objectstack/service-analytics';
import {
rlsFixtureStack,
ownerScopedMemberSet,
Expand Down Expand Up @@ -144,3 +145,74 @@ describe('dogfood: analytics aggregates are RLS-scoped to the caller (#3597)', (
expect(memberSees).toBeLessThan(all.length);
});
});

/**
* The SECOND belt, alone (#3602).
*
* Everything above runs the fully-wired stack, where the analytics layer scopes
* the query itself (#3601). That proves the system is safe today; it cannot
* prove the belt BENEATH it works, because the first belt masks it. #3597 was
* exactly a first-belt miss — a strategy that never called `getReadScope` — and
* a third strategy will eventually repeat it.
*
* So boot with the analytics belt explicitly OFF (`getReadScope: () => undefined`)
* and assert the member still counts only their own rows. The only thing left
* scoping the read is the ExecutionContext the `executeAggregate` bridge now
* hands `engine.aggregate`, which the security middleware turns into an RLS
* predicate on `opCtx.ast.where`. Before #3602 the bridge passed no context, the
* middleware's principal-less fall-open skipped its own injection, and this case
* would count every note in the table.
*/
describe('dogfood: engine-side RLS scopes analytics even with the analytics belt off (#3602)', () => {
let stack: VerifyStack;
let memberToken: string;

beforeAll(async () => {
stack = await bootStack(rlsFixtureStack as never, {
security: rlsFixtureSecurity(ownerScopedMemberSet),
analytics: new AnalyticsServicePlugin({ getReadScope: () => undefined }),
});

const adminToken = await stack.signIn();
memberToken = await stack.signUp('analytics-depth@verify.test');

for (let i = 0; i < ADMIN_NOTES; i++) {
const r = await stack.apiAs(adminToken, 'POST', '/data/rls_note', {
name: `admin-note-${i}`,
body: 'owned by admin',
});
expect(r.status).toBeLessThan(300);
}
for (let i = 0; i < MEMBER_NOTES; i++) {
const r = await stack.apiAs(memberToken, 'POST', '/data/rls_note', {
name: `member-note-${i}`,
body: 'owned by member',
});
expect(r.status).toBeLessThan(300);
}
}, 90_000);

afterAll(async () => {
await stack?.stop();
});

it('the member counts ONLY their own rows on the ObjectQL (date-bucketed) path', async () => {
const res = await stack.apiAs(memberToken, 'POST', '/analytics/dataset/query', {
dataset: notesByDay,
selection: { dimensions: ['created'], measures: ['cnt'] },
});
expect(res.status).toBe(200);
const body = (await res.json()) as { rows?: Array<Record<string, number>> };
const seen = (body.rows ?? []).reduce((sum, row) => sum + Number(row.cnt ?? 0), 0);

expect(seen).toBe(MEMBER_NOTES);
});

it('the rows it cannot see DO exist — the belt is scoping, not an empty table', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ql = await stack.kernel.getServiceAsync<any>('objectql');
const all = (await ql.find('rls_note', { context: { isSystem: true } })) as unknown[];

expect(all).toHaveLength(ADMIN_NOTES + MEMBER_NOTES);
});
});
Loading
Loading