diff --git a/.changeset/20356-query-dataset-request-scope.md b/.changeset/20356-query-dataset-request-scope.md new file mode 100644 index 00000000000..ec9142341c8 --- /dev/null +++ b/.changeset/20356-query-dataset-request-scope.md @@ -0,0 +1,11 @@ +--- +"@objectstack/service-analytics": patch +--- + +`AnalyticsService.queryDataset` no longer writes the service-wide cube and dataset registries: each call compiles its dataset into a scope of its own (#20356). + +Clause-②: no + +- **What changes**: a dataset query — an inline draft or a saved definition passed to `queryDataset` — used to register its compiled cube and compiled dataset under the dataset's name before it ran. From then on the name meant that request's definition for every later reader (`getMeta()` and `GET /api/v1/analytics/meta`, and every query by that name) until restart, whatever the request's own admission answered. The dataset is now compiled for the call only. The queries it runs resolve its name through a request-local lookup that overlays the shared registry read-only: the cube, the object-level admission and read-scope object sets, the join allowlist and the dataset scope all come from the call's own dataset, and a measure the call infers stays with the call. +- **What does not change**: the request is served as before, from its own definition, with the same admission, read scope and refusals. `registerDataset` still compiles and registers into the shared registry — the configuration door behind `AnalyticsServiceConfig.datasets` and embedders — and configured cubes are untouched. No refusal is added for a dataset whose name matches a configured cube. +- **The one observable difference**: a cube that only a `queryDataset` call ever compiled is no longer listed by `getMeta()`, and is no longer queryable by name through `query()` / `POST /api/v1/analytics/query` after that call returns. To make a dataset addressable by name, register it through `registerDataset` or `AnalyticsServiceConfig.datasets`. diff --git a/packages/qa/dogfood/test/analytics-inline-dataset-isolation.dogfood.test.ts b/packages/qa/dogfood/test/analytics-inline-dataset-isolation.dogfood.test.ts new file mode 100644 index 00000000000..494dc45727b --- /dev/null +++ b/packages/qa/dogfood/test/analytics-inline-dataset-isolation.dogfood.test.ts @@ -0,0 +1,266 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// END-TO-END gate: an INLINE dataset posted to `POST /analytics/dataset/query` +// is that request's definition and nobody else's — another member's +// `GET /analytics/meta` and their own queries answer exactly as they did before +// it, whether the request was refused or admitted (#20356). +// +// ## The defect +// +// The dataset door compiled the posted definition and registered it in the +// analytics service's process-wide registry under the dataset's name, BEFORE +// the object-level admission ran. The name then meant the poster's definition +// for every later reader of it, whatever the request's own verdict, until a +// restart. The service now compiles each request's dataset into a scope of its +// own, and the registry every caller reads is only ever written at boot. +// +// ## How it is observed +// +// Two separate sign-ups. Member A posts; member B observes. B's observation is +// the whole of what B can see through the three doors — the `meta` listing, +// B's query of the authored cube, and B's query of the app's saved dataset — +// and each leg asserts it is EQUAL to the baseline B took before A posted +// anything. Equality over the whole answer, not a spot check of one title, so +// a partial replacement cannot pass. +// +// ## The legs +// +// - REFUSED: A's dataset reads an object A holds no grant on, under the name of +// the authored cube → `403 PERMISSION_DENIED`, and B's view is unchanged. +// - ADMITTED: the same name over an object A may read → `200`, served from A's +// definition (A's measure, over A's own rows), and B's view is unchanged. +// This is the negative control a fix that simply refused would lose. +// - HIDDEN: the name of a cube declared `public: false` → still `200` (no new +// refusal on this door: a name shared with a configured cube is harmless +// once nothing is shared), and B's view is unchanged. Whether `meta` LISTS +// a hidden cube at all is `analytics_cube.public`'s enforcement, which this +// tree does not carry; the equality holds either way, so this leg keeps its +// meaning once that lands. +// - CONTROL: the saved dataset the app declares still serves by name, in the +// baseline and after every leg. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { AnalyticsServicePlugin } from '@objectstack/service-analytics'; +import { defineStack } from '@objectstack/spec'; +import type { Cube } from '@objectstack/spec/data'; +import { + AdmissionOpen, + AdmissionWalled, + admissionFixtureSecurity, +} from './fixtures/analytics-admission-fixture.js'; + +const A_OPEN_ROWS = 3; +/** Deliberately different from A's count, so the two members' numbers cannot be confused. */ +const B_OPEN_ROWS = 2; +const WALLED_ROWS = 4; + +/** The configured cube other members read, over the object every member may read. */ +const OPEN_SUMMARY: Cube = { + name: 'open_summary', + title: 'Open summary', + sql: 'admission_open', + measures: { + authored_total: { name: 'authored_total', label: 'Authored total', type: 'count', sql: '*' }, + }, + dimensions: { + region: { name: 'region', label: 'Region', type: 'string', sql: 'region' }, + }, +}; + +/** A configured cube its author hid. */ +const HIDDEN_SUMMARY: Cube = { + name: 'hidden_summary', + title: 'Hidden summary', + sql: 'admission_open', + measures: { + hidden_total: { name: 'hidden_total', label: 'Hidden total', type: 'count', sql: '*' }, + }, + dimensions: {}, + public: false, +}; + +const isolationStack = defineStack({ + manifest: { + id: 'com.dogfood.analytics-inline-isolation', + // The fixture objects' own prefix — they are reused, not renamed. + namespace: 'admission', + version: '0.0.0', + type: 'app', + name: 'Analytics Inline Dataset Isolation Fixture', + description: 'The admission fixture objects plus one saved dataset, with configured cubes on the analytics plugin.', + }, + objects: [AdmissionOpen, AdmissionWalled], + datasets: [ + { + name: 'saved_open_summary', + label: 'Saved open summary', + object: 'admission_open', + dimensions: [], + measures: [{ name: 'saved_total', label: 'Saved total', aggregate: 'count' }], + }, + ], +}); + +/** A member's own dataset, posted inline under a chosen name. */ +const inlineDataset = (name: string, object: string) => ({ + name, + label: `inline ${name}`, + object, + dimensions: [], + measures: [{ name: 'inline_total', label: 'Inline total', aggregate: 'count' }], +}); + +const DRIVERS = ['sqlite-wasm', 'memory'] as const; + +interface Boot { + stack: VerifyStack; + tokenA: string; + tokenB: string; + baseline: Observation; +} + +interface Observation { + meta: { status: number; body: unknown }; + authored: { status: number; body: unknown }; + saved: { status: number; body: unknown }; +} + +const boots = new Map(); + +async function read(res: Response): Promise<{ status: number; body: unknown }> { + return { status: res.status, body: await res.json() }; +} + +/** Everything member B can see of analytics through the three doors. */ +async function observeAsB(stack: VerifyStack, tokenB: string): Promise { + return { + meta: await read(await stack.apiAs(tokenB, 'GET', '/analytics/meta')), + authored: await read( + await stack.apiAs(tokenB, 'POST', '/analytics/query', { + cube: 'open_summary', + measures: ['authored_total'], + }), + ), + saved: await read( + await stack.apiAs(tokenB, 'POST', '/analytics/dataset/query', { + datasetName: 'saved_open_summary', + selection: { measures: ['saved_total'] }, + }), + ), + }; +} + +/** The single count a one-measure answer carries, whichever envelope the door uses. */ +function countOf(body: unknown, measure: string): number { + const payload = (body as { data?: unknown })?.data ?? body; + const rows = (payload as { rows?: Array> })?.rows ?? []; + return rows.reduce((sum, row) => sum + Number(row[measure] ?? 0), 0); +} + +async function bootFor(driver: (typeof DRIVERS)[number]): Promise { + const stack = await bootStack(isolationStack as never, { + security: admissionFixtureSecurity(), + databaseDriver: driver, + analytics: new AnalyticsServicePlugin({ cubes: [OPEN_SUMMARY, HIDDEN_SUMMARY] }), + }); + const adminToken = await stack.signIn(); + const tokenA = await stack.signUp(`isolation-a-${driver}@verify.test`); + const tokenB = await stack.signUp(`isolation-b-${driver}@verify.test`); + + // Each member authors their own rows over HTTP, so `created_by` is the real + // caller and the owner policy makes each member's count their own number. + for (const [token, rows, who] of [ + [tokenA, A_OPEN_ROWS, 'a'], + [tokenB, B_OPEN_ROWS, 'b'], + ] as const) { + for (let i = 0; i < rows; i++) { + const r = await stack.apiAs(token, 'POST', '/data/admission_open', { name: `${who}-open-${i}`, region: 'west' }); + expect(r.status).toBeLessThan(300); + } + } + for (let i = 0; i < WALLED_ROWS; i++) { + const w = await stack.apiAs(adminToken, 'POST', '/data/admission_walled', { name: `walled-${i}`, region: 'west' }); + expect(w.status).toBeLessThan(300); + } + + const baseline = await observeAsB(stack, tokenB); + return { stack, tokenA, tokenB, baseline }; +} + +describe.each(DRIVERS)( + 'dogfood: an inline dataset changes nothing another member sees [driver=%s]', + (driver) => { + beforeAll(async () => { + boots.set(driver, await bootFor(driver)); + }, 120_000); + + afterAll(async () => { + await boots.get(driver)?.stack.stop(); + boots.delete(driver); + }); + + it('baseline: B sees the configured cube and the saved dataset, each over B\'s own rows', () => { + const { baseline } = boots.get(driver)!; + expect(baseline.meta.status).toBe(200); + const listed = JSON.stringify(baseline.meta.body); + expect(listed).toContain('Open summary'); + expect(listed).not.toContain('inline '); + expect(baseline.authored.status).toBe(200); + expect(countOf(baseline.authored.body, 'authored_total')).toBe(B_OPEN_ROWS); + // CONTROL — the app's saved dataset serves by name. + expect(baseline.saved.status).toBe(200); + expect(countOf(baseline.saved.body, 'saved_total')).toBe(B_OPEN_ROWS); + }); + + it('REFUSED: A\'s dataset over an object A may not read answers 403 PERMISSION_DENIED, and B\'s view is unchanged', async () => { + const boot = boots.get(driver)!; + const res = await boot.stack.apiAs(boot.tokenA, 'POST', '/analytics/dataset/query', { + dataset: inlineDataset('open_summary', 'admission_walled'), + selection: { measures: ['inline_total'] }, + }); + expect(res.status).toBe(403); + expect(((await res.json()) as { code?: string }).code).toBe('PERMISSION_DENIED'); + + expect(await observeAsB(boot.stack, boot.tokenB)).toEqual(boot.baseline); + }); + + it('ADMITTED: the same name over an object A may read is served from A\'s definition, and B\'s view is unchanged', async () => { + const boot = boots.get(driver)!; + const res = await boot.stack.apiAs(boot.tokenA, 'POST', '/analytics/dataset/query', { + dataset: inlineDataset('open_summary', 'admission_open'), + selection: { measures: ['inline_total'] }, + }); + expect(res.status).toBe(200); + // A's measure, over A's own rows — the request's definition, not B's cube. + expect(countOf(await res.json(), 'inline_total')).toBe(A_OPEN_ROWS); + + expect(await observeAsB(boot.stack, boot.tokenB)).toEqual(boot.baseline); + }); + + it('HIDDEN: a dataset named like a `public: false` cube is served without a new refusal, and B\'s view is unchanged', async () => { + const boot = boots.get(driver)!; + const res = await boot.stack.apiAs(boot.tokenA, 'POST', '/analytics/dataset/query', { + dataset: inlineDataset('hidden_summary', 'admission_open'), + selection: { measures: ['inline_total'] }, + }); + expect(res.status).toBe(200); + + const after = await observeAsB(boot.stack, boot.tokenB); + expect(after).toEqual(boot.baseline); + expect(JSON.stringify(after.meta.body)).not.toContain('inline hidden_summary'); + }); + + it('CONTROL: a dataset posted under the saved dataset\'s name leaves the saved dataset serving as before', async () => { + const boot = boots.get(driver)!; + const res = await boot.stack.apiAs(boot.tokenA, 'POST', '/analytics/dataset/query', { + dataset: inlineDataset('saved_open_summary', 'admission_walled'), + selection: { measures: ['inline_total'] }, + }); + expect(res.status).toBe(403); + expect(((await res.json()) as { code?: string }).code).toBe('PERMISSION_DENIED'); + + expect(await observeAsB(boot.stack, boot.tokenB)).toEqual(boot.baseline); + }); + }, +); diff --git a/packages/services/service-analytics/src/__tests__/dataset-i18n-label-resolution.test.ts b/packages/services/service-analytics/src/__tests__/dataset-i18n-label-resolution.test.ts index e1821501212..c56616929e9 100644 --- a/packages/services/service-analytics/src/__tests__/dataset-i18n-label-resolution.test.ts +++ b/packages/services/service-analytics/src/__tests__/dataset-i18n-label-resolution.test.ts @@ -288,10 +288,13 @@ describe('#6761 — /analytics/meta no longer publishes the machine name as a di it('stays request-independent — a zh-CN query does not leak its locale into the registry', async () => { const svc = sqlService(); - // `queryDataset` re-registers the cube on every call. If the compiler baked - // the request locale in, this Chinese query would leave a Chinese-labelled - // cube behind and `/analytics/meta` — which takes no execution context at - // all — would answer whoever queried last. + // The registered cube is what `/analytics/meta` publishes, with no execution + // context at all. A Chinese query of the same dataset must leave it as it + // was: `queryDataset` compiles into its own request scope and writes nothing + // back (#20356), and the compiler takes no locale — so neither a + // re-registration nor a locale baked into the compile can make meta answer + // in whichever language queried last. + svc.registerDataset(dataset); await svc.queryDataset( dataset, { dimensions: ALL_DIMENSIONS, measures: ALL_MEASURES }, diff --git a/packages/services/service-analytics/src/__tests__/query-dataset-request-scope.test.ts b/packages/services/service-analytics/src/__tests__/query-dataset-request-scope.test.ts new file mode 100644 index 00000000000..14f93297e44 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/query-dataset-request-scope.test.ts @@ -0,0 +1,237 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20356] A dataset query writes nothing into the registries every caller + * shares. + * + * `queryDataset` used to compile its dataset and REGISTER it — the cube in the + * service-wide `CubeRegistry`, the compiled dataset in the join-allowlist / + * dataset-scope registry — before the selection ran and before any admission + * was asked. The dataset's name then meant the caller's definition for every + * later reader of that name, whatever the request's own verdict was. It now + * compiles into a request scope that overlays the shared registry read-only. + * + * ## What each case is shaped to catch + * + * - The OBSERVER is a second caller: its `getMeta()` and the exact call its own + * query puts on the driver, both taken before and after. Equality of the two + * snapshots is the assertion — a fix that registered and later restored + * would pass a spot check on one field and fail this. + * - The REFUSED leg asserts the refusal's envelope (`PERMISSION_DENIED` / 403) + * and that the driver never saw the walled object. Its dataset carries the + * name of an ADMITTED cube, so the verdict also proves admission judged the + * request's own dataset: a scope applied to the strategy but not to the + * admission set would admit it against the shared cube's object. + * - The ADMITTED leg is the negative control a lazy fix loses: the request + * must still be served, from ITS definition — the driver sees the request's + * object, not the shared cube's. + * - A `public: false` cube keeps its authored definition in the registry. + * Nothing on this tree reads `public` yet, so "stays hidden from meta" is + * not expressible here; what this pins is the precondition that leg needs: + * the entry a visibility filter would read is still the author's. + * - CONTROL: a dataset registered at construction (`datasets`, the boot door) + * still serves by name, and a request under its name leaves its compiled + * scope — the definition-level `filter` the shared query applies — intact. + */ + +import { describe, it, expect } from 'vitest'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { Cube } from '@objectstack/spec/data'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { AnalyticsService } from '../analytics-service.js'; + +const CALLER_A = { userId: 'u_a', tenantId: 'org_a' } as ExecutionContext; +const CALLER_B = { userId: 'u_b', tenantId: 'org_a' } as ExecutionContext; + +/** The object no caller may read. */ +const WALLED = 'walled_obj'; + +const OPEN_SUMMARY: Cube = { + name: 'open_summary', + title: 'Open summary', + sql: 'open_obj', + measures: { + authored_total: { name: 'authored_total', label: 'Authored total', type: 'count', sql: '*' }, + }, + dimensions: { + region: { name: 'region', label: 'Region', type: 'string', sql: 'region' }, + }, +}; + +const HIDDEN_SUMMARY: Cube = { + name: 'hidden_summary', + title: 'Hidden summary', + sql: 'open_obj', + measures: { + hidden_total: { name: 'hidden_total', label: 'Hidden total', type: 'count', sql: '*' }, + }, + dimensions: { + region: { name: 'region', label: 'Region', type: 'string', sql: 'region' }, + }, + public: false, +}; + +/** Registered at construction — the boot door this card keeps. */ +const SAVED = DatasetSchema.parse({ + name: 'saved_summary', + label: 'Saved summary', + object: 'open_obj', + filter: { region: 'west' }, + dimensions: [{ name: 'region', label: 'Region', field: 'region', type: 'string' }], + measures: [{ name: 'saved_total', label: 'Saved total', aggregate: 'count' }], +}); + +/** A request's own dataset, under a name the shared registry may already hold. */ +const inline = (name: string, object: string) => + DatasetSchema.parse({ + name, + label: `inline ${name}`, + object, + dimensions: [], + measures: [{ name: 'inline_total', label: 'Inline total', aggregate: 'count' }], + }); + +const nativeSqlOnly = () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }); +const objectqlOnly = () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }); + +const STRATEGY_PATHS = [ + { label: 'NativeSQLStrategy', capabilities: nativeSqlOnly }, + { label: 'ObjectQLStrategy', capabilities: objectqlOnly }, +] as const; + +/** Every call either strategy put on the driver, in order. */ +type DriverCall = { object: string; detail: unknown }; + +function makeService(capabilities: () => { nativeSql: boolean; objectqlAggregate: boolean; inMemory: boolean }) { + const calls: DriverCall[] = []; + const svc = new AnalyticsService({ + cubes: [OPEN_SUMMARY, HIDDEN_SUMMARY], + datasets: [SAVED], + queryCapabilities: capabilities, + admitObjectRead: (object) => object !== WALLED, + executeRawSql: async (object, sql, params) => { + calls.push({ object, detail: { sql, params } }); + return [{ authored_total: 3, saved_total: 2, inline_total: 5, amount_sum: 7 }]; + }, + executeAggregate: async (object, options) => { + calls.push({ object, detail: options }); + return [{ authored_total: 3, saved_total: 2, inline_total: 5, amount_sum: 7 }]; + }, + }); + return { svc, calls }; +} + +type Harness = ReturnType; + +/** + * The second caller's whole view: what discovery lists, and the exact driver + * call its queries of the authored cube and of the saved dataset produce. + */ +async function observe({ svc, calls }: Harness) { + const meta = await svc.getMeta(); + const from = calls.length; + await svc.query({ cube: 'open_summary', measures: ['authored_total'] }, CALLER_B); + await svc.query({ cube: 'saved_summary', measures: ['saved_total'] }, CALLER_B); + const driven = calls.slice(from); + return { meta, driven }; +} + +describe.each(STRATEGY_PATHS)('queryDataset leaves the shared registries alone — $label', ({ capabilities }) => { + it('observer baseline: the authored cube and the saved dataset serve by name, on their own objects', async () => { + const h = makeService(capabilities); + const { meta, driven } = await observe(h); + expect(meta.map((c) => c.name).sort()).toEqual(['hidden_summary', 'open_summary', 'saved_summary']); + expect(driven.map((c) => c.object)).toEqual(['open_obj', 'open_obj']); + // The saved dataset's definition-level filter reaches the driver — the + // compiled scope this card must leave in place. + expect(JSON.stringify(driven[1].detail)).toContain('west'); + }); + + it('a REFUSED request under an authored name answers PERMISSION_DENIED / 403 and changes nothing another caller sees', async () => { + const h = makeService(capabilities); + const before = await observe(h); + const from = h.calls.length; + + await expect( + h.svc.queryDataset(inline('open_summary', WALLED), { measures: ['inline_total'] }, CALLER_A), + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + // Admission judged the request's own object — the walled one — so nothing + // reached the driver for it. + expect(h.calls.slice(from)).toEqual([]); + + expect(await observe(h)).toEqual(before); + expect(h.svc.cubeRegistry.get('open_summary')).toBe(OPEN_SUMMARY); + }); + + it('an ADMITTED request under an authored name is served from ITS definition and changes nothing another caller sees', async () => { + const h = makeService(capabilities); + const before = await observe(h); + const from = h.calls.length; + + const result = await h.svc.queryDataset( + inline('open_summary', 'other_obj'), + { measures: ['inline_total'] }, + CALLER_A, + ); + expect(result.rows).toHaveLength(1); + // Served, and from the request's own dataset: its object, not the + // authored cube's. + expect(h.calls.slice(from).map((c) => c.object)).toEqual(['other_obj']); + + expect(await observe(h)).toEqual(before); + expect(h.svc.cubeRegistry.get('open_summary')).toBe(OPEN_SUMMARY); + }); + + it('a request under a `public: false` cube\'s name leaves the author\'s hidden definition in the registry', async () => { + const h = makeService(capabilities); + const before = await observe(h); + + await h.svc.queryDataset(inline('hidden_summary', 'other_obj'), { measures: ['inline_total'] }, CALLER_A); + + expect(await observe(h)).toEqual(before); + const entry = h.svc.cubeRegistry.get('hidden_summary'); + expect(entry).toBe(HIDDEN_SUMMARY); + expect(entry?.public).toBe(false); + }); + + it('a request under a fresh name leaves no entry — refused or admitted, augmented measures included', async () => { + const h = makeService(capabilities); + const names = h.svc.cubeRegistry.names(); + const before = await observe(h); + + await expect( + h.svc.queryDataset(inline('fresh_walled', WALLED), { measures: ['inline_total'] }, CALLER_A), + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + // `amount_sum` is not declared by the dataset: `ensureCube` augments the + // request's cube with a suffix-inferred measure, and that augmentation is + // the request's too. + const from = h.calls.length; + await h.svc.queryDataset( + inline('fresh_open', 'other_obj'), + { measures: ['inline_total', 'amount_sum'] }, + CALLER_A, + ); + const driven = h.calls.slice(from); + expect(driven.map((c) => c.object)).toEqual(['other_obj']); + // …and the augmented measure really reached the driver (`SUM(amount)`). + expect(JSON.stringify(driven[0].detail)).toContain('amount'); + + expect(h.svc.cubeRegistry.names()).toEqual(names); + expect(await observe(h)).toEqual(before); + }); + + it('CONTROL: the boot-registered dataset still serves, and a request under its name leaves its compiled scope intact', async () => { + const h = makeService(capabilities); + const before = await observe(h); + + // The saved definition itself, through the dataset door — still served. + const saved = await h.svc.queryDataset(SAVED, { measures: ['saved_total'] }, CALLER_A); + expect(saved.rows).toHaveLength(1); + // A different definition under the saved name: no filter, another object. + await h.svc.queryDataset(inline('saved_summary', 'other_obj'), { measures: ['inline_total'] }, CALLER_A); + + const after = await observe(h); + expect(after).toEqual(before); + expect(JSON.stringify(after.driven[1].detail)).toContain('west'); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 178fc717956..26a980464f3 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -842,6 +842,46 @@ const DEFAULT_CAPABILITIES: AnalyticsDriverCapabilities = { inMemory: true, }; +/** + * [#20356] The name-keyed cube reads ONE call resolves against, and the place + * that call records a cube it mints. + * + * A query names its cube (`AnalyticsQuery.cube`), and everything after that + * resolves the NAME: `ensureCube`'s existence and source-field gates, the + * object-level admission and the read scopes (`queryObjects`), and every + * strategy through its context — `getCube`, the join allowlist + * (`getAllowedRelationships`) and the dataset scope (`getDatasetScope`), the + * last two read off the COMPILED dataset. A name has one of two meanings: + * + * - the SHARED scope — this service's `CubeRegistry` and compiled-dataset + * registry, which every caller reads and `getMeta` publishes: the configured + * cubes, the datasets `registerDataset` registered (the constructor's + * `datasets`, or an embedder), and what the ad-hoc path infers; + * - a REQUEST scope — the dataset one `queryDataset` call compiled, visible to + * that call only, under its own name, over the shared scope read-only. + * + * A request's dataset is that caller's definition, and a name it shares with a + * shared cube is harmless only while the two never meet. Registering it made + * it every caller's: whatever the registry held under the name — an authored + * cube included — was replaced for every later reader, and the replacement + * happened before any admission was asked, so a refused request left it + * behind too. A request scope therefore has no path into the shared one: its + * `register` writes only to itself, and it is dropped with the call. + */ +interface CubeScope { + getCube(name: string): Cube | undefined; + getCompiledDataset(name: string): CompiledDataset | undefined; + /** Record a cube this call minted — `ensureCube`'s inference or augmentation. */ + register(cube: Cube): void; +} + +/** The strategy context's three name-keyed reads, answered from one {@link CubeScope}. */ +interface CubeReads { + getCube(name: string): Cube | undefined; + getAllowedRelationships(cubeName: string): Set | undefined; + getDatasetScope(cubeName: string): DatasetScope | undefined; +} + /** * AnalyticsService — Multi-driver analytics orchestrator. * @@ -868,8 +908,16 @@ export class AnalyticsService implements IAnalyticsService { private readonly readScopeProvider?: AnalyticsServiceConfig['getReadScope']; /** Object-level read-admission provider (bound per call to the request context). */ private readonly readAdmissionProvider?: ObjectReadAdmissionProvider; - /** Compiled datasets by name — feeds the join allowlist (D-C) and queryDataset. */ + /** + * Compiled datasets by name, as `registerDataset` registered them — feeds the + * shared scope's join allowlist (D-C) and dataset scope. `queryDataset` + * never writes here (#20356): its dataset lives in a request scope. + */ private readonly datasetRegistry = new Map(); + /** [#20356] The registry-backed {@link CubeScope} every caller shares. */ + private readonly sharedScope: CubeScope; + /** The configured join-allowlist hook for cubes that are not compiled datasets. */ + private readonly configuredAllowedRelationships?: AnalyticsServiceConfig['getAllowedRelationships']; /** Optional object-graph resolver used when compiling datasets. */ private readonly relationshipResolver?: RelationshipResolver; private readonly sourceFieldMeta?: AnalyticsServiceConfig['sourceFieldMeta']; @@ -915,6 +963,11 @@ export class AnalyticsService implements IAnalyticsService { constructor(config: AnalyticsServiceConfig = {}) { this.logger = config.logger || createLogger({ level: 'info', format: 'pretty' }); this.cubeRegistry = new CubeRegistry(); + this.sharedScope = { + getCube: (name) => this.cubeRegistry.get(name), + getCompiledDataset: (name) => this.datasetRegistry.get(name), + register: (cube) => this.cubeRegistry.register(cube), + }; // Register pre-defined cubes if (config.cubes) { @@ -923,6 +976,7 @@ export class AnalyticsService implements IAnalyticsService { this.readScopeProvider = config.getReadScope; this.readAdmissionProvider = config.admitObjectRead; + this.configuredAllowedRelationships = config.getAllowedRelationships; this.relationshipResolver = config.relationshipResolver; this.sourceFieldMeta = config.sourceFieldMeta; this.labelResolver = config.labelResolver; @@ -950,26 +1004,13 @@ export class AnalyticsService implements IAnalyticsService { // Build the context-independent strategy context. `getReadScope` is bound // per query in `callCtx(context)` so it can resolve the active tenant. this.baseCtx = { - getCube: (name) => this.cubeRegistry.get(name), + // The shared scope's reads. `callCtx` answers them from the call's own + // scope, which for every door but `queryDataset` is this same one. + ...this.cubeReads(this.sharedScope), queryCapabilities: config.queryCapabilities || (() => DEFAULT_CAPABILITIES), executeRawSql: config.executeRawSql, executeAggregate: config.executeAggregate, fallbackService: config.fallbackService, - // Prefer a compiled dataset's declared relationships (D-C join allowlist); - // fall back to any explicitly-configured provider for legacy cubes. - getAllowedRelationships: (cubeName: string) => - this.datasetRegistry.get(cubeName)?.allowedRelationships - ?? config.getAllowedRelationships?.(cubeName), - // [#10298] The compiled dataset's definition-level filter and its - // per-measure filters — the half of the declaration the Cube model has - // no room for. Same shape and same registry as `getAllowedRelationships` - // directly above: answered for a cube that IS a compiled dataset, - // `undefined` for every other cube. - getDatasetScope: (cubeName: string) => { - const compiled = this.datasetRegistry.get(cubeName); - if (!compiled) return undefined; - return { filter: compiled.filter, measureFilters: compiled.measureFilters }; - }, coerceTemporalFilterValue: config.coerceTemporalFilterValue, coerceTemporalFilterColumn: config.coerceTemporalFilterColumn, isExternalObject: config.isExternalObject, @@ -1113,16 +1154,45 @@ export class AnalyticsService implements IAnalyticsService { ); } + /** + * The strategy context's name-keyed reads, answered from `scope` (#20356). + * + * - `getCube` — the cube the name means in this call. + * - `getAllowedRelationships` — a compiled dataset's declared relationships + * (D-C join allowlist) win; the configured hook answers only for a cube + * that is not a compiled dataset in this scope (legacy hand-authored cubes). + * - [#10298] `getDatasetScope` — the compiled dataset's definition-level + * filter and its per-measure filters, the half of the declaration the Cube + * model has no room for. Same source as the allowlist: answered for a cube + * that IS a compiled dataset, `undefined` for every other cube. + */ + private cubeReads(scope: CubeScope): CubeReads { + return { + getCube: (name: string) => scope.getCube(name), + getAllowedRelationships: (cubeName: string) => + scope.getCompiledDataset(cubeName)?.allowedRelationships + ?? this.configuredAllowedRelationships?.(cubeName), + getDatasetScope: (cubeName: string) => { + const compiled = scope.getCompiledDataset(cubeName); + if (!compiled) return undefined; + return { filter: compiled.filter, measureFilters: compiled.measureFilters }; + }, + }; + } + /** * Build a per-call StrategyContext that binds the read-scope provider to the * current request's ExecutionContext (ADR-0021 D-C). The strategy then sees a - * `getReadScope(objectName)` that already knows the active tenant. + * `getReadScope(objectName)` that already knows the active tenant, and cube + * reads answered from the call's {@link CubeScope} (#20356). */ private async callCtx( query: AnalyticsQuery, context: ExecutionContext | undefined, tokenCtx: FilterTokenResolutionContext, + scope: CubeScope, ): Promise { + const reads = this.cubeReads(scope); // [#12230] The dataset-scope channel (#10298) hands the strategy the // REGISTRY's compiled filter/measureFilters — shared across requests, so // it still carries the authored `{current_user_id}` literally. On the @@ -1132,7 +1202,7 @@ export class AnalyticsService implements IAnalyticsService { // #10298's "redundant and idempotent" claim holds only for token-free // filters. Resolve the channel per request, with the SAME instant as the // query's own fields. - const getDatasetScope = this.resolvedDatasetScopeGetter(tokenCtx); + const getDatasetScope = this.resolvedDatasetScopeGetter(tokenCtx, reads.getDatasetScope); // The OBJECT-LEVEL gate, ahead of everything else on this path — including // the early return below, which is why it is not folded into the // read-scope pre-pass: a deployment that wired an admission provider and no @@ -1141,20 +1211,21 @@ export class AnalyticsService implements IAnalyticsService { // read"). `callCtx` is the ONE thing `query()` and `generateSql()` share, // so gating it covers the direct `/analytics/query` door, the `/analytics/sql` // echo door and — through `DatasetExecutor` — every dataset door. - await this.assertReadAdmitted(this.queryObjects(query), context); + await this.assertReadAdmitted(this.queryObjects(query, scope), context); // #3602 — `context` rides along unconditionally. It is the ENGINE-side belt // (forwarded to `engine.aggregate`, where the middleware chain applies its // own RLS), so it must not be gated on the analytics-side belt being wired: // a deployment with no `getReadScope` provider is exactly the one that most // needs the engine to scope for it. - if (!this.readScopeProvider) return { ...this.baseCtx, context, getDatasetScope }; + if (!this.readScopeProvider) return { ...this.baseCtx, ...reads, context, getDatasetScope }; // Pre-resolve the read scope for every object the strategy will scan (base // + all declared joins) BEFORE the synchronous SQL builder runs, since the // provider may be async (the production `security.getReadFilter` bridge). // The strategy then reads each object's filter synchronously from the map. - const scopes = await this.resolveReadScopes(query, context); + const scopes = await this.resolveReadScopes(query, context, scope); return { ...this.baseCtx, + ...reads, context, getDatasetScope, getReadScope: (objectName: string) => scopes.get(objectName) ?? null, @@ -1202,9 +1273,10 @@ export class AnalyticsService implements IAnalyticsService { */ private resolvedDatasetScopeGetter( tokenCtx: FilterTokenResolutionContext, + getRawDatasetScope: CubeReads['getDatasetScope'], ): (cubeName: string) => DatasetScope | undefined { return (cubeName: string) => { - const scope = this.baseCtx.getDatasetScope?.(cubeName); + const scope = getRawDatasetScope(cubeName); if (!scope) return scope; const filter = resolveFilterTokens(scope.filter, tokenCtx); const measureFilters = resolveFilterTokens(scope.measureFilters, tokenCtx); @@ -1228,10 +1300,16 @@ export class AnalyticsService implements IAnalyticsService { * An unregistered cube yields the empty set — the query fails its own * cube-existence gate downstream, and inventing an object name here would * gate something the request never named. + * + * [#20356] The cube is resolved through the call's `scope`, the SAME scope + * the strategy reads, so the objects admitted and scoped are the objects the + * strategy will scan. For `queryDataset` that is the caller's own compiled + * dataset — never a shared cube that happens to carry its name, whose objects + * would gate a read the request never makes and leave its own read ungated. */ - private queryObjects(query: AnalyticsQuery): Set { + private queryObjects(query: AnalyticsQuery, scope: CubeScope): Set { if (!query.cube) return new Set(); - const cube = this.cubeRegistry.get(query.cube); + const cube = scope.getCube(query.cube); return cube ? this.cubeObjects(cube) : new Set(); } @@ -1303,13 +1381,14 @@ export class AnalyticsService implements IAnalyticsService { */ private async resolveReadScopes( query: AnalyticsQuery, - context?: ExecutionContext, + context: ExecutionContext | undefined, + scope: CubeScope, ): Promise> { const map = new Map(); const provider = this.readScopeProvider; if (!provider || !query.cube) return map; - for (const object of this.queryObjects(query)) { + for (const object of this.queryObjects(query, scope)) { let filter: FilterCondition | null | undefined; try { filter = await provider(object, context); @@ -1344,6 +1423,20 @@ export class AnalyticsService implements IAnalyticsService { * Any other error propagates untouched. */ async query(queryInput: AnalyticsQuery, context?: ExecutionContext): Promise { + return this.queryIn(this.sharedScope, queryInput, context); + } + + /** + * {@link query} with the cube name resolved through `scope`: the shared scope + * for `/analytics/query`, and a request scope for the queries `queryDataset` + * runs through `DatasetExecutor` (#20356). One body for both, so every gate + * below asks the same question whichever scope answers the name. + */ + private async queryIn( + scope: CubeScope, + queryInput: AnalyticsQuery, + context?: ExecutionContext, + ): Promise { if (!queryInput.cube) { throw new Error('Cube name is required in analytics query'); } @@ -1361,8 +1454,8 @@ export class AnalyticsService implements IAnalyticsService { const tokenCtx = filterTokenContextFrom(context, new Date()); const query = this.resolveQueryTokens(queryInput, tokenCtx); - this.ensureCube(query); - const ctx = await this.callCtx(query, context, tokenCtx); + this.ensureCube(query, scope); + const ctx = await this.callCtx(query, context, tokenCtx, scope); let skip: Set | undefined; for (;;) { const strategy = this.resolveStrategy(query, ctx, skip); @@ -1419,15 +1512,16 @@ export class AnalyticsService implements IAnalyticsService { } /** - * Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it - * can be queried by name. Idempotent (re-registering overwrites). Returns the - * compiled dataset. + * Compile a `dataset` (ADR-0021) with this service's probes. Pure: it + * registers nothing, and both dataset doors compile through it — the + * registration door {@link registerDataset} and the request door + * {@link queryDataset}, which never registers (#20356). */ - registerDataset(dataset: Dataset): CompiledDataset { + private compile(dataset: Dataset): CompiledDataset { // #5115 — the datasource/federation probes turn a cross-datasource join - // from a query-time explosion into a registration-time rejection. Both are + // from a query-time explosion into a compile-time rejection. Both are // optional and tiered "cannot answer, do not block" inside the compiler. - const compiled = compileDataset(dataset, this.relationshipResolver, { + return compileDataset(dataset, this.relationshipResolver, { getObjectDatasource: this.getObjectDatasource, isExternalObject: this.isExternalObject, // [#16737 / #16099] …and the aggregate × field-type compatibility table @@ -1438,16 +1532,69 @@ export class AnalyticsService implements IAnalyticsService { declaredFieldType: (object: string, field: string) => this.sourceFieldMeta?.(object, field)?.type, }); + } + + /** + * Compile a `dataset` (ADR-0021) and register its Cube + join allowlist in the + * SHARED registry, so every caller can query it by name and `getMeta` lists + * it. This is the configuration door — the constructor's `datasets`, or an + * embedder holding the service — and it overwrites whatever the registry held + * under the name, which is why no request path calls it: `queryDataset` + * compiles into a request scope instead (#20356). Idempotent. Returns the + * compiled dataset. + */ + registerDataset(dataset: Dataset): CompiledDataset { + const compiled = this.compile(dataset); this.cubeRegistry.register(compiled.cube); this.datasetRegistry.set(dataset.name, compiled); return compiled; } + /** + * [#20356] The {@link CubeScope} one `queryDataset` call runs in: the call's + * own compiled dataset answers its name, every other name reads the shared + * scope, and what the call mints (`ensureCube`'s measure augmentation) stays + * here and is dropped with the call. + */ + private requestScope(compiled: CompiledDataset): CubeScope { + const name = compiled.cube.name; + const shared = this.sharedScope; + const cubes = new Map([[name, compiled.cube]]); + return { + getCube: (cubeName) => cubes.get(cubeName) ?? shared.getCube(cubeName), + getCompiledDataset: (cubeName) => + cubeName === name ? compiled : shared.getCompiledDataset(cubeName), + register: (cube) => { + cubes.set(cube.name, cube); + }, + }; + } + + /** + * [#20356] The face `DatasetExecutor` queries through for one `queryDataset` + * call. `query()` is {@link queryIn} over the call's scope — the same body, + * gates and strategy chain as {@link query}, with the cube name answered by + * the call's own dataset. `getMeta` is the shared discovery, unchanged: the + * executor never asks it, and a request's dataset is not published. + */ + private scopedService(scope: CubeScope): IAnalyticsService { + return { + query: (query, context) => this.queryIn(scope, query, context), + getMeta: (cubeName) => this.getMeta(cubeName), + }; + } + /** * Execute a semantic-layer dataset (ADR-0021). Compiles the dataset (saved or - * inline draft — Studio preview), registers its Cube + join allowlist, then - * runs the selection through the `DatasetExecutor` with the request context so - * tenant/RLS scoping (D-C) is applied. See {@link IAnalyticsService.queryDataset}. + * inline draft — Studio preview) for THIS call, then runs the selection + * through the `DatasetExecutor` with the request context so tenant/RLS + * scoping (D-C) is applied. See {@link IAnalyticsService.queryDataset}. + * + * [#20356] Nothing here writes the shared registries. The compiled dataset + * lives in a request scope: its name means the caller's definition for this + * call's queries and nothing else, so a dataset named like a configured cube + * neither replaces that cube nor re-publishes it, whatever the request's + * admission answers. */ async queryDataset( dataset: Dataset, @@ -1455,7 +1602,7 @@ export class AnalyticsService implements IAnalyticsService { context?: ExecutionContext, options?: { previewDrafts?: boolean }, ): Promise { - const compiled = this.registerDataset(dataset); + const compiled = this.compile(dataset); this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(',') || '—'})`); // ── ADR-0037 P3 — draft data preview ──────────────────────────────────── @@ -1573,7 +1720,11 @@ export class AnalyticsService implements IAnalyticsService { // a confident empty chart. See {@link hasDeclaredErrorEnvelope}. let result: AnalyticsResult; try { - result = await new DatasetExecutor(this, orderLabels).execute(compiled, selection, context); + // [#20356] Through the call's own scope, never `this`: every query the + // executor issues resolves `compiled`'s name to `compiled`, with no + // registry write before, during or after admission. + const scoped = this.scopedService(this.requestScope(compiled)); + result = await new DatasetExecutor(scoped, orderLabels).execute(compiled, selection, context); } catch (err) { // The producer answered the classification question — the route's // envelope reader serves it (4xx as itself; a declared 5xx relayed with @@ -2007,8 +2158,8 @@ export class AnalyticsService implements IAnalyticsService { const tokenCtx = filterTokenContextFrom(context, new Date()); const query = this.resolveQueryTokens(queryInput, tokenCtx); - this.ensureCube(query); - const ctx = await this.callCtx(query, context, tokenCtx); + this.ensureCube(query, this.sharedScope); + const ctx = await this.callCtx(query, context, tokenCtx, this.sharedScope); const strategy = this.resolveStrategy(query, ctx); this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" → ${strategy.name}`); @@ -2041,10 +2192,15 @@ export class AnalyticsService implements IAnalyticsService { * They run in request-key order (measures → dimensions/timeDimensions → * where), so a query that gets several wrong is answered about one at a time, * naming a real mistake either way. + * + * [#20356] "Registered" means registered in `scope`: the cube is read from it + * and what this method mints is recorded in it. On the shared scope that is + * the service's registry; on a `queryDataset` call's scope it is the call's + * own, so augmenting a request's dataset never reaches the shared registry. */ - private ensureCube(query: AnalyticsQuery): void { + private ensureCube(query: AnalyticsQuery, scope: CubeScope): void { const name = query.cube!; - let cube = this.cubeRegistry.get(name); + let cube = scope.getCube(name); if (!cube) { // [#3867] Auto-inference below sets `cube.sql = name`, so from here on @@ -2070,7 +2226,7 @@ export class AnalyticsService implements IAnalyticsService { // `cube.dimensions` — which on this path was minted from this very query, // bogus spelling included. this.assertWhereFields(query, cube, Object.keys(cube.dimensions)); - this.cubeRegistry.register(cube); + scope.register(cube); // A scalar query — only measures, no grouping (no `dimensions`/ // `timeDimensions`) — is the first-class "metric over an object" path // (e.g. the `object-metric` KPI widget). Auto-inferring a count/sum cube @@ -2138,7 +2294,7 @@ export class AnalyticsService implements IAnalyticsService { // handed the AUGMENTED cube — a caller filtering on a suffix-inferred // measure must be judged against the same bag the strategy will read. this.assertWhereFields(query, augmented, Object.keys(cube.dimensions)); - this.cubeRegistry.register(augmented); + scope.register(augmented); this.logger.debug( `[Analytics] Augmented cube "${name}" with inferred measures: ${Object.keys(extraMeasures).join(',')}`, ); diff --git a/packages/services/service-analytics/src/cube-registry.ts b/packages/services/service-analytics/src/cube-registry.ts index d7ab4efe229..8ffb0eacd3c 100644 --- a/packages/services/service-analytics/src/cube-registry.ts +++ b/packages/services/service-analytics/src/cube-registry.ts @@ -14,7 +14,11 @@ import type { Cube } from '@objectstack/spec/data'; * 1. **Manifest definitions** — `AnalyticsServiceConfig.cubes` (`registerAll`), * i.e. explicit cube definitions authored in `objectstack.config.ts`. * 2. **Compiled datasets** (ADR-0021) — `compileDataset()`'s Cube, registered - * under the dataset's name by `queryDataset`. + * under the dataset's name by `registerDataset`: the constructor's + * `datasets`, or an embedder. ⛔ Never by `queryDataset`, which compiles a + * request's dataset into that call's own scope (#20356) — a registration + * from a request would replace, for every caller, whatever cube the name + * held. * 3. **Ad-hoc query inference** — `ensureCube` / `inferCubeFromQuery` mints a * minimal Cube from the members an `AnalyticsQuery` references, once * `assertInferableCube` (#3867) has confirmed the name is a registered diff --git a/packages/services/service-analytics/src/dataset-compiler.ts b/packages/services/service-analytics/src/dataset-compiler.ts index d9a2386ccac..82a7a2b8aa4 100644 --- a/packages/services/service-analytics/src/dataset-compiler.ts +++ b/packages/services/service-analytics/src/dataset-compiler.ts @@ -431,15 +431,16 @@ const joinAlias = (path: string): string => path.replace(/\./g, '__'); * (the resolver takes `locale` positionally for exactly that reason). * * **A compiled Cube is a REGISTRY artifact, not a response.** `registerDataset` - * writes it into `CubeRegistry` under the dataset's name, `queryDataset` - * re-registers on every call, and `getMeta()` — the `/analytics/meta` face — - * reads it back with **no execution context at all** (`IAnalyticsService.getMeta` - * takes `cubeName?` and nothing else, and the route calls it without one). So - * the request locale must NOT be baked in here: one `zh-CN` query would leave a - * Chinese-labelled cube in a registry every later reader shares, and - * `/analytics/meta` would answer whoever queried last. Request-scoped - * resolution belongs where a request is in hand — `queryDataset`'s two field - * enrichment sites, which read `context.locale`. + * writes it into `CubeRegistry` under the dataset's name, and `getMeta()` — the + * `/analytics/meta` face — reads it back with **no execution context at all** + * (`IAnalyticsService.getMeta` takes `cubeName?` and nothing else, and the route + * calls it without one). So the request locale must NOT be baked in here: a + * cube compiled at one caller's locale and registered would answer every later + * reader in that locale. `queryDataset` compiles through this same function and + * registers nothing (#20356), so there is one compilation for both doors and + * neither takes a locale. Request-scoped resolution belongs where a request is + * in hand — `queryDataset`'s two field enrichment sites, which read + * `context.locale`. * * **The fallback stays `d.name`, and that is safe against the `f.label == null` * guard** (#5199 route A / #6761). `Metric.label` and `Dimension.label` are