From 9c12bad1c5c6f217f21bbdf5e182c8258853e3df Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:27:38 +0000 Subject: [PATCH 1/4] test(service-analytics): pin that an object read through a relationship path is admitted and scoped as a declared join is (#20933) Pins, on both strategies and every analytics face, that an object a query reads through a relationship path answers what a declared join to the same object answers: refused without a read grant, its rows outside the caller's row scope not read, answered when readable, and a read scope the native strategy cannot compile routes the query to the engine path. Covers inferred and authored cubes, a two-hop path resolved hop by hop, and the dataset door. These pins are red on this commit; the next commit makes them green. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- ...lytics-relationship-path-admission.test.ts | 386 ++++++++++++++++++ .../relationship-path-admission.test.ts | 222 ++++++++++ 2 files changed, 608 insertions(+) create mode 100644 packages/rest/src/analytics-relationship-path-admission.test.ts create mode 100644 packages/services/service-analytics/src/__tests__/relationship-path-admission.test.ts diff --git a/packages/rest/src/analytics-relationship-path-admission.test.ts b/packages/rest/src/analytics-relationship-path-admission.test.ts new file mode 100644 index 00000000000..0b37bbc37a4 --- /dev/null +++ b/packages/rest/src/analytics-relationship-path-admission.test.ts @@ -0,0 +1,386 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20933] An object an analytics query reads through a RELATIONSHIP PATH is + * admitted and row-scoped exactly as a declared join to the same object is, + * on both strategies and on every analytics face: the cube read + * (`AnalyticsService.query`, what `POST /api/v1/analytics/query` relays), the + * SQL echo (`AnalyticsService.generateSql`, what `POST /api/v1/analytics/sql` + * relays) and the dataset door (`POST /api/v1/analytics/dataset/query`, through + * this package's own route). + * + * The reference for every answer is the same question asked through a DECLARED + * join — an authored cube whose `joins` lists the relationship — by the same + * caller, on the same strategy, in the same test. A declared join has always + * been in the object set the door admits and scopes, so "answers what the + * declared join answers" is the property: a related object the caller may not + * read is refused, related rows outside the caller's row scope are not read, + * and a readable related object is answered. Each reference is also checked + * absolutely, so an equality between two wrong answers cannot pass. + * + * The composition is the shipped one, with the REAL security layer: + * `SecurityPlugin` over a real `ObjectQL` on a real `SqlDriver` (SQLite), and + * `AnalyticsServicePlugin` over the same engine as its `'data'` service, with no + * admission or scope hook of its own — the plugin reaches the `security` + * service itself. Two compositions, one per strategy: `native` (the plugin's + * own capabilities, so `NativeSQLStrategy` is asked first) and `objectql` (the + * capabilities narrowed to the engine-aggregate path). + * + * Covered: an inferred cube's dimension, filter member and time-dimension + * window; an authored cube's dimension and measure over a relationship it does + * not declare, and a path the query names on it; a two-hop path whose first hop + * resolves by falling back to its alias; the dataset door. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SecurityPlugin } from '@objectstack/plugin-security'; +import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics'; +import { RestServer } from './rest-server'; + +const LEDGER = 'rest_an_rpa_ledger'; +/** Reached through a relationship path; the member holds no read grant on it. */ +const DENIED = 'rest_an_rpa_denied'; +/** Reached through a relationship path; a row-level policy hides some of its rows from the member. */ +const SCOPED = 'rest_an_rpa_scoped'; +/** Reached through a relationship path; readable — the control. */ +const OPEN = 'rest_an_rpa_open'; +/** The second hop of the two-hop path. */ +const LEAF = 'rest_an_rpa_leaf'; + +const SYS_CTX = { isSystem: true, userId: 'usr_system' }; + +const MEMBER_SET = PermissionSetSchema.parse({ + name: 'member_default', + label: 'Member', + objects: { + '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + [DENIED]: { allowRead: false, allowCreate: false, allowEdit: false, allowDelete: false }, + }, + rowLevelSecurity: [{ name: 'scoped_visible_region', object: SCOPED, operation: 'all', using: "record.region != 'r_out'" }], +}); + +const MEMBER_CTX = { userId: 'usr_member', positions: [], permissions: [MEMBER_SET.name], posture: 'MEMBER' }; + +const count = { type: 'count', sql: '*', label: 'Count' }; + +/** The declared-join references: one authored cube per related object, each listing its join. */ +const VIA_DENIED = { + name: 'rest_an_rpa_via_denied', + title: 'Via denied', + sql: LEDGER, + measures: { count, denied_total: { type: 'sum', sql: `${DENIED}.amount`, label: 'Total' } }, + dimensions: { + denied_label: { type: 'string', sql: `${DENIED}.label`, label: 'Label' }, + denied_seen_at: { type: 'time', sql: `${DENIED}.seen_at`, label: 'Seen' }, + }, + joins: { [DENIED]: { name: DENIED } }, +}; +const VIA_SCOPED = { + name: 'rest_an_rpa_via_scoped', + title: 'Via scoped', + sql: LEDGER, + measures: { count }, + dimensions: { scoped_region: { type: 'string', sql: `${SCOPED}.region`, label: 'Region' } }, + joins: { [SCOPED]: { name: SCOPED } }, +}; +const VIA_OPEN = { + name: 'rest_an_rpa_via_open', + title: 'Via open', + sql: LEDGER, + measures: { count }, + dimensions: { open_name: { type: 'string', sql: `${OPEN}.name`, label: 'Name' } }, + joins: { [OPEN]: { name: OPEN } }, +}; +const VIA_TWO_HOPS = { + name: 'rest_an_rpa_via_two_hops', + title: 'Via two hops', + sql: LEDGER, + measures: { count }, + dimensions: { leaf_label: { type: 'string', sql: `${DENIED}.${LEAF}.label`, label: 'Leaf' } }, + joins: { [DENIED]: { name: DENIED }, [`${DENIED}__${LEAF}`]: { name: LEAF } }, +}; + +/** An authored cube whose members walk relationships it does not declare. */ +const PATHS = { + name: 'rest_an_rpa_paths', + title: 'Paths', + sql: LEDGER, + measures: { count, denied_total: { type: 'sum', sql: `${DENIED}.amount`, label: 'Total' } }, + dimensions: { + title: { type: 'string', sql: 'title', label: 'Title' }, + denied_label: { type: 'string', sql: `${DENIED}.label`, label: 'Label' }, + scoped_region: { type: 'string', sql: `${SCOPED}.region`, label: 'Region' }, + }, +}; +/** A two-hop path whose second hop is keyed and whose first falls back to its alias. */ +const TWO_HOP = { + name: 'rest_an_rpa_two_hop', + title: 'Two hop', + sql: LEDGER, + measures: { count }, + dimensions: { leaf_label: { type: 'string', sql: `${DENIED}.${LEAF}.label`, label: 'Leaf' } }, + joins: { [`${DENIED}__${LEAF}`]: { name: LEAF } }, +}; + +const LEAVES = [{ id: 'f1', label: 'f_one' }]; +const DENIED_ROWS = [{ id: 'k1', label: 'k_one', amount: 3, seen_at: '2026-01-05T00:00:00.000Z', [LEAF]: 'f1' }]; +const SCOPED_ROWS = [{ id: 's1', region: 'r_in' }, { id: 's2', region: 'r_out' }]; +const OPEN_ROWS = [{ id: 'o1', name: 'o_one' }]; +const LEDGER_ROWS = [ + { id: 'd1', title: 't1', [DENIED]: 'k1', [SCOPED]: 's1', [OPEN]: 'o1' }, + { id: 'd2', title: 't2', [DENIED]: 'k1', [SCOPED]: 's2', [OPEN]: 'o1' }, + { id: 'd3', title: 't3' }, +]; + +/** The dataset door's inline datasets: one declaring the relationship, one not. */ +const dataset = (include: string[], dimensions: Array>) => ({ + name: 'rpa_inline', + label: 'Relationship path inline', + object: LEDGER, + include, + dimensions: [{ name: 'title_dim', field: 'title', type: 'string' }, ...dimensions], + measures: [{ name: 'row_count', aggregate: 'count' }], +}); + +const quiet: any = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function mockProtocol() { + return { + getDiscovery: async () => ({ version: 'v0', routes: { data: '', metadata: '' } }), + getMetaTypes: async () => [], + getMetaItems: async () => [], + }; +} + +function makeRes() { + const res: any = { + statusCode: 200, + body: undefined as any, + header: () => res, + status: (code: number) => { res.statusCode = code; return res; }, + json: (body: unknown) => { res.body = body; return res; }, + end: () => res, + }; + return res; +} + +interface Harness { + engine: ObjectQL; + service: AnalyticsService; + datasetDoor: (definition: Record, selection: Record) => Promise<{ status: number; body: any }>; +} + +async function boot(strategy: 'native' | 'objectql'): Promise { + const engine = new ObjectQL({ logger: quiet } as any); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.analytics-relationship-path-admission-20933', + name: 'Analytics relationship path admission', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + { name: LEAF, label: 'Leaf', sharingModel: 'public_read_write', fields: { label: { name: 'label', type: 'text' } } }, + { + name: DENIED, + label: 'Denied', + sharingModel: 'public_read_write', + fields: { + label: { name: 'label', type: 'text' }, + amount: { name: 'amount', type: 'number' }, + seen_at: { name: 'seen_at', type: 'datetime' }, + [LEAF]: { name: LEAF, type: 'lookup', reference: LEAF }, + }, + }, + { name: SCOPED, label: 'Scoped', sharingModel: 'public_read_write', fields: { region: { name: 'region', type: 'text' } } }, + { name: OPEN, label: 'Open', sharingModel: 'public_read_write', fields: { name: { name: 'name', type: 'text' } } }, + { + name: LEDGER, + label: 'Ledger', + sharingModel: 'public_read_write', + fields: { + title: { name: 'title', type: 'text' }, + // Each relationship field is named after its target object: the + // spelling a relationship path joins through (`.`). + [DENIED]: { name: DENIED, type: 'lookup', reference: DENIED }, + [SCOPED]: { name: SCOPED, type: 'lookup', reference: SCOPED }, + [OPEN]: { name: OPEN, type: 'lookup', reference: OPEN }, + }, + }, + ], + } as never); + await engine.syncSchemas(); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + data: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [MEMBER_SET], + }, + }; + const ctx: any = { + logger: quiet, + hook: () => {}, + registerService: (name: string, svc: unknown) => { services[name] = svc; }, + replaceService: (name: string, svc: unknown) => { services[name] = svc; }, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const security = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await security.init(ctx); + await security.start(ctx); + vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); + + await engine.insert(LEAF, LEAVES.map((r) => ({ ...r })), { context: SYS_CTX } as never); + await engine.insert(DENIED, DENIED_ROWS.map((r) => ({ ...r })), { context: SYS_CTX } as never); + await engine.insert(SCOPED, SCOPED_ROWS.map((r) => ({ ...r })), { context: SYS_CTX } as never); + await engine.insert(OPEN, OPEN_ROWS.map((r) => ({ ...r })), { context: SYS_CTX } as never); + await engine.insert(LEDGER, LEDGER_ROWS.map((r) => ({ ...r })), { context: SYS_CTX } as never); + + await new AnalyticsServicePlugin({ + cubes: [VIA_DENIED, VIA_SCOPED, VIA_OPEN, VIA_TWO_HOPS, PATHS, TWO_HOP] as never, + ...(strategy === 'objectql' + ? { queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }) } + : {}), + }).init(ctx); + const service = services.analytics as AnalyticsService; + + const rest = new RestServer( + createMockServer() as any, mockProtocol() as any, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + async () => service, + ); + (rest as any).resolveExecCtx = async () => MEMBER_CTX; + rest.registerRoutes(); + const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/analytics/dataset/query'); + expect(route).toBeDefined(); + const datasetDoor = async (definition: Record, selection: Record) => { + const res = makeRes(); + const body = JSON.parse(JSON.stringify({ dataset: definition, selection })); + await route!.handler({ method: 'POST', params: {}, headers: {}, body, query: {} } as any, res); + return { status: res.statusCode, body: res.body }; + }; + return { engine, service, datasetDoor }; +} + +type Thrown = { code?: string; status?: number; statusCode?: number; object?: string } | null; + +/** + * What a face answered — its rows with the column names dropped (a path member + * and the declared member that reads the same column are named differently), + * or its refusal's envelope and the object it names. + */ +const answerOf = (run: () => Promise<{ rows?: ReadonlyArray>; sql?: unknown }>) => + run().then( + (r) => ({ answered: r.rows ? [...r.rows].map((row) => JSON.stringify(Object.values(row))).sort() : typeof r.sql }), + (e: Thrown) => ({ refused: { code: e?.code, status: e?.status ?? e?.statusCode, object: e?.object } }), + ); + +/** One question asked through a relationship path, and the same question through a declared join. */ +type Case = readonly [label: string, path: Record, declared: Record]; + +const DENIED_CASES: readonly Case[] = [ + ['an inferred cube\'s dimension', { cube: LEDGER, measures: ['count'], dimensions: [`${DENIED}.label`] }, { cube: VIA_DENIED.name, measures: ['count'], dimensions: ['denied_label'] }], + ['an inferred cube\'s filter member', { cube: LEDGER, measures: ['count'], where: { [`${DENIED}.label`]: 'k_one' } }, { cube: VIA_DENIED.name, measures: ['count'], where: { denied_label: 'k_one' } }], + [ + 'an inferred cube\'s time-dimension window', + { cube: LEDGER, measures: ['count'], timeDimensions: [{ dimension: `${DENIED}.seen_at`, dateRange: ['2026-01-01', '2026-01-31'] }] }, + { cube: VIA_DENIED.name, measures: ['count'], timeDimensions: [{ dimension: 'denied_seen_at', dateRange: ['2026-01-01', '2026-01-31'] }] }, + ], + ['an authored dimension over an undeclared relationship', { cube: PATHS.name, measures: ['count'], dimensions: ['denied_label'] }, { cube: VIA_DENIED.name, measures: ['count'], dimensions: ['denied_label'] }], + ['an authored measure over an undeclared relationship', { cube: PATHS.name, measures: ['denied_total'] }, { cube: VIA_DENIED.name, measures: ['denied_total'] }], + ['a path the query names on an authored cube', { cube: PATHS.name, measures: ['count'], dimensions: [`${DENIED}.label`] }, { cube: VIA_DENIED.name, measures: ['count'], dimensions: ['denied_label'] }], + ['a two-hop path whose first hop falls back to its alias', { cube: TWO_HOP.name, measures: ['count'], dimensions: ['leaf_label'] }, { cube: VIA_TWO_HOPS.name, measures: ['count'], dimensions: ['leaf_label'] }], +]; + +const SCOPED_CASES: readonly Case[] = [ + ['an inferred cube\'s dimension', { cube: LEDGER, measures: ['count'], dimensions: [`${SCOPED}.region`] }, { cube: VIA_SCOPED.name, measures: ['count'], dimensions: ['scoped_region'] }], + ['an inferred cube\'s filter member', { cube: LEDGER, measures: ['count'], where: { [`${SCOPED}.region`]: 'r_out' } }, { cube: VIA_SCOPED.name, measures: ['count'], where: { scoped_region: 'r_out' } }], + ['an authored dimension over an undeclared relationship', { cube: PATHS.name, measures: ['count'], dimensions: ['scoped_region'] }, { cube: VIA_SCOPED.name, measures: ['count'], dimensions: ['scoped_region'] }], +]; + +for (const strategy of ['native', 'objectql'] as const) { + describe(`[#20933] an object read through a relationship path is admitted and scoped as a declared join is — ${strategy} composition`, () => { + let h: Harness; + + beforeAll(async () => { + h = await boot(strategy); + }, 60_000); + + afterAll(async () => { + try { await h?.engine.destroy(); } catch { /* noop */ } + }); + + it('the fixture: a system caller reads the denied object and the related rows outside the member\'s scope', async () => { + const denied = await answerOf(() => h.service.query({ cube: LEDGER, measures: ['count'], dimensions: [`${DENIED}.label`] } as never, SYS_CTX as never)); + expect(JSON.stringify(denied)).toContain('k_one'); + const scoped = await answerOf(() => h.service.query({ cube: LEDGER, measures: ['count'], dimensions: [`${SCOPED}.region`] } as never, SYS_CTX as never)); + expect(JSON.stringify(scoped)).toContain('r_out'); + }); + + it.each(DENIED_CASES)('%s: a related object without a read grant is refused, as through a declared join', async (_label, path, declared) => { + const reference = await answerOf(() => h.service.query(declared as never, MEMBER_CTX as never)); + expect(reference).toEqual({ refused: { code: 'PERMISSION_DENIED', status: 403, object: DENIED } }); + expect(await answerOf(() => h.service.query(path as never, MEMBER_CTX as never)), 'the cube read').toEqual(reference); + expect(await answerOf(() => h.service.generateSql(path as never, MEMBER_CTX as never)), 'the SQL echo').toEqual(reference); + }); + + it.each(SCOPED_CASES)('%s: related rows outside the caller\'s scope are not read, as through a declared join', async (_label, path, declared) => { + const reference = await answerOf(() => h.service.query(declared as never, MEMBER_CTX as never)); + expect(JSON.stringify(reference)).not.toContain('r_out'); + const answer = await answerOf(() => h.service.query(path as never, MEMBER_CTX as never)); + expect(answer).toEqual(reference); + expect(JSON.stringify(answer)).not.toContain('r_out'); + }); + + it('a filter on a related value outside the caller\'s scope does not count the row that holds it', async () => { + const system = await answerOf(() => h.service.query({ cube: LEDGER, measures: ['count'], where: { [`${SCOPED}.region`]: 'r_out' } } as never, SYS_CTX as never)); + const member = await answerOf(() => h.service.query({ cube: LEDGER, measures: ['count'], where: { [`${SCOPED}.region`]: 'r_out' } } as never, MEMBER_CTX as never)); + expect(member).not.toEqual({ answered: [JSON.stringify([1])] }); + // A strategy that serves the filter at all counts the row for a caller the policy does not hide it from. + if (strategy === 'native') expect(system).toEqual({ answered: [JSON.stringify([1])] }); + }); + + it('the control: a readable related object is answered, as through a declared join', async () => { + const reference = await answerOf(() => h.service.query({ cube: VIA_OPEN.name, measures: ['count'], dimensions: ['open_name'] } as never, MEMBER_CTX as never)); + expect(JSON.stringify(reference)).toContain('o_one'); + expect(await answerOf(() => h.service.query({ cube: LEDGER, measures: ['count'], dimensions: [`${OPEN}.name`] } as never, MEMBER_CTX as never))).toEqual(reference); + expect(await answerOf(() => h.service.generateSql({ cube: LEDGER, measures: ['count'], dimensions: [`${OPEN}.name`] } as never, MEMBER_CTX as never))).toEqual({ answered: 'string' }); + }); + + it('the dataset door refuses a related object without a read grant, named through a relationship the dataset does not declare, as a declared one', async () => { + const reference = await h.datasetDoor( + dataset([DENIED], [{ name: 'denied_dim', field: `${DENIED}.label`, type: 'string' }]), + { measures: ['row_count'], dimensions: ['denied_dim'] }, + ); + expect({ status: reference.status, code: reference.body?.code }).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + const answer = await h.datasetDoor( + dataset([OPEN], [{ name: 'open_dim', field: `${OPEN}.name`, type: 'string' }]), + { measures: ['row_count'], dimensions: [`${DENIED}.label`] }, + ); + expect({ status: answer.status, code: answer.body?.code, message: answer.body?.message }).toEqual({ + status: reference.status, + code: reference.body?.code, + message: reference.body?.message, + }); + expect(answer.body?.rows).toBeUndefined(); + }); + }); +} diff --git a/packages/services/service-analytics/src/__tests__/relationship-path-admission.test.ts b/packages/services/service-analytics/src/__tests__/relationship-path-admission.test.ts new file mode 100644 index 00000000000..3ffcc99b8ea --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/relationship-path-admission.test.ts @@ -0,0 +1,222 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20933] An object a query reads through a RELATIONSHIP PATH belongs to the + * one object set the analytics door admits and row-scopes, so it is admitted + * and scoped exactly as the base object and a declared join are. + * + * The door asks two questions over one set, `AnalyticsService.queryObjects`: + * the object-level read admission, and the read scope each strategy applies to + * the objects it reads. A declared join (`cube.joins`, a dataset's `include`) + * was always in that set. A path the cube does not declare — a dotted member + * of an inferred cube, an authored member whose `sql` walks a relationship the + * cube never lists, a dotted member the query names itself — was not, although + * both strategies read the object at its end: `NativeSQLStrategy` joins it and + * `ObjectQLStrategy` reads it to resolve the related value. + * + * Each hop's object is resolved as PR #20931's field gate resolves it — the + * cube's join keyed by the path with its dots as `__`, falling back to the + * alias itself — because that is the object both strategies read there. The + * two-hop case below keys only its second hop, so one path exercises both + * arms. + * + * What is pinned, for every position and on both strategies: + * - a related object the caller may not read is refused, by name, before any + * statement runs; + * - the set admission asks for and the set the read scope is resolved for are + * the same set, and it carries the path's object; + * - the related object's read scope reaches what each strategy executes, and + * a scope `NativeSQLStrategy` cannot compile routes the query to the engine + * path as a declared join's does; + * - the control: a readable related object is answered. + * + * The end-to-end form, over the real security layer, is the route pin + * `packages/rest/src/analytics-relationship-path-admission.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { AnalyticsQuery } from '@objectstack/spec/contracts'; +import { AnalyticsService } from '../analytics-service.js'; + +const BASE = 'rp_ledger'; +const RELATED = 'rp_related'; +const HOP = 'rp_hop'; +const LEAF = 'rp_leaf'; + +const CALLER = { userId: 'u_member', tenantId: 'org_a' } as ExecutionContext; + +/** An authored cube whose members walk a relationship it does not declare. */ +const AUTHORED = { + name: 'rp_authored', + title: 'Authored', + sql: BASE, + measures: { + count: { type: 'count', sql: '*', label: 'Count' }, + related_total: { type: 'sum', sql: `${RELATED}.amount`, label: 'Total' }, + }, + dimensions: { + title: { type: 'string', sql: 'title', label: 'Title' }, + related_label: { type: 'string', sql: `${RELATED}.label`, label: 'Label' }, + }, +}; + +/** A two-hop path whose second hop is keyed and whose first falls back to its alias. */ +const TWO_HOP = { + name: 'rp_two_hop', + title: 'Two hop', + sql: BASE, + measures: { count: { type: 'count', sql: '*', label: 'Count' } }, + dimensions: { leaf_label: { type: 'string', sql: `${HOP}.${LEAF}.label`, label: 'Leaf' } }, + joins: { [`${HOP}__${LEAF}`]: { name: LEAF } }, +}; + +/** The same relationship, declared: the reference a path must answer like. */ +const VIA_RELATED = { + name: 'rp_via_related', + title: 'Via related', + sql: BASE, + measures: { count: { type: 'count', sql: '*', label: 'Count' } }, + dimensions: { related_label: { type: 'string', sql: `${RELATED}.label`, label: 'Label' } }, + joins: { [RELATED]: { name: RELATED } }, +}; + +/** Every position a relationship path reaches the query through, and the object it reads. */ +const POSITIONS: ReadonlyArray = [ + ['an inferred cube\'s dimension', { cube: BASE, measures: ['count'], dimensions: [`${RELATED}.label`] }, RELATED], + ['an inferred cube\'s filter member', { cube: BASE, measures: ['count'], where: { [`${RELATED}.label`]: 'l_one' } }, RELATED], + [ + 'an inferred cube\'s time-dimension window', + { cube: BASE, measures: ['count'], timeDimensions: [{ dimension: `${RELATED}.seen_at`, dateRange: ['2026-01-01', '2026-01-31'] }] }, + RELATED, + ], + ['an authored dimension over an undeclared relationship', { cube: AUTHORED.name, measures: ['count'], dimensions: ['related_label'] }, RELATED], + ['an authored measure over an undeclared relationship', { cube: AUTHORED.name, measures: ['related_total'] }, RELATED], + ['a path the query names on an authored cube', { cube: AUTHORED.name, measures: ['count'], dimensions: [`${RELATED}.label`] }, RELATED], + ['the first hop of a two-hop path', { cube: TWO_HOP.name, measures: ['count'], dimensions: ['leaf_label'] }, HOP], +] as never; + +const STRATEGIES = [ + { label: 'NativeSQLStrategy', capabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }) }, + { label: 'ObjectQLStrategy', capabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }) }, +] as const; + +/** A driver both strategies can serve, so the native strategy's decline has somewhere to route. */ +const BOTH = () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }); + +interface Seen { + admitted: string[]; + scoped: string[]; + sql: Array<{ sql: string; params: unknown[] }>; + aggregate: Array<{ object: string; filter: unknown }>; +} + +const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; + +function makeService( + capabilities: () => { nativeSql: boolean; objectqlAggregate: boolean; inMemory: boolean }, + opts: { denied?: string; scopeOf?: (object: string) => Record | undefined }, +): { service: AnalyticsService; seen: Seen } { + const seen: Seen = { admitted: [], scoped: [], sql: [], aggregate: [] }; + const service = new AnalyticsService({ + logger: quiet as never, + cubes: [AUTHORED as never, TWO_HOP as never, VIA_RELATED as never], + queryCapabilities: capabilities, + isRegisteredObject: (name: string) => name === BASE, + admitObjectRead: (object: string) => { + seen.admitted.push(object); + return object !== opts.denied; + }, + getReadScope: (async (object: string) => { + seen.scoped.push(object); + return opts.scopeOf?.(object); + }) as never, + executeRawSql: async (_object: string, sql: string, params: unknown[]) => { + seen.sql.push({ sql, params }); + return [{ [`${RELATED}.label`]: 'l_one', related_label: 'l_one', leaf_label: 'f_one', count: 2, related_total: 5 }]; + }, + executeAggregate: async (object: string, options: { filter?: unknown }) => { + seen.aggregate.push({ object, filter: options?.filter }); + if (object === BASE) return [{ [RELATED]: 'r1', count: 2 }]; + return [{ id: 'r1', label: 'l_one', _c: 1 }]; + }, + } as never); + return { service, seen }; +} + +/** The refusal's envelope and the object it names, or the answer. */ +const outcomeOf = (run: () => Promise<{ rows?: unknown }>) => + run().then( + (r) => ({ answered: r.rows }), + (e: { code?: string; status?: number; object?: string }) => ({ refused: { code: e?.code, status: e?.status, object: e?.object } }), + ); + +describe('[#20933] an object read through a relationship path is admitted and scoped as a declared join is', () => { + describe.each(STRATEGIES)('$label', ({ capabilities }) => { + it.each(POSITIONS)('%s: a related object the caller may not read is refused by name before anything runs', async (_label, query, object) => { + const { service, seen } = makeService(capabilities, { denied: object }); + expect(await outcomeOf(() => service.query(query, CALLER))).toEqual({ + refused: { code: 'PERMISSION_DENIED', status: 403, object }, + }); + expect(await outcomeOf(() => service.generateSql(query, CALLER))).toEqual({ + refused: { code: 'PERMISSION_DENIED', status: 403, object }, + }); + expect(seen.sql).toEqual([]); + expect(seen.aggregate).toEqual([]); + }); + + it('the control: a readable related object is answered', async () => { + const { service } = makeService(capabilities, {}); + const result = await service.query({ cube: BASE, measures: ['count'], dimensions: [`${RELATED}.label`] }, CALLER); + expect(result.rows).toHaveLength(1); + expect(result.rows[0]).toMatchObject({ [`${RELATED}.label`]: 'l_one', count: 2 }); + }); + }); + + it.each(POSITIONS)('%s: admission and the read scope are asked for one set, and it carries the path\'s object', async (_label, query, object) => { + const { service, seen } = makeService(STRATEGIES[0].capabilities, {}); + await service.generateSql(query, CALLER); + expect(seen.admitted).toContain(BASE); + expect(seen.admitted).toContain(object); + expect([...seen.scoped].sort()).toEqual([...seen.admitted].sort()); + }); + + it('a two-hop path resolves hop by hop: the alias it falls back to, then the join it is keyed by', async () => { + const { service, seen } = makeService(STRATEGIES[0].capabilities, {}); + await service.generateSql({ cube: TWO_HOP.name, measures: ['count'], dimensions: ['leaf_label'] }, CALLER); + expect([...seen.admitted].sort()).toEqual([BASE, HOP, LEAF].sort()); + }); + + it('NativeSQLStrategy: the related object\'s read scope is applied to the join it reads', async () => { + const { service, seen } = makeService(STRATEGIES[0].capabilities, { + scopeOf: (object) => (object === RELATED ? { tenant_id: 'org_a' } : undefined), + }); + await service.query({ cube: BASE, measures: ['count'], dimensions: [`${RELATED}.label`] }, CALLER); + expect(seen.sql).toHaveLength(1); + expect(seen.sql[0].sql).toContain(`LEFT JOIN "${RELATED}"`); + expect(seen.sql[0].sql).toContain(`"${RELATED}"."tenant_id" = $`); + expect(seen.sql[0].params).toContain('org_a'); + }); + + it('ObjectQLStrategy: the related object\'s read scope is applied to the read that resolves the related value', async () => { + const { service, seen } = makeService(STRATEGIES[1].capabilities, { + scopeOf: (object) => (object === RELATED ? { tenant_id: 'org_a' } : undefined), + }); + await service.query({ cube: BASE, measures: ['count'], dimensions: [`${RELATED}.label`] }, CALLER); + const related = seen.aggregate.filter((a) => a.object === RELATED); + expect(related).toHaveLength(1); + expect(JSON.stringify(related[0].filter)).toContain('"tenant_id":"org_a"'); + }); + + it('NativeSQLStrategy declines a query whose related object\'s read scope carries a field reference, as it does for a declared join', async () => { + const referenceScope = (object: string) => (object === RELATED ? { label: { $eq: { $field: 'alt_label' } } } : undefined); + const served = async (query: AnalyticsQuery) => { + const { service, seen } = makeService(BOTH, { scopeOf: referenceScope }); + const outcome = await outcomeOf(() => service.query(query, CALLER)); + return { answered: 'answered' in outcome, native: seen.sql.length, engine: seen.aggregate.map((a) => a.object) }; + }; + const declared = await served({ cube: VIA_RELATED.name, measures: ['count'], dimensions: ['related_label'] }); + expect(declared).toEqual({ answered: true, native: 0, engine: [BASE, RELATED] }); + expect(await served({ cube: BASE, measures: ['count'], dimensions: [`${RELATED}.label`] })).toEqual(declared); + }); +}); From b48c42e1a9e587a5970d68ea17c2463d246ce7fd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:32:21 +0000 Subject: [PATCH 2/4] fix(service-analytics)!: an object read through a relationship path joins the one admitted and scoped object set (#20933) The analytics door admits and row-scopes one object set, and both strategies read their scopes from it. That set held the cube's base object and its declared joins, but not an object reached through a relationship path the cube does not declare, although both strategies read that object. It is now in the set: each hop's object, resolved as the field gate resolves it, is admitted and scoped exactly as a declared join is. The set is derived once per call and handed to the admission, the scope resolution and the native strategy's cross-field decline, so a read scope that strategy cannot compile routes the query to the engine path for a related object as it does for a declared join. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- .../src/analytics-service.ts | 75 +++++++++++++------ .../src/strategies/native-sql-strategy.ts | 14 +++- .../service-analytics/src/strategies/types.ts | 13 ++++ 3 files changed, 76 insertions(+), 26 deletions(-) diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 3893e2a18ff..7e80788d987 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -1443,7 +1443,10 @@ 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, scope), context); + // [#20933] The set is derived ONCE here and handed to both questions, so + // the objects admitted and the objects scoped are one value, not two calls. + const objects = this.queryObjects(query, scope); + await this.assertReadAdmitted(objects, context); // [#20917] …and the FIELD-level gate, right behind it and for the same // reason: every member the query names is judged here, once, so every // strategy — and the SQL echo — inherits the verdict by construction. The @@ -1461,17 +1464,21 @@ export class AnalyticsService implements IAnalyticsService { // 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, ...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 + // Pre-resolve the read scope for every object the strategy will scan (base, + // declared joins, relationship paths — `queryObjects`, the set the admission + // above read) 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, scope); + const scopes = await this.resolveReadScopes(objects, context); return { ...this.baseCtx, ...reads, context, getDatasetScope, getReadScope: (objectName: string) => scopes.get(objectName) ?? null, + // [#20933] …and the set itself, for the strategy that looks at the scopes + // before it compiles (`NativeSQLStrategy`'s cross-field decline). + readScopedObjects: [...objects], }; } @@ -1530,15 +1537,30 @@ export class AnalyticsService implements IAnalyticsService { } /** - * Every object this query will READ — the cube's base object plus every - * joined object. + * Every object this query will READ — the cube's base object, every join the + * cube declares, and every object a member the query names reaches through a + * relationship path. * * ONE derivation, two consumers: {@link resolveReadScopes} scopes exactly * this set and {@link assertReadAdmitted} admits exactly this set, so the set * that is row-scoped and the set that is admitted are provably the same set * rather than two lists that agree today. It is a SUPERSET of what a strategy - * actually scans (a strategy only joins along declared relationships), which - * is the safe direction: no scanned object is ever left ungated. + * actually scans (a declared join the query never uses is in it), which is + * the safe direction: no scanned object is ever left ungated. + * + * [#20933] A relationship path is read whether or not the cube declares it. + * A dotted member of an inferred cube, an authored member whose `sql` walks a + * relationship the cube's `joins` never lists, a dotted member the query + * names itself: `NativeSQLStrategy` joins the object at each hop + * (`qualifyAndRegisterJoin`) and `ObjectQLStrategy` reads it to resolve the + * related value. Those objects are in this set, so each is admitted and its + * read scope reaches the strategy exactly as a declared join's does — the + * strategies apply the scope of every object they read from this set, and + * carry no rule of their own. Each hop's object is the one the field gate + * attributes the hop's fields to ({@link namedQueryFields}: the cube's join + * keyed by the path with its dots as `__`, falling back to the alias itself), + * reused rather than re-derived, so the field gate and this set can never + * name different objects for the same hop. * * An unregistered cube yields the empty set — the query fails its own * cube-existence gate downstream, and inventing an object name here would @@ -1553,14 +1575,21 @@ export class AnalyticsService implements IAnalyticsService { private queryObjects(query: AnalyticsQuery, scope: CubeScope): Set { if (!query.cube) return new Set(); const cube = scope.getCube(query.cube); - return cube ? this.cubeObjects(cube) : new Set(); + if (!cube) return new Set(); + const objects = this.cubeObjects(cube); + for (const { object } of namedQueryFields(query, cube, this.cubeReads(scope).getDatasetScope(query.cube))) { + objects.add(object); + } + return objects; } /** - * {@link queryObjects} for a cube already in hand — the draft-preview branch - * holds the COMPILED dataset rather than a query naming it, and reaching for - * the registry there would make the gate depend on a registration side - * effect. One derivation, two entry points. + * The cube's own part of {@link queryObjects}: its base object and every + * join it declares. The draft-preview branch asks this part alone — it holds + * the COMPILED dataset rather than a query naming it (reaching for the + * registry there would make the gate depend on a registration side effect), + * and it evaluates the executor's queries over the base object's drafted seed + * rows in memory, so it reads no object through a relationship path. */ private cubeObjects(cube: Cube): Set { const objects = new Set(); @@ -1631,13 +1660,14 @@ export class AnalyticsService implements IAnalyticsService { /** * Resolve the read scope (tenant + RLS `FilterCondition`) for the base object - * AND every joined object of the query's cube, keyed by object name. This is - * the async pre-pass that lets the synchronous strategy enforce scoping even - * when the provider (security `getReadFilter`) resolves asynchronously. + * AND every object the query reads through a join or a relationship path, + * keyed by object name. This is the async pre-pass that lets the synchronous + * strategy enforce scoping even when the provider (security `getReadFilter`) + * resolves asynchronously. * - * The object set is `cube.sql` (base) plus every `cube.joins[*].name` — a - * SUPERSET of what the strategy actually scans (the strategy only joins along - * declared relationships), so no scanned object is ever left unscoped. + * The object set is {@link queryObjects} — the one the admission reads, a + * SUPERSET of what the strategy actually scans, so no scanned object is ever + * left unscoped. * * Fail-closed: if the provider throws for an object, the whole query is * rejected rather than emitting SQL with that object unscoped. @@ -1651,15 +1681,14 @@ export class AnalyticsService implements IAnalyticsService { * enveloped, it is re-thrown before the wording is ever read. */ private async resolveReadScopes( - query: AnalyticsQuery, + objects: Iterable, context: ExecutionContext | undefined, - scope: CubeScope, ): Promise> { const map = new Map(); const provider = this.readScopeProvider; - if (!provider || !query.cube) return map; + if (!provider) return map; - for (const object of this.queryObjects(query, scope)) { + for (const object of objects) { let filter: FilterCondition | null | undefined; try { filter = await provider(object, context); diff --git a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts index c3132edd036..bc6e15abfc9 100644 --- a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts @@ -332,9 +332,17 @@ export class NativeSQLStrategy implements AnalyticsStrategy { if (typeof ctx.getReadScope !== 'function') return null; const cube = query.cube ? ctx.getCube(query.cube) : undefined; if (!cube) return null; - const objects = [this.extractObjectName(cube)]; - for (const alias of Object.keys(cube.joins ?? {})) { - objects.push(cube.joins?.[alias]?.name ?? alias); + // [#20933] The scopes the door resolved, over the set it resolved them for: + // an object read through a relationship path carries its scope too, and a + // reference in it declines the query exactly as one in a declared join's + // does. The cube's own objects stand in only for a context built without + // that set. + const scoped = (ctx as DatasetScopedStrategyContext).readScopedObjects; + const objects = scoped ? [...scoped] : [this.extractObjectName(cube)]; + if (!scoped) { + for (const alias of Object.keys(cube.joins ?? {})) { + objects.push(cube.joins?.[alias]?.name ?? alias); + } } for (const objectName of objects) { const scope = ctx.getReadScope(objectName); diff --git a/packages/services/service-analytics/src/strategies/types.ts b/packages/services/service-analytics/src/strategies/types.ts index 41a6aeaefc1..a9dfcf5f51c 100644 --- a/packages/services/service-analytics/src/strategies/types.ts +++ b/packages/services/service-analytics/src/strategies/types.ts @@ -70,6 +70,19 @@ export interface DatasetScope { /** A {@link StrategyContext} that can answer for a compiled dataset (#10298). */ export interface DatasetScopedStrategyContext extends StrategyContext { getDatasetScope?(cubeName: string): DatasetScope | undefined; + /** + * [#20933] Every object whose read scope `getReadScope` answers for this + * query — `AnalyticsService.queryObjects`, the one set the object-level + * admission read: the base object, every join the cube declares, and every + * object a member reaches through a relationship path. + * + * For a strategy that must look at the scopes BEFORE it compiles, so it reads + * them over the set the door scoped instead of re-deriving which objects the + * query reads. `undefined` when the context carries no read scope at all. + * Declared HERE rather than on the spec's {@link StrategyContext} for the + * reason `getDatasetScope` is: nothing about it is an authorable surface. + */ + readScopedObjects?: readonly string[]; /** * [#14079] The DECLARED type of `field` on `objectName` — `'number'`, * `'boolean'`, `'text'`, … — or `undefined` when the host cannot answer (no From 5671f2482e11d46090b3a0f59b5d4dc001dbdcd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:34:06 +0000 Subject: [PATCH 3/4] chore(changeset): service-analytics minor, breaking, for the relationship-path admission and row scope (#20933) Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- ...3-analytics-relationship-path-admission.md | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .changeset/20933-analytics-relationship-path-admission.md diff --git a/.changeset/20933-analytics-relationship-path-admission.md b/.changeset/20933-analytics-relationship-path-admission.md new file mode 100644 index 00000000000..7b1ecf4d4c1 --- /dev/null +++ b/.changeset/20933-analytics-relationship-path-admission.md @@ -0,0 +1,53 @@ +--- +'@objectstack/service-analytics': minor +--- + +fix(service-analytics)!: an object an analytics query reads through a relationship path is admitted and row-scoped exactly as a declared join to it is, on both strategies (#20933) + +Clause-②: no (narrowing) + + + +**BREAKING for analytics queries on a SQL deployment that read a related object through a relationship path the cube does not declare.** + +**What changed.** The analytics door admits and row-scopes one object set +before either strategy runs. It held the cube's base object and the joins the +cube declares (`joins`, or a dataset's `include`). An object reached through a +relationship path the cube does not declare was not in it, although both +strategies read that object: a dotted member of an inferred cube, an authored +member whose `sql` walks a relationship the cube's `joins` does not list, or a +dotted member the query names itself. Every such object is now in the set, so +`POST /api/v1/analytics/query`, `POST /api/v1/analytics/sql` and +`POST /api/v1/analytics/dataset/query` treat it exactly as a declared join: + +- a related object the caller may not read answers `403 PERMISSION_DENIED`, + naming that object, before any statement runs; +- the caller's row scope on the related object is applied, so related rows + outside it are not read. On the native-SQL strategy a base row whose related + record is outside the scope drops out of the answer, as it already did for a + declared join; the ObjectQL strategy still groups such rows as restricted; +- a related-object scope the native-SQL strategy cannot compile routes the + query to the ObjectQL strategy, as it already did for a declared join. + +Each hop of a multi-hop path is judged on its own object, resolved the way the +field-level gate resolves it: the join the cube keys by the path, or else the +relationship name itself. + +**What is not affected.** A query through a related object the caller may read +answers as before, within the caller's row scope. A system context, and a +caller with no permission sets, are unaffected, as on the data API. A +deployment with no security service applies no object-level check, as on the +data API. + +**Refusals that change form.** On the ObjectQL strategy a related object the +caller may not read was already refused; it now answers the analytics door's +refusal rather than the engine's, the same one a declared join gets. A filter, +a time window or a two-hop path through such an object moves from +`400 INVALID_FIELD` to that `403`. A relationship path whose relationship name +is not itself an object name was never served by either strategy; for a caller +the object-level check applies to, it now answers `403 PERMISSION_DENIED` +naming that relationship. + +**If a widget stopped answering for some users,** it reads a related object +those users may not read. Grant read access on that object to the users who +need it, or build the widget on objects they can read. From fdbdc670dc858a9198add526963a2f21ad7c1a3f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:41:10 +0000 Subject: [PATCH 4/4] test(service-analytics): type the relationship-path pin's outcome reader for both faces (#20933) Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- .../src/__tests__/relationship-path-admission.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/services/service-analytics/src/__tests__/relationship-path-admission.test.ts b/packages/services/service-analytics/src/__tests__/relationship-path-admission.test.ts index 3ffcc99b8ea..f7b9fe5b1c4 100644 --- a/packages/services/service-analytics/src/__tests__/relationship-path-admission.test.ts +++ b/packages/services/service-analytics/src/__tests__/relationship-path-admission.test.ts @@ -145,9 +145,9 @@ function makeService( } /** The refusal's envelope and the object it names, or the answer. */ -const outcomeOf = (run: () => Promise<{ rows?: unknown }>) => +const outcomeOf = (run: () => Promise) => run().then( - (r) => ({ answered: r.rows }), + (r) => ({ answered: (r as { rows?: unknown }).rows }), (e: { code?: string; status?: number; object?: string }) => ({ refused: { code: e?.code, status: e?.status, object: e?.object } }), );