diff --git a/.changeset/20917-analytics-field-permission-gate.md b/.changeset/20917-analytics-field-permission-gate.md new file mode 100644 index 00000000000..8b099e60cde --- /dev/null +++ b/.changeset/20917-analytics-field-permission-gate.md @@ -0,0 +1,38 @@ +--- +'@objectstack/service-analytics': minor +--- + +fix(service-analytics)!: every analytics face answers the engine's field-level read refusal, whichever strategy serves the cube: a field the caller may not read is judged before either strategy runs (#20917) + +Clause-②: yes (narrowing) + + + +**BREAKING for analytics queries on a SQL deployment that read a field the caller may not read.** + +**What changed.** `POST /api/v1/analytics/query`, `POST /api/v1/analytics/sql` +and `POST /api/v1/analytics/dataset/query` now judge every field a query reads +against the caller's field-level read permissions before a strategy is chosen: +dimensions, measures, time dimensions, filter members, order keys, members +joined through a relationship, and a dataset's own and its requested measures' +filters. A member of an authored cube is judged by the field it resolves to, +not by its name in the cube. A field the caller may not read answers +`403 PERMISSION_DENIED`, in the words the engine uses for the same field. The +native-SQL strategy, the one a SQL driver serves first, answered such queries; +the ObjectQL strategy and the data API already refused them. + +**What is not affected.** A query that reads only fields the caller may read +answers as before. A system context, and a caller with no permission sets, are +unaffected, as on the data API. A host read scope (row-level policy) may still +name fields the caller cannot read. A deployment with no security service applies +no field-level check, as on the data API. A member of an authored cube whose `sql` +is an expression is not attributed to a field. + +**New hook.** `AnalyticsServiceConfig.getReadableFields(object, context)` supplies +the reader. `AnalyticsServicePlugin` wires it to the `security` service's +`getReadableFields`; a host that constructs `AnalyticsService` itself passes its +own, and without one no field-level check applies. + +**If a widget stopped answering for some users,** it reads a field those users +may not read. Grant that field's read permission to the users who need it, or +build the widget on fields they can read. diff --git a/packages/rest/src/analytics-field-permission-gate.test.ts b/packages/rest/src/analytics-field-permission-gate.test.ts new file mode 100644 index 00000000000..96c908f1353 --- /dev/null +++ b/packages/rest/src/analytics-field-permission-gate.test.ts @@ -0,0 +1,378 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20917] Every member an analytics query names is judged against the + * caller's field-level read permissions BEFORE either strategy runs, and a + * member the caller may not read answers the engine's own refusal: + * `403 PERMISSION_DENIED`, in the engine's words, whichever strategy would have + * served the cube 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 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 + * field-permission hook of its own — the plugin reaches the `security` + * service's reader itself. Two compositions, one per strategy: + * + * - `native` — the plugin's own capabilities, so `NativeSQLStrategy` is the + * strategy the service asks first (a SQL driver serves raw statements); + * - `objectql` — the capabilities narrowed to the engine-aggregate path, so + * `ObjectQLStrategy` answers every query. + * + * The reference for every refusal is the engine's answer for the same field, + * computed in the same test as the same caller: `engine.aggregate` grouping by + * the field for a member the query groups or aggregates, `engine.find` + * filtering on it for a member the query constrains. Code, status and message + * must all equal it. + * + * Both cube kinds are covered: the object's INFERRED cube (the ad-hoc read) and + * an AUTHORED cube whose members are aliases over the fields, where the gate + * judges the field a member resolves to rather than the alias. + * + * SQLite only. The gate asks its question before a strategy is selected, so no + * statement is compiled and no driver is reached for a refused query — the + * dialect cannot enter into the verdict. + */ + +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 OBJECT = 'rest_an_fls_ledger'; +const OWNER = 'rest_an_fls_owner'; +const AUTHORED = 'rest_an_fls_authored'; + +const SYS_CTX = { isSystem: true, userId: 'usr_system' }; + +/** The member's grants: every object readable, four fields not. */ +const MEMBER_SET = PermissionSetSchema.parse({ + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + fields: { + [`${OBJECT}.hidden_text`]: { readable: false, editable: false }, + [`${OBJECT}.hidden_number`]: { readable: false, editable: false }, + [`${OBJECT}.hidden_at`]: { readable: false, editable: false }, + [`${OWNER}.hidden_text`]: { readable: false, editable: false }, + }, +}); + +const MEMBER_CTX = { userId: 'usr_member', positions: [], permissions: [MEMBER_SET.name], posture: 'MEMBER' }; + +const OWNERS = [ + { id: 'u1', region: 'r1', hidden_text: 'h1' }, + { id: 'u2', region: 'r2', hidden_text: 'h2' }, +]; +const ROWS = [ + { id: 'd1', title: 't1', hidden_text: 'x1', hidden_number: 1, hidden_at: '2026-01-05T00:00:00.000Z', owner: 'u1', [OWNER]: 'u1' }, + { id: 'd2', title: 't2', hidden_text: 'x2', hidden_number: 2, hidden_at: '2026-02-05T00:00:00.000Z', owner: 'u2', [OWNER]: 'u2' }, + { id: 'd3', title: 't3', hidden_text: 'x1', hidden_number: 3, hidden_at: '2026-03-05T00:00:00.000Z', owner: 'u1', [OWNER]: 'u1' }, +]; + +/** An authored cube over the ledger: its members are aliases over the fields. */ +const AUTHORED_CUBE = { + name: AUTHORED, + title: 'Authored ledger', + sql: OBJECT, + measures: { + count: { type: 'count', sql: '*', label: 'Count' }, + alias_total: { type: 'sum', sql: 'hidden_number', label: 'Total' }, + }, + dimensions: { + title: { type: 'string', sql: 'title', label: 'Title' }, + alias_code: { type: 'string', sql: 'hidden_text', label: 'Code' }, + alias_owner_code: { type: 'string', sql: 'owner.hidden_text', label: 'Owner code' }, + alias_owner_region: { type: 'string', sql: 'owner.region', label: 'Owner region' }, + }, + joins: { owner: { name: OWNER } }, +}; + +/** The inline dataset the dataset door queries. It includes the `owner` relationship. */ +const DATASET = { + name: 'fls_gate_inline', + label: 'Field gate inline', + object: OBJECT, + include: ['owner'], + dimensions: [ + { name: 'title_dim', field: 'title', type: 'string' }, + { name: 'hidden_dim', field: 'hidden_text', type: 'string' }, + { name: 'owner_hidden_dim', field: 'owner.hidden_text', type: 'string' }, + { name: 'owner_region_dim', field: 'owner.region', type: 'string' }, + ], + measures: [ + { name: 'row_count', aggregate: 'count' }, + { name: 'hidden_total', aggregate: 'sum', field: 'hidden_number' }, + { name: 'hidden_filtered_count', aggregate: 'count', filter: { hidden_text: 'x1' } }, + ], +}; + +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; +} + +type Thrown = { code?: string; status?: number; statusCode?: number; message?: string } | null; +/** The three things a caller reads off a refusal. */ +const refusalOf = (e: Thrown) => ({ code: e?.code, status: e?.status ?? e?.statusCode, message: String(e?.message) }); + +interface Harness { + engine: ObjectQL; + service: AnalyticsService; + dataset: (selection: Record, dataset?: 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-field-permission-gate-20917', + name: 'Analytics field permission gate', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + { + name: OWNER, + label: 'Owner', + sharingModel: 'public_read_write', + fields: { region: { name: 'region', type: 'text' }, hidden_text: { name: 'hidden_text', type: 'text' } }, + }, + { + name: OBJECT, + label: 'Ledger', + sharingModel: 'public_read_write', + fields: { + title: { name: 'title', type: 'text' }, + hidden_text: { name: 'hidden_text', type: 'text' }, + hidden_number: { name: 'hidden_number', type: 'number' }, + hidden_at: { name: 'hidden_at', type: 'datetime' }, + owner: { name: 'owner', type: 'lookup', reference: OWNER }, + // The same relationship under a field named after its target object: + // the spelling an inferred cube joins through (`.`). + [OWNER]: { name: OWNER, type: 'lookup', reference: OWNER }, + }, + }, + ], + } 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(OWNER, OWNERS.map((r) => ({ ...r })), { context: SYS_CTX } as never); + await engine.insert(OBJECT, ROWS.map((r) => ({ ...r })), { context: SYS_CTX } as never); + + await new AnalyticsServicePlugin({ + cubes: [AUTHORED_CUBE 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 dataset = async (selection: Record, definition: Record = DATASET) => { + 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, dataset }; +} + +/** The engine's refusal for a member it GROUPS or AGGREGATES, as the member. */ +async function engineAggregateRefusal(engine: ObjectQL, object: string, field: string) { + return engine + .aggregate(object, { groupBy: [field], aggregations: [{ function: 'count', alias: 'n' }], context: MEMBER_CTX } as never) + .then(() => null, (e: Thrown) => refusalOf(e)); +} + +/** A comparand each hidden field's type admits, so the engine reaches its permission check. */ +const COMPARAND: Readonly> = { hidden_text: 'x1', hidden_number: 1, hidden_at: '2026-01-05T00:00:00.000Z' }; + +/** The engine's refusal for a member it FILTERS on, as the member. */ +async function enginePredicateRefusal(engine: ObjectQL, object: string, field: string) { + return engine + .find(object, { where: { [field]: COMPARAND[field] }, context: MEMBER_CTX } as never) + .then(() => null, (e: Thrown) => refusalOf(e)); +} + +/** What a face answered: its rows, or its refusal. */ +const answerOf = (run: () => Promise<{ rows?: unknown; sql?: unknown }>) => + run().then( + (r) => ({ answered: r }), + (e: Thrown) => ({ refused: refusalOf(e) }), + ); + +const sortRows = (rows: ReadonlyArray>) => + [...rows].map((r) => JSON.stringify(r)).sort(); + +for (const strategy of ['native', 'objectql'] as const) { + describe(`[#20917] a member naming a field the caller may not read answers the engine's refusal — ${strategy} composition`, () => { + let h: Harness; + + beforeAll(async () => { + h = await boot(strategy); + }, 60_000); + + afterAll(async () => { + try { await h?.engine.destroy(); } catch { /* noop */ } + }); + + it('the references: the engine refuses each hidden field as the caller, grouped and filtered', async () => { + for (const [object, field] of [[OBJECT, 'hidden_text'], [OBJECT, 'hidden_number'], [OBJECT, 'hidden_at'], [OWNER, 'hidden_text']] as const) { + expect(await engineAggregateRefusal(h.engine, object, field), `${object}.${field} grouped`).toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + expect(await enginePredicateRefusal(h.engine, object, field), `${object}.${field} filtered`).toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + } + }); + + it('the cube read and the SQL echo refuse a grouped, aggregated, filtered or joined hidden member of the inferred cube', async () => { + const cases: ReadonlyArray, 'aggregate' | 'predicate', string, string]> = [ + ['a grouped member', { measures: ['count'], dimensions: ['hidden_text'] }, 'aggregate', OBJECT, 'hidden_text'], + ['an aggregated member', { measures: ['hidden_number_sum'] }, 'aggregate', OBJECT, 'hidden_number'], + ['a bucketed time dimension', { measures: ['count'], dimensions: ['hidden_at'], timeDimensions: [{ dimension: 'hidden_at', granularity: 'month' }] }, 'aggregate', OBJECT, 'hidden_at'], + ['a filtered member', { measures: ['count'], where: { hidden_text: 'x1' } }, 'predicate', OBJECT, 'hidden_text'], + ['a filtered member under $or', { measures: ['count'], where: { $or: [{ title: 't2' }, { hidden_text: 'x1' }] } }, 'predicate', OBJECT, 'hidden_text'], + ['a time dimension\'s window', { measures: ['count'], timeDimensions: [{ dimension: 'hidden_at', dateRange: ['2026-01-01', '2026-01-31'] }] }, 'predicate', OBJECT, 'hidden_at'], + ['an order key', { measures: ['count'], dimensions: ['title'], order: { hidden_text: 'asc' } }, 'predicate', OBJECT, 'hidden_text'], + ['a joined member, grouped', { measures: ['count'], dimensions: [`${OWNER}.hidden_text`] }, 'aggregate', OWNER, 'hidden_text'], + ['a joined member, filtered', { measures: ['count'], where: { [`${OWNER}.hidden_text`]: 'h1' } }, 'predicate', OWNER, 'hidden_text'], + ]; + for (const [label, body, role, object, field] of cases) { + const reference = role === 'aggregate' + ? await engineAggregateRefusal(h.engine, object, field) + : await enginePredicateRefusal(h.engine, object, field); + const query = { cube: OBJECT, ...body } as never; + expect(await answerOf(() => h.service.query(query, MEMBER_CTX as never)), `${label}: the cube read`).toEqual({ refused: reference }); + expect(await answerOf(() => h.service.generateSql(query, MEMBER_CTX as never)), `${label}: the SQL echo`).toEqual({ refused: reference }); + } + }); + + it('an authored cube is judged by the field each alias resolves to, joined aliases included', async () => { + const cases: ReadonlyArray, 'aggregate' | 'predicate', string, string]> = [ + ['a grouped alias', { measures: ['count'], dimensions: ['alias_code'] }, 'aggregate', OBJECT, 'hidden_text'], + ['an aggregated alias', { measures: ['alias_total'] }, 'aggregate', OBJECT, 'hidden_number'], + ['a filtered alias', { measures: ['count'], where: { alias_code: 'x1' } }, 'predicate', OBJECT, 'hidden_text'], + ['a joined alias, grouped', { measures: ['count'], dimensions: ['alias_owner_code'] }, 'aggregate', OWNER, 'hidden_text'], + ['a joined alias, filtered', { measures: ['count'], where: { alias_owner_code: 'h1' } }, 'predicate', OWNER, 'hidden_text'], + ]; + for (const [label, body, role, object, field] of cases) { + const reference = role === 'aggregate' + ? await engineAggregateRefusal(h.engine, object, field) + : await enginePredicateRefusal(h.engine, object, field); + const query = { cube: AUTHORED, ...body } as never; + expect(await answerOf(() => h.service.query(query, MEMBER_CTX as never)), `${label}: the cube read`).toEqual({ refused: reference }); + expect(await answerOf(() => h.service.generateSql(query, MEMBER_CTX as never)), `${label}: the SQL echo`).toEqual({ refused: reference }); + } + }); + + it('the dataset door refuses a hidden member in every position it carries one — never rows beside the refusal', async () => { + const cases: ReadonlyArray, Record | undefined, 'aggregate' | 'predicate', string, string]> = [ + ['a grouped member', { measures: ['row_count'], dimensions: ['hidden_dim'] }, undefined, 'aggregate', OBJECT, 'hidden_text'], + ['an aggregated member', { measures: ['hidden_total'], dimensions: ['title_dim'] }, undefined, 'aggregate', OBJECT, 'hidden_number'], + ['a joined member, grouped', { measures: ['row_count'], dimensions: ['owner_hidden_dim'] }, undefined, 'aggregate', OWNER, 'hidden_text'], + ['a filtered member', { measures: ['row_count'], dimensions: ['title_dim'], runtimeFilter: { hidden_text: 'x1' } }, undefined, 'predicate', OBJECT, 'hidden_text'], + ['a joined member, filtered', { measures: ['row_count'], dimensions: ['title_dim'], runtimeFilter: { 'owner.hidden_text': 'h1' } }, undefined, 'predicate', OWNER, 'hidden_text'], + ['a measure\'s own filter', { measures: ['hidden_filtered_count'], dimensions: ['title_dim'] }, undefined, 'predicate', OBJECT, 'hidden_text'], + ['the dataset\'s own filter', { measures: ['row_count'], dimensions: ['title_dim'] }, { ...DATASET, filter: { hidden_text: 'x1' } }, 'predicate', OBJECT, 'hidden_text'], + ]; + for (const [label, selection, definition, role, object, field] of cases) { + const reference = role === 'aggregate' + ? await engineAggregateRefusal(h.engine, object, field) + : await enginePredicateRefusal(h.engine, object, field); + const ds = await h.dataset(selection, definition); + expect({ status: ds.status, code: ds.body?.code, message: ds.body?.message }, `${label}: the dataset door`).toEqual(reference); + expect(ds.body?.rows, `${label}: no rows beside the refusal`).toBeUndefined(); + } + }); + + it('the control: members the caller may read answer as before, joined members included', async () => { + const cube = await h.service.query( + { cube: OBJECT, measures: ['count'], dimensions: ['title'], where: { title: { $in: ['t1', 't3'] } } } as never, + MEMBER_CTX as never, + ); + expect(sortRows(cube.rows)).toEqual(sortRows([{ title: 't1', count: 1 }, { title: 't3', count: 1 }])); + + const joined = await h.service.query({ cube: OBJECT, measures: ['count'], dimensions: [`${OWNER}.region`] } as never, MEMBER_CTX as never); + expect(sortRows(joined.rows)).toEqual(sortRows([{ [`${OWNER}.region`]: 'r1', count: 2 }, { [`${OWNER}.region`]: 'r2', count: 1 }])); + + const authored = await h.service.query({ cube: AUTHORED, measures: ['count'], dimensions: ['alias_owner_region'] } as never, MEMBER_CTX as never); + expect(sortRows(authored.rows)).toEqual(sortRows([{ alias_owner_region: 'r1', count: 2 }, { alias_owner_region: 'r2', count: 1 }])); + + const ds = await h.dataset({ measures: ['row_count'], dimensions: ['owner_region_dim'], runtimeFilter: { title: { $in: ['t1', 't2'] } } }); + expect(ds.status, JSON.stringify(ds.body)).toBe(200); + expect(sortRows(ds.body.rows)).toEqual(sortRows([{ owner_region_dim: 'r1', row_count: 1 }, { owner_region_dim: 'r2', row_count: 1 }])); + + const echo = await h.service.generateSql({ cube: OBJECT, measures: ['count'], dimensions: ['title'] } as never, MEMBER_CTX as never); + expect(typeof echo.sql).toBe('string'); + }); + + it('what the refusal withholds: a system caller is answered for the same hidden member', async () => { + const system = await h.service.query({ cube: OBJECT, measures: ['count'], dimensions: ['hidden_text'] } as never, SYS_CTX as never); + expect(sortRows(system.rows)).toEqual(sortRows([{ hidden_text: 'x1', count: 2 }, { hidden_text: 'x2', count: 1 }])); + }); + }); +} diff --git a/packages/services/service-analytics/src/__tests__/field-read-admission-gate.test.ts b/packages/services/service-analytics/src/__tests__/field-read-admission-gate.test.ts new file mode 100644 index 00000000000..a6649f699cc --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/field-read-admission-gate.test.ts @@ -0,0 +1,347 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20917] The FIELD-LEVEL read gate at the analytics door. + * + * Every member a query names is resolved to the field it reads — through the + * cube's own declaration, through a join, or as the object's own column — and + * judged against the caller's readable fields, asked of the host's reader + * (`AnalyticsServiceConfig.getReadableFields`; the plugin bridges it to the + * `security` service). A member the caller may not read is refused + * `PERMISSION_DENIED` / 403 in the engine's words, BEFORE a strategy is + * selected — so every case below runs through both strategy paths from one + * table, and asserts that nothing executed. + * + * The route-level half, over the real `SecurityPlugin`, `ObjectQL` and + * `SqlDriver`, compares each refusal with the engine's own answer: + * `packages/rest/src/analytics-field-permission-gate.test.ts`. + */ + +import { describe, it, expect, vi } 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'; +import { AnalyticsServicePlugin } from '../plugin.js'; + +const LEDGER = 'fr_ledger'; +const OWNER = 'fr_owner'; + +/** Each object's declared fields, as the schema registry answers them. */ +const FIELDS: Readonly> = { + [LEDGER]: ['title', 'hidden_text', 'hidden_number', 'hidden_at', 'owner', 'hidden_link', 'created_at'], + [OWNER]: ['region', 'hidden_text'], +}; + +/** What the caller may read of each: the reader's answer. */ +const READABLE: Readonly> = { + [LEDGER]: ['id', 'title', 'owner', 'created_at'], + [OWNER]: ['id', 'region'], +}; + +const CALLER = { userId: 'u_member', tenantId: 'org_a' } as ExecutionContext; + +const AUTHORED: Cube = { + name: 'fr_authored', + title: 'Authored ledger', + sql: LEDGER, + public: true, + measures: { + count: { type: 'count', sql: '*', label: 'Count' }, + alias_total: { type: 'sum', sql: 'hidden_number', label: 'Total' }, + expression_total: { type: 'number', sql: 'SUM(hidden_number) / 2', label: 'Expression total' }, + }, + dimensions: { + title: { type: 'string', sql: 'title', label: 'Title' }, + alias_code: { type: 'string', sql: 'hidden_text', label: 'Code' }, + alias_owner_code: { type: 'string', sql: 'owner.hidden_text', label: 'Owner code' }, + alias_owner_region: { type: 'string', sql: 'owner.region', label: 'Owner region' }, + alias_link_region: { type: 'string', sql: 'hidden_link.region', label: 'Linked region' }, + expression_flag: { type: 'number', sql: "CASE WHEN hidden_text = 'x1' THEN 1 ELSE 0 END", label: 'Flag' }, + }, + joins: { owner: { name: OWNER }, hidden_link: { name: OWNER } }, +} as Cube; + +/** A registered dataset whose OWN filter, and one of whose measures' filter, name a hidden field. */ +const SCOPED_DATASET = DatasetSchema.parse({ + name: 'fr_scoped', + label: 'Scoped', + object: LEDGER, + filter: { hidden_text: 'x1' }, + dimensions: [{ name: 'title', field: 'title', type: 'string' }], + measures: [ + { name: 'row_count', aggregate: 'count' }, + ], +}); +const MEASURE_FILTER_DATASET = DatasetSchema.parse({ + name: 'fr_measure_scoped', + label: 'Measure scoped', + object: LEDGER, + dimensions: [{ name: 'title', field: 'title', type: 'string' }], + measures: [ + { name: 'row_count', aggregate: 'count' }, + { name: 'filtered_count', aggregate: 'count', filter: { hidden_text: 'x1' } }, + ], +}); + +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; + +type ReadableFields = (object: string, context?: ExecutionContext) => + readonly string[] | undefined | Promise; + +function makeService(opts: { + capabilities: () => { nativeSql: boolean; objectqlAggregate: boolean; inMemory: boolean }; + getReadableFields?: ReadableFields; + getObjectFieldNames?: ((object: string) => readonly string[] | undefined) | null; + getReadScope?: (object: string) => Record | undefined; + draftRowsResolver?: (object: string) => Promise[] | null>; + logger?: Record; +}) { + const executed: string[] = []; + const service = new AnalyticsService({ + cubes: [AUTHORED], + datasets: [SCOPED_DATASET, MEASURE_FILTER_DATASET], + queryCapabilities: opts.capabilities, + getReadableFields: opts.getReadableFields, + getObjectFieldNames: opts.getObjectFieldNames === null + ? undefined + : (opts.getObjectFieldNames ?? ((object: string) => FIELDS[object])), + getReadScope: opts.getReadScope as never, + draftRowsResolver: opts.draftRowsResolver, + ...(opts.logger ? { logger: opts.logger as never } : {}), + executeRawSql: async (object: string, sql: string) => { + executed.push(`sql:${object}:${sql}`); + return []; + }, + executeAggregate: async (object: string) => { + executed.push(`aggregate:${object}`); + return []; + }, + } as never); + return { service, executed }; +} + +const readable: ReadableFields = (object) => READABLE[object]; + +/** The first sentence of each of the engine's two refusals. */ +const aggregateSentence = (object: string, fields: string[]) => + `[Security] Field read denied: not permitted to aggregate [${fields.join(', ')}] on '${object}'`; +const predicateSentence = (object: string, fields: string[]) => + `[Security] Access denied: query on '${object}' references field(s) not readable by the caller: ${fields.join(', ')}.`; + +interface RefusalCase { + label: string; + query: Record; + role: 'aggregate' | 'predicate'; + object: string; + fields: string[]; +} + +const REFUSED: readonly RefusalCase[] = [ + { label: 'a grouped alias', query: { cube: 'fr_authored', measures: ['count'], dimensions: ['alias_code'] }, role: 'aggregate', object: LEDGER, fields: ['hidden_text'] }, + { label: 'an aggregated alias', query: { cube: 'fr_authored', measures: ['alias_total'] }, role: 'aggregate', object: LEDGER, fields: ['hidden_number'] }, + { label: 'an inferred measure', query: { cube: LEDGER, measures: ['hidden_number_sum'] }, role: 'aggregate', object: LEDGER, fields: ['hidden_number'] }, + { label: 'a grouped column of the inferred cube', query: { cube: LEDGER, measures: ['count'], dimensions: ['hidden_text'] }, role: 'aggregate', object: LEDGER, fields: ['hidden_text'] }, + { label: 'a bucketed time dimension', query: { cube: LEDGER, measures: ['count'], timeDimensions: [{ dimension: 'hidden_at', granularity: 'month' }] }, role: 'aggregate', object: LEDGER, fields: ['hidden_at'] }, + { label: 'a time dimension\'s window', query: { cube: LEDGER, measures: ['count'], timeDimensions: [{ dimension: 'hidden_at', dateRange: ['2026-01-01', '2026-01-31'] }] }, role: 'predicate', object: LEDGER, fields: ['hidden_at'] }, + { label: 'a filtered alias', query: { cube: 'fr_authored', measures: ['count'], where: { alias_code: 'x1' } }, role: 'predicate', object: LEDGER, fields: ['hidden_text'] }, + { label: 'a filtered column under $or and $not', query: { cube: LEDGER, measures: ['count'], where: { $or: [{ title: 't1' }, { $not: { hidden_text: 'x1' } }] } }, role: 'predicate', object: LEDGER, fields: ['hidden_text'] }, + { label: 'an order key', query: { cube: LEDGER, measures: ['count'], dimensions: ['title'], order: { hidden_text: 'asc' } }, role: 'predicate', object: LEDGER, fields: ['hidden_text'] }, + { label: 'a joined alias, judged on the joined object', query: { cube: 'fr_authored', measures: ['count'], dimensions: ['alias_owner_code'] }, role: 'aggregate', object: OWNER, fields: ['hidden_text'] }, + { label: 'an undeclared joined member', query: { cube: 'fr_authored', measures: ['count'], where: { 'owner.hidden_text': 'h1' } }, role: 'predicate', object: OWNER, fields: ['hidden_text'] }, + { label: 'a join through a hidden relationship field', query: { cube: 'fr_authored', measures: ['count'], dimensions: ['alias_link_region'] }, role: 'aggregate', object: LEDGER, fields: ['hidden_link'] }, + { label: 'a dataset\'s own filter', query: { cube: 'fr_scoped', measures: ['row_count'], dimensions: ['title'] }, role: 'predicate', object: LEDGER, fields: ['hidden_text'] }, + { label: 'a requested measure\'s own filter', query: { cube: 'fr_measure_scoped', measures: ['filtered_count'], dimensions: ['title'] }, role: 'predicate', object: LEDGER, fields: ['hidden_text'] }, +]; + +describe('[#20917] analytics — the field-level read gate at the door', () => { + describe.each(STRATEGY_PATHS)('$label', ({ capabilities }) => { + it.each(REFUSED)('$label: refused PERMISSION_DENIED / 403 in the engine\'s words, before any strategy ran', async ({ query, role, object, fields }) => { + const { service, executed } = makeService({ capabilities, getReadableFields: readable }); + const sentence = role === 'aggregate' ? aggregateSentence(object, fields) : predicateSentence(object, fields); + for (const run of [() => service.query(query as never, CALLER), () => service.generateSql(query as never, CALLER)]) { + const refusal = await run().then(() => null, (e: unknown) => e as Record); + expect(refusal).toMatchObject({ code: 'PERMISSION_DENIED', status: 403, object, fields }); + expect(String(refusal?.message).startsWith(sentence), String(refusal?.message)).toBe(true); + } + expect(executed).toEqual([]); + }); + + it('members the caller may read are served, and the reader is asked once per object with the caller\'s context', async () => { + const getReadableFields = vi.fn(readable); + const { service, executed } = makeService({ capabilities, getReadableFields }); + await service.query({ cube: 'fr_authored', measures: ['count'], dimensions: ['title', 'alias_owner_region'], where: { title: 't1' } } as never, CALLER); + expect(executed.length).toBeGreaterThan(0); + expect(getReadableFields.mock.calls.map((c) => c[0]).sort()).toEqual([LEDGER, OWNER]); + for (const call of getReadableFields.mock.calls) expect(call[1]).toBe(CALLER); + }); + + it('a query naming no field asks the reader nothing', async () => { + const getReadableFields = vi.fn(readable); + const { service } = makeService({ capabilities, getReadableFields }); + await service.query({ cube: LEDGER, measures: ['count'] } as never, CALLER); + expect(getReadableFields).not.toHaveBeenCalled(); + }); + + it('grouping words win over filtering words on the same object, and the base object is judged before a joined one', async () => { + const { service } = makeService({ capabilities, getReadableFields: readable }); + const both = await service + .query({ cube: 'fr_authored', measures: ['count'], dimensions: ['alias_owner_code'], where: { alias_code: 'x1' } } as never, CALLER) + .then(() => null, (e: Error) => e.message); + expect(both?.startsWith(predicateSentence(LEDGER, ['hidden_text'])), String(both)).toBe(true); + const grouped = await service + .query({ cube: LEDGER, measures: ['count'], dimensions: ['hidden_text'], where: { hidden_number: 1 } } as never, CALLER) + .then(() => null, (e: Error) => e.message); + expect(grouped).toBe(aggregateSentence(LEDGER, ['hidden_text'])); + }); + + it('stands down where no field can be named: an authored expression member, and an object the reader has no answer for', async () => { + const { service, executed } = makeService({ capabilities: nativeSqlOnly, getReadableFields: readable }); + await service.query({ cube: 'fr_authored', measures: ['expression_total'], dimensions: ['expression_flag'] } as never, CALLER); + expect(executed).toHaveLength(1); + + const unanswered = makeService({ capabilities, getReadableFields: (object) => (object === OWNER ? undefined : READABLE[object]) }); + await unanswered.service.query({ cube: 'fr_authored', measures: ['count'], dimensions: ['alias_owner_code'] } as never, CALLER); + expect(unanswered.executed.length).toBeGreaterThan(0); + }); + + it('judges only names the object\'s field list carries, and every name when that list is unavailable', async () => { + const listed = makeService({ capabilities: nativeSqlOnly, getReadableFields: readable }); + await listed.service.query({ cube: 'fr_authored', measures: ['count'], where: { 'owner.not_a_field': 'v' } } as never, CALLER); + expect(listed.executed).toHaveLength(1); + + const unlisted = makeService({ capabilities, getReadableFields: readable, getObjectFieldNames: null }); + await expect( + unlisted.service.query({ cube: 'fr_authored', measures: ['count'], where: { 'owner.not_a_field': 'v' } } as never, CALLER), + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403, object: OWNER, fields: ['not_a_field'] }); + expect(unlisted.executed).toEqual([]); + }); + + it('a host read scope is not judged: a policy predicate may name a field the caller cannot read', async () => { + const { service, executed } = makeService({ + capabilities, + getReadableFields: readable, + getReadScope: (object) => (object === LEDGER ? { hidden_text: 'x1' } : undefined), + }); + await service.query({ cube: LEDGER, measures: ['count'], dimensions: ['title'] } as never, CALLER); + expect(executed.length).toBeGreaterThan(0); + }); + + it('fails closed: a reader that throws refuses the query, and says so in the error log', async () => { + const error = vi.fn(); + const logger = { debug() {}, info() {}, warn() {}, error, child() { return logger; } }; + const { service, executed } = makeService({ + capabilities, + getReadableFields: () => { throw new Error('reader unavailable'); }, + logger, + }); + await expect( + service.query({ cube: LEDGER, measures: ['count'], dimensions: ['title'] } as never, CALLER), + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403, object: LEDGER }); + expect(executed).toEqual([]); + expect(error.mock.calls.map((c) => String(c[0])).join('\n')).toMatch(/field-level read admission could not be resolved .*\(fail-closed\)/); + }); + + it('with no reader wired, no field-level gate applies', async () => { + const { service, executed } = makeService({ capabilities }); + await service.query({ cube: LEDGER, measures: ['count'], dimensions: ['hidden_text'] } as never, CALLER); + expect(executed.length).toBeGreaterThan(0); + }); + }); + + it('the draft-preview branch asks the same gate before it evaluates drafted rows', async () => { + const draft = makeService({ + capabilities: nativeSqlOnly, + getReadableFields: readable, + draftRowsResolver: async () => [{ id: 'd1', title: 't1', hidden_text: 'x1' }], + }); + const dataset = DatasetSchema.parse({ + name: 'fr_draft', + label: 'Draft', + object: LEDGER, + dimensions: [{ name: 'code', field: 'hidden_text', type: 'string' }, { name: 'title', field: 'title', type: 'string' }], + measures: [{ name: 'row_count', aggregate: 'count' }], + }); + await expect( + draft.service.queryDataset(dataset, { measures: ['row_count'], dimensions: ['code'] } as never, CALLER, { previewDrafts: true }), + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403, object: LEDGER, fields: ['hidden_text'] }); + const served = await draft.service.queryDataset(dataset, { measures: ['row_count'], dimensions: ['title'] } as never, CALLER, { previewDrafts: true }); + expect(served.rows).toEqual([{ title: 't1', row_count: 1 }]); + }); +}); + +// ── The plugin's bridge to the `security` service ───────────────────────────── + +function fakeEngine() { + const reads: string[] = []; + return { + reads, + engine: { + execute: async (_sql: unknown, options?: { object?: string }) => { + reads.push(`execute:${options?.object ?? ''}`); + return { rows: [] }; + }, + aggregate: async (object: string) => { + reads.push(`aggregate:${object}`); + return []; + }, + getObject: (name: string) => + FIELDS[name] ? { fields: Object.fromEntries(FIELDS[name].map((f) => [f, { type: 'text' }])) } : undefined, + resolveEffectiveDatasource: () => undefined, + }, + }; +} + +async function bootPlugin(security?: () => unknown) { + const { engine, reads } = fakeEngine(); + const registered: Record = {}; + const error = vi.fn(); + const ctx = { + getService: (name: string) => { + if (name === 'security') return security ? security() : undefined; + if (name === 'data') return engine; + return registered[name]; + }, + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, + logger: { info() {}, warn() {}, error, debug() {} }, + }; + await new AnalyticsServicePlugin({ queryCapabilities: nativeSqlOnly }).init(ctx as never); + return { service: registered.analytics as AnalyticsService, reads, error }; +} + +/** The object-level and row-level halves of a working security service. */ +const objectAndRowsOpen = { getReadFilter: async () => undefined, canReadObject: async () => true }; +const groupedHidden = { cube: LEDGER, measures: ['count'], dimensions: ['hidden_text'] }; +const groupedReadable = { cube: LEDGER, measures: ['count'], dimensions: ['title'] }; + +describe('[#20917] analytics plugin — the field-level half of the "security" bridge', () => { + it('asks the security service\'s getReadableFields with the caller\'s context, refusing a hidden member and serving a readable one', async () => { + const getReadableFields = vi.fn(async (object: string) => READABLE[object]); + const { service, reads } = await bootPlugin(() => ({ ...objectAndRowsOpen, getReadableFields })); + await expect(service.query(groupedHidden as never, CALLER)).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403, fields: ['hidden_text'] }); + expect(reads).toEqual([]); + expect(getReadableFields).toHaveBeenCalledWith(LEDGER, CALLER); + await service.query(groupedReadable as never, CALLER); + expect(reads).toHaveLength(1); + }); + + it('refuses, and says why, when the registered security service exposes no getReadableFields', async () => { + const { service, reads, error } = await bootPlugin(() => ({ ...objectAndRowsOpen })); + await expect(service.query(groupedReadable as never, CALLER)).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + expect(reads).toEqual([]); + expect(error.mock.calls.map((c) => String(c[0])).join('\n')).toMatch(/getReadableFields\(\)/); + }); + + it('applies no field-level gate when no security service is registered at all', async () => { + const { service, reads } = await bootPlugin(undefined); + await service.query(groupedHidden as never, CALLER); + expect(reads).toHaveLength(1); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index bcbc71ca3bc..3893e2a18ff 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -45,6 +45,14 @@ import { // object-level `readAdmissionDeniedError` above, and the reason a fail-closed // row-scope denial can no longer be re-judged by its wording. import { readScopeUnresolvedError } from './read-scope-refusal.js'; +// [#20917] …and the FIELD-level half, asked at the same door right after it: +// every member a query names, judged against the caller's readable fields. +import { + assertNamedFieldsReadable, + type NamedField, + type FieldReadRole, + type ReadableFieldsProvider, +} from './field-read-admission.js'; // [#15768] The measure result-type rule — which aggregates return a value of // the aggregated field's own type, and which are numeric whatever they read. // Owned in its own module so the enumerated verdict per `AggregationFunction` @@ -382,6 +390,112 @@ function resolveMemberSource( return { key: member, source: BARE_IDENTIFIER.test(member) ? member : null }; } +/** [#20917] A dotted identifier path: relationship hops, then one column — `NativeSQLStrategy`'s own test. */ +const IDENTIFIER_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$/; + +/** + * [#20917] The fields a member's column `sql` reads, each on the object that + * declares it. + * + * - A bare identifier is a column of the base object. + * - A dotted identifier path is a relationship path: every segment but the + * last is a relationship field on the object before it, the last is the + * column. Each hop's object is the cube's join at that path, keyed as the + * strategies key it (the path with its dots as `__`) and falling back to the + * alias itself — the object both strategies read there. The relationship + * fields are named too: a hidden relationship field discloses which record + * each row points to, and the engine judges a path's first segment on the + * local object for the same reason. + * - Anything else is an EXPRESSION the cube's author wrote (`CASE WHEN …`, + * `SUM(…)`, `*`). It names no field this gate can attribute, so it adds + * nothing — the author's declaration of a derived value, the way a formula + * field is. + */ +function fieldsOfColumnSql(cube: Cube, baseObject: string, sql: string, role: FieldReadRole): NamedField[] { + const path = sql.trim(); + if (BARE_IDENTIFIER.test(path)) return [{ object: baseObject, field: path, role }]; + if (!IDENTIFIER_PATH.test(path)) return []; + const segments = path.split('.'); + const joins = cube.joins as Record | undefined; + const out: NamedField[] = []; + let object = baseObject; + let alias = ''; + for (const segment of segments.slice(0, -1)) { + out.push({ object, field: segment, role }); + alias = alias ? `${alias}__${segment}` : segment; + const joined = joins?.[alias]?.name; + object = typeof joined === 'string' && joined !== '' ? joined : alias; + } + out.push({ object, field: segments[segments.length - 1], role }); + return out; +} + +/** + * [#20917] Every field a query reads, for the field-level read gate — the + * members the caller named and the compiled dataset's own filters, each + * resolved the way the strategies resolve it ({@link declaredMemberEntry}), + * base-object fields first. + * + * | position | resolved as | role | + * |:--|:--|:--| + * | `dimensions` | dimension | aggregate | + * | `timeDimensions` | any | aggregate when bucketed, else predicate (a window) | + * | `measures` | measure (the field it aggregates) | aggregate | + * | `where` leaves | any | predicate | + * | a requested measure's own filter | any | predicate | + * | the dataset's own filter | any | predicate | + * | `order` keys | any | predicate | + * + * An undeclared member is read as the column the strategies read for it: the + * member itself, a dotted one as a relationship path. A measure has no such + * fallback. The `where` members are read through the SAME lowering the + * strategies compile with (`normalizeAnalyticsFilterTree` + + * `collectFilterLeaves`), so the field the gate judges is the column that + * reaches the statement; a filter that lowering refuses is refused by the + * strategy, and names nothing here. A host read scope is deliberately absent: + * it is policy, and the engine's own field guard never judges policy + * predicates — they may name fields the caller cannot read. + * + * A cube whose `sql` is not a bare object name names no attributable field. + */ +function namedQueryFields( + query: AnalyticsQuery, + cube: Cube, + datasetScope: DatasetScope | undefined, +): NamedField[] { + const baseObject = typeof cube.sql === 'string' ? cube.sql.trim() : ''; + if (!baseObject || !BARE_IDENTIFIER.test(baseObject)) return []; + const out: NamedField[] = []; + const name = (member: string, kind: 'dimension' | 'measure' | 'any', role: FieldReadRole) => { + if (typeof member !== 'string' || member === '') return; + const entry = declaredMemberEntry(cube, member, kind); + const sql = entry ? entry.sql : kind === 'measure' ? undefined : member; + if (typeof sql === 'string') out.push(...fieldsOfColumnSql(cube, baseObject, sql, role)); + }; + const filterMembers = (where: unknown): string[] => { + if (!where || typeof where !== 'object') return []; + try { + return collectFilterLeaves(normalizeAnalyticsFilterTree({ where }, NO_DATETIME_COLUMNS)).map((leaf) => leaf.member); + } catch { + return []; + } + }; + + for (const member of query.dimensions ?? []) name(member, 'dimension', 'aggregate'); + for (const td of query.timeDimensions ?? []) name(td.dimension, 'any', td.granularity ? 'aggregate' : 'predicate'); + for (const member of query.measures ?? []) name(member, 'measure', 'aggregate'); + for (const member of filterMembers((query as { where?: unknown }).where)) name(member, 'any', 'predicate'); + for (const measure of query.measures ?? []) { + for (const member of filterMembers(datasetScope?.measureFilters?.[measure])) name(member, 'any', 'predicate'); + } + for (const member of filterMembers(datasetScope?.filter)) name(member, 'any', 'predicate'); + const order = (query as { order?: unknown }).order; + if (order && typeof order === 'object' && !Array.isArray(order)) { + for (const key of Object.keys(order)) name(key, 'any', 'predicate'); + } + return [...out.filter((f) => f.object === baseObject), ...out.filter((f) => f.object !== baseObject)]; +} + /** * `analytics_cube.dimensions.granularities` on the query doors: bucket every * GROUPED time dimension that names no granularity at the default its cube @@ -582,6 +696,27 @@ export interface AnalyticsServiceConfig { * is the same deployment in which `/data` has no object-level gate either. */ admitObjectRead?: ObjectReadAdmissionProvider; + /** + * [#20917] The FIELD-LEVEL read admission — which fields of an object the + * caller may read. Asked at the door, right after the object-level gate and + * before a strategy is selected, for every object a query names a field of: + * every member the query names — dimensions, measures, time dimensions, + * `where` members, order keys, joined members, and the compiled dataset's own + * and its requested measures' filters — is resolved to the field it reads + * and refused `PERMISSION_DENIED` / 403, in the engine's words, when that + * field is not readable. A host read scope ({@link getReadScope}) is not + * judged: a policy predicate may name fields the caller cannot read, as the + * engine's own field guard allows. See `field-read-admission.ts`. + * + * The plugin auto-bridges this to the `security` service's + * `getReadableFields`, so the answer is the one the engine middleware + * enforces with. MAY be async. `undefined` for an object is "no answer" and + * judges none of its fields; a THROW refuses the query (fail-closed). When + * the hook is absent entirely no field-level gate applies — the deployment + * has no security service, which is the one in which `/data` has no + * field-level security either. + */ + getReadableFields?: ReadableFieldsProvider; /** * ADR-0021 D-C — join allowlist per cube (the dataset's declared `include`). * Joins outside this set are rejected by the strategy. Compiled datasets @@ -993,6 +1128,8 @@ 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; + /** [#20917] Field-level read-admission provider (bound per call to the request context). */ + private readonly readableFieldsProvider?: ReadableFieldsProvider; /** * Compiled datasets by name, as `registerDataset` registered them — feeds the * shared scope's join allowlist (D-C) and dataset scope. `queryDataset` @@ -1061,6 +1198,7 @@ export class AnalyticsService implements IAnalyticsService { this.readScopeProvider = config.getReadScope; this.readAdmissionProvider = config.admitObjectRead; + this.readableFieldsProvider = config.getReadableFields; this.configuredAllowedRelationships = config.getAllowedRelationships; this.relationshipResolver = config.relationshipResolver; this.sourceFieldMeta = config.sourceFieldMeta; @@ -1306,6 +1444,17 @@ export class AnalyticsService implements IAnalyticsService { // 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); + // [#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 + // cube is read from `scope` AFTER `ensureCube`, so a measure it minted for + // this call is judged by the field it aggregates. + await this.assertFieldsReadable( + query, + query.cube ? scope.getCube(query.cube) : undefined, + query.cube ? reads.getDatasetScope(query.cube) : undefined, + 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: @@ -1452,6 +1601,34 @@ export class AnalyticsService implements IAnalyticsService { await assertObjectsReadable(objects, provider, context, this.logger); } + /** + * [#20917] The FIELD-LEVEL read gate, asked at this door for every field the + * query names, BEFORE a strategy is selected — `NativeSQLStrategy` compiles + * members straight into a statement no middleware sees, so the engine's field + * guard could never reach it. See `field-read-admission.ts`. + * + * A no-op when no provider is wired (no security service) or when the query + * names no field. + */ + private async assertFieldsReadable( + query: AnalyticsQuery, + cube: Cube | undefined, + datasetScope: DatasetScope | undefined, + context: ExecutionContext | undefined, + ): Promise { + const provider = this.readableFieldsProvider; + if (!provider || !cube) return; + const named = namedQueryFields(query, cube, datasetScope); + if (named.length === 0) return; + await assertNamedFieldsReadable( + named, + provider, + context, + (object) => this.getObjectFieldNames?.(object), + this.logger, + ); + } + /** * 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 @@ -1773,8 +1950,19 @@ export class AnalyticsService implements IAnalyticsService { // pending seed either. await this.assertReadAdmitted(this.cubeObjects(compiled.cube), context); this.logger.debug(`[Analytics] queryDataset "${dataset.name}" → preview over ${seedRows.length} drafted seed row(s)`); + // [#20917] …and the field-level gate, per query the executor issues, + // over the same compiled dataset: a drafted seed row carries the same + // fields the published ones do. const previewService = { - query: async (q: AnalyticsQuery) => evaluateAnalyticsQueryOverRows(q, compiled.cube, seedRows!), + query: async (q: AnalyticsQuery) => { + await this.assertFieldsReadable( + q, + compiled.cube, + { filter: compiled.filter, measureFilters: compiled.measureFilters }, + context, + ); + return evaluateAnalyticsQueryOverRows(q, compiled.cube, seedRows!); + }, } as IAnalyticsService; const previewResult = await new DatasetExecutor(previewService).execute(compiled, selection, context); // ADR-0021 result-column enrichment runs on this path too. Every key it diff --git a/packages/services/service-analytics/src/field-read-admission.ts b/packages/services/service-analytics/src/field-read-admission.ts new file mode 100644 index 00000000000..dabef187a6e --- /dev/null +++ b/packages/services/service-analytics/src/field-read-admission.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The FIELD-LEVEL read admission this service asks BEFORE it selects a + * strategy — the sibling of the object-level gate in `read-admission.ts`, and + * the layer below it. + * + * ## Why the door, and not a strategy + * + * `engine.find` and `engine.aggregate` refuse a query that groups, aggregates, + * filters or sorts by a field the caller's field-level permissions hide: a + * hidden field is never a predicate or a group key, because row presence and + * group keys disclose its values even when the column itself is masked out of + * the result. `ObjectQLStrategy` inherits that refusal from the engine. + * `NativeSQLStrategy` compiles its own statement and runs it through the + * driver's raw `execute()`, which no middleware sits in front of, so it holds + * no field permissions at all and answered those queries. + * + * Teaching one strategy to refuse would leave the next strategy to learn it + * again. The question is therefore asked HERE, once, over every member the + * query names, ahead of the strategy chain: every strategy — and the SQL echo, + * which shares the admission step — inherits the verdict by construction. + * + * ## One permission rule, the security service's + * + * This module holds no permission rule. Which fields a caller may read is the + * host's answer ({@link ReadableFieldsProvider}); the plugin bridges it to the + * `security` service's `getReadableFields`, the reader computed from the same + * permission-set resolution, field map and `requiredPermissions` fold the + * engine middleware enforces with. What this module adds is only what the + * analytics layer alone knows: which (object, field) each member of a cube + * query reads. + * + * ## The engine's words + * + * A refused member answers what the engine answers for the same field, word + * for word, so a caller sees one refusal whichever strategy would have served + * the cube: the aggregate refusal for a member the query groups or aggregates, + * the predicate refusal for a member it filters or sorts by. When a query + * names hidden fields in both roles on one object, the aggregate refusal + * speaks first — the engine's own order on an aggregate. + * + * ## Fail direction + * + * - The provider THROWS → the query is refused (fail-closed) and the failure is + * logged at `error`. + * - The provider answers `undefined` for an object → the reader has no field + * answer for it (its contract's "no answer"), and no field of that object is + * judged: an object the security service cannot resolve is one the engine + * serves nothing from either. The object-level gate and the row scope still + * apply. + * - No provider wired → no field-level gate. That is a deployment with no + * security service, where `/data` has no field-level security either. + */ + +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { StandardErrorCode } from '@objectstack/spec/api'; + +/** + * `PERMISSION_DENIED`, pinned against the STANDARD catalog — the code and the + * 403 the engine answers for the same field. + */ +const PERMISSION_DENIED: StandardErrorCode = 'PERMISSION_DENIED'; + +/** + * The fields `context` may READ on `objectName` — the host's answer, the + * `security` service's `getReadableFields` in the shipped composition. + * + * MAY be async. `undefined` is "no answer for this object" (see the module + * header); an array is the answer, `[]` included. A throw refuses the query. + */ +export type ReadableFieldsProvider = ( + objectName: string, + context?: ExecutionContext, +) => readonly string[] | undefined | Promise; + +/** + * How a query uses a field, which decides the engine's words for it. + * + * - `aggregate` — grouped by or aggregated over (a dimension, a bucketed time + * dimension, a measure's field). + * - `predicate` — filtered or sorted by (a `where` member, a time dimension's + * window, a dataset's or a measure's own filter, an order key). + */ +export type FieldReadRole = 'aggregate' | 'predicate'; + +/** One field a query reads, on the object that declares it. */ +export interface NamedField { + readonly object: string; + readonly field: string; + readonly role: FieldReadRole; +} + +/** Log sink — the subset of `Logger` this module uses (see `read-admission.ts`). */ +interface AdmissionLogger { + error?(message: string, error?: Error): void; + warn(message: string): void; +} + +type FieldRefusal = Error & { code?: string; status?: number; object?: string; fields?: string[] }; + +/** + * The refusal, in the ADR-0112 envelope — `PERMISSION_DENIED` / 403 — and in + * the words the engine uses for the same field in the same role. + * + * `object` is the object that declares the fields and `fields` the fields + * refused, each the field the member resolved to: the names the engine's + * refusal carries for the same query. + */ +export function fieldReadDeniedError(object: string, fields: readonly string[], role: FieldReadRole): Error { + const message = role === 'aggregate' + ? `[Security] Field read denied: not permitted to aggregate [${fields.join(', ')}] on '${object}'` + : `[Security] Access denied: query on '${object}' references field(s) not readable by the caller: ` + + `${fields.join(', ')}. Filtering, sorting, grouping, or aggregating by a hidden field ` + + `would leak its values (filter oracle) — remove these predicates or grant field read access.`; + const err = new Error(message) as FieldRefusal; + err.code = PERMISSION_DENIED; + err.status = 403; + err.object = object; + err.fields = [...fields]; + return err; +} + +/** + * The fail-closed refusal: the reader could not answer for `object`. Names the + * object and nothing else — the cause is the operator's, logged at the site. + */ +function fieldReadUnresolvedError(object: string): Error { + const err = new Error( + `[Analytics] Access denied: the field-level read permissions for "${object}" could not be resolved, ` + + 'so the query was not run.', + ) as FieldRefusal; + err.code = PERMISSION_DENIED; + err.status = 403; + err.object = object; + return err; +} + +/** + * Refuse the query unless the caller may read every field it names. + * + * @param named - Every field the query reads, in the order the query names + * them. The objects are judged in their first-named order, so the base + * object — named first by the caller's collector — speaks before a joined + * one, as it does on the engine path. + * @param knownFields - The object's declared fields, or `undefined` when no + * list is available. A name the list does not carry is not a field of the + * object (a relationship path segment that names none, a system column the + * registry adds), and the reader's list, which is built from fields, could + * never contain it — so only listed names are judged. With no list, every + * name is judged against the reader's answer. + */ +export async function assertNamedFieldsReadable( + named: readonly NamedField[], + provider: ReadableFieldsProvider, + context: ExecutionContext | undefined, + knownFields: (object: string) => readonly string[] | undefined, + logger?: AdmissionLogger, +): Promise { + const byObject = new Map(); + for (const f of named) { + const list = byObject.get(f.object); + if (list) list.push(f); + else byObject.set(f.object, [f]); + } + + for (const [object, fields] of byObject) { + let readable: readonly string[] | undefined; + try { + readable = await provider(object, context); + } catch (e) { + // Fail CLOSED: a reader that could not answer must not be read as + // "every field readable". + const cause = e instanceof Error ? e : new Error(String(e)); + const report = + `[Analytics] field-level read admission could not be resolved for object "${object}" — ` + + `denying query (fail-closed): ${cause.message}`; + if (logger?.error) logger.error(report, cause); + else logger?.warn(report); + throw fieldReadUnresolvedError(object); + } + if (readable === undefined) continue; + + const known = knownFields(object); + const knownSet = known ? new Set(known) : undefined; + const readableSet = new Set(readable); + const hidden = (f: NamedField) => + (!knownSet || knownSet.has(f.field)) && !readableSet.has(f.field); + + for (const role of ['aggregate', 'predicate'] as const) { + const refused = [...new Set(fields.filter((f) => f.role === role && hidden(f)).map((f) => f.field))]; + if (refused.length === 0) continue; + logger?.warn( + `[Analytics] field-level read admission denied ${refused.join(', ')} on "${object}" ` + + `(user ${String((context as { userId?: unknown } | undefined)?.userId ?? 'unknown')}) — ` + + `the verdict the engine reaches for the same fields`, + ); + throw fieldReadDeniedError(object, refused, role); + } + } +} diff --git a/packages/services/service-analytics/src/plugin.ts b/packages/services/service-analytics/src/plugin.ts index ad3dc9fff4d..73f5534f402 100644 --- a/packages/services/service-analytics/src/plugin.ts +++ b/packages/services/service-analytics/src/plugin.ts @@ -760,6 +760,45 @@ export class AnalyticsServicePlugin implements Plugin { autoBridgedReadAdmission = true; } + // [#20917] The FIELD-LEVEL half of the same read + // (`AnalyticsServiceConfig.getReadableFields`), bridged the same way and + // for the same reasons as the two halves above: resolution at CALL time, + // and the three resolutions kept apart. There is no plugin option for it: + // the reader is the security service's, and a host that composes its own + // reader constructs `AnalyticsService` with it. + // + // ABSENT — no security service: no field-level security anywhere on + // this deployment, `/data` included. The provider answers + // `undefined` ("no answer"), which judges no field. + // UNUSABLE — the service exists but cannot answer: resolving it threw, + // or it carries no `getReadableFields` (a REQUIRED member of + // `ISecurityService`, so a conforming provider never lands + // here). The provider THROWS, and the service refuses the + // query fail-closed: a reader that never answered must not be + // read as "every field readable". + // USABLE — ask it. + interface SecurityReadableFields { + getReadableFields?(object: string, context?: ExecutionContext): Promise; + } + const getReadableFields: AnalyticsServiceConfig['getReadableFields'] = async (object, context) => { + let svc: SecurityReadableFields | undefined; + try { + svc = ctx.getService('security'); + } catch (e) { + throw new Error( + `resolving the "security" service threw (${String((e as Error)?.message ?? e)})`, + ); + } + if (!svc) return undefined; + if (typeof svc.getReadableFields !== 'function') { + throw new Error( + 'the registered "security" service exposes no getReadableFields(), so it cannot answer ' + + 'which fields the caller may read', + ); + } + return svc.getReadableFields(object, context); + }; + // ADR-0021 — relationship → target-object resolver. A dataset's `include` // names lookup/master_detail FIELDS on the base object; the joined TABLE is // each field's `reference` target (which can differ from the field name, @@ -1113,6 +1152,7 @@ export class AnalyticsServicePlugin implements Plugin { fallbackService, getReadScope, admitObjectRead, + getReadableFields, getAllowedRelationships: this.options.getAllowedRelationships, coerceTemporalFilterValue, coerceTemporalFilterColumn,