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
9 changes: 9 additions & 0 deletions .changeset/tidy-ducks-tickle.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/service-analytics': patch
---

Guard the analytics record-label lookup with `assertReadScopeCannotVacate` — the fourth read-scope door

`AnalyticsServicePlugin`'s `fetchRecordLabels` hook `$and`s the **referenced** object's read scope with an `id $in [...]` filter and hands the result straight to `executeAggregate`. Unlike the three faces unified previously (the ObjectQL engine merge, the `/analytics/sql` echo merge, and `NativeSQLStrategy.applyReadScope`), it met neither `compileScopedFilterToSql` nor the vacancy guard, so a read scope that lowers to a boolean constant — the `$not`-over-`$in: []` family reachable from any out-of-repo `StrategyContext.getReadScope` producer — let that per-record read run effectively unscoped for the ids in hand, surfacing the display names the referenced object's RLS exists to hide.

The hook now calls the already-exported `assertReadScopeCannotVacate` on the referenced object's scope before composing the filter, refusing in the same envelope as its siblings (`READ_SCOPE_COMPILE_FAILED` / 500). No behaviour changes for scopes that bind: an ordinary referenced-object scope still narrows the label lookup, and the `$in: []` zero-rows reduction (including the live RLS composite that pairs it with an own-rows grant) still passes through untouched. The read-scope SQL compiler is unchanged.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14329] The FOURTH read-scope door — `AnalyticsServicePlugin`'s
* `fetchRecordLabels` hook — answers the same verdict as the other three.
*
* #13640 guarded the ObjectQL ENGINE merge and #13926 the `/analytics/sql`
* ECHO merge plus `NativeSQLStrategy.applyReadScope`; the three-faces file
* next door pins those. This hook is a FOURTH consumer of the very same
* `readScopeProvider` output, reached by a different route entirely
* (`AnalyticsService.queryDataset` → `resolveScope` → `dimension-labels.ts` →
* `DimensionLabelDeps.fetchRecordLabels`, the closure `plugin.ts` builds), and
* it met NEITHER `compileScopedFilterToSql` nor `assertReadScopeCannotVacate`:
* it `$and`s the REFERENCED object's scope with `id $in [...]` and hands that
* straight to `executeAggregate`.
*
* So a vacating scope spelling from an out-of-repo `getReadScope` producer
* (`StrategyContext.getReadScope` is a spec contract — that population is
* exactly who this contract exists for, and the one with no producer-side
* #13570 guard) let this per-record read run effectively unscoped for the ids
* in hand, surfacing the display names the referenced object's RLS exists to
* hide. The leak is row-granular by construction: `group by (id, name)` is a
* record read dressed as an aggregate.
*
* ## What is measured here, and what is NOT
*
* These cases drive the REAL plugin wiring — `new AnalyticsServicePlugin(...).init(ctx)`
* — so the closure under test is the one `plugin.ts` actually ships, not a
* stub standing in for it. What they do NOT re-measure is the ENGINE's
* lowering of a vacating scope: that table (which spellings come back with the
* whole table, driven against a real `SqliteWasmDriver`) is
* `read-scope-vacancy-three-faces.test.ts`'s, and re-deriving it here would be
* a second copy of one ruling. The fixture engine below therefore honours the
* filter it is handed by a small, deliberately obvious evaluator — which is
* the right authority for THIS seam's question: *does the hook forward a scope
* that a scope-honouring engine can narrow by, and does it refuse the
* spellings that cannot narrow anything at all?*
*
* ## Two label passes, two DIFFERENT dispositions — both fail closed
*
* A refusal from this hook surfaces differently depending on which of
* `queryDataset`'s two label passes raised it, and both are asserted below
* because a reader who checks only one will conclude the other is unguarded:
*
* - **sort-key pass** (`order` on a lookup dimension, #3680) runs inside
* `DatasetExecutor.execute`, whose catch in `queryDataset` re-throws a
* DECLARED ADR-0112 envelope untouched (`hasDeclaredErrorEnvelope`). The
* refusal reaches the caller as itself — `READ_SCOPE_COMPILE_FAILED` / 500.
* - **display pass** (#3602) is wrapped in its own try/catch that degrades to
* a `warn` and leaves raw ids rendering. That is not this card weakening:
* it is the disposition #3602 already chose for this surface one frame up
* (`dimension-labels.ts` skips a dimension's labels rather than fetch
* unscoped when the scope cannot be resolved), and it is fail-CLOSED — no
* name is fetched, so none can leak.
*
* The security property is therefore identical on both passes and is asserted
* as such: **the referenced object is never read at all**. A bare "it threw"
* would not distinguish that from a read that happened and then threw.
*/

import { describe, it, expect, vi } from 'vitest';
import { DatasetSchema } from '@objectstack/spec/ui';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { FilterCondition } from '@objectstack/spec/data';
import { AnalyticsService } from '../analytics-service.js';
import { AnalyticsServicePlugin } from '../plugin.js';

const CTX = { tenantId: 'org_A', userId: 'u_me' } as ExecutionContext;

/** Tasks grouped by a lookup dimension whose target is `crm_account`. */
const DATASET = DatasetSchema.parse({
name: 'tasks_by_account',
label: 'Tasks by account',
object: 'task',
dimensions: [{ name: 'account', field: 'account', type: 'lookup', label: 'Account' }],
measures: [{ name: 'cnt', aggregate: 'count' }],
});

/**
* Referenced-object fixture rows. `organization_id` is what an ordinary
* tenant scope narrows by; `owner` is what the emptied-membership spellings
* address. `acc2` is the row an ordinary `org_A` scope must NOT surface.
*/
const ACCOUNTS = [
{ id: 'acc1', name: 'Acme Corp', organization_id: 'org_A', owner: 'u_me' },
{ id: 'acc2', name: 'Umbrella Ltd', organization_id: 'org_B', owner: 'u_other' },
];

/** The grouped base aggregate: both FK ids reach the label pass. */
const TASK_ROWS = [
{ account: 'acc1', cnt: 3 },
{ account: 'acc2', cnt: 1 },
];

/**
* A deliberately small filter evaluator for the FIXTURE rows — equality,
* `$in`, `$and`, `$or`. It exists so "an ordinary scope still narrows" and
* "`$in: []` still reduces to zero rows" are read off real returned rows
* rather than off the filter object, which would only echo the assertion.
*
* ⛔ Not an engine-lowering model, and not where a vacating spelling's row
* consequence is established: an unrecognised operator throws rather than
* quietly matching, so a spelling this cannot judge fails loudly instead of
* manufacturing a comfortable answer. The measured lowering table lives in
* `read-scope-vacancy-three-faces.test.ts`, against a real driver.
*/
function matches(row: Record<string, unknown>, filter: unknown): boolean {
if (filter == null) return true;
if (typeof filter !== 'object' || Array.isArray(filter)) {
throw new Error(`[fixture] not a filter node: ${JSON.stringify(filter)}`);
}
return Object.entries(filter as Record<string, unknown>).every(([key, value]) => {
if (key === '$and') return (value as unknown[]).every((n) => matches(row, n));
if (key === '$or') return (value as unknown[]).some((n) => matches(row, n));
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
const ops = Object.entries(value as Record<string, unknown>);
return ops.every(([op, comparand]) => {
if (op === '$in') return (comparand as unknown[]).includes(row[key]);
throw new Error(`[fixture] unsupported operator ${op} — this evaluator judges no spelling it was not written for`);
});
}
return row[key] === value;
});
}

type EngineCall = { object: string; where?: Record<string, unknown> };

function fakePluginContext(services: Record<string, unknown>) {
const registered: Record<string, unknown> = {};
const warn = vi.fn();
return {
registered,
warn,
ctx: {
getService: (name: string) => services[name] ?? registered[name],
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
logger: { info() {}, warn, error() {}, debug() {} },
},
};
}

const objectqlOnly = () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false });

/**
* Drive the label path through the real plugin wiring.
*
* `order` selects WHICH label pass runs: with it, the sort-key pass (#3680)
* resolves labels inside `DatasetExecutor.execute`; without it, only the
* display pass (#3602) does. The two have different refusal dispositions, so
* every case below states which one it is exercising.
*/
async function runLabels(opts: { scope: FilterCondition | undefined; order?: boolean }) {
const seen: EngineCall[] = [];
const engine = {
aggregate: async (object: string, options: Record<string, unknown>) => {
seen.push({ object, where: options.where as Record<string, unknown> | undefined });
if (object === 'task') return TASK_ROWS;
return ACCOUNTS.filter((r) => matches(r, options.where)).map((r) => ({ id: r.id, name: r.name, _c: 1 }));
},
getObject: (name: string) =>
name === 'task'
? { fields: { account: { type: 'lookup', reference: 'crm_account' } } }
: name === 'crm_account'
? { fields: { name: { type: 'text' } } }
: undefined,
};
const { ctx, registered, warn } = fakePluginContext({ data: engine });

await new AnalyticsServicePlugin({
queryCapabilities: objectqlOnly,
getReadScope: (object: string) => (object === 'crm_account' ? opts.scope : undefined),
}).init(ctx as never);

const run = () =>
(registered.analytics as AnalyticsService).queryDataset(
DATASET as never,
{
dimensions: ['account'],
measures: ['cnt'],
...(opts.order ? { order: { account: 'asc' } } : {}),
} as never,
CTX,
);

return { run, seen, warn };
}

/** Did anything read the REFERENCED object? The security question, directly. */
const readReferenced = (seen: EngineCall[]) => seen.filter((c) => c.object === 'crm_account');

/**
* The vacating family, as measured in `read-scope-sql.ts`'s #13640 section:
* every one of these came back with the whole table from a real engine.
* `$nin: []` is refused at any polarity (matching `compileOperator`'s own
* `$nin` arm); the rest are emptied POSITIVE memberships under an odd number
* of negations, which is what makes them vacate.
*/
const VACATING: Array<[string, FilterCondition]> = [
['empty $nin', { owner: { $nin: [] } } as FilterCondition],
['$not over empty $in', { $not: { owner: { $in: [] } } } as FilterCondition],
['$not over a bare empty array', { $not: { owner: [] } } as FilterCondition],
['$not over a multi-key operator object holding an empty $in', { $not: { owner: { $in: [], $ne: 'u_other' } } } as FilterCondition],
['a vacating arm inside an $or', { $or: [{ $not: { owner: { $in: [] } } }, { owner: 'u_me' }] } as FilterCondition],
];

describe('#14329 — a vacating referenced-object scope is refused before the label lookup runs', () => {
it.each(VACATING)('sort-key pass: %s refuses in the sibling envelope', async (_name, scope) => {
const { run, seen } = await runLabels({ scope, order: true });

// ADR-0112 envelope, `code` AND `status` — the same two the three sibling
// faces answer with. A bare `toThrow` would stay green against a driver
// throwing a naked `Error`, which is the failure this assertion exists to
// exclude.
const err = await run().then(
() => { throw new Error('expected a refusal, got a result'); },
(e: unknown) => e as { code?: unknown; status?: unknown; message?: string },
);
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(err.status).toBe(500);
expect(String(err.message)).toContain('read scope for "crm_account"');

// The other half of a refusal pin: the referenced object was NEVER read.
// "It threw" alone does not distinguish a guard from a leak followed by a
// throw — and the leak is precisely a read that happened.
expect(readReferenced(seen)).toEqual([]);
// The base aggregate still ran: the refusal is scoped to the label door.
expect(seen.map((c) => c.object)).toEqual(['task']);
});

it.each(VACATING)('display pass: %s fails closed to raw ids without reading the target', async (_name, scope) => {
const { run, seen, warn } = await runLabels({ scope });

// The display pass has its own catch (analytics-service.ts) that degrades
// to a warn — the #3602 disposition for this surface. So the CALLER sees
// rows, and what matters is that no name was fetched to put in them.
const result = await run() as unknown as { rows: Record<string, unknown>[] };
expect(readReferenced(seen)).toEqual([]);
expect(result.rows.map((r) => r.account)).toEqual(['acc1', 'acc2']);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('dimension label resolution failed'));
});
});

describe('#14329 over-denial controls — the guard refuses ONLY the vacating shapes', () => {
it('an ordinary referenced-object scope still narrows the label lookup', async () => {
const { run, seen } = await runLabels({ scope: { organization_id: 'org_A' } as FilterCondition });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

// Preservation pin — the scope reached the engine `$and`-composed with the
// id filter, never key-merged, so it cannot be displaced by the ids.
const labelCall = readReferenced(seen);
expect(labelCall).toHaveLength(1);
expect(labelCall[0].where).toEqual({
$and: [{ id: { $in: ['acc1', 'acc2'] } }, { organization_id: 'org_A' }],
});

// ...and the NARROWED RESULT SET, not merely "no throw": `acc1` is in the
// tenant and renders its name; `acc2` is out and keeps its raw id, which is
// the whole point of scoping this read.
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'acc2']);
});

it('the `$in: []` zero-rows reduction still yields no labels and no refusal', async () => {
// Positive polarity: the ruled #5322/#5243 reduction to constant FALSE.
// Narrowing at its own arm — the SAFE direction on a read scope — and
// deliberately NOT refused, here or at any sibling door.
const { run, seen } = await runLabels({ scope: { owner: { $in: [] } } as FilterCondition });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(readReferenced(seen)[0].where).toEqual({
$and: [{ id: { $in: ['acc1', 'acc2'] } }, { owner: { $in: [] } }],
});
// Zero rows came back, so no label overwrites a raw id — and no refusal.
expect(result.rows.map((r) => r.account)).toEqual(['acc1', 'acc2']);
});

it('the live #13570 RLS composite keeps own rows flowing', async () => {
// `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }` — an emptied
// membership beside an own-rows grant, which the RLS compiler really emits
// when a membership set resolves empty. Refusing it would 500 every
// analytics query for such a user, the outcome #13571's verdict rejected.
const { run, seen } = await runLabels({
scope: { $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] } as FilterCondition,
});

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'acc2']);
});

it('no scope at all still reads the target, unchanged', async () => {
// The `undefined` arm — "no scope for this object" is a legitimate answer
// from the provider contract, and the guard must not turn it into a
// refusal. Without this case a guard that refused everything would pass
// every refusal assertion above.
const { run, seen } = await runLabels({ scope: undefined });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(readReferenced(seen)[0].where).toEqual({ id: { $in: ['acc1', 'acc2'] } });
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'Umbrella Ltd']);
});
});
25 changes: 25 additions & 0 deletions packages/services/service-analytics/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import { AnalyticsService } from './analytics-service.js';
import type { AnalyticsServiceConfig } from './analytics-service.js';
import type { AnalyticsDriverCapabilities } from './strategies/types.js';
import { pickDisplayField, type DimensionLabelDeps } from './dimension-labels.js';
import { assertReadScopeCannotVacate } from './read-scope-sql.js';

/**
* The slice of the DECLARED engine contracts this plugin's auto-bridges
Expand DownExpand Up@@ -518,6 +519,30 @@ export class AnalyticsServicePlugin implements Plugin {
const map = new Map<unknown, string>();
const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
if (!displayField || !executeAggregate || ids.length === 0) return map;
// [#14329] The FOURTH read-scope door, and the same guard the other
// three answer with. #13640 guarded the ObjectQL engine merge and
// #13926 the echo merge and `NativeSQLStrategy.applyReadScope`; this
// hook is a fourth consumer of the same `readScopeProvider` output
// that meets NEITHER `compileScopedFilterToSql` nor the guard — the
// `$and` below hands the scope straight to `executeAggregate`, so a
// vacating spelling (`$not` over `$in: []` and its measured siblings,
// reachable from any out-of-repo `getReadScope` producer the
// `StrategyContext` spec contract admits) used to let this per-record
// read run effectively unscoped for the ids in hand — leaking exactly
// the display names the referenced object's RLS exists to hide.
//
// Placement mirrors `ObjectQLStrategy.resolveFkAttr`, this hook's
// structural twin (same id-`$in` `$and` scope, same `executeAggregate`,
// guarded since #13640): AFTER the early returns, because a call that
// reads nothing cannot widen anything and refusing it would be pure
// over-denial; and BEFORE the chunk loop, so one scope gets one verdict
// rather than one per 500 ids. The condition is spelled to match the
// composition on the next line exactly, so the set of scopes guarded
// and the set of scopes `$and`-ed are provably the same set.
//
// ⛔ Zero compiler change: the #13571 lowering residue is ruled and
// untouched. This guard is the walk, not the lowering.
if (scope) assertReadScopeCannotVacate(scope, targetObject);
// #3680 — the sort-key pass hands over the PRE-window id set (every
// grouped value, not just the displayed page), so a high-cardinality
// lookup dimension can push thousands of ids through here. Chunk the
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
9 changes: 9 additions & 0 deletions .changeset/tidy-ducks-tickle.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/service-analytics': patch
---

Guard the analytics record-label lookup with `assertReadScopeCannotVacate` — the fourth read-scope door

`AnalyticsServicePlugin`'s `fetchRecordLabels` hook `$and`s the **referenced** object's read scope with an `id $in [...]` filter and hands the result straight to `executeAggregate`. Unlike the three faces unified previously (the ObjectQL engine merge, the `/analytics/sql` echo merge, and `NativeSQLStrategy.applyReadScope`), it met neither `compileScopedFilterToSql` nor the vacancy guard, so a read scope that lowers to a boolean constant — the `$not`-over-`$in: []` family reachable from any out-of-repo `StrategyContext.getReadScope` producer — let that per-record read run effectively unscoped for the ids in hand, surfacing the display names the referenced object's RLS exists to hide.

The hook now calls the already-exported `assertReadScopeCannotVacate` on the referenced object's scope before composing the filter, refusing in the same envelope as its siblings (`READ_SCOPE_COMPILE_FAILED` / 500). No behaviour changes for scopes that bind: an ordinary referenced-object scope still narrows the label lookup, and the `$in: []` zero-rows reduction (including the live RLS composite that pairs it with an own-rows grant) still passes through untouched. The read-scope SQL compiler is unchanged.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14329] The FOURTH read-scope door — `AnalyticsServicePlugin`'s
* `fetchRecordLabels` hook — answers the same verdict as the other three.
*
* #13640 guarded the ObjectQL ENGINE merge and #13926 the `/analytics/sql`
* ECHO merge plus `NativeSQLStrategy.applyReadScope`; the three-faces file
* next door pins those. This hook is a FOURTH consumer of the very same
* `readScopeProvider` output, reached by a different route entirely
* (`AnalyticsService.queryDataset` → `resolveScope` → `dimension-labels.ts` →
* `DimensionLabelDeps.fetchRecordLabels`, the closure `plugin.ts` builds), and
* it met NEITHER `compileScopedFilterToSql` nor `assertReadScopeCannotVacate`:
* it `$and`s the REFERENCED object's scope with `id $in [...]` and hands that
* straight to `executeAggregate`.
*
* So a vacating scope spelling from an out-of-repo `getReadScope` producer
* (`StrategyContext.getReadScope` is a spec contract — that population is
* exactly who this contract exists for, and the one with no producer-side
* #13570 guard) let this per-record read run effectively unscoped for the ids
* in hand, surfacing the display names the referenced object's RLS exists to
* hide. The leak is row-granular by construction: `group by (id, name)` is a
* record read dressed as an aggregate.
*
* ## What is measured here, and what is NOT
*
* These cases drive the REAL plugin wiring — `new AnalyticsServicePlugin(...).init(ctx)`
* — so the closure under test is the one `plugin.ts` actually ships, not a
* stub standing in for it. What they do NOT re-measure is the ENGINE's
* lowering of a vacating scope: that table (which spellings come back with the
* whole table, driven against a real `SqliteWasmDriver`) is
* `read-scope-vacancy-three-faces.test.ts`'s, and re-deriving it here would be
* a second copy of one ruling. The fixture engine below therefore honours the
* filter it is handed by a small, deliberately obvious evaluator — which is
* the right authority for THIS seam's question: *does the hook forward a scope
* that a scope-honouring engine can narrow by, and does it refuse the
* spellings that cannot narrow anything at all?*
*
* ## Two label passes, two DIFFERENT dispositions — both fail closed
*
* A refusal from this hook surfaces differently depending on which of
* `queryDataset`'s two label passes raised it, and both are asserted below
* because a reader who checks only one will conclude the other is unguarded:
*
* - **sort-key pass** (`order` on a lookup dimension, #3680) runs inside
* `DatasetExecutor.execute`, whose catch in `queryDataset` re-throws a
* DECLARED ADR-0112 envelope untouched (`hasDeclaredErrorEnvelope`). The
* refusal reaches the caller as itself — `READ_SCOPE_COMPILE_FAILED` / 500.
* - **display pass** (#3602) is wrapped in its own try/catch that degrades to
* a `warn` and leaves raw ids rendering. That is not this card weakening:
* it is the disposition #3602 already chose for this surface one frame up
* (`dimension-labels.ts` skips a dimension's labels rather than fetch
* unscoped when the scope cannot be resolved), and it is fail-CLOSED — no
* name is fetched, so none can leak.
*
* The security property is therefore identical on both passes and is asserted
* as such: **the referenced object is never read at all**. A bare "it threw"
* would not distinguish that from a read that happened and then threw.
*/

import { describe, it, expect, vi } from 'vitest';
import { DatasetSchema } from '@objectstack/spec/ui';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { FilterCondition } from '@objectstack/spec/data';
import { AnalyticsService } from '../analytics-service.js';
import { AnalyticsServicePlugin } from '../plugin.js';

const CTX = { tenantId: 'org_A', userId: 'u_me' } as ExecutionContext;

/** Tasks grouped by a lookup dimension whose target is `crm_account`. */
const DATASET = DatasetSchema.parse({
name: 'tasks_by_account',
label: 'Tasks by account',
object: 'task',
dimensions: [{ name: 'account', field: 'account', type: 'lookup', label: 'Account' }],
measures: [{ name: 'cnt', aggregate: 'count' }],
});

/**
* Referenced-object fixture rows. `organization_id` is what an ordinary
* tenant scope narrows by; `owner` is what the emptied-membership spellings
* address. `acc2` is the row an ordinary `org_A` scope must NOT surface.
*/
const ACCOUNTS = [
{ id: 'acc1', name: 'Acme Corp', organization_id: 'org_A', owner: 'u_me' },
{ id: 'acc2', name: 'Umbrella Ltd', organization_id: 'org_B', owner: 'u_other' },
];

/** The grouped base aggregate: both FK ids reach the label pass. */
const TASK_ROWS = [
{ account: 'acc1', cnt: 3 },
{ account: 'acc2', cnt: 1 },
];

/**
* A deliberately small filter evaluator for the FIXTURE rows — equality,
* `$in`, `$and`, `$or`. It exists so "an ordinary scope still narrows" and
* "`$in: []` still reduces to zero rows" are read off real returned rows
* rather than off the filter object, which would only echo the assertion.
*
* ⛔ Not an engine-lowering model, and not where a vacating spelling's row
* consequence is established: an unrecognised operator throws rather than
* quietly matching, so a spelling this cannot judge fails loudly instead of
* manufacturing a comfortable answer. The measured lowering table lives in
* `read-scope-vacancy-three-faces.test.ts`, against a real driver.
*/
function matches(row: Record<string, unknown>, filter: unknown): boolean {
if (filter == null) return true;
if (typeof filter !== 'object' || Array.isArray(filter)) {
throw new Error(`[fixture] not a filter node: ${JSON.stringify(filter)}`);
}
return Object.entries(filter as Record<string, unknown>).every(([key, value]) => {
if (key === '$and') return (value as unknown[]).every((n) => matches(row, n));
if (key === '$or') return (value as unknown[]).some((n) => matches(row, n));
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
const ops = Object.entries(value as Record<string, unknown>);
return ops.every(([op, comparand]) => {
if (op === '$in') return (comparand as unknown[]).includes(row[key]);
throw new Error(`[fixture] unsupported operator ${op} — this evaluator judges no spelling it was not written for`);
});
}
return row[key] === value;
});
}

type EngineCall = { object: string; where?: Record<string, unknown> };

function fakePluginContext(services: Record<string, unknown>) {
const registered: Record<string, unknown> = {};
const warn = vi.fn();
return {
registered,
warn,
ctx: {
getService: (name: string) => services[name] ?? registered[name],
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
logger: { info() {}, warn, error() {}, debug() {} },
},
};
}

const objectqlOnly = () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false });

/**
* Drive the label path through the real plugin wiring.
*
* `order` selects WHICH label pass runs: with it, the sort-key pass (#3680)
* resolves labels inside `DatasetExecutor.execute`; without it, only the
* display pass (#3602) does. The two have different refusal dispositions, so
* every case below states which one it is exercising.
*/
async function runLabels(opts: { scope: FilterCondition | undefined; order?: boolean }) {
const seen: EngineCall[] = [];
const engine = {
aggregate: async (object: string, options: Record<string, unknown>) => {
seen.push({ object, where: options.where as Record<string, unknown> | undefined });
if (object === 'task') return TASK_ROWS;
return ACCOUNTS.filter((r) => matches(r, options.where)).map((r) => ({ id: r.id, name: r.name, _c: 1 }));
},
getObject: (name: string) =>
name === 'task'
? { fields: { account: { type: 'lookup', reference: 'crm_account' } } }
: name === 'crm_account'
? { fields: { name: { type: 'text' } } }
: undefined,
};
const { ctx, registered, warn } = fakePluginContext({ data: engine });

await new AnalyticsServicePlugin({
queryCapabilities: objectqlOnly,
getReadScope: (object: string) => (object === 'crm_account' ? opts.scope : undefined),
}).init(ctx as never);

const run = () =>
(registered.analytics as AnalyticsService).queryDataset(
DATASET as never,
{
dimensions: ['account'],
measures: ['cnt'],
...(opts.order ? { order: { account: 'asc' } } : {}),
} as never,
CTX,
);

return { run, seen, warn };
}

/** Did anything read the REFERENCED object? The security question, directly. */
const readReferenced = (seen: EngineCall[]) => seen.filter((c) => c.object === 'crm_account');

/**
* The vacating family, as measured in `read-scope-sql.ts`'s #13640 section:
* every one of these came back with the whole table from a real engine.
* `$nin: []` is refused at any polarity (matching `compileOperator`'s own
* `$nin` arm); the rest are emptied POSITIVE memberships under an odd number
* of negations, which is what makes them vacate.
*/
const VACATING: Array<[string, FilterCondition]> = [
['empty $nin', { owner: { $nin: [] } } as FilterCondition],
['$not over empty $in', { $not: { owner: { $in: [] } } } as FilterCondition],
['$not over a bare empty array', { $not: { owner: [] } } as FilterCondition],
['$not over a multi-key operator object holding an empty $in', { $not: { owner: { $in: [], $ne: 'u_other' } } } as FilterCondition],
['a vacating arm inside an $or', { $or: [{ $not: { owner: { $in: [] } } }, { owner: 'u_me' }] } as FilterCondition],
];

describe('#14329 — a vacating referenced-object scope is refused before the label lookup runs', () => {
it.each(VACATING)('sort-key pass: %s refuses in the sibling envelope', async (_name, scope) => {
const { run, seen } = await runLabels({ scope, order: true });

// ADR-0112 envelope, `code` AND `status` — the same two the three sibling
// faces answer with. A bare `toThrow` would stay green against a driver
// throwing a naked `Error`, which is the failure this assertion exists to
// exclude.
const err = await run().then(
() => { throw new Error('expected a refusal, got a result'); },
(e: unknown) => e as { code?: unknown; status?: unknown; message?: string },
);
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(err.status).toBe(500);
expect(String(err.message)).toContain('read scope for "crm_account"');

// The other half of a refusal pin: the referenced object was NEVER read.
// "It threw" alone does not distinguish a guard from a leak followed by a
// throw — and the leak is precisely a read that happened.
expect(readReferenced(seen)).toEqual([]);
// The base aggregate still ran: the refusal is scoped to the label door.
expect(seen.map((c) => c.object)).toEqual(['task']);
});

it.each(VACATING)('display pass: %s fails closed to raw ids without reading the target', async (_name, scope) => {
const { run, seen, warn } = await runLabels({ scope });

// The display pass has its own catch (analytics-service.ts) that degrades
// to a warn — the #3602 disposition for this surface. So the CALLER sees
// rows, and what matters is that no name was fetched to put in them.
const result = await run() as unknown as { rows: Record<string, unknown>[] };
expect(readReferenced(seen)).toEqual([]);
expect(result.rows.map((r) => r.account)).toEqual(['acc1', 'acc2']);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('dimension label resolution failed'));
});
});

describe('#14329 over-denial controls — the guard refuses ONLY the vacating shapes', () => {
it('an ordinary referenced-object scope still narrows the label lookup', async () => {
const { run, seen } = await runLabels({ scope: { organization_id: 'org_A' } as FilterCondition });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

// Preservation pin — the scope reached the engine `$and`-composed with the
// id filter, never key-merged, so it cannot be displaced by the ids.
const labelCall = readReferenced(seen);
expect(labelCall).toHaveLength(1);
expect(labelCall[0].where).toEqual({
$and: [{ id: { $in: ['acc1', 'acc2'] } }, { organization_id: 'org_A' }],
});

// ...and the NARROWED RESULT SET, not merely "no throw": `acc1` is in the
// tenant and renders its name; `acc2` is out and keeps its raw id, which is
// the whole point of scoping this read.
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'acc2']);
});

it('the `$in: []` zero-rows reduction still yields no labels and no refusal', async () => {
// Positive polarity: the ruled #5322/#5243 reduction to constant FALSE.
// Narrowing at its own arm — the SAFE direction on a read scope — and
// deliberately NOT refused, here or at any sibling door.
const { run, seen } = await runLabels({ scope: { owner: { $in: [] } } as FilterCondition });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(readReferenced(seen)[0].where).toEqual({
$and: [{ id: { $in: ['acc1', 'acc2'] } }, { owner: { $in: [] } }],
});
// Zero rows came back, so no label overwrites a raw id — and no refusal.
expect(result.rows.map((r) => r.account)).toEqual(['acc1', 'acc2']);
});

it('the live #13570 RLS composite keeps own rows flowing', async () => {
// `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }` — an emptied
// membership beside an own-rows grant, which the RLS compiler really emits
// when a membership set resolves empty. Refusing it would 500 every
// analytics query for such a user, the outcome #13571's verdict rejected.
const { run, seen } = await runLabels({
scope: { $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] } as FilterCondition,
});

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'acc2']);
});

it('no scope at all still reads the target, unchanged', async () => {
// The `undefined` arm — "no scope for this object" is a legitimate answer
// from the provider contract, and the guard must not turn it into a
// refusal. Without this case a guard that refused everything would pass
// every refusal assertion above.
const { run, seen } = await runLabels({ scope: undefined });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(readReferenced(seen)[0].where).toEqual({ id: { $in: ['acc1', 'acc2'] } });
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'Umbrella Ltd']);
});
});
25 changes: 25 additions & 0 deletions packages/services/service-analytics/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import { AnalyticsService } from './analytics-service.js';
import type { AnalyticsServiceConfig } from './analytics-service.js';
import type { AnalyticsDriverCapabilities } from './strategies/types.js';
import { pickDisplayField, type DimensionLabelDeps } from './dimension-labels.js';
import { assertReadScopeCannotVacate } from './read-scope-sql.js';

/**
* The slice of the DECLARED engine contracts this plugin's auto-bridges
Expand DownExpand Up@@ -518,6 +519,30 @@ export class AnalyticsServicePlugin implements Plugin {
const map = new Map<unknown, string>();
const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
if (!displayField || !executeAggregate || ids.length === 0) return map;
// [#14329] The FOURTH read-scope door, and the same guard the other
// three answer with. #13640 guarded the ObjectQL engine merge and
// #13926 the echo merge and `NativeSQLStrategy.applyReadScope`; this
// hook is a fourth consumer of the same `readScopeProvider` output
// that meets NEITHER `compileScopedFilterToSql` nor the guard — the
// `$and` below hands the scope straight to `executeAggregate`, so a
// vacating spelling (`$not` over `$in: []` and its measured siblings,
// reachable from any out-of-repo `getReadScope` producer the
// `StrategyContext` spec contract admits) used to let this per-record
// read run effectively unscoped for the ids in hand — leaking exactly
// the display names the referenced object's RLS exists to hide.
//
// Placement mirrors `ObjectQLStrategy.resolveFkAttr`, this hook's
// structural twin (same id-`$in` `$and` scope, same `executeAggregate`,
// guarded since #13640): AFTER the early returns, because a call that
// reads nothing cannot widen anything and refusing it would be pure
// over-denial; and BEFORE the chunk loop, so one scope gets one verdict
// rather than one per 500 ids. The condition is spelled to match the
// composition on the next line exactly, so the set of scopes guarded
// and the set of scopes `$and`-ed are provably the same set.
//
// ⛔ Zero compiler change: the #13571 lowering residue is ruled and
// untouched. This guard is the walk, not the lowering.
if (scope) assertReadScopeCannotVacate(scope, targetObject);
// #3680 — the sort-key pass hands over the PRE-window id set (every
// grouped value, not just the displayed page), so a high-cardinality
// lookup dimension can push thousands of ids through here. Chunk the
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
9 changes: 9 additions & 0 deletions .changeset/tidy-ducks-tickle.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/service-analytics': patch
---

Guard the analytics record-label lookup with `assertReadScopeCannotVacate` — the fourth read-scope door

`AnalyticsServicePlugin`'s `fetchRecordLabels` hook `$and`s the **referenced** object's read scope with an `id $in [...]` filter and hands the result straight to `executeAggregate`. Unlike the three faces unified previously (the ObjectQL engine merge, the `/analytics/sql` echo merge, and `NativeSQLStrategy.applyReadScope`), it met neither `compileScopedFilterToSql` nor the vacancy guard, so a read scope that lowers to a boolean constant — the `$not`-over-`$in: []` family reachable from any out-of-repo `StrategyContext.getReadScope` producer — let that per-record read run effectively unscoped for the ids in hand, surfacing the display names the referenced object's RLS exists to hide.

The hook now calls the already-exported `assertReadScopeCannotVacate` on the referenced object's scope before composing the filter, refusing in the same envelope as its siblings (`READ_SCOPE_COMPILE_FAILED` / 500). No behaviour changes for scopes that bind: an ordinary referenced-object scope still narrows the label lookup, and the `$in: []` zero-rows reduction (including the live RLS composite that pairs it with an own-rows grant) still passes through untouched. The read-scope SQL compiler is unchanged.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14329] The FOURTH read-scope door — `AnalyticsServicePlugin`'s
* `fetchRecordLabels` hook — answers the same verdict as the other three.
*
* #13640 guarded the ObjectQL ENGINE merge and #13926 the `/analytics/sql`
* ECHO merge plus `NativeSQLStrategy.applyReadScope`; the three-faces file
* next door pins those. This hook is a FOURTH consumer of the very same
* `readScopeProvider` output, reached by a different route entirely
* (`AnalyticsService.queryDataset` → `resolveScope` → `dimension-labels.ts` →
* `DimensionLabelDeps.fetchRecordLabels`, the closure `plugin.ts` builds), and
* it met NEITHER `compileScopedFilterToSql` nor `assertReadScopeCannotVacate`:
* it `$and`s the REFERENCED object's scope with `id $in [...]` and hands that
* straight to `executeAggregate`.
*
* So a vacating scope spelling from an out-of-repo `getReadScope` producer
* (`StrategyContext.getReadScope` is a spec contract — that population is
* exactly who this contract exists for, and the one with no producer-side
* #13570 guard) let this per-record read run effectively unscoped for the ids
* in hand, surfacing the display names the referenced object's RLS exists to
* hide. The leak is row-granular by construction: `group by (id, name)` is a
* record read dressed as an aggregate.
*
* ## What is measured here, and what is NOT
*
* These cases drive the REAL plugin wiring — `new AnalyticsServicePlugin(...).init(ctx)`
* — so the closure under test is the one `plugin.ts` actually ships, not a
* stub standing in for it. What they do NOT re-measure is the ENGINE's
* lowering of a vacating scope: that table (which spellings come back with the
* whole table, driven against a real `SqliteWasmDriver`) is
* `read-scope-vacancy-three-faces.test.ts`'s, and re-deriving it here would be
* a second copy of one ruling. The fixture engine below therefore honours the
* filter it is handed by a small, deliberately obvious evaluator — which is
* the right authority for THIS seam's question: *does the hook forward a scope
* that a scope-honouring engine can narrow by, and does it refuse the
* spellings that cannot narrow anything at all?*
*
* ## Two label passes, two DIFFERENT dispositions — both fail closed
*
* A refusal from this hook surfaces differently depending on which of
* `queryDataset`'s two label passes raised it, and both are asserted below
* because a reader who checks only one will conclude the other is unguarded:
*
* - **sort-key pass** (`order` on a lookup dimension, #3680) runs inside
* `DatasetExecutor.execute`, whose catch in `queryDataset` re-throws a
* DECLARED ADR-0112 envelope untouched (`hasDeclaredErrorEnvelope`). The
* refusal reaches the caller as itself — `READ_SCOPE_COMPILE_FAILED` / 500.
* - **display pass** (#3602) is wrapped in its own try/catch that degrades to
* a `warn` and leaves raw ids rendering. That is not this card weakening:
* it is the disposition #3602 already chose for this surface one frame up
* (`dimension-labels.ts` skips a dimension's labels rather than fetch
* unscoped when the scope cannot be resolved), and it is fail-CLOSED — no
* name is fetched, so none can leak.
*
* The security property is therefore identical on both passes and is asserted
* as such: **the referenced object is never read at all**. A bare "it threw"
* would not distinguish that from a read that happened and then threw.
*/

import { describe, it, expect, vi } from 'vitest';
import { DatasetSchema } from '@objectstack/spec/ui';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { FilterCondition } from '@objectstack/spec/data';
import { AnalyticsService } from '../analytics-service.js';
import { AnalyticsServicePlugin } from '../plugin.js';

const CTX = { tenantId: 'org_A', userId: 'u_me' } as ExecutionContext;

/** Tasks grouped by a lookup dimension whose target is `crm_account`. */
const DATASET = DatasetSchema.parse({
name: 'tasks_by_account',
label: 'Tasks by account',
object: 'task',
dimensions: [{ name: 'account', field: 'account', type: 'lookup', label: 'Account' }],
measures: [{ name: 'cnt', aggregate: 'count' }],
});

/**
* Referenced-object fixture rows. `organization_id` is what an ordinary
* tenant scope narrows by; `owner` is what the emptied-membership spellings
* address. `acc2` is the row an ordinary `org_A` scope must NOT surface.
*/
const ACCOUNTS = [
{ id: 'acc1', name: 'Acme Corp', organization_id: 'org_A', owner: 'u_me' },
{ id: 'acc2', name: 'Umbrella Ltd', organization_id: 'org_B', owner: 'u_other' },
];

/** The grouped base aggregate: both FK ids reach the label pass. */
const TASK_ROWS = [
{ account: 'acc1', cnt: 3 },
{ account: 'acc2', cnt: 1 },
];

/**
* A deliberately small filter evaluator for the FIXTURE rows — equality,
* `$in`, `$and`, `$or`. It exists so "an ordinary scope still narrows" and
* "`$in: []` still reduces to zero rows" are read off real returned rows
* rather than off the filter object, which would only echo the assertion.
*
* ⛔ Not an engine-lowering model, and not where a vacating spelling's row
* consequence is established: an unrecognised operator throws rather than
* quietly matching, so a spelling this cannot judge fails loudly instead of
* manufacturing a comfortable answer. The measured lowering table lives in
* `read-scope-vacancy-three-faces.test.ts`, against a real driver.
*/
function matches(row: Record<string, unknown>, filter: unknown): boolean {
if (filter == null) return true;
if (typeof filter !== 'object' || Array.isArray(filter)) {
throw new Error(`[fixture] not a filter node: ${JSON.stringify(filter)}`);
}
return Object.entries(filter as Record<string, unknown>).every(([key, value]) => {
if (key === '$and') return (value as unknown[]).every((n) => matches(row, n));
if (key === '$or') return (value as unknown[]).some((n) => matches(row, n));
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
const ops = Object.entries(value as Record<string, unknown>);
return ops.every(([op, comparand]) => {
if (op === '$in') return (comparand as unknown[]).includes(row[key]);
throw new Error(`[fixture] unsupported operator ${op} — this evaluator judges no spelling it was not written for`);
});
}
return row[key] === value;
});
}

type EngineCall = { object: string; where?: Record<string, unknown> };

function fakePluginContext(services: Record<string, unknown>) {
const registered: Record<string, unknown> = {};
const warn = vi.fn();
return {
registered,
warn,
ctx: {
getService: (name: string) => services[name] ?? registered[name],
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
logger: { info() {}, warn, error() {}, debug() {} },
},
};
}

const objectqlOnly = () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false });

/**
* Drive the label path through the real plugin wiring.
*
* `order` selects WHICH label pass runs: with it, the sort-key pass (#3680)
* resolves labels inside `DatasetExecutor.execute`; without it, only the
* display pass (#3602) does. The two have different refusal dispositions, so
* every case below states which one it is exercising.
*/
async function runLabels(opts: { scope: FilterCondition | undefined; order?: boolean }) {
const seen: EngineCall[] = [];
const engine = {
aggregate: async (object: string, options: Record<string, unknown>) => {
seen.push({ object, where: options.where as Record<string, unknown> | undefined });
if (object === 'task') return TASK_ROWS;
return ACCOUNTS.filter((r) => matches(r, options.where)).map((r) => ({ id: r.id, name: r.name, _c: 1 }));
},
getObject: (name: string) =>
name === 'task'
? { fields: { account: { type: 'lookup', reference: 'crm_account' } } }
: name === 'crm_account'
? { fields: { name: { type: 'text' } } }
: undefined,
};
const { ctx, registered, warn } = fakePluginContext({ data: engine });

await new AnalyticsServicePlugin({
queryCapabilities: objectqlOnly,
getReadScope: (object: string) => (object === 'crm_account' ? opts.scope : undefined),
}).init(ctx as never);

const run = () =>
(registered.analytics as AnalyticsService).queryDataset(
DATASET as never,
{
dimensions: ['account'],
measures: ['cnt'],
...(opts.order ? { order: { account: 'asc' } } : {}),
} as never,
CTX,
);

return { run, seen, warn };
}

/** Did anything read the REFERENCED object? The security question, directly. */
const readReferenced = (seen: EngineCall[]) => seen.filter((c) => c.object === 'crm_account');

/**
* The vacating family, as measured in `read-scope-sql.ts`'s #13640 section:
* every one of these came back with the whole table from a real engine.
* `$nin: []` is refused at any polarity (matching `compileOperator`'s own
* `$nin` arm); the rest are emptied POSITIVE memberships under an odd number
* of negations, which is what makes them vacate.
*/
const VACATING: Array<[string, FilterCondition]> = [
['empty $nin', { owner: { $nin: [] } } as FilterCondition],
['$not over empty $in', { $not: { owner: { $in: [] } } } as FilterCondition],
['$not over a bare empty array', { $not: { owner: [] } } as FilterCondition],
['$not over a multi-key operator object holding an empty $in', { $not: { owner: { $in: [], $ne: 'u_other' } } } as FilterCondition],
['a vacating arm inside an $or', { $or: [{ $not: { owner: { $in: [] } } }, { owner: 'u_me' }] } as FilterCondition],
];

describe('#14329 — a vacating referenced-object scope is refused before the label lookup runs', () => {
it.each(VACATING)('sort-key pass: %s refuses in the sibling envelope', async (_name, scope) => {
const { run, seen } = await runLabels({ scope, order: true });

// ADR-0112 envelope, `code` AND `status` — the same two the three sibling
// faces answer with. A bare `toThrow` would stay green against a driver
// throwing a naked `Error`, which is the failure this assertion exists to
// exclude.
const err = await run().then(
() => { throw new Error('expected a refusal, got a result'); },
(e: unknown) => e as { code?: unknown; status?: unknown; message?: string },
);
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(err.status).toBe(500);
expect(String(err.message)).toContain('read scope for "crm_account"');

// The other half of a refusal pin: the referenced object was NEVER read.
// "It threw" alone does not distinguish a guard from a leak followed by a
// throw — and the leak is precisely a read that happened.
expect(readReferenced(seen)).toEqual([]);
// The base aggregate still ran: the refusal is scoped to the label door.
expect(seen.map((c) => c.object)).toEqual(['task']);
});

it.each(VACATING)('display pass: %s fails closed to raw ids without reading the target', async (_name, scope) => {
const { run, seen, warn } = await runLabels({ scope });

// The display pass has its own catch (analytics-service.ts) that degrades
// to a warn — the #3602 disposition for this surface. So the CALLER sees
// rows, and what matters is that no name was fetched to put in them.
const result = await run() as unknown as { rows: Record<string, unknown>[] };
expect(readReferenced(seen)).toEqual([]);
expect(result.rows.map((r) => r.account)).toEqual(['acc1', 'acc2']);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('dimension label resolution failed'));
});
});

describe('#14329 over-denial controls — the guard refuses ONLY the vacating shapes', () => {
it('an ordinary referenced-object scope still narrows the label lookup', async () => {
const { run, seen } = await runLabels({ scope: { organization_id: 'org_A' } as FilterCondition });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

// Preservation pin — the scope reached the engine `$and`-composed with the
// id filter, never key-merged, so it cannot be displaced by the ids.
const labelCall = readReferenced(seen);
expect(labelCall).toHaveLength(1);
expect(labelCall[0].where).toEqual({
$and: [{ id: { $in: ['acc1', 'acc2'] } }, { organization_id: 'org_A' }],
});

// ...and the NARROWED RESULT SET, not merely "no throw": `acc1` is in the
// tenant and renders its name; `acc2` is out and keeps its raw id, which is
// the whole point of scoping this read.
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'acc2']);
});

it('the `$in: []` zero-rows reduction still yields no labels and no refusal', async () => {
// Positive polarity: the ruled #5322/#5243 reduction to constant FALSE.
// Narrowing at its own arm — the SAFE direction on a read scope — and
// deliberately NOT refused, here or at any sibling door.
const { run, seen } = await runLabels({ scope: { owner: { $in: [] } } as FilterCondition });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(readReferenced(seen)[0].where).toEqual({
$and: [{ id: { $in: ['acc1', 'acc2'] } }, { owner: { $in: [] } }],
});
// Zero rows came back, so no label overwrites a raw id — and no refusal.
expect(result.rows.map((r) => r.account)).toEqual(['acc1', 'acc2']);
});

it('the live #13570 RLS composite keeps own rows flowing', async () => {
// `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }` — an emptied
// membership beside an own-rows grant, which the RLS compiler really emits
// when a membership set resolves empty. Refusing it would 500 every
// analytics query for such a user, the outcome #13571's verdict rejected.
const { run, seen } = await runLabels({
scope: { $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] } as FilterCondition,
});

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'acc2']);
});

it('no scope at all still reads the target, unchanged', async () => {
// The `undefined` arm — "no scope for this object" is a legitimate answer
// from the provider contract, and the guard must not turn it into a
// refusal. Without this case a guard that refused everything would pass
// every refusal assertion above.
const { run, seen } = await runLabels({ scope: undefined });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(readReferenced(seen)[0].where).toEqual({ id: { $in: ['acc1', 'acc2'] } });
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'Umbrella Ltd']);
});
});
25 changes: 25 additions & 0 deletions packages/services/service-analytics/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import { AnalyticsService } from './analytics-service.js';
import type { AnalyticsServiceConfig } from './analytics-service.js';
import type { AnalyticsDriverCapabilities } from './strategies/types.js';
import { pickDisplayField, type DimensionLabelDeps } from './dimension-labels.js';
import { assertReadScopeCannotVacate } from './read-scope-sql.js';

/**
* The slice of the DECLARED engine contracts this plugin's auto-bridges
Expand DownExpand Up@@ -518,6 +519,30 @@ export class AnalyticsServicePlugin implements Plugin {
const map = new Map<unknown, string>();
const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
if (!displayField || !executeAggregate || ids.length === 0) return map;
// [#14329] The FOURTH read-scope door, and the same guard the other
// three answer with. #13640 guarded the ObjectQL engine merge and
// #13926 the echo merge and `NativeSQLStrategy.applyReadScope`; this
// hook is a fourth consumer of the same `readScopeProvider` output
// that meets NEITHER `compileScopedFilterToSql` nor the guard — the
// `$and` below hands the scope straight to `executeAggregate`, so a
// vacating spelling (`$not` over `$in: []` and its measured siblings,
// reachable from any out-of-repo `getReadScope` producer the
// `StrategyContext` spec contract admits) used to let this per-record
// read run effectively unscoped for the ids in hand — leaking exactly
// the display names the referenced object's RLS exists to hide.
//
// Placement mirrors `ObjectQLStrategy.resolveFkAttr`, this hook's
// structural twin (same id-`$in` `$and` scope, same `executeAggregate`,
// guarded since #13640): AFTER the early returns, because a call that
// reads nothing cannot widen anything and refusing it would be pure
// over-denial; and BEFORE the chunk loop, so one scope gets one verdict
// rather than one per 500 ids. The condition is spelled to match the
// composition on the next line exactly, so the set of scopes guarded
// and the set of scopes `$and`-ed are provably the same set.
//
// ⛔ Zero compiler change: the #13571 lowering residue is ruled and
// untouched. This guard is the walk, not the lowering.
if (scope) assertReadScopeCannotVacate(scope, targetObject);
// #3680 — the sort-key pass hands over the PRE-window id set (every
// grouped value, not just the displayed page), so a high-cardinality
// lookup dimension can push thousands of ids through here. Chunk the
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
9 changes: 9 additions & 0 deletions .changeset/tidy-ducks-tickle.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/service-analytics': patch
---

Guard the analytics record-label lookup with `assertReadScopeCannotVacate` — the fourth read-scope door

`AnalyticsServicePlugin`'s `fetchRecordLabels` hook `$and`s the **referenced** object's read scope with an `id $in [...]` filter and hands the result straight to `executeAggregate`. Unlike the three faces unified previously (the ObjectQL engine merge, the `/analytics/sql` echo merge, and `NativeSQLStrategy.applyReadScope`), it met neither `compileScopedFilterToSql` nor the vacancy guard, so a read scope that lowers to a boolean constant — the `$not`-over-`$in: []` family reachable from any out-of-repo `StrategyContext.getReadScope` producer — let that per-record read run effectively unscoped for the ids in hand, surfacing the display names the referenced object's RLS exists to hide.

The hook now calls the already-exported `assertReadScopeCannotVacate` on the referenced object's scope before composing the filter, refusing in the same envelope as its siblings (`READ_SCOPE_COMPILE_FAILED` / 500). No behaviour changes for scopes that bind: an ordinary referenced-object scope still narrows the label lookup, and the `$in: []` zero-rows reduction (including the live RLS composite that pairs it with an own-rows grant) still passes through untouched. The read-scope SQL compiler is unchanged.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14329] The FOURTH read-scope door — `AnalyticsServicePlugin`'s
* `fetchRecordLabels` hook — answers the same verdict as the other three.
*
* #13640 guarded the ObjectQL ENGINE merge and #13926 the `/analytics/sql`
* ECHO merge plus `NativeSQLStrategy.applyReadScope`; the three-faces file
* next door pins those. This hook is a FOURTH consumer of the very same
* `readScopeProvider` output, reached by a different route entirely
* (`AnalyticsService.queryDataset` → `resolveScope` → `dimension-labels.ts` →
* `DimensionLabelDeps.fetchRecordLabels`, the closure `plugin.ts` builds), and
* it met NEITHER `compileScopedFilterToSql` nor `assertReadScopeCannotVacate`:
* it `$and`s the REFERENCED object's scope with `id $in [...]` and hands that
* straight to `executeAggregate`.
*
* So a vacating scope spelling from an out-of-repo `getReadScope` producer
* (`StrategyContext.getReadScope` is a spec contract — that population is
* exactly who this contract exists for, and the one with no producer-side
* #13570 guard) let this per-record read run effectively unscoped for the ids
* in hand, surfacing the display names the referenced object's RLS exists to
* hide. The leak is row-granular by construction: `group by (id, name)` is a
* record read dressed as an aggregate.
*
* ## What is measured here, and what is NOT
*
* These cases drive the REAL plugin wiring — `new AnalyticsServicePlugin(...).init(ctx)`
* — so the closure under test is the one `plugin.ts` actually ships, not a
* stub standing in for it. What they do NOT re-measure is the ENGINE's
* lowering of a vacating scope: that table (which spellings come back with the
* whole table, driven against a real `SqliteWasmDriver`) is
* `read-scope-vacancy-three-faces.test.ts`'s, and re-deriving it here would be
* a second copy of one ruling. The fixture engine below therefore honours the
* filter it is handed by a small, deliberately obvious evaluator — which is
* the right authority for THIS seam's question: *does the hook forward a scope
* that a scope-honouring engine can narrow by, and does it refuse the
* spellings that cannot narrow anything at all?*
*
* ## Two label passes, two DIFFERENT dispositions — both fail closed
*
* A refusal from this hook surfaces differently depending on which of
* `queryDataset`'s two label passes raised it, and both are asserted below
* because a reader who checks only one will conclude the other is unguarded:
*
* - **sort-key pass** (`order` on a lookup dimension, #3680) runs inside
* `DatasetExecutor.execute`, whose catch in `queryDataset` re-throws a
* DECLARED ADR-0112 envelope untouched (`hasDeclaredErrorEnvelope`). The
* refusal reaches the caller as itself — `READ_SCOPE_COMPILE_FAILED` / 500.
* - **display pass** (#3602) is wrapped in its own try/catch that degrades to
* a `warn` and leaves raw ids rendering. That is not this card weakening:
* it is the disposition #3602 already chose for this surface one frame up
* (`dimension-labels.ts` skips a dimension's labels rather than fetch
* unscoped when the scope cannot be resolved), and it is fail-CLOSED — no
* name is fetched, so none can leak.
*
* The security property is therefore identical on both passes and is asserted
* as such: **the referenced object is never read at all**. A bare "it threw"
* would not distinguish that from a read that happened and then threw.
*/

import { describe, it, expect, vi } from 'vitest';
import { DatasetSchema } from '@objectstack/spec/ui';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { FilterCondition } from '@objectstack/spec/data';
import { AnalyticsService } from '../analytics-service.js';
import { AnalyticsServicePlugin } from '../plugin.js';

const CTX = { tenantId: 'org_A', userId: 'u_me' } as ExecutionContext;

/** Tasks grouped by a lookup dimension whose target is `crm_account`. */
const DATASET = DatasetSchema.parse({
name: 'tasks_by_account',
label: 'Tasks by account',
object: 'task',
dimensions: [{ name: 'account', field: 'account', type: 'lookup', label: 'Account' }],
measures: [{ name: 'cnt', aggregate: 'count' }],
});

/**
* Referenced-object fixture rows. `organization_id` is what an ordinary
* tenant scope narrows by; `owner` is what the emptied-membership spellings
* address. `acc2` is the row an ordinary `org_A` scope must NOT surface.
*/
const ACCOUNTS = [
{ id: 'acc1', name: 'Acme Corp', organization_id: 'org_A', owner: 'u_me' },
{ id: 'acc2', name: 'Umbrella Ltd', organization_id: 'org_B', owner: 'u_other' },
];

/** The grouped base aggregate: both FK ids reach the label pass. */
const TASK_ROWS = [
{ account: 'acc1', cnt: 3 },
{ account: 'acc2', cnt: 1 },
];

/**
* A deliberately small filter evaluator for the FIXTURE rows — equality,
* `$in`, `$and`, `$or`. It exists so "an ordinary scope still narrows" and
* "`$in: []` still reduces to zero rows" are read off real returned rows
* rather than off the filter object, which would only echo the assertion.
*
* ⛔ Not an engine-lowering model, and not where a vacating spelling's row
* consequence is established: an unrecognised operator throws rather than
* quietly matching, so a spelling this cannot judge fails loudly instead of
* manufacturing a comfortable answer. The measured lowering table lives in
* `read-scope-vacancy-three-faces.test.ts`, against a real driver.
*/
function matches(row: Record<string, unknown>, filter: unknown): boolean {
if (filter == null) return true;
if (typeof filter !== 'object' || Array.isArray(filter)) {
throw new Error(`[fixture] not a filter node: ${JSON.stringify(filter)}`);
}
return Object.entries(filter as Record<string, unknown>).every(([key, value]) => {
if (key === '$and') return (value as unknown[]).every((n) => matches(row, n));
if (key === '$or') return (value as unknown[]).some((n) => matches(row, n));
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
const ops = Object.entries(value as Record<string, unknown>);
return ops.every(([op, comparand]) => {
if (op === '$in') return (comparand as unknown[]).includes(row[key]);
throw new Error(`[fixture] unsupported operator ${op} — this evaluator judges no spelling it was not written for`);
});
}
return row[key] === value;
});
}

type EngineCall = { object: string; where?: Record<string, unknown> };

function fakePluginContext(services: Record<string, unknown>) {
const registered: Record<string, unknown> = {};
const warn = vi.fn();
return {
registered,
warn,
ctx: {
getService: (name: string) => services[name] ?? registered[name],
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
logger: { info() {}, warn, error() {}, debug() {} },
},
};
}

const objectqlOnly = () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false });

/**
* Drive the label path through the real plugin wiring.
*
* `order` selects WHICH label pass runs: with it, the sort-key pass (#3680)
* resolves labels inside `DatasetExecutor.execute`; without it, only the
* display pass (#3602) does. The two have different refusal dispositions, so
* every case below states which one it is exercising.
*/
async function runLabels(opts: { scope: FilterCondition | undefined; order?: boolean }) {
const seen: EngineCall[] = [];
const engine = {
aggregate: async (object: string, options: Record<string, unknown>) => {
seen.push({ object, where: options.where as Record<string, unknown> | undefined });
if (object === 'task') return TASK_ROWS;
return ACCOUNTS.filter((r) => matches(r, options.where)).map((r) => ({ id: r.id, name: r.name, _c: 1 }));
},
getObject: (name: string) =>
name === 'task'
? { fields: { account: { type: 'lookup', reference: 'crm_account' } } }
: name === 'crm_account'
? { fields: { name: { type: 'text' } } }
: undefined,
};
const { ctx, registered, warn } = fakePluginContext({ data: engine });

await new AnalyticsServicePlugin({
queryCapabilities: objectqlOnly,
getReadScope: (object: string) => (object === 'crm_account' ? opts.scope : undefined),
}).init(ctx as never);

const run = () =>
(registered.analytics as AnalyticsService).queryDataset(
DATASET as never,
{
dimensions: ['account'],
measures: ['cnt'],
...(opts.order ? { order: { account: 'asc' } } : {}),
} as never,
CTX,
);

return { run, seen, warn };
}

/** Did anything read the REFERENCED object? The security question, directly. */
const readReferenced = (seen: EngineCall[]) => seen.filter((c) => c.object === 'crm_account');

/**
* The vacating family, as measured in `read-scope-sql.ts`'s #13640 section:
* every one of these came back with the whole table from a real engine.
* `$nin: []` is refused at any polarity (matching `compileOperator`'s own
* `$nin` arm); the rest are emptied POSITIVE memberships under an odd number
* of negations, which is what makes them vacate.
*/
const VACATING: Array<[string, FilterCondition]> = [
['empty $nin', { owner: { $nin: [] } } as FilterCondition],
['$not over empty $in', { $not: { owner: { $in: [] } } } as FilterCondition],
['$not over a bare empty array', { $not: { owner: [] } } as FilterCondition],
['$not over a multi-key operator object holding an empty $in', { $not: { owner: { $in: [], $ne: 'u_other' } } } as FilterCondition],
['a vacating arm inside an $or', { $or: [{ $not: { owner: { $in: [] } } }, { owner: 'u_me' }] } as FilterCondition],
];

describe('#14329 — a vacating referenced-object scope is refused before the label lookup runs', () => {
it.each(VACATING)('sort-key pass: %s refuses in the sibling envelope', async (_name, scope) => {
const { run, seen } = await runLabels({ scope, order: true });

// ADR-0112 envelope, `code` AND `status` — the same two the three sibling
// faces answer with. A bare `toThrow` would stay green against a driver
// throwing a naked `Error`, which is the failure this assertion exists to
// exclude.
const err = await run().then(
() => { throw new Error('expected a refusal, got a result'); },
(e: unknown) => e as { code?: unknown; status?: unknown; message?: string },
);
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(err.status).toBe(500);
expect(String(err.message)).toContain('read scope for "crm_account"');

// The other half of a refusal pin: the referenced object was NEVER read.
// "It threw" alone does not distinguish a guard from a leak followed by a
// throw — and the leak is precisely a read that happened.
expect(readReferenced(seen)).toEqual([]);
// The base aggregate still ran: the refusal is scoped to the label door.
expect(seen.map((c) => c.object)).toEqual(['task']);
});

it.each(VACATING)('display pass: %s fails closed to raw ids without reading the target', async (_name, scope) => {
const { run, seen, warn } = await runLabels({ scope });

// The display pass has its own catch (analytics-service.ts) that degrades
// to a warn — the #3602 disposition for this surface. So the CALLER sees
// rows, and what matters is that no name was fetched to put in them.
const result = await run() as unknown as { rows: Record<string, unknown>[] };
expect(readReferenced(seen)).toEqual([]);
expect(result.rows.map((r) => r.account)).toEqual(['acc1', 'acc2']);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('dimension label resolution failed'));
});
});

describe('#14329 over-denial controls — the guard refuses ONLY the vacating shapes', () => {
it('an ordinary referenced-object scope still narrows the label lookup', async () => {
const { run, seen } = await runLabels({ scope: { organization_id: 'org_A' } as FilterCondition });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

// Preservation pin — the scope reached the engine `$and`-composed with the
// id filter, never key-merged, so it cannot be displaced by the ids.
const labelCall = readReferenced(seen);
expect(labelCall).toHaveLength(1);
expect(labelCall[0].where).toEqual({
$and: [{ id: { $in: ['acc1', 'acc2'] } }, { organization_id: 'org_A' }],
});

// ...and the NARROWED RESULT SET, not merely "no throw": `acc1` is in the
// tenant and renders its name; `acc2` is out and keeps its raw id, which is
// the whole point of scoping this read.
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'acc2']);
});

it('the `$in: []` zero-rows reduction still yields no labels and no refusal', async () => {
// Positive polarity: the ruled #5322/#5243 reduction to constant FALSE.
// Narrowing at its own arm — the SAFE direction on a read scope — and
// deliberately NOT refused, here or at any sibling door.
const { run, seen } = await runLabels({ scope: { owner: { $in: [] } } as FilterCondition });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(readReferenced(seen)[0].where).toEqual({
$and: [{ id: { $in: ['acc1', 'acc2'] } }, { owner: { $in: [] } }],
});
// Zero rows came back, so no label overwrites a raw id — and no refusal.
expect(result.rows.map((r) => r.account)).toEqual(['acc1', 'acc2']);
});

it('the live #13570 RLS composite keeps own rows flowing', async () => {
// `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }` — an emptied
// membership beside an own-rows grant, which the RLS compiler really emits
// when a membership set resolves empty. Refusing it would 500 every
// analytics query for such a user, the outcome #13571's verdict rejected.
const { run, seen } = await runLabels({
scope: { $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] } as FilterCondition,
});

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'acc2']);
});

it('no scope at all still reads the target, unchanged', async () => {
// The `undefined` arm — "no scope for this object" is a legitimate answer
// from the provider contract, and the guard must not turn it into a
// refusal. Without this case a guard that refused everything would pass
// every refusal assertion above.
const { run, seen } = await runLabels({ scope: undefined });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(readReferenced(seen)[0].where).toEqual({ id: { $in: ['acc1', 'acc2'] } });
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'Umbrella Ltd']);
});
});
25 changes: 25 additions & 0 deletions packages/services/service-analytics/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import { AnalyticsService } from './analytics-service.js';
import type { AnalyticsServiceConfig } from './analytics-service.js';
import type { AnalyticsDriverCapabilities } from './strategies/types.js';
import { pickDisplayField, type DimensionLabelDeps } from './dimension-labels.js';
import { assertReadScopeCannotVacate } from './read-scope-sql.js';

/**
* The slice of the DECLARED engine contracts this plugin's auto-bridges
Expand DownExpand Up@@ -518,6 +519,30 @@ export class AnalyticsServicePlugin implements Plugin {
const map = new Map<unknown, string>();
const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
if (!displayField || !executeAggregate || ids.length === 0) return map;
// [#14329] The FOURTH read-scope door, and the same guard the other
// three answer with. #13640 guarded the ObjectQL engine merge and
// #13926 the echo merge and `NativeSQLStrategy.applyReadScope`; this
// hook is a fourth consumer of the same `readScopeProvider` output
// that meets NEITHER `compileScopedFilterToSql` nor the guard — the
// `$and` below hands the scope straight to `executeAggregate`, so a
// vacating spelling (`$not` over `$in: []` and its measured siblings,
// reachable from any out-of-repo `getReadScope` producer the
// `StrategyContext` spec contract admits) used to let this per-record
// read run effectively unscoped for the ids in hand — leaking exactly
// the display names the referenced object's RLS exists to hide.
//
// Placement mirrors `ObjectQLStrategy.resolveFkAttr`, this hook's
// structural twin (same id-`$in` `$and` scope, same `executeAggregate`,
// guarded since #13640): AFTER the early returns, because a call that
// reads nothing cannot widen anything and refusing it would be pure
// over-denial; and BEFORE the chunk loop, so one scope gets one verdict
// rather than one per 500 ids. The condition is spelled to match the
// composition on the next line exactly, so the set of scopes guarded
// and the set of scopes `$and`-ed are provably the same set.
//
// ⛔ Zero compiler change: the #13571 lowering residue is ruled and
// untouched. This guard is the walk, not the lowering.
if (scope) assertReadScopeCannotVacate(scope, targetObject);
// #3680 — the sort-key pass hands over the PRE-window id set (every
// grouped value, not just the displayed page), so a high-cardinality
// lookup dimension can push thousands of ids through here. Chunk the
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
9 changes: 9 additions & 0 deletions .changeset/tidy-ducks-tickle.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/service-analytics': patch
---

Guard the analytics record-label lookup with `assertReadScopeCannotVacate` — the fourth read-scope door

`AnalyticsServicePlugin`'s `fetchRecordLabels` hook `$and`s the **referenced** object's read scope with an `id $in [...]` filter and hands the result straight to `executeAggregate`. Unlike the three faces unified previously (the ObjectQL engine merge, the `/analytics/sql` echo merge, and `NativeSQLStrategy.applyReadScope`), it met neither `compileScopedFilterToSql` nor the vacancy guard, so a read scope that lowers to a boolean constant — the `$not`-over-`$in: []` family reachable from any out-of-repo `StrategyContext.getReadScope` producer — let that per-record read run effectively unscoped for the ids in hand, surfacing the display names the referenced object's RLS exists to hide.

The hook now calls the already-exported `assertReadScopeCannotVacate` on the referenced object's scope before composing the filter, refusing in the same envelope as its siblings (`READ_SCOPE_COMPILE_FAILED` / 500). No behaviour changes for scopes that bind: an ordinary referenced-object scope still narrows the label lookup, and the `$in: []` zero-rows reduction (including the live RLS composite that pairs it with an own-rows grant) still passes through untouched. The read-scope SQL compiler is unchanged.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14329] The FOURTH read-scope door — `AnalyticsServicePlugin`'s
* `fetchRecordLabels` hook — answers the same verdict as the other three.
*
* #13640 guarded the ObjectQL ENGINE merge and #13926 the `/analytics/sql`
* ECHO merge plus `NativeSQLStrategy.applyReadScope`; the three-faces file
* next door pins those. This hook is a FOURTH consumer of the very same
* `readScopeProvider` output, reached by a different route entirely
* (`AnalyticsService.queryDataset` → `resolveScope` → `dimension-labels.ts` →
* `DimensionLabelDeps.fetchRecordLabels`, the closure `plugin.ts` builds), and
* it met NEITHER `compileScopedFilterToSql` nor `assertReadScopeCannotVacate`:
* it `$and`s the REFERENCED object's scope with `id $in [...]` and hands that
* straight to `executeAggregate`.
*
* So a vacating scope spelling from an out-of-repo `getReadScope` producer
* (`StrategyContext.getReadScope` is a spec contract — that population is
* exactly who this contract exists for, and the one with no producer-side
* #13570 guard) let this per-record read run effectively unscoped for the ids
* in hand, surfacing the display names the referenced object's RLS exists to
* hide. The leak is row-granular by construction: `group by (id, name)` is a
* record read dressed as an aggregate.
*
* ## What is measured here, and what is NOT
*
* These cases drive the REAL plugin wiring — `new AnalyticsServicePlugin(...).init(ctx)`
* — so the closure under test is the one `plugin.ts` actually ships, not a
* stub standing in for it. What they do NOT re-measure is the ENGINE's
* lowering of a vacating scope: that table (which spellings come back with the
* whole table, driven against a real `SqliteWasmDriver`) is
* `read-scope-vacancy-three-faces.test.ts`'s, and re-deriving it here would be
* a second copy of one ruling. The fixture engine below therefore honours the
* filter it is handed by a small, deliberately obvious evaluator — which is
* the right authority for THIS seam's question: *does the hook forward a scope
* that a scope-honouring engine can narrow by, and does it refuse the
* spellings that cannot narrow anything at all?*
*
* ## Two label passes, two DIFFERENT dispositions — both fail closed
*
* A refusal from this hook surfaces differently depending on which of
* `queryDataset`'s two label passes raised it, and both are asserted below
* because a reader who checks only one will conclude the other is unguarded:
*
* - **sort-key pass** (`order` on a lookup dimension, #3680) runs inside
* `DatasetExecutor.execute`, whose catch in `queryDataset` re-throws a
* DECLARED ADR-0112 envelope untouched (`hasDeclaredErrorEnvelope`). The
* refusal reaches the caller as itself — `READ_SCOPE_COMPILE_FAILED` / 500.
* - **display pass** (#3602) is wrapped in its own try/catch that degrades to
* a `warn` and leaves raw ids rendering. That is not this card weakening:
* it is the disposition #3602 already chose for this surface one frame up
* (`dimension-labels.ts` skips a dimension's labels rather than fetch
* unscoped when the scope cannot be resolved), and it is fail-CLOSED — no
* name is fetched, so none can leak.
*
* The security property is therefore identical on both passes and is asserted
* as such: **the referenced object is never read at all**. A bare "it threw"
* would not distinguish that from a read that happened and then threw.
*/

import { describe, it, expect, vi } from 'vitest';
import { DatasetSchema } from '@objectstack/spec/ui';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { FilterCondition } from '@objectstack/spec/data';
import { AnalyticsService } from '../analytics-service.js';
import { AnalyticsServicePlugin } from '../plugin.js';

const CTX = { tenantId: 'org_A', userId: 'u_me' } as ExecutionContext;

/** Tasks grouped by a lookup dimension whose target is `crm_account`. */
const DATASET = DatasetSchema.parse({
name: 'tasks_by_account',
label: 'Tasks by account',
object: 'task',
dimensions: [{ name: 'account', field: 'account', type: 'lookup', label: 'Account' }],
measures: [{ name: 'cnt', aggregate: 'count' }],
});

/**
* Referenced-object fixture rows. `organization_id` is what an ordinary
* tenant scope narrows by; `owner` is what the emptied-membership spellings
* address. `acc2` is the row an ordinary `org_A` scope must NOT surface.
*/
const ACCOUNTS = [
{ id: 'acc1', name: 'Acme Corp', organization_id: 'org_A', owner: 'u_me' },
{ id: 'acc2', name: 'Umbrella Ltd', organization_id: 'org_B', owner: 'u_other' },
];

/** The grouped base aggregate: both FK ids reach the label pass. */
const TASK_ROWS = [
{ account: 'acc1', cnt: 3 },
{ account: 'acc2', cnt: 1 },
];

/**
* A deliberately small filter evaluator for the FIXTURE rows — equality,
* `$in`, `$and`, `$or`. It exists so "an ordinary scope still narrows" and
* "`$in: []` still reduces to zero rows" are read off real returned rows
* rather than off the filter object, which would only echo the assertion.
*
* ⛔ Not an engine-lowering model, and not where a vacating spelling's row
* consequence is established: an unrecognised operator throws rather than
* quietly matching, so a spelling this cannot judge fails loudly instead of
* manufacturing a comfortable answer. The measured lowering table lives in
* `read-scope-vacancy-three-faces.test.ts`, against a real driver.
*/
function matches(row: Record<string, unknown>, filter: unknown): boolean {
if (filter == null) return true;
if (typeof filter !== 'object' || Array.isArray(filter)) {
throw new Error(`[fixture] not a filter node: ${JSON.stringify(filter)}`);
}
return Object.entries(filter as Record<string, unknown>).every(([key, value]) => {
if (key === '$and') return (value as unknown[]).every((n) => matches(row, n));
if (key === '$or') return (value as unknown[]).some((n) => matches(row, n));
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
const ops = Object.entries(value as Record<string, unknown>);
return ops.every(([op, comparand]) => {
if (op === '$in') return (comparand as unknown[]).includes(row[key]);
throw new Error(`[fixture] unsupported operator ${op} — this evaluator judges no spelling it was not written for`);
});
}
return row[key] === value;
});
}

type EngineCall = { object: string; where?: Record<string, unknown> };

function fakePluginContext(services: Record<string, unknown>) {
const registered: Record<string, unknown> = {};
const warn = vi.fn();
return {
registered,
warn,
ctx: {
getService: (name: string) => services[name] ?? registered[name],
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
logger: { info() {}, warn, error() {}, debug() {} },
},
};
}

const objectqlOnly = () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false });

/**
* Drive the label path through the real plugin wiring.
*
* `order` selects WHICH label pass runs: with it, the sort-key pass (#3680)
* resolves labels inside `DatasetExecutor.execute`; without it, only the
* display pass (#3602) does. The two have different refusal dispositions, so
* every case below states which one it is exercising.
*/
async function runLabels(opts: { scope: FilterCondition | undefined; order?: boolean }) {
const seen: EngineCall[] = [];
const engine = {
aggregate: async (object: string, options: Record<string, unknown>) => {
seen.push({ object, where: options.where as Record<string, unknown> | undefined });
if (object === 'task') return TASK_ROWS;
return ACCOUNTS.filter((r) => matches(r, options.where)).map((r) => ({ id: r.id, name: r.name, _c: 1 }));
},
getObject: (name: string) =>
name === 'task'
? { fields: { account: { type: 'lookup', reference: 'crm_account' } } }
: name === 'crm_account'
? { fields: { name: { type: 'text' } } }
: undefined,
};
const { ctx, registered, warn } = fakePluginContext({ data: engine });

await new AnalyticsServicePlugin({
queryCapabilities: objectqlOnly,
getReadScope: (object: string) => (object === 'crm_account' ? opts.scope : undefined),
}).init(ctx as never);

const run = () =>
(registered.analytics as AnalyticsService).queryDataset(
DATASET as never,
{
dimensions: ['account'],
measures: ['cnt'],
...(opts.order ? { order: { account: 'asc' } } : {}),
} as never,
CTX,
);

return { run, seen, warn };
}

/** Did anything read the REFERENCED object? The security question, directly. */
const readReferenced = (seen: EngineCall[]) => seen.filter((c) => c.object === 'crm_account');

/**
* The vacating family, as measured in `read-scope-sql.ts`'s #13640 section:
* every one of these came back with the whole table from a real engine.
* `$nin: []` is refused at any polarity (matching `compileOperator`'s own
* `$nin` arm); the rest are emptied POSITIVE memberships under an odd number
* of negations, which is what makes them vacate.
*/
const VACATING: Array<[string, FilterCondition]> = [
['empty $nin', { owner: { $nin: [] } } as FilterCondition],
['$not over empty $in', { $not: { owner: { $in: [] } } } as FilterCondition],
['$not over a bare empty array', { $not: { owner: [] } } as FilterCondition],
['$not over a multi-key operator object holding an empty $in', { $not: { owner: { $in: [], $ne: 'u_other' } } } as FilterCondition],
['a vacating arm inside an $or', { $or: [{ $not: { owner: { $in: [] } } }, { owner: 'u_me' }] } as FilterCondition],
];

describe('#14329 — a vacating referenced-object scope is refused before the label lookup runs', () => {
it.each(VACATING)('sort-key pass: %s refuses in the sibling envelope', async (_name, scope) => {
const { run, seen } = await runLabels({ scope, order: true });

// ADR-0112 envelope, `code` AND `status` — the same two the three sibling
// faces answer with. A bare `toThrow` would stay green against a driver
// throwing a naked `Error`, which is the failure this assertion exists to
// exclude.
const err = await run().then(
() => { throw new Error('expected a refusal, got a result'); },
(e: unknown) => e as { code?: unknown; status?: unknown; message?: string },
);
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(err.status).toBe(500);
expect(String(err.message)).toContain('read scope for "crm_account"');

// The other half of a refusal pin: the referenced object was NEVER read.
// "It threw" alone does not distinguish a guard from a leak followed by a
// throw — and the leak is precisely a read that happened.
expect(readReferenced(seen)).toEqual([]);
// The base aggregate still ran: the refusal is scoped to the label door.
expect(seen.map((c) => c.object)).toEqual(['task']);
});

it.each(VACATING)('display pass: %s fails closed to raw ids without reading the target', async (_name, scope) => {
const { run, seen, warn } = await runLabels({ scope });

// The display pass has its own catch (analytics-service.ts) that degrades
// to a warn — the #3602 disposition for this surface. So the CALLER sees
// rows, and what matters is that no name was fetched to put in them.
const result = await run() as unknown as { rows: Record<string, unknown>[] };
expect(readReferenced(seen)).toEqual([]);
expect(result.rows.map((r) => r.account)).toEqual(['acc1', 'acc2']);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('dimension label resolution failed'));
});
});

describe('#14329 over-denial controls — the guard refuses ONLY the vacating shapes', () => {
it('an ordinary referenced-object scope still narrows the label lookup', async () => {
const { run, seen } = await runLabels({ scope: { organization_id: 'org_A' } as FilterCondition });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

// Preservation pin — the scope reached the engine `$and`-composed with the
// id filter, never key-merged, so it cannot be displaced by the ids.
const labelCall = readReferenced(seen);
expect(labelCall).toHaveLength(1);
expect(labelCall[0].where).toEqual({
$and: [{ id: { $in: ['acc1', 'acc2'] } }, { organization_id: 'org_A' }],
});

// ...and the NARROWED RESULT SET, not merely "no throw": `acc1` is in the
// tenant and renders its name; `acc2` is out and keeps its raw id, which is
// the whole point of scoping this read.
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'acc2']);
});

it('the `$in: []` zero-rows reduction still yields no labels and no refusal', async () => {
// Positive polarity: the ruled #5322/#5243 reduction to constant FALSE.
// Narrowing at its own arm — the SAFE direction on a read scope — and
// deliberately NOT refused, here or at any sibling door.
const { run, seen } = await runLabels({ scope: { owner: { $in: [] } } as FilterCondition });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(readReferenced(seen)[0].where).toEqual({
$and: [{ id: { $in: ['acc1', 'acc2'] } }, { owner: { $in: [] } }],
});
// Zero rows came back, so no label overwrites a raw id — and no refusal.
expect(result.rows.map((r) => r.account)).toEqual(['acc1', 'acc2']);
});

it('the live #13570 RLS composite keeps own rows flowing', async () => {
// `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }` — an emptied
// membership beside an own-rows grant, which the RLS compiler really emits
// when a membership set resolves empty. Refusing it would 500 every
// analytics query for such a user, the outcome #13571's verdict rejected.
const { run, seen } = await runLabels({
scope: { $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] } as FilterCondition,
});

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'acc2']);
});

it('no scope at all still reads the target, unchanged', async () => {
// The `undefined` arm — "no scope for this object" is a legitimate answer
// from the provider contract, and the guard must not turn it into a
// refusal. Without this case a guard that refused everything would pass
// every refusal assertion above.
const { run, seen } = await runLabels({ scope: undefined });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(readReferenced(seen)[0].where).toEqual({ id: { $in: ['acc1', 'acc2'] } });
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'Umbrella Ltd']);
});
});
25 changes: 25 additions & 0 deletions packages/services/service-analytics/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import { AnalyticsService } from './analytics-service.js';
import type { AnalyticsServiceConfig } from './analytics-service.js';
import type { AnalyticsDriverCapabilities } from './strategies/types.js';
import { pickDisplayField, type DimensionLabelDeps } from './dimension-labels.js';
import { assertReadScopeCannotVacate } from './read-scope-sql.js';

/**
* The slice of the DECLARED engine contracts this plugin's auto-bridges
Expand DownExpand Up@@ -518,6 +519,30 @@ export class AnalyticsServicePlugin implements Plugin {
const map = new Map<unknown, string>();
const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
if (!displayField || !executeAggregate || ids.length === 0) return map;
// [#14329] The FOURTH read-scope door, and the same guard the other
// three answer with. #13640 guarded the ObjectQL engine merge and
// #13926 the echo merge and `NativeSQLStrategy.applyReadScope`; this
// hook is a fourth consumer of the same `readScopeProvider` output
// that meets NEITHER `compileScopedFilterToSql` nor the guard — the
// `$and` below hands the scope straight to `executeAggregate`, so a
// vacating spelling (`$not` over `$in: []` and its measured siblings,
// reachable from any out-of-repo `getReadScope` producer the
// `StrategyContext` spec contract admits) used to let this per-record
// read run effectively unscoped for the ids in hand — leaking exactly
// the display names the referenced object's RLS exists to hide.
//
// Placement mirrors `ObjectQLStrategy.resolveFkAttr`, this hook's
// structural twin (same id-`$in` `$and` scope, same `executeAggregate`,
// guarded since #13640): AFTER the early returns, because a call that
// reads nothing cannot widen anything and refusing it would be pure
// over-denial; and BEFORE the chunk loop, so one scope gets one verdict
// rather than one per 500 ids. The condition is spelled to match the
// composition on the next line exactly, so the set of scopes guarded
// and the set of scopes `$and`-ed are provably the same set.
//
// ⛔ Zero compiler change: the #13571 lowering residue is ruled and
// untouched. This guard is the walk, not the lowering.
if (scope) assertReadScopeCannotVacate(scope, targetObject);
// #3680 — the sort-key pass hands over the PRE-window id set (every
// grouped value, not just the displayed page), so a high-cardinality
// lookup dimension can push thousands of ids through here. Chunk the
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
9 changes: 9 additions & 0 deletions .changeset/tidy-ducks-tickle.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/service-analytics': patch
---

Guard the analytics record-label lookup with `assertReadScopeCannotVacate` — the fourth read-scope door

`AnalyticsServicePlugin`'s `fetchRecordLabels` hook `$and`s the **referenced** object's read scope with an `id $in [...]` filter and hands the result straight to `executeAggregate`. Unlike the three faces unified previously (the ObjectQL engine merge, the `/analytics/sql` echo merge, and `NativeSQLStrategy.applyReadScope`), it met neither `compileScopedFilterToSql` nor the vacancy guard, so a read scope that lowers to a boolean constant — the `$not`-over-`$in: []` family reachable from any out-of-repo `StrategyContext.getReadScope` producer — let that per-record read run effectively unscoped for the ids in hand, surfacing the display names the referenced object's RLS exists to hide.

The hook now calls the already-exported `assertReadScopeCannotVacate` on the referenced object's scope before composing the filter, refusing in the same envelope as its siblings (`READ_SCOPE_COMPILE_FAILED` / 500). No behaviour changes for scopes that bind: an ordinary referenced-object scope still narrows the label lookup, and the `$in: []` zero-rows reduction (including the live RLS composite that pairs it with an own-rows grant) still passes through untouched. The read-scope SQL compiler is unchanged.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14329] The FOURTH read-scope door — `AnalyticsServicePlugin`'s
* `fetchRecordLabels` hook — answers the same verdict as the other three.
*
* #13640 guarded the ObjectQL ENGINE merge and #13926 the `/analytics/sql`
* ECHO merge plus `NativeSQLStrategy.applyReadScope`; the three-faces file
* next door pins those. This hook is a FOURTH consumer of the very same
* `readScopeProvider` output, reached by a different route entirely
* (`AnalyticsService.queryDataset` → `resolveScope` → `dimension-labels.ts` →
* `DimensionLabelDeps.fetchRecordLabels`, the closure `plugin.ts` builds), and
* it met NEITHER `compileScopedFilterToSql` nor `assertReadScopeCannotVacate`:
* it `$and`s the REFERENCED object's scope with `id $in [...]` and hands that
* straight to `executeAggregate`.
*
* So a vacating scope spelling from an out-of-repo `getReadScope` producer
* (`StrategyContext.getReadScope` is a spec contract — that population is
* exactly who this contract exists for, and the one with no producer-side
* #13570 guard) let this per-record read run effectively unscoped for the ids
* in hand, surfacing the display names the referenced object's RLS exists to
* hide. The leak is row-granular by construction: `group by (id, name)` is a
* record read dressed as an aggregate.
*
* ## What is measured here, and what is NOT
*
* These cases drive the REAL plugin wiring — `new AnalyticsServicePlugin(...).init(ctx)`
* — so the closure under test is the one `plugin.ts` actually ships, not a
* stub standing in for it. What they do NOT re-measure is the ENGINE's
* lowering of a vacating scope: that table (which spellings come back with the
* whole table, driven against a real `SqliteWasmDriver`) is
* `read-scope-vacancy-three-faces.test.ts`'s, and re-deriving it here would be
* a second copy of one ruling. The fixture engine below therefore honours the
* filter it is handed by a small, deliberately obvious evaluator — which is
* the right authority for THIS seam's question: *does the hook forward a scope
* that a scope-honouring engine can narrow by, and does it refuse the
* spellings that cannot narrow anything at all?*
*
* ## Two label passes, two DIFFERENT dispositions — both fail closed
*
* A refusal from this hook surfaces differently depending on which of
* `queryDataset`'s two label passes raised it, and both are asserted below
* because a reader who checks only one will conclude the other is unguarded:
*
* - **sort-key pass** (`order` on a lookup dimension, #3680) runs inside
* `DatasetExecutor.execute`, whose catch in `queryDataset` re-throws a
* DECLARED ADR-0112 envelope untouched (`hasDeclaredErrorEnvelope`). The
* refusal reaches the caller as itself — `READ_SCOPE_COMPILE_FAILED` / 500.
* - **display pass** (#3602) is wrapped in its own try/catch that degrades to
* a `warn` and leaves raw ids rendering. That is not this card weakening:
* it is the disposition #3602 already chose for this surface one frame up
* (`dimension-labels.ts` skips a dimension's labels rather than fetch
* unscoped when the scope cannot be resolved), and it is fail-CLOSED — no
* name is fetched, so none can leak.
*
* The security property is therefore identical on both passes and is asserted
* as such: **the referenced object is never read at all**. A bare "it threw"
* would not distinguish that from a read that happened and then threw.
*/

import { describe, it, expect, vi } from 'vitest';
import { DatasetSchema } from '@objectstack/spec/ui';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { FilterCondition } from '@objectstack/spec/data';
import { AnalyticsService } from '../analytics-service.js';
import { AnalyticsServicePlugin } from '../plugin.js';

const CTX = { tenantId: 'org_A', userId: 'u_me' } as ExecutionContext;

/** Tasks grouped by a lookup dimension whose target is `crm_account`. */
const DATASET = DatasetSchema.parse({
name: 'tasks_by_account',
label: 'Tasks by account',
object: 'task',
dimensions: [{ name: 'account', field: 'account', type: 'lookup', label: 'Account' }],
measures: [{ name: 'cnt', aggregate: 'count' }],
});

/**
* Referenced-object fixture rows. `organization_id` is what an ordinary
* tenant scope narrows by; `owner` is what the emptied-membership spellings
* address. `acc2` is the row an ordinary `org_A` scope must NOT surface.
*/
const ACCOUNTS = [
{ id: 'acc1', name: 'Acme Corp', organization_id: 'org_A', owner: 'u_me' },
{ id: 'acc2', name: 'Umbrella Ltd', organization_id: 'org_B', owner: 'u_other' },
];

/** The grouped base aggregate: both FK ids reach the label pass. */
const TASK_ROWS = [
{ account: 'acc1', cnt: 3 },
{ account: 'acc2', cnt: 1 },
];

/**
* A deliberately small filter evaluator for the FIXTURE rows — equality,
* `$in`, `$and`, `$or`. It exists so "an ordinary scope still narrows" and
* "`$in: []` still reduces to zero rows" are read off real returned rows
* rather than off the filter object, which would only echo the assertion.
*
* ⛔ Not an engine-lowering model, and not where a vacating spelling's row
* consequence is established: an unrecognised operator throws rather than
* quietly matching, so a spelling this cannot judge fails loudly instead of
* manufacturing a comfortable answer. The measured lowering table lives in
* `read-scope-vacancy-three-faces.test.ts`, against a real driver.
*/
function matches(row: Record<string, unknown>, filter: unknown): boolean {
if (filter == null) return true;
if (typeof filter !== 'object' || Array.isArray(filter)) {
throw new Error(`[fixture] not a filter node: ${JSON.stringify(filter)}`);
}
return Object.entries(filter as Record<string, unknown>).every(([key, value]) => {
if (key === '$and') return (value as unknown[]).every((n) => matches(row, n));
if (key === '$or') return (value as unknown[]).some((n) => matches(row, n));
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
const ops = Object.entries(value as Record<string, unknown>);
return ops.every(([op, comparand]) => {
if (op === '$in') return (comparand as unknown[]).includes(row[key]);
throw new Error(`[fixture] unsupported operator ${op} — this evaluator judges no spelling it was not written for`);
});
}
return row[key] === value;
});
}

type EngineCall = { object: string; where?: Record<string, unknown> };

function fakePluginContext(services: Record<string, unknown>) {
const registered: Record<string, unknown> = {};
const warn = vi.fn();
return {
registered,
warn,
ctx: {
getService: (name: string) => services[name] ?? registered[name],
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
logger: { info() {}, warn, error() {}, debug() {} },
},
};
}

const objectqlOnly = () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false });

/**
* Drive the label path through the real plugin wiring.
*
* `order` selects WHICH label pass runs: with it, the sort-key pass (#3680)
* resolves labels inside `DatasetExecutor.execute`; without it, only the
* display pass (#3602) does. The two have different refusal dispositions, so
* every case below states which one it is exercising.
*/
async function runLabels(opts: { scope: FilterCondition | undefined; order?: boolean }) {
const seen: EngineCall[] = [];
const engine = {
aggregate: async (object: string, options: Record<string, unknown>) => {
seen.push({ object, where: options.where as Record<string, unknown> | undefined });
if (object === 'task') return TASK_ROWS;
return ACCOUNTS.filter((r) => matches(r, options.where)).map((r) => ({ id: r.id, name: r.name, _c: 1 }));
},
getObject: (name: string) =>
name === 'task'
? { fields: { account: { type: 'lookup', reference: 'crm_account' } } }
: name === 'crm_account'
? { fields: { name: { type: 'text' } } }
: undefined,
};
const { ctx, registered, warn } = fakePluginContext({ data: engine });

await new AnalyticsServicePlugin({
queryCapabilities: objectqlOnly,
getReadScope: (object: string) => (object === 'crm_account' ? opts.scope : undefined),
}).init(ctx as never);

const run = () =>
(registered.analytics as AnalyticsService).queryDataset(
DATASET as never,
{
dimensions: ['account'],
measures: ['cnt'],
...(opts.order ? { order: { account: 'asc' } } : {}),
} as never,
CTX,
);

return { run, seen, warn };
}

/** Did anything read the REFERENCED object? The security question, directly. */
const readReferenced = (seen: EngineCall[]) => seen.filter((c) => c.object === 'crm_account');

/**
* The vacating family, as measured in `read-scope-sql.ts`'s #13640 section:
* every one of these came back with the whole table from a real engine.
* `$nin: []` is refused at any polarity (matching `compileOperator`'s own
* `$nin` arm); the rest are emptied POSITIVE memberships under an odd number
* of negations, which is what makes them vacate.
*/
const VACATING: Array<[string, FilterCondition]> = [
['empty $nin', { owner: { $nin: [] } } as FilterCondition],
['$not over empty $in', { $not: { owner: { $in: [] } } } as FilterCondition],
['$not over a bare empty array', { $not: { owner: [] } } as FilterCondition],
['$not over a multi-key operator object holding an empty $in', { $not: { owner: { $in: [], $ne: 'u_other' } } } as FilterCondition],
['a vacating arm inside an $or', { $or: [{ $not: { owner: { $in: [] } } }, { owner: 'u_me' }] } as FilterCondition],
];

describe('#14329 — a vacating referenced-object scope is refused before the label lookup runs', () => {
it.each(VACATING)('sort-key pass: %s refuses in the sibling envelope', async (_name, scope) => {
const { run, seen } = await runLabels({ scope, order: true });

// ADR-0112 envelope, `code` AND `status` — the same two the three sibling
// faces answer with. A bare `toThrow` would stay green against a driver
// throwing a naked `Error`, which is the failure this assertion exists to
// exclude.
const err = await run().then(
() => { throw new Error('expected a refusal, got a result'); },
(e: unknown) => e as { code?: unknown; status?: unknown; message?: string },
);
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(err.status).toBe(500);
expect(String(err.message)).toContain('read scope for "crm_account"');

// The other half of a refusal pin: the referenced object was NEVER read.
// "It threw" alone does not distinguish a guard from a leak followed by a
// throw — and the leak is precisely a read that happened.
expect(readReferenced(seen)).toEqual([]);
// The base aggregate still ran: the refusal is scoped to the label door.
expect(seen.map((c) => c.object)).toEqual(['task']);
});

it.each(VACATING)('display pass: %s fails closed to raw ids without reading the target', async (_name, scope) => {
const { run, seen, warn } = await runLabels({ scope });

// The display pass has its own catch (analytics-service.ts) that degrades
// to a warn — the #3602 disposition for this surface. So the CALLER sees
// rows, and what matters is that no name was fetched to put in them.
const result = await run() as unknown as { rows: Record<string, unknown>[] };
expect(readReferenced(seen)).toEqual([]);
expect(result.rows.map((r) => r.account)).toEqual(['acc1', 'acc2']);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('dimension label resolution failed'));
});
});

describe('#14329 over-denial controls — the guard refuses ONLY the vacating shapes', () => {
it('an ordinary referenced-object scope still narrows the label lookup', async () => {
const { run, seen } = await runLabels({ scope: { organization_id: 'org_A' } as FilterCondition });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

// Preservation pin — the scope reached the engine `$and`-composed with the
// id filter, never key-merged, so it cannot be displaced by the ids.
const labelCall = readReferenced(seen);
expect(labelCall).toHaveLength(1);
expect(labelCall[0].where).toEqual({
$and: [{ id: { $in: ['acc1', 'acc2'] } }, { organization_id: 'org_A' }],
});

// ...and the NARROWED RESULT SET, not merely "no throw": `acc1` is in the
// tenant and renders its name; `acc2` is out and keeps its raw id, which is
// the whole point of scoping this read.
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'acc2']);
});

it('the `$in: []` zero-rows reduction still yields no labels and no refusal', async () => {
// Positive polarity: the ruled #5322/#5243 reduction to constant FALSE.
// Narrowing at its own arm — the SAFE direction on a read scope — and
// deliberately NOT refused, here or at any sibling door.
const { run, seen } = await runLabels({ scope: { owner: { $in: [] } } as FilterCondition });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(readReferenced(seen)[0].where).toEqual({
$and: [{ id: { $in: ['acc1', 'acc2'] } }, { owner: { $in: [] } }],
});
// Zero rows came back, so no label overwrites a raw id — and no refusal.
expect(result.rows.map((r) => r.account)).toEqual(['acc1', 'acc2']);
});

it('the live #13570 RLS composite keeps own rows flowing', async () => {
// `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }` — an emptied
// membership beside an own-rows grant, which the RLS compiler really emits
// when a membership set resolves empty. Refusing it would 500 every
// analytics query for such a user, the outcome #13571's verdict rejected.
const { run, seen } = await runLabels({
scope: { $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] } as FilterCondition,
});

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'acc2']);
});

it('no scope at all still reads the target, unchanged', async () => {
// The `undefined` arm — "no scope for this object" is a legitimate answer
// from the provider contract, and the guard must not turn it into a
// refusal. Without this case a guard that refused everything would pass
// every refusal assertion above.
const { run, seen } = await runLabels({ scope: undefined });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(readReferenced(seen)[0].where).toEqual({ id: { $in: ['acc1', 'acc2'] } });
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'Umbrella Ltd']);
});
});
25 changes: 25 additions & 0 deletions packages/services/service-analytics/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import { AnalyticsService } from './analytics-service.js';
import type { AnalyticsServiceConfig } from './analytics-service.js';
import type { AnalyticsDriverCapabilities } from './strategies/types.js';
import { pickDisplayField, type DimensionLabelDeps } from './dimension-labels.js';
import { assertReadScopeCannotVacate } from './read-scope-sql.js';

/**
* The slice of the DECLARED engine contracts this plugin's auto-bridges
Expand DownExpand Up@@ -518,6 +519,30 @@ export class AnalyticsServicePlugin implements Plugin {
const map = new Map<unknown, string>();
const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
if (!displayField || !executeAggregate || ids.length === 0) return map;
// [#14329] The FOURTH read-scope door, and the same guard the other
// three answer with. #13640 guarded the ObjectQL engine merge and
// #13926 the echo merge and `NativeSQLStrategy.applyReadScope`; this
// hook is a fourth consumer of the same `readScopeProvider` output
// that meets NEITHER `compileScopedFilterToSql` nor the guard — the
// `$and` below hands the scope straight to `executeAggregate`, so a
// vacating spelling (`$not` over `$in: []` and its measured siblings,
// reachable from any out-of-repo `getReadScope` producer the
// `StrategyContext` spec contract admits) used to let this per-record
// read run effectively unscoped for the ids in hand — leaking exactly
// the display names the referenced object's RLS exists to hide.
//
// Placement mirrors `ObjectQLStrategy.resolveFkAttr`, this hook's
// structural twin (same id-`$in` `$and` scope, same `executeAggregate`,
// guarded since #13640): AFTER the early returns, because a call that
// reads nothing cannot widen anything and refusing it would be pure
// over-denial; and BEFORE the chunk loop, so one scope gets one verdict
// rather than one per 500 ids. The condition is spelled to match the
// composition on the next line exactly, so the set of scopes guarded
// and the set of scopes `$and`-ed are provably the same set.
//
// ⛔ Zero compiler change: the #13571 lowering residue is ruled and
// untouched. This guard is the walk, not the lowering.
if (scope) assertReadScopeCannotVacate(scope, targetObject);
// #3680 — the sort-key pass hands over the PRE-window id set (every
// grouped value, not just the displayed page), so a high-cardinality
// lookup dimension can push thousands of ids through here. Chunk the
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
9 changes: 9 additions & 0 deletions .changeset/tidy-ducks-tickle.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/service-analytics': patch
---

Guard the analytics record-label lookup with `assertReadScopeCannotVacate` — the fourth read-scope door

`AnalyticsServicePlugin`'s `fetchRecordLabels` hook `$and`s the **referenced** object's read scope with an `id $in [...]` filter and hands the result straight to `executeAggregate`. Unlike the three faces unified previously (the ObjectQL engine merge, the `/analytics/sql` echo merge, and `NativeSQLStrategy.applyReadScope`), it met neither `compileScopedFilterToSql` nor the vacancy guard, so a read scope that lowers to a boolean constant — the `$not`-over-`$in: []` family reachable from any out-of-repo `StrategyContext.getReadScope` producer — let that per-record read run effectively unscoped for the ids in hand, surfacing the display names the referenced object's RLS exists to hide.

The hook now calls the already-exported `assertReadScopeCannotVacate` on the referenced object's scope before composing the filter, refusing in the same envelope as its siblings (`READ_SCOPE_COMPILE_FAILED` / 500). No behaviour changes for scopes that bind: an ordinary referenced-object scope still narrows the label lookup, and the `$in: []` zero-rows reduction (including the live RLS composite that pairs it with an own-rows grant) still passes through untouched. The read-scope SQL compiler is unchanged.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14329] The FOURTH read-scope door — `AnalyticsServicePlugin`'s
* `fetchRecordLabels` hook — answers the same verdict as the other three.
*
* #13640 guarded the ObjectQL ENGINE merge and #13926 the `/analytics/sql`
* ECHO merge plus `NativeSQLStrategy.applyReadScope`; the three-faces file
* next door pins those. This hook is a FOURTH consumer of the very same
* `readScopeProvider` output, reached by a different route entirely
* (`AnalyticsService.queryDataset` → `resolveScope` → `dimension-labels.ts` →
* `DimensionLabelDeps.fetchRecordLabels`, the closure `plugin.ts` builds), and
* it met NEITHER `compileScopedFilterToSql` nor `assertReadScopeCannotVacate`:
* it `$and`s the REFERENCED object's scope with `id $in [...]` and hands that
* straight to `executeAggregate`.
*
* So a vacating scope spelling from an out-of-repo `getReadScope` producer
* (`StrategyContext.getReadScope` is a spec contract — that population is
* exactly who this contract exists for, and the one with no producer-side
* #13570 guard) let this per-record read run effectively unscoped for the ids
* in hand, surfacing the display names the referenced object's RLS exists to
* hide. The leak is row-granular by construction: `group by (id, name)` is a
* record read dressed as an aggregate.
*
* ## What is measured here, and what is NOT
*
* These cases drive the REAL plugin wiring — `new AnalyticsServicePlugin(...).init(ctx)`
* — so the closure under test is the one `plugin.ts` actually ships, not a
* stub standing in for it. What they do NOT re-measure is the ENGINE's
* lowering of a vacating scope: that table (which spellings come back with the
* whole table, driven against a real `SqliteWasmDriver`) is
* `read-scope-vacancy-three-faces.test.ts`'s, and re-deriving it here would be
* a second copy of one ruling. The fixture engine below therefore honours the
* filter it is handed by a small, deliberately obvious evaluator — which is
* the right authority for THIS seam's question: *does the hook forward a scope
* that a scope-honouring engine can narrow by, and does it refuse the
* spellings that cannot narrow anything at all?*
*
* ## Two label passes, two DIFFERENT dispositions — both fail closed
*
* A refusal from this hook surfaces differently depending on which of
* `queryDataset`'s two label passes raised it, and both are asserted below
* because a reader who checks only one will conclude the other is unguarded:
*
* - **sort-key pass** (`order` on a lookup dimension, #3680) runs inside
* `DatasetExecutor.execute`, whose catch in `queryDataset` re-throws a
* DECLARED ADR-0112 envelope untouched (`hasDeclaredErrorEnvelope`). The
* refusal reaches the caller as itself — `READ_SCOPE_COMPILE_FAILED` / 500.
* - **display pass** (#3602) is wrapped in its own try/catch that degrades to
* a `warn` and leaves raw ids rendering. That is not this card weakening:
* it is the disposition #3602 already chose for this surface one frame up
* (`dimension-labels.ts` skips a dimension's labels rather than fetch
* unscoped when the scope cannot be resolved), and it is fail-CLOSED — no
* name is fetched, so none can leak.
*
* The security property is therefore identical on both passes and is asserted
* as such: **the referenced object is never read at all**. A bare "it threw"
* would not distinguish that from a read that happened and then threw.
*/

import { describe, it, expect, vi } from 'vitest';
import { DatasetSchema } from '@objectstack/spec/ui';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { FilterCondition } from '@objectstack/spec/data';
import { AnalyticsService } from '../analytics-service.js';
import { AnalyticsServicePlugin } from '../plugin.js';

const CTX = { tenantId: 'org_A', userId: 'u_me' } as ExecutionContext;

/** Tasks grouped by a lookup dimension whose target is `crm_account`. */
const DATASET = DatasetSchema.parse({
name: 'tasks_by_account',
label: 'Tasks by account',
object: 'task',
dimensions: [{ name: 'account', field: 'account', type: 'lookup', label: 'Account' }],
measures: [{ name: 'cnt', aggregate: 'count' }],
});

/**
* Referenced-object fixture rows. `organization_id` is what an ordinary
* tenant scope narrows by; `owner` is what the emptied-membership spellings
* address. `acc2` is the row an ordinary `org_A` scope must NOT surface.
*/
const ACCOUNTS = [
{ id: 'acc1', name: 'Acme Corp', organization_id: 'org_A', owner: 'u_me' },
{ id: 'acc2', name: 'Umbrella Ltd', organization_id: 'org_B', owner: 'u_other' },
];

/** The grouped base aggregate: both FK ids reach the label pass. */
const TASK_ROWS = [
{ account: 'acc1', cnt: 3 },
{ account: 'acc2', cnt: 1 },
];

/**
* A deliberately small filter evaluator for the FIXTURE rows — equality,
* `$in`, `$and`, `$or`. It exists so "an ordinary scope still narrows" and
* "`$in: []` still reduces to zero rows" are read off real returned rows
* rather than off the filter object, which would only echo the assertion.
*
* ⛔ Not an engine-lowering model, and not where a vacating spelling's row
* consequence is established: an unrecognised operator throws rather than
* quietly matching, so a spelling this cannot judge fails loudly instead of
* manufacturing a comfortable answer. The measured lowering table lives in
* `read-scope-vacancy-three-faces.test.ts`, against a real driver.
*/
function matches(row: Record<string, unknown>, filter: unknown): boolean {
if (filter == null) return true;
if (typeof filter !== 'object' || Array.isArray(filter)) {
throw new Error(`[fixture] not a filter node: ${JSON.stringify(filter)}`);
}
return Object.entries(filter as Record<string, unknown>).every(([key, value]) => {
if (key === '$and') return (value as unknown[]).every((n) => matches(row, n));
if (key === '$or') return (value as unknown[]).some((n) => matches(row, n));
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
const ops = Object.entries(value as Record<string, unknown>);
return ops.every(([op, comparand]) => {
if (op === '$in') return (comparand as unknown[]).includes(row[key]);
throw new Error(`[fixture] unsupported operator ${op} — this evaluator judges no spelling it was not written for`);
});
}
return row[key] === value;
});
}

type EngineCall = { object: string; where?: Record<string, unknown> };

function fakePluginContext(services: Record<string, unknown>) {
const registered: Record<string, unknown> = {};
const warn = vi.fn();
return {
registered,
warn,
ctx: {
getService: (name: string) => services[name] ?? registered[name],
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
logger: { info() {}, warn, error() {}, debug() {} },
},
};
}

const objectqlOnly = () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false });

/**
* Drive the label path through the real plugin wiring.
*
* `order` selects WHICH label pass runs: with it, the sort-key pass (#3680)
* resolves labels inside `DatasetExecutor.execute`; without it, only the
* display pass (#3602) does. The two have different refusal dispositions, so
* every case below states which one it is exercising.
*/
async function runLabels(opts: { scope: FilterCondition | undefined; order?: boolean }) {
const seen: EngineCall[] = [];
const engine = {
aggregate: async (object: string, options: Record<string, unknown>) => {
seen.push({ object, where: options.where as Record<string, unknown> | undefined });
if (object === 'task') return TASK_ROWS;
return ACCOUNTS.filter((r) => matches(r, options.where)).map((r) => ({ id: r.id, name: r.name, _c: 1 }));
},
getObject: (name: string) =>
name === 'task'
? { fields: { account: { type: 'lookup', reference: 'crm_account' } } }
: name === 'crm_account'
? { fields: { name: { type: 'text' } } }
: undefined,
};
const { ctx, registered, warn } = fakePluginContext({ data: engine });

await new AnalyticsServicePlugin({
queryCapabilities: objectqlOnly,
getReadScope: (object: string) => (object === 'crm_account' ? opts.scope : undefined),
}).init(ctx as never);

const run = () =>
(registered.analytics as AnalyticsService).queryDataset(
DATASET as never,
{
dimensions: ['account'],
measures: ['cnt'],
...(opts.order ? { order: { account: 'asc' } } : {}),
} as never,
CTX,
);

return { run, seen, warn };
}

/** Did anything read the REFERENCED object? The security question, directly. */
const readReferenced = (seen: EngineCall[]) => seen.filter((c) => c.object === 'crm_account');

/**
* The vacating family, as measured in `read-scope-sql.ts`'s #13640 section:
* every one of these came back with the whole table from a real engine.
* `$nin: []` is refused at any polarity (matching `compileOperator`'s own
* `$nin` arm); the rest are emptied POSITIVE memberships under an odd number
* of negations, which is what makes them vacate.
*/
const VACATING: Array<[string, FilterCondition]> = [
['empty $nin', { owner: { $nin: [] } } as FilterCondition],
['$not over empty $in', { $not: { owner: { $in: [] } } } as FilterCondition],
['$not over a bare empty array', { $not: { owner: [] } } as FilterCondition],
['$not over a multi-key operator object holding an empty $in', { $not: { owner: { $in: [], $ne: 'u_other' } } } as FilterCondition],
['a vacating arm inside an $or', { $or: [{ $not: { owner: { $in: [] } } }, { owner: 'u_me' }] } as FilterCondition],
];

describe('#14329 — a vacating referenced-object scope is refused before the label lookup runs', () => {
it.each(VACATING)('sort-key pass: %s refuses in the sibling envelope', async (_name, scope) => {
const { run, seen } = await runLabels({ scope, order: true });

// ADR-0112 envelope, `code` AND `status` — the same two the three sibling
// faces answer with. A bare `toThrow` would stay green against a driver
// throwing a naked `Error`, which is the failure this assertion exists to
// exclude.
const err = await run().then(
() => { throw new Error('expected a refusal, got a result'); },
(e: unknown) => e as { code?: unknown; status?: unknown; message?: string },
);
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(err.status).toBe(500);
expect(String(err.message)).toContain('read scope for "crm_account"');

// The other half of a refusal pin: the referenced object was NEVER read.
// "It threw" alone does not distinguish a guard from a leak followed by a
// throw — and the leak is precisely a read that happened.
expect(readReferenced(seen)).toEqual([]);
// The base aggregate still ran: the refusal is scoped to the label door.
expect(seen.map((c) => c.object)).toEqual(['task']);
});

it.each(VACATING)('display pass: %s fails closed to raw ids without reading the target', async (_name, scope) => {
const { run, seen, warn } = await runLabels({ scope });

// The display pass has its own catch (analytics-service.ts) that degrades
// to a warn — the #3602 disposition for this surface. So the CALLER sees
// rows, and what matters is that no name was fetched to put in them.
const result = await run() as unknown as { rows: Record<string, unknown>[] };
expect(readReferenced(seen)).toEqual([]);
expect(result.rows.map((r) => r.account)).toEqual(['acc1', 'acc2']);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('dimension label resolution failed'));
});
});

describe('#14329 over-denial controls — the guard refuses ONLY the vacating shapes', () => {
it('an ordinary referenced-object scope still narrows the label lookup', async () => {
const { run, seen } = await runLabels({ scope: { organization_id: 'org_A' } as FilterCondition });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

// Preservation pin — the scope reached the engine `$and`-composed with the
// id filter, never key-merged, so it cannot be displaced by the ids.
const labelCall = readReferenced(seen);
expect(labelCall).toHaveLength(1);
expect(labelCall[0].where).toEqual({
$and: [{ id: { $in: ['acc1', 'acc2'] } }, { organization_id: 'org_A' }],
});

// ...and the NARROWED RESULT SET, not merely "no throw": `acc1` is in the
// tenant and renders its name; `acc2` is out and keeps its raw id, which is
// the whole point of scoping this read.
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'acc2']);
});

it('the `$in: []` zero-rows reduction still yields no labels and no refusal', async () => {
// Positive polarity: the ruled #5322/#5243 reduction to constant FALSE.
// Narrowing at its own arm — the SAFE direction on a read scope — and
// deliberately NOT refused, here or at any sibling door.
const { run, seen } = await runLabels({ scope: { owner: { $in: [] } } as FilterCondition });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(readReferenced(seen)[0].where).toEqual({
$and: [{ id: { $in: ['acc1', 'acc2'] } }, { owner: { $in: [] } }],
});
// Zero rows came back, so no label overwrites a raw id — and no refusal.
expect(result.rows.map((r) => r.account)).toEqual(['acc1', 'acc2']);
});

it('the live #13570 RLS composite keeps own rows flowing', async () => {
// `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }` — an emptied
// membership beside an own-rows grant, which the RLS compiler really emits
// when a membership set resolves empty. Refusing it would 500 every
// analytics query for such a user, the outcome #13571's verdict rejected.
const { run, seen } = await runLabels({
scope: { $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] } as FilterCondition,
});

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'acc2']);
});

it('no scope at all still reads the target, unchanged', async () => {
// The `undefined` arm — "no scope for this object" is a legitimate answer
// from the provider contract, and the guard must not turn it into a
// refusal. Without this case a guard that refused everything would pass
// every refusal assertion above.
const { run, seen } = await runLabels({ scope: undefined });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(readReferenced(seen)[0].where).toEqual({ id: { $in: ['acc1', 'acc2'] } });
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'Umbrella Ltd']);
});
});
25 changes: 25 additions & 0 deletions packages/services/service-analytics/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import { AnalyticsService } from './analytics-service.js';
import type { AnalyticsServiceConfig } from './analytics-service.js';
import type { AnalyticsDriverCapabilities } from './strategies/types.js';
import { pickDisplayField, type DimensionLabelDeps } from './dimension-labels.js';
import { assertReadScopeCannotVacate } from './read-scope-sql.js';

/**
* The slice of the DECLARED engine contracts this plugin's auto-bridges
Expand DownExpand Up@@ -518,6 +519,30 @@ export class AnalyticsServicePlugin implements Plugin {
const map = new Map<unknown, string>();
const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
if (!displayField || !executeAggregate || ids.length === 0) return map;
// [#14329] The FOURTH read-scope door, and the same guard the other
// three answer with. #13640 guarded the ObjectQL engine merge and
// #13926 the echo merge and `NativeSQLStrategy.applyReadScope`; this
// hook is a fourth consumer of the same `readScopeProvider` output
// that meets NEITHER `compileScopedFilterToSql` nor the guard — the
// `$and` below hands the scope straight to `executeAggregate`, so a
// vacating spelling (`$not` over `$in: []` and its measured siblings,
// reachable from any out-of-repo `getReadScope` producer the
// `StrategyContext` spec contract admits) used to let this per-record
// read run effectively unscoped for the ids in hand — leaking exactly
// the display names the referenced object's RLS exists to hide.
//
// Placement mirrors `ObjectQLStrategy.resolveFkAttr`, this hook's
// structural twin (same id-`$in` `$and` scope, same `executeAggregate`,
// guarded since #13640): AFTER the early returns, because a call that
// reads nothing cannot widen anything and refusing it would be pure
// over-denial; and BEFORE the chunk loop, so one scope gets one verdict
// rather than one per 500 ids. The condition is spelled to match the
// composition on the next line exactly, so the set of scopes guarded
// and the set of scopes `$and`-ed are provably the same set.
//
// ⛔ Zero compiler change: the #13571 lowering residue is ruled and
// untouched. This guard is the walk, not the lowering.
if (scope) assertReadScopeCannotVacate(scope, targetObject);
// #3680 — the sort-key pass hands over the PRE-window id set (every
// grouped value, not just the displayed page), so a high-cardinality
// lookup dimension can push thousands of ids through here. Chunk the
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
9 changes: 9 additions & 0 deletions .changeset/tidy-ducks-tickle.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/service-analytics': patch
---

Guard the analytics record-label lookup with `assertReadScopeCannotVacate` — the fourth read-scope door

`AnalyticsServicePlugin`'s `fetchRecordLabels` hook `$and`s the **referenced** object's read scope with an `id $in [...]` filter and hands the result straight to `executeAggregate`. Unlike the three faces unified previously (the ObjectQL engine merge, the `/analytics/sql` echo merge, and `NativeSQLStrategy.applyReadScope`), it met neither `compileScopedFilterToSql` nor the vacancy guard, so a read scope that lowers to a boolean constant — the `$not`-over-`$in: []` family reachable from any out-of-repo `StrategyContext.getReadScope` producer — let that per-record read run effectively unscoped for the ids in hand, surfacing the display names the referenced object's RLS exists to hide.

The hook now calls the already-exported `assertReadScopeCannotVacate` on the referenced object's scope before composing the filter, refusing in the same envelope as its siblings (`READ_SCOPE_COMPILE_FAILED` / 500). No behaviour changes for scopes that bind: an ordinary referenced-object scope still narrows the label lookup, and the `$in: []` zero-rows reduction (including the live RLS composite that pairs it with an own-rows grant) still passes through untouched. The read-scope SQL compiler is unchanged.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14329] The FOURTH read-scope door — `AnalyticsServicePlugin`'s
* `fetchRecordLabels` hook — answers the same verdict as the other three.
*
* #13640 guarded the ObjectQL ENGINE merge and #13926 the `/analytics/sql`
* ECHO merge plus `NativeSQLStrategy.applyReadScope`; the three-faces file
* next door pins those. This hook is a FOURTH consumer of the very same
* `readScopeProvider` output, reached by a different route entirely
* (`AnalyticsService.queryDataset` → `resolveScope` → `dimension-labels.ts` →
* `DimensionLabelDeps.fetchRecordLabels`, the closure `plugin.ts` builds), and
* it met NEITHER `compileScopedFilterToSql` nor `assertReadScopeCannotVacate`:
* it `$and`s the REFERENCED object's scope with `id $in [...]` and hands that
* straight to `executeAggregate`.
*
* So a vacating scope spelling from an out-of-repo `getReadScope` producer
* (`StrategyContext.getReadScope` is a spec contract — that population is
* exactly who this contract exists for, and the one with no producer-side
* #13570 guard) let this per-record read run effectively unscoped for the ids
* in hand, surfacing the display names the referenced object's RLS exists to
* hide. The leak is row-granular by construction: `group by (id, name)` is a
* record read dressed as an aggregate.
*
* ## What is measured here, and what is NOT
*
* These cases drive the REAL plugin wiring — `new AnalyticsServicePlugin(...).init(ctx)`
* — so the closure under test is the one `plugin.ts` actually ships, not a
* stub standing in for it. What they do NOT re-measure is the ENGINE's
* lowering of a vacating scope: that table (which spellings come back with the
* whole table, driven against a real `SqliteWasmDriver`) is
* `read-scope-vacancy-three-faces.test.ts`'s, and re-deriving it here would be
* a second copy of one ruling. The fixture engine below therefore honours the
* filter it is handed by a small, deliberately obvious evaluator — which is
* the right authority for THIS seam's question: *does the hook forward a scope
* that a scope-honouring engine can narrow by, and does it refuse the
* spellings that cannot narrow anything at all?*
*
* ## Two label passes, two DIFFERENT dispositions — both fail closed
*
* A refusal from this hook surfaces differently depending on which of
* `queryDataset`'s two label passes raised it, and both are asserted below
* because a reader who checks only one will conclude the other is unguarded:
*
* - **sort-key pass** (`order` on a lookup dimension, #3680) runs inside
* `DatasetExecutor.execute`, whose catch in `queryDataset` re-throws a
* DECLARED ADR-0112 envelope untouched (`hasDeclaredErrorEnvelope`). The
* refusal reaches the caller as itself — `READ_SCOPE_COMPILE_FAILED` / 500.
* - **display pass** (#3602) is wrapped in its own try/catch that degrades to
* a `warn` and leaves raw ids rendering. That is not this card weakening:
* it is the disposition #3602 already chose for this surface one frame up
* (`dimension-labels.ts` skips a dimension's labels rather than fetch
* unscoped when the scope cannot be resolved), and it is fail-CLOSED — no
* name is fetched, so none can leak.
*
* The security property is therefore identical on both passes and is asserted
* as such: **the referenced object is never read at all**. A bare "it threw"
* would not distinguish that from a read that happened and then threw.
*/

import { describe, it, expect, vi } from 'vitest';
import { DatasetSchema } from '@objectstack/spec/ui';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { FilterCondition } from '@objectstack/spec/data';
import { AnalyticsService } from '../analytics-service.js';
import { AnalyticsServicePlugin } from '../plugin.js';

const CTX = { tenantId: 'org_A', userId: 'u_me' } as ExecutionContext;

/** Tasks grouped by a lookup dimension whose target is `crm_account`. */
const DATASET = DatasetSchema.parse({
name: 'tasks_by_account',
label: 'Tasks by account',
object: 'task',
dimensions: [{ name: 'account', field: 'account', type: 'lookup', label: 'Account' }],
measures: [{ name: 'cnt', aggregate: 'count' }],
});

/**
* Referenced-object fixture rows. `organization_id` is what an ordinary
* tenant scope narrows by; `owner` is what the emptied-membership spellings
* address. `acc2` is the row an ordinary `org_A` scope must NOT surface.
*/
const ACCOUNTS = [
{ id: 'acc1', name: 'Acme Corp', organization_id: 'org_A', owner: 'u_me' },
{ id: 'acc2', name: 'Umbrella Ltd', organization_id: 'org_B', owner: 'u_other' },
];

/** The grouped base aggregate: both FK ids reach the label pass. */
const TASK_ROWS = [
{ account: 'acc1', cnt: 3 },
{ account: 'acc2', cnt: 1 },
];

/**
* A deliberately small filter evaluator for the FIXTURE rows — equality,
* `$in`, `$and`, `$or`. It exists so "an ordinary scope still narrows" and
* "`$in: []` still reduces to zero rows" are read off real returned rows
* rather than off the filter object, which would only echo the assertion.
*
* ⛔ Not an engine-lowering model, and not where a vacating spelling's row
* consequence is established: an unrecognised operator throws rather than
* quietly matching, so a spelling this cannot judge fails loudly instead of
* manufacturing a comfortable answer. The measured lowering table lives in
* `read-scope-vacancy-three-faces.test.ts`, against a real driver.
*/
function matches(row: Record<string, unknown>, filter: unknown): boolean {
if (filter == null) return true;
if (typeof filter !== 'object' || Array.isArray(filter)) {
throw new Error(`[fixture] not a filter node: ${JSON.stringify(filter)}`);
}
return Object.entries(filter as Record<string, unknown>).every(([key, value]) => {
if (key === '$and') return (value as unknown[]).every((n) => matches(row, n));
if (key === '$or') return (value as unknown[]).some((n) => matches(row, n));
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
const ops = Object.entries(value as Record<string, unknown>);
return ops.every(([op, comparand]) => {
if (op === '$in') return (comparand as unknown[]).includes(row[key]);
throw new Error(`[fixture] unsupported operator ${op} — this evaluator judges no spelling it was not written for`);
});
}
return row[key] === value;
});
}

type EngineCall = { object: string; where?: Record<string, unknown> };

function fakePluginContext(services: Record<string, unknown>) {
const registered: Record<string, unknown> = {};
const warn = vi.fn();
return {
registered,
warn,
ctx: {
getService: (name: string) => services[name] ?? registered[name],
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
logger: { info() {}, warn, error() {}, debug() {} },
},
};
}

const objectqlOnly = () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false });

/**
* Drive the label path through the real plugin wiring.
*
* `order` selects WHICH label pass runs: with it, the sort-key pass (#3680)
* resolves labels inside `DatasetExecutor.execute`; without it, only the
* display pass (#3602) does. The two have different refusal dispositions, so
* every case below states which one it is exercising.
*/
async function runLabels(opts: { scope: FilterCondition | undefined; order?: boolean }) {
const seen: EngineCall[] = [];
const engine = {
aggregate: async (object: string, options: Record<string, unknown>) => {
seen.push({ object, where: options.where as Record<string, unknown> | undefined });
if (object === 'task') return TASK_ROWS;
return ACCOUNTS.filter((r) => matches(r, options.where)).map((r) => ({ id: r.id, name: r.name, _c: 1 }));
},
getObject: (name: string) =>
name === 'task'
? { fields: { account: { type: 'lookup', reference: 'crm_account' } } }
: name === 'crm_account'
? { fields: { name: { type: 'text' } } }
: undefined,
};
const { ctx, registered, warn } = fakePluginContext({ data: engine });

await new AnalyticsServicePlugin({
queryCapabilities: objectqlOnly,
getReadScope: (object: string) => (object === 'crm_account' ? opts.scope : undefined),
}).init(ctx as never);

const run = () =>
(registered.analytics as AnalyticsService).queryDataset(
DATASET as never,
{
dimensions: ['account'],
measures: ['cnt'],
...(opts.order ? { order: { account: 'asc' } } : {}),
} as never,
CTX,
);

return { run, seen, warn };
}

/** Did anything read the REFERENCED object? The security question, directly. */
const readReferenced = (seen: EngineCall[]) => seen.filter((c) => c.object === 'crm_account');

/**
* The vacating family, as measured in `read-scope-sql.ts`'s #13640 section:
* every one of these came back with the whole table from a real engine.
* `$nin: []` is refused at any polarity (matching `compileOperator`'s own
* `$nin` arm); the rest are emptied POSITIVE memberships under an odd number
* of negations, which is what makes them vacate.
*/
const VACATING: Array<[string, FilterCondition]> = [
['empty $nin', { owner: { $nin: [] } } as FilterCondition],
['$not over empty $in', { $not: { owner: { $in: [] } } } as FilterCondition],
['$not over a bare empty array', { $not: { owner: [] } } as FilterCondition],
['$not over a multi-key operator object holding an empty $in', { $not: { owner: { $in: [], $ne: 'u_other' } } } as FilterCondition],
['a vacating arm inside an $or', { $or: [{ $not: { owner: { $in: [] } } }, { owner: 'u_me' }] } as FilterCondition],
];

describe('#14329 — a vacating referenced-object scope is refused before the label lookup runs', () => {
it.each(VACATING)('sort-key pass: %s refuses in the sibling envelope', async (_name, scope) => {
const { run, seen } = await runLabels({ scope, order: true });

// ADR-0112 envelope, `code` AND `status` — the same two the three sibling
// faces answer with. A bare `toThrow` would stay green against a driver
// throwing a naked `Error`, which is the failure this assertion exists to
// exclude.
const err = await run().then(
() => { throw new Error('expected a refusal, got a result'); },
(e: unknown) => e as { code?: unknown; status?: unknown; message?: string },
);
expect(err.code).toBe('READ_SCOPE_COMPILE_FAILED');
expect(err.status).toBe(500);
expect(String(err.message)).toContain('read scope for "crm_account"');

// The other half of a refusal pin: the referenced object was NEVER read.
// "It threw" alone does not distinguish a guard from a leak followed by a
// throw — and the leak is precisely a read that happened.
expect(readReferenced(seen)).toEqual([]);
// The base aggregate still ran: the refusal is scoped to the label door.
expect(seen.map((c) => c.object)).toEqual(['task']);
});

it.each(VACATING)('display pass: %s fails closed to raw ids without reading the target', async (_name, scope) => {
const { run, seen, warn } = await runLabels({ scope });

// The display pass has its own catch (analytics-service.ts) that degrades
// to a warn — the #3602 disposition for this surface. So the CALLER sees
// rows, and what matters is that no name was fetched to put in them.
const result = await run() as unknown as { rows: Record<string, unknown>[] };
expect(readReferenced(seen)).toEqual([]);
expect(result.rows.map((r) => r.account)).toEqual(['acc1', 'acc2']);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('dimension label resolution failed'));
});
});

describe('#14329 over-denial controls — the guard refuses ONLY the vacating shapes', () => {
it('an ordinary referenced-object scope still narrows the label lookup', async () => {
const { run, seen } = await runLabels({ scope: { organization_id: 'org_A' } as FilterCondition });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

// Preservation pin — the scope reached the engine `$and`-composed with the
// id filter, never key-merged, so it cannot be displaced by the ids.
const labelCall = readReferenced(seen);
expect(labelCall).toHaveLength(1);
expect(labelCall[0].where).toEqual({
$and: [{ id: { $in: ['acc1', 'acc2'] } }, { organization_id: 'org_A' }],
});

// ...and the NARROWED RESULT SET, not merely "no throw": `acc1` is in the
// tenant and renders its name; `acc2` is out and keeps its raw id, which is
// the whole point of scoping this read.
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'acc2']);
});

it('the `$in: []` zero-rows reduction still yields no labels and no refusal', async () => {
// Positive polarity: the ruled #5322/#5243 reduction to constant FALSE.
// Narrowing at its own arm — the SAFE direction on a read scope — and
// deliberately NOT refused, here or at any sibling door.
const { run, seen } = await runLabels({ scope: { owner: { $in: [] } } as FilterCondition });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(readReferenced(seen)[0].where).toEqual({
$and: [{ id: { $in: ['acc1', 'acc2'] } }, { owner: { $in: [] } }],
});
// Zero rows came back, so no label overwrites a raw id — and no refusal.
expect(result.rows.map((r) => r.account)).toEqual(['acc1', 'acc2']);
});

it('the live #13570 RLS composite keeps own rows flowing', async () => {
// `{ $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }` — an emptied
// membership beside an own-rows grant, which the RLS compiler really emits
// when a membership set resolves empty. Refusing it would 500 every
// analytics query for such a user, the outcome #13571's verdict rejected.
const { run, seen } = await runLabels({
scope: { $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] } as FilterCondition,
});

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'acc2']);
});

it('no scope at all still reads the target, unchanged', async () => {
// The `undefined` arm — "no scope for this object" is a legitimate answer
// from the provider contract, and the guard must not turn it into a
// refusal. Without this case a guard that refused everything would pass
// every refusal assertion above.
const { run, seen } = await runLabels({ scope: undefined });

const result = await run() as unknown as { rows: Record<string, unknown>[] };

expect(readReferenced(seen)).toHaveLength(1);
expect(readReferenced(seen)[0].where).toEqual({ id: { $in: ['acc1', 'acc2'] } });
expect(result.rows.map((r) => r.account)).toEqual(['Acme Corp', 'Umbrella Ltd']);
});
});
25 changes: 25 additions & 0 deletions packages/services/service-analytics/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ import { AnalyticsService } from './analytics-service.js';
import type { AnalyticsServiceConfig } from './analytics-service.js';
import type { AnalyticsDriverCapabilities } from './strategies/types.js';
import { pickDisplayField, type DimensionLabelDeps } from './dimension-labels.js';
import { assertReadScopeCannotVacate } from './read-scope-sql.js';

/**
* The slice of the DECLARED engine contracts this plugin's auto-bridges
Expand DownExpand Up@@ -518,6 +519,30 @@ export class AnalyticsServicePlugin implements Plugin {
const map = new Map<unknown, string>();
const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
if (!displayField || !executeAggregate || ids.length === 0) return map;
// [#14329] The FOURTH read-scope door, and the same guard the other
// three answer with. #13640 guarded the ObjectQL engine merge and
// #13926 the echo merge and `NativeSQLStrategy.applyReadScope`; this
// hook is a fourth consumer of the same `readScopeProvider` output
// that meets NEITHER `compileScopedFilterToSql` nor the guard — the
// `$and` below hands the scope straight to `executeAggregate`, so a
// vacating spelling (`$not` over `$in: []` and its measured siblings,
// reachable from any out-of-repo `getReadScope` producer the
// `StrategyContext` spec contract admits) used to let this per-record
// read run effectively unscoped for the ids in hand — leaking exactly
// the display names the referenced object's RLS exists to hide.
//
// Placement mirrors `ObjectQLStrategy.resolveFkAttr`, this hook's
// structural twin (same id-`$in` `$and` scope, same `executeAggregate`,
// guarded since #13640): AFTER the early returns, because a call that
// reads nothing cannot widen anything and refusing it would be pure
// over-denial; and BEFORE the chunk loop, so one scope gets one verdict
// rather than one per 500 ids. The condition is spelled to match the
// composition on the next line exactly, so the set of scopes guarded
// and the set of scopes `$and`-ed are provably the same set.
//
// ⛔ Zero compiler change: the #13571 lowering residue is ruled and
// untouched. This guard is the walk, not the lowering.
if (scope) assertReadScopeCannotVacate(scope, targetObject);
// #3680 — the sort-key pass hands over the PRE-window id set (every
// grouped value, not just the displayed page), so a high-cardinality
// lookup dimension can push thousands of ids through here. Chunk the
Expand Down
Loading