From 237e0cd07b7ce6452f18314fe5d5e4c4c5fbfcec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:17:07 +0000 Subject: [PATCH 1/5] feat(security,analytics): publish the queryable-fields answer and have the analytics field gate ask it The security contract gains an optional getQueryableFields: the fields a caller may filter, sort, group or aggregate by. plugin-security derives it from the one query-guard map its predicate guard and aggregate-input guard now share, so a field served masked is readable and not queryable. The analytics field gate asks it beside the read projection; the plugin bridge fails closed for masking-rule fields when the security service cannot answer. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../src/get-queryable-fields.test.ts | 202 ++++++++++++++++++ .../plugin-security/src/security-plugin.ts | 141 ++++++++---- .../src/analytics-service.ts | 31 +++ .../src/field-read-admission.ts | 52 ++++- .../services/service-analytics/src/plugin.ts | 65 ++++++ .../src/contracts/security-service.test.ts | 29 +++ .../spec/src/contracts/security-service.ts | 50 ++++- 7 files changed, 523 insertions(+), 47 deletions(-) create mode 100644 packages/plugins/plugin-security/src/get-queryable-fields.test.ts diff --git a/packages/plugins/plugin-security/src/get-queryable-fields.test.ts b/packages/plugins/plugin-security/src/get-queryable-fields.test.ts new file mode 100644 index 00000000000..8995a67bdcb --- /dev/null +++ b/packages/plugins/plugin-security/src/get-queryable-fields.test.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20935] `getQueryableFields` — the query-side twin of `getReadableFields`. + * + * The first block is an EQUIVALENCE, not a table of expected lists: for every + * field of the object it drives the REAL registered middleware with a query + * naming only that field — as a filter, as a sort key, as a group key and as an + * aggregate input — and requires "the middleware admitted it" to equal "the + * field is in `getQueryableFields`". The published answer and the engine's two + * query guards come from one derivation; this is what keeps them one. + * + * The second block pins the answers the contract names, above all the one that + * makes this method necessary: a field the caller is served MASKED is in the + * read projection and NOT in the query one. + * + * Harness mirrors `get-writable-fields.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin } from './security-plugin.js'; + +const CRUD = { allowRead: true, allowCreate: true, allowEdit: true }; + +/** + * The baseline every authenticated caller resolves: reads the object, and two + * fields not at all — one of them declares a masking rule, which never widens + * an explicit deny. + */ +const MEMBER_SET = { + name: 'member_default', + label: 'Reader', + objects: { ledger: CRUD }, + fields: { + 'ledger.secret': { readable: false, editable: false }, + 'ledger.denied_masked': { readable: false, editable: false }, + }, +} as unknown as PermissionSet; + +/** The same grant plus the capability that lifts `gated_masked`'s rule. */ +const UNMASK_SET = { + ...(MEMBER_SET as object), + label: 'Reader who may see the gated field unmasked', + systemPermissions: ['view_gated'], +} as unknown as PermissionSet; + +/** An agent's own set, holding the capability: the D10 case turns on the intersection alone. */ +const AGENT_SET = { + name: 'agent_reader', + label: 'Agent', + objects: { ledger: CRUD }, + systemPermissions: ['view_gated'], +} as unknown as PermissionSet; + +const SCHEMAS: Record = { + ledger: { + name: 'ledger', + fields: { + title: { type: 'text', label: 'Title' }, + // Masked unless the caller holds `view_gated` (the rule's unmask gate). + gated_masked: { type: 'text', label: 'Gated', maskingRule: { keepHead: 1, keepTail: 1 }, requiredPermissions: ['view_gated'] }, + // A rule with no gate: masked for every non-system caller. + always_masked: { type: 'text', label: 'Always', maskingRule: 'name' }, + secret: { type: 'text', label: 'Secret' }, + denied_masked: { type: 'text', label: 'Denied', maskingRule: 'name' }, + }, + }, +}; +/** The field universe the plugin resolves: the schema's fields plus `id`. */ +const FIELDS = ['id', 'title', 'gated_masked', 'always_masked', 'secret', 'denied_masked']; + +const MEMBER_CTX = { userId: 'u_member', tenantId: 'org-1', positions: [], permissions: [], posture: 'MEMBER' }; +const LIVE_DELEGATOR = 'u_boss'; +const AGENT_CTX = { userId: 'u_agent', tenantId: 'org-1', positions: [], permissions: ['agent_reader'], posture: 'MEMBER' }; +const DELEGATED_AGENT_CTX = { ...AGENT_CTX, onBehalfOf: { userId: LIVE_DELEGATOR } }; + +async function boot(sets: PermissionSet[], opts: { noBaseline?: boolean } = {}) { + const middlewares: Array<(opCtx: any, next: () => Promise) => Promise> = []; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: { + registerMiddleware: (mw: any) => middlewares.push(mw), + getSchema: (name: string) => SCHEMAS[name], + findOne: vi.fn(async (_object: string, query: any) => + (query?.where?.id === LIVE_DELEGATOR ? { id: LIVE_DELEGATOR, email: 'boss@example.test' } : null)), + }, + metadata: { + get: async (_type: string, name: string) => SCHEMAS[name], + list: async () => sets, + }, + }; + const registerService = vi.fn(); + const ctx: Record = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin( + opts.noBaseline + ? { defaultPermissionSets: [], fallbackPermissionSet: null } + : { fallbackPermissionSet: 'member_default' }, + ); + await plugin.init(ctx as any); + await plugin.start(ctx as any); + if (middlewares.length === 0) throw new Error('SecurityPlugin registered no middleware'); + return { plugin, middleware: middlewares[0], registerService }; +} + +/** Each way a query can name one field: the four positions the engine's two query guards judge. */ +const PROBES: ReadonlyArray Record]> = [ + ['a filter', 'find', (field) => ({ where: { [field]: 'v' } })], + ['a sort key', 'find', (field) => ({ where: {}, orderBy: [{ field, order: 'asc' }] })], + ['a group key', 'aggregate', (field) => ({ groupBy: [field], aggregations: [{ function: 'count', alias: 'n' }] })], + ['an aggregate input', 'aggregate', (field) => ({ aggregations: [{ function: 'max', field, alias: 'm' }] })], +]; + +async function middlewareAdmits( + middleware: (opCtx: any, next: () => Promise) => Promise, + operation: 'find' | 'aggregate', + context: Record, + ast: Record, +): Promise<{ admitted: true } | { admitted: false; code?: unknown; status?: unknown }> { + try { + await middleware({ object: 'ledger', operation, context: { ...context }, options: {}, ast }, async () => {}); + return { admitted: true }; + } catch (e) { + const err = e as { code?: unknown; status?: unknown; statusCode?: unknown }; + return { admitted: false, code: err.code, status: err.status ?? err.statusCode }; + } +} + +describe('[#20935] getQueryableFields agrees with the middleware\'s query guards, field for field', () => { + const CASES: Array<{ label: string; sets: PermissionSet[]; context: Record; queryable: string[] }> = [ + { label: 'a member: two fields served masked, two hidden', sets: [MEMBER_SET], context: MEMBER_CTX, queryable: ['id', 'title'] }, + { label: 'a member holding the capability that lifts one rule', sets: [UNMASK_SET], context: MEMBER_CTX, queryable: ['id', 'title', 'gated_masked'] }, + { label: 'an agent holding that capability, acting for nobody', sets: [AGENT_SET, MEMBER_SET], context: AGENT_CTX, queryable: ['id', 'title', 'gated_masked'] }, + { label: 'the same agent acting for a delegator who does not hold it', sets: [AGENT_SET, MEMBER_SET], context: DELEGATED_AGENT_CTX, queryable: ['id', 'title'] }, + ]; + + for (const c of CASES) { + it(c.label, async () => { + const { plugin, middleware } = await boot(c.sets); + const queryable = await plugin.getQueryableFields('ledger', c.context); + // The expected list keeps each case honest about what it exercises; the + // equivalence below is the pin. + expect(queryable).toEqual(c.queryable); + for (const field of FIELDS) { + for (const [position, operation, ast] of PROBES) { + const verdict = await middlewareAdmits(middleware, operation, c.context, ast(field)); + if (queryable!.includes(field)) { + expect(verdict, `${field} as ${position}`).toEqual({ admitted: true }); + } else { + expect(verdict, `${field} as ${position}`).toMatchObject({ admitted: false, code: 'PERMISSION_DENIED', status: 403 }); + } + } + } + }); + } +}); + +describe('[#20935] getQueryableFields — the answers the contract names', () => { + it('a field the caller is served MASKED is readable and NOT queryable; the difference is exactly the masked fields', async () => { + const { plugin } = await boot([MEMBER_SET]); + const readable = await plugin.getReadableFields('ledger', MEMBER_CTX); + const queryable = await plugin.getQueryableFields('ledger', MEMBER_CTX); + expect(readable).toEqual(['id', 'title', 'gated_masked', 'always_masked']); + expect(queryable).toEqual(['id', 'title']); + expect(queryable!.every((f) => readable!.includes(f))).toBe(true); + expect(readable!.filter((f) => !queryable!.includes(f))).toEqual(['gated_masked', 'always_masked']); + }); + + it('a system context bypasses: the full field set', async () => { + const { plugin } = await boot([MEMBER_SET]); + expect(await plugin.getQueryableFields('ledger', { isSystem: true })).toEqual(FIELDS); + }); + + it('no permission sets resolved: the full field set, as the middleware skips both guards', async () => { + const { plugin } = await boot([], { noBaseline: true }); + expect(await plugin.getQueryableFields('ledger', MEMBER_CTX)).toEqual(FIELDS); + }); + + it('an unresolvable object is no answer (undefined), not an empty one', async () => { + const { plugin } = await boot([MEMBER_SET]); + expect(await plugin.getQueryableFields('no_such_object', MEMBER_CTX)).toBeUndefined(); + }); + + it('a delegator that does not exist fails closed: []', async () => { + const { plugin } = await boot([AGENT_SET, MEMBER_SET]); + expect(await plugin.getQueryableFields('ledger', { ...AGENT_CTX, onBehalfOf: { userId: 'u_ghost' } })).toEqual([]); + }); + + it('is exposed on the registered "security" service', async () => { + const { registerService } = await boot([MEMBER_SET]); + const svc = registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1]; + expect(typeof svc?.getQueryableFields).toBe('function'); + expect(await svc.getQueryableFields('ledger', MEMBER_CTX)).toEqual(['id', 'title']); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index f662d5c27b3..58bf1990916 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1715,6 +1715,12 @@ export class SecurityPlugin implements Plugin { // [#18386] Its write-side twin: the fields step 2.5 would not refuse. // The import template narrows its columns by it. See getWritableFields. getWritableFields: (object: string, context?: any) => this.getWritableFields(object, context), + // [#20935] Its query-side twin: the fields steps 2.5b and 2.9 would not + // refuse as a group key, an aggregate input, a filter or a sort key. A + // field served MASKED is readable and NOT queryable, so a door that + // compiles its own statement (the analytics raw-SQL path) asks this as + // well as the read projection. See getQueryableFields. + getQueryableFields: (object: string, context?: any) => this.getQueryableFields(object, context), // [#3544] User-level export axis. `export ⊆ list`, so a bulk export // reaches the middleware as a plain `find` and `allowExport` would never // be consulted — the REST export route asks HERE before it streams. @@ -1927,7 +1933,7 @@ export class SecurityPlugin implements Plugin { discardPermissionSetOverlay(overlayDiscardDeps, callerContext, id), }); ctx.registerService('security', registeredSecurityService); - ctx.logger.info('[security] registered "security" service (getReadFilter, canReadObject, getReadableFields, getWritableFields, getMetadataReadableFields, canExport, checkAuthoredRowWrite, resolvePermissionSetNames, resolvePermissionSetsForContext, explain, audience-binding suggestions, discardPermissionSetOverlay) — ADR-0021 D-C / ADR-0090 D5/D6/D9 / ADR-0094 / ADR-0106 D7 / #3544 / #3547 / #5493 / #7616'); + ctx.logger.info('[security] registered "security" service (getReadFilter, canReadObject, getReadableFields, getWritableFields, getQueryableFields, getMetadataReadableFields, canExport, checkAuthoredRowWrite, resolvePermissionSetNames, resolvePermissionSetsForContext, explain, audience-binding suggestions, discardPermissionSetOverlay) — ADR-0021 D-C / ADR-0090 D5/D6/D9 / ADR-0094 / ADR-0106 D7 / #3544 / #3547 / #5493 / #7616'); } catch (e) { ctx.logger.warn?.('[security] failed to register "security" service', { error: (e as Error).message, @@ -2880,21 +2886,16 @@ export class SecurityPlugin implements Plugin { // (mirrors the write gate in 2.5). `where`-filter probing is a // platform-wide class shared with find() and is not widened here. if (opCtx.operation === 'aggregate' && permissionSets.length > 0) { - let fieldPerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, permissionSets); - // [ADR-0066 D3] AND-gate field-level requiredPermissions into the map. - fieldPerms = this.foldFieldRequiredPermissions(fieldPerms, secMeta.fieldRequiredPermissions, permissionSets); - // [ADR-0090 D10] Intersect with the delegator's field perms — a field - // the agent may read but the delegator may not stays forbidden. - if (delegatorSets) { - let delFieldPerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, delegatorSets); - delFieldPerms = this.foldFieldRequiredPermissions(delFieldPerms, secMeta.fieldRequiredPermissions, delegatorSets); - fieldPerms = intersectFieldMasks(fieldPerms, delFieldPerms); - } - // [#8993] A partial-masked field's statistics leak the very span the - // mask hides (min/max reveal full values outright on a single-row - // group), so masked-for-this-caller fields join the forbidden set. - const aggMaskRules = this.computePartialMaskRules(secMeta, permissionSets, delegatorSets); - if (Object.keys(fieldPerms).length > 0 || Object.keys(aggMaskRules).length > 0) { + // The field map (ADR-0066 D3 `requiredPermissions` AND-gate, ADR-0090 + // D10 delegator intersection — a field the agent may read but the + // delegator may not stays forbidden) with every masked-for-this-caller + // field folded in as forbidden: [#8993] a partial-masked field's + // statistics leak the very span the mask hides (min/max reveal full + // values outright on a single-row group). One derivation, shared with + // step 2.9 and the published `getQueryableFields` — see + // computeQueryGuardFieldPerms. + const queryGuard = this.computeQueryGuardFieldPerms(opCtx.object, secMeta, permissionSets, delegatorSets); + if (Object.keys(queryGuard).length > 0) { const ast: any = opCtx.ast ?? {}; const referenced = new Set(); for (const g of Array.isArray(ast.groupBy) ? ast.groupBy : []) { @@ -2904,9 +2905,7 @@ export class SecurityPlugin implements Plugin { for (const a of Array.isArray(ast.aggregations) ? ast.aggregations : []) { if (typeof a?.field === 'string' && a.field) referenced.add(a.field); } - const forbidden = [...referenced].filter( - (f) => (fieldPerms[f] && fieldPerms[f].readable === false) || aggMaskRules[f] !== undefined, - ); + const forbidden = [...referenced].filter((f) => queryGuard[f]?.readable === false); if (forbidden.length > 0) { throw new PermissionDeniedError( `[Security] Field read denied: not permitted to aggregate ` + @@ -3612,27 +3611,13 @@ export class SecurityPlugin implements Plugin { // reference fields the caller cannot read (e.g. owner_id) and must not be // rejected. if (opCtx.ast) { - let guardPerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, permissionSets); - guardPerms = this.foldFieldRequiredPermissions(guardPerms, secMeta.fieldRequiredPermissions, permissionSets); // [ADR-0090 D10] A field readable only by the agent is not queryable on - // the delegator's behalf — intersect before the oracle guard. - if (delegatorSets) { - let delGuard = this.permissionEvaluator.getFieldPermissions(opCtx.object, delegatorSets); - delGuard = this.foldFieldRequiredPermissions(delGuard, secMeta.fieldRequiredPermissions, delegatorSets); - guardPerms = intersectFieldMasks(guardPerms, delGuard); - } - // [#8993] A field this caller sees PARTIALLY MASKED is just as - // probe-able as a hidden one — an equality filter reconstructs the - // masked span digit by digit (row presence is the same oracle), and - // sorting orders by the very characters the mask hides. Fold every - // masked-for-this-caller field in as non-queryable; no explicit-deny - // exclusion here, because masked and hidden fields answer a predicate - // probe identically (reject). - for (const f of Object.keys(this.computePartialMaskRules(secMeta, permissionSets, delegatorSets))) { - if (guardPerms[f]?.readable !== false) { - guardPerms[f] = { readable: false, editable: guardPerms[f]?.editable ?? false }; - } - } + // the delegator's behalf, and [#8993] a field this caller sees + // PARTIALLY MASKED is just as probe-able as a hidden one — both are + // folded in as non-queryable by the one derivation this guard shares + // with step 2.5b and the published `getQueryableFields` + // (computeQueryGuardFieldPerms). + const guardPerms = this.computeQueryGuardFieldPerms(opCtx.object, secMeta, permissionSets, delegatorSets); if (Object.keys(guardPerms).length > 0) { // [#2982 follow-up] For a bulk WRITE the caller's own predicate is // `opCtx.options.where` (untouched); `opCtx.ast.where` may ALREADY @@ -5494,6 +5479,33 @@ export class SecurityPlugin implements Plugin { return mask.allFields.filter((f) => !nonEditable.has(f)); } + /** + * [#20935] Query surface: the field names the caller may QUERY ON in + * `object` — filter, sort, group or aggregate by — the query-side twin of + * {@link getReadableFields}, for the doors that compile their own statement + * and so never reach this middleware's field guards. + * + * It is not a second reading of masking: the answer is every schema field + * the ONE query-guard derivation ({@link computeQueryGuardFieldPerms}) does + * not mark non-queryable — the same map the predicate guard (step 2.9) and + * the aggregate-input guard (step 2.5b) refuse from, so a field is here iff + * a query naming it passes both. It differs from {@link getReadableFields} + * by exactly the fields this caller is served MASKED: a masked field is a + * served column (readable) that no predicate, group key or aggregate may name. + * + * The settled answers are the projection's ({@link resolveProjectionFieldMask}): + * `undefined` when the schema cannot be resolved; the full set for a system + * context and for a caller with no permission sets (the middleware then + * skips both guards, and no masking rule reaches it); `[]` on an + * unresolvable posture or a dangling delegator (fail closed). + */ + async getQueryableFields(object: string, context?: any): Promise { + const mask = await this.resolveProjectionFieldMask(object, context, { fallbackOnEmptySets: false }); + if (mask.kind === 'answer') return mask.fields; + const queryGuard = mask.readQueryGuardPerms(); + return mask.allFields.filter((f) => queryGuard[f]?.readable !== false); + } + /** * The derivation both field projections share: the schema's field universe, * the caller's permission sets, the evaluator's field map with the ADR-0066 @@ -5513,6 +5525,8 @@ export class SecurityPlugin implements Plugin { allFields: string[]; fieldPerms: Record; readPartialMaskRules: () => Record; + /** [#20935] The middleware's query-guard map for these sets (computeQueryGuardFieldPerms). */ + readQueryGuardPerms: () => Record; } > { const objectName = String(object ?? ''); @@ -5571,6 +5585,9 @@ export class SecurityPlugin implements Plugin { readPartialMaskRules: () => this.computeReadPartialMaskRules( secMeta, permissionSets, delegatorSets, basePerms, delBasePerms, ), + readQueryGuardPerms: () => this.computeQueryGuardFieldPerms( + objectName, secMeta, permissionSets, delegatorSets, + ), }; } @@ -8822,6 +8839,52 @@ export class SecurityPlugin implements Plugin { return out; } + /** + * [#8993 / #20935] The engine's ONE answer to "may this caller filter, sort, + * group or aggregate by this field of `objectName`?" — a field map in which + * every field the caller may NOT query on reads `readable: false`. + * + * The field map the read mask starts from (the evaluator's grants, the + * ADR-0066 D3 `requiredPermissions` AND-gate, and on an on-behalf-of request + * the ADR-0090 D10 intersection with the delegator's map: a field readable + * only by the agent is not queryable on the delegator's behalf), with every + * field whose masking rule applies to this caller + * ({@link computePartialMaskRules}) folded in as non-queryable. A partially + * masked field is SERVED — its key stays, its value is replaced — yet it is + * as probe-able as a hidden one: an equality filter reconstructs the masked + * span one probe at a time (row presence is the oracle), sorting orders by + * the very characters the mask hides, and a group key or an aggregate hands + * back the unmasked value outright. No explicit-deny exclusion applies here, + * unlike the read path's {@link computeReadPartialMaskRules}: masked and + * hidden fields answer a query probe identically (refuse). + * + * Three readers, one derivation, so they cannot drift apart: the predicate + * guard (step 2.9), the aggregate-input guard (step 2.5b) and + * {@link getQueryableFields}, the published answer a door that compiles its + * own statement asks instead of re-deriving masking. + */ + private computeQueryGuardFieldPerms( + objectName: string, + secMeta: Pick, + permissionSets: PermissionSet[], + delegatorSets: PermissionSet[] | null, + ): Record { + let guard = this.permissionEvaluator.getFieldPermissions(objectName, permissionSets); + guard = this.foldFieldRequiredPermissions(guard, secMeta.fieldRequiredPermissions, permissionSets); + if (delegatorSets) { + let delegatorGuard = this.permissionEvaluator.getFieldPermissions(objectName, delegatorSets); + delegatorGuard = this.foldFieldRequiredPermissions(delegatorGuard, secMeta.fieldRequiredPermissions, delegatorSets); + guard = intersectFieldMasks(guard, delegatorGuard); + } + const out: Record = { ...guard }; + for (const f of Object.keys(this.computePartialMaskRules(secMeta, permissionSets, delegatorSets))) { + if (out[f]?.readable !== false) { + out[f] = { readable: false, editable: out[f]?.editable ?? false }; + } + } + return out; + } + /** * [#8993 / #9127] The READ path's EFFECTIVE partial-mask set: the caller's * applicable rules ({@link computePartialMaskRules}) MINUS every field an diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 3893e2a18ff..0103a70079e 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -49,6 +49,7 @@ import { readScopeUnresolvedError } from './read-scope-refusal.js'; // every member a query names, judged against the caller's readable fields. import { assertNamedFieldsReadable, + type QueryableFieldsProvider, type NamedField, type FieldReadRole, type ReadableFieldsProvider, @@ -717,6 +718,25 @@ export interface AnalyticsServiceConfig { * field-level security either. */ getReadableFields?: ReadableFieldsProvider; + /** + * [#20935] The QUERY-side half of the same field-level gate — which fields of + * an object the caller may filter, sort, group or aggregate by. Asked beside + * {@link getReadableFields}, at the same point and for the same objects, and + * a member is admitted only when BOTH answers carry its field: every member + * an analytics query names is a query position, and a field the caller is + * served MASKED is readable but not queryable (as a group key it hands back + * the unmasked value; as a filter it rebuilds the masked span). Refused + * `PERMISSION_DENIED` / 403 in the engine's words for the same field. + * + * The plugin auto-bridges this to the `security` service's + * `getQueryableFields`, and when that service predates the method, or + * answers `undefined`, it fails CLOSED: every field that declares a + * `maskingRule` is treated as not queryable. MAY be async; a THROW refuses + * the query. A host that wires {@link getReadableFields} and not this judges + * masked fields by the read projection alone, which admits them — the + * service says so once, at construction. + */ + getQueryableFields?: QueryableFieldsProvider; /** * ADR-0021 D-C — join allowlist per cube (the dataset's declared `include`). * Joins outside this set are rejected by the strategy. Compiled datasets @@ -1130,6 +1150,8 @@ export class AnalyticsService implements IAnalyticsService { private readonly readAdmissionProvider?: ObjectReadAdmissionProvider; /** [#20917] Field-level read-admission provider (bound per call to the request context). */ private readonly readableFieldsProvider?: ReadableFieldsProvider; + /** [#20935] Field-level query-admission provider (bound per call to the request context). */ + private readonly queryableFieldsProvider?: QueryableFieldsProvider; /** * Compiled datasets by name, as `registerDataset` registered them — feeds the * shared scope's join allowlist (D-C) and dataset scope. `queryDataset` @@ -1199,6 +1221,14 @@ export class AnalyticsService implements IAnalyticsService { this.readScopeProvider = config.getReadScope; this.readAdmissionProvider = config.admitObjectRead; this.readableFieldsProvider = config.getReadableFields; + this.queryableFieldsProvider = config.getQueryableFields; + if (this.readableFieldsProvider && !this.queryableFieldsProvider) { + this.logger.warn( + '[Analytics] getReadableFields is configured without getQueryableFields: a field a caller is served ' + + 'MASKED is readable, so the field-level gate admits it as a group key, a filter or a sort key, ' + + 'which the data API refuses. Supply getQueryableFields (the security service\'s getQueryableFields).', + ); + } this.configuredAllowedRelationships = config.getAllowedRelationships; this.relationshipResolver = config.relationshipResolver; this.sourceFieldMeta = config.sourceFieldMeta; @@ -1626,6 +1656,7 @@ export class AnalyticsService implements IAnalyticsService { context, (object) => this.getObjectFieldNames?.(object), this.logger, + this.queryableFieldsProvider, ); } diff --git a/packages/services/service-analytics/src/field-read-admission.ts b/packages/services/service-analytics/src/field-read-admission.ts index dabef187a6e..00796ab2bd6 100644 --- a/packages/services/service-analytics/src/field-read-admission.ts +++ b/packages/services/service-analytics/src/field-read-admission.ts @@ -31,6 +31,22 @@ * analytics layer alone knows: which (object, field) each member of a cube * query reads. * + * ## Readable is not queryable (#20935) + * + * Every member an analytics query names is a QUERY position — a group key, an + * aggregate input, a filter or a sort key — and the engine refuses one more + * field there than the read projection leaves out: a field the caller is + * served MASKED. Its key stays in the row and its value is replaced, so the + * read projection counts it readable; as a group key it would hand back the + * unmasked value, and as a filter it rebuilds the masked span probe by probe. + * So the gate asks a second answer beside the read projection, + * {@link QueryableFieldsProvider} — the `security` service's + * `getQueryableFields`, the same derivation the engine's two query guards + * refuse from — and a member is admitted only when both answers carry its + * field. The masking rule is the security service's, and it is not re-derived + * here; a security service too old to answer is the plugin bridge's to fail + * closed on (see `plugin.ts`). + * * ## The engine's words * * A refused member answers what the engine answers for the same field, word @@ -42,8 +58,9 @@ * * ## Fail direction * - * - The provider THROWS → the query is refused (fail-closed) and the failure is - * logged at `error`. + * - A provider THROWS → the query is refused (fail-closed) and the failure is + * logged at `error`. That holds for the queryable provider exactly as for + * the readable one. * - 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 @@ -74,6 +91,18 @@ export type ReadableFieldsProvider = ( context?: ExecutionContext, ) => readonly string[] | undefined | Promise; +/** + * [#20935] The fields `context` may QUERY ON in `objectName` — filter, sort, + * group or aggregate by — the host's answer, the `security` service's + * `getQueryableFields` in the shipped composition. A field the caller is + * served masked is readable and NOT queryable. + * + * The same shape and the same answers as {@link ReadableFieldsProvider}: + * MAY be async; `undefined` is "no answer for this object"; an array is the + * answer, `[]` included; a throw refuses the query. + */ +export type QueryableFieldsProvider = ReadableFieldsProvider; + /** * How a query uses a field, which decides the engine's words for it. * @@ -137,7 +166,8 @@ function fieldReadUnresolvedError(object: string): Error { } /** - * Refuse the query unless the caller may read every field it names. + * Refuse the query unless the caller may read — and query on — 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 @@ -149,6 +179,10 @@ function fieldReadUnresolvedError(object: string): Error { * 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. + * @param queryable - [#20935] The queryable-fields reader. When wired, a field + * its answer leaves out is refused in the same words as an unreadable one — + * the words the engine answers a masked field with. Each reader's answer is + * judged on its own: `undefined` from one leaves the other's verdict intact. */ export async function assertNamedFieldsReadable( named: readonly NamedField[], @@ -156,6 +190,7 @@ export async function assertNamedFieldsReadable( context: ExecutionContext | undefined, knownFields: (object: string) => readonly string[] | undefined, logger?: AdmissionLogger, + queryable?: QueryableFieldsProvider, ): Promise { const byObject = new Map(); for (const f of named) { @@ -166,8 +201,10 @@ export async function assertNamedFieldsReadable( for (const [object, fields] of byObject) { let readable: readonly string[] | undefined; + let queryableFields: readonly string[] | undefined; try { readable = await provider(object, context); + queryableFields = queryable ? await queryable(object, context) : undefined; } catch (e) { // Fail CLOSED: a reader that could not answer must not be read as // "every field readable". @@ -179,13 +216,16 @@ export async function assertNamedFieldsReadable( else logger?.warn(report); throw fieldReadUnresolvedError(object); } - if (readable === undefined) continue; + if (readable === undefined && queryableFields === undefined) continue; const known = knownFields(object); const knownSet = known ? new Set(known) : undefined; - const readableSet = new Set(readable); + const readableSet = readable === undefined ? undefined : new Set(readable); + const queryableSet = queryableFields === undefined ? undefined : new Set(queryableFields); const hidden = (f: NamedField) => - (!knownSet || knownSet.has(f.field)) && !readableSet.has(f.field); + (!knownSet || knownSet.has(f.field)) && + ((readableSet !== undefined && !readableSet.has(f.field)) || + (queryableSet !== undefined && !queryableSet.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))]; diff --git a/packages/services/service-analytics/src/plugin.ts b/packages/services/service-analytics/src/plugin.ts index 73f5534f402..690e72d73ac 100644 --- a/packages/services/service-analytics/src/plugin.ts +++ b/packages/services/service-analytics/src/plugin.ts @@ -799,6 +799,70 @@ export class AnalyticsServicePlugin implements Plugin { return svc.getReadableFields(object, context); }; + // [#20935] The QUERY-side half (`AnalyticsServiceConfig.getQueryableFields`): + // which fields the caller may filter, sort, group or aggregate by. A field + // the caller is served MASKED is in the read projection above and is NOT + // queryable — the engine's two query guards refuse it — so the gate asks + // both. Bridged the same way, with the same three resolutions: + // + // ABSENT — no security service: `undefined`, no answer, as above. + // UNUSABLE — resolving it threw: THROW, the query is refused. + // USABLE — ask its `getQueryableFields`. + // + // ⛔ And one more state the read half does not have: a USABLE service that + // cannot give THIS answer — it predates the method, or it answered + // `undefined`. The fallback is NOT the read projection alone, which counts a + // masked field readable and would admit exactly the queries this half + // exists to refuse. It fails CLOSED: the read projection less every field + // whose declaration carries a `maskingRule`, for every caller but a system + // one (the contract's own bypass). That over-refuses a caller the rule is + // lifted for, which is the safe direction and the only one available: an + // older reader cannot say for whom a rule is lifted, and deciding that here + // would be a second copy of the masking rule. + interface SecurityQueryableFields extends SecurityReadableFields { + getQueryableFields?(object: string, context?: ExecutionContext): Promise; + } + const maskingRuleFields = (object: string): Set => { + const fields = dataEngine()?.getObject?.(object)?.fields as unknown; + const out = new Set(); + const collect = (name: unknown, def: unknown) => { + const rule = (def as { maskingRule?: unknown } | null | undefined)?.maskingRule; + if (typeof name === 'string' && name && rule !== undefined && rule !== null) out.add(name); + }; + if (Array.isArray(fields)) { + for (const f of fields) collect((f as { name?: unknown } | null)?.name, f); + } else if (fields && typeof fields === 'object') { + for (const [name, def] of Object.entries(fields as Record)) collect(name, def); + } + return out; + }; + const getQueryableFields: AnalyticsServiceConfig['getQueryableFields'] = async (object, context) => { + let svc: SecurityQueryableFields | 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.getQueryableFields === 'function') { + const answer = await svc.getQueryableFields(object, context); + if (answer !== undefined) return answer; + } + if (typeof svc.getReadableFields !== 'function') { + throw new Error( + 'the registered "security" service exposes neither getQueryableFields() nor getReadableFields(), ' + + 'so it cannot answer which fields the caller may query on', + ); + } + const readable = await svc.getReadableFields(object, context); + if (readable === undefined) return undefined; + if ((context as { isSystem?: unknown } | undefined)?.isSystem === true) return readable; + const masked = maskingRuleFields(object); + return readable.filter((f) => !masked.has(f)); + }; + // 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, @@ -1153,6 +1217,7 @@ export class AnalyticsServicePlugin implements Plugin { getReadScope, admitObjectRead, getReadableFields, + getQueryableFields, getAllowedRelationships: this.options.getAllowedRelationships, coerceTemporalFilterValue, coerceTemporalFilterColumn, diff --git a/packages/spec/src/contracts/security-service.test.ts b/packages/spec/src/contracts/security-service.test.ts index 55522ea2e2f..416f82fcee0 100644 --- a/packages/spec/src/contracts/security-service.test.ts +++ b/packages/spec/src/contracts/security-service.test.ts @@ -255,6 +255,35 @@ describe('Security Service Contract', () => { .resolves.toEqual([]); }); + it('[#20935] getQueryableFields is OPTIONAL — and a field served masked is readable but NOT queryable', async () => { + const withoutIt: ISecurityService = makeService({ getReadableFields: async () => ['id', 'name', 'masked'] }); + expect(typeof withoutIt.getQueryableFields).toBe('undefined'); + const mustNotCompileWithoutAGuard = () => + // @ts-expect-error possibly undefined — a consumer must feature-detect first + withoutIt.getQueryableFields('deal', { userId: 'u1' }); + expect(typeof mustNotCompileWithoutAGuard).toBe('function'); + + // The masked field is in the read projection (it is served, masked) and + // out of the query one — which is why the read projection alone is never + // the fallback for this answer. The query answer is a subset of the read one. + const withIt = makeService({ + getReadableFields: async () => ['id', 'name', 'masked'], + getQueryableFields: async (_object, context) => (context?.isSystem ? ['id', 'name', 'masked'] : ['id', 'name']), + }); + const readable = await withIt.getReadableFields('deal', { userId: 'u1' }); + const queryable = await withIt.getQueryableFields?.('deal', { userId: 'u1' }); + expect(readable).toContain('masked'); + expect(queryable).not.toContain('masked'); + expect(queryable?.every((f) => readable?.includes(f))).toBe(true); + await expect(withIt.getQueryableFields?.('deal', { isSystem: true })).resolves.toEqual(['id', 'name', 'masked']); + + // The same two empty answers as the read side. + await expect(makeService({ getQueryableFields: async () => undefined }).getQueryableFields?.('deal', {})) + .resolves.toBeUndefined(); + await expect(makeService({ getQueryableFields: async () => [] }).getQueryableFields?.('deal', {})) + .resolves.toEqual([]); + }); + it('[#7616] resolvePermissionSetsForContext is OPTIONAL — absence keeps the consumer on its own resolution (compile-time)', () => { // THE structural pin behind "a consumer must keep its local resolution as // the fallback until a floor version carrying this method can be assumed". diff --git a/packages/spec/src/contracts/security-service.ts b/packages/spec/src/contracts/security-service.ts index 6fcd579ba0a..b581dd6ef06 100644 --- a/packages/spec/src/contracts/security-service.ts +++ b/packages/spec/src/contracts/security-service.ts @@ -35,8 +35,11 @@ * "no answer — use your own fallback", NOT "no fields are readable". An empty * array is a real answer and means the opposite: nothing is readable. Its * metadata-plane sibling {@link ISecurityService.getMetadataReadableFields} - * (ADR-0106 D7) and its write-side twin {@link ISecurityService.getWritableFields} - * read the same two empty answers the same way. + * (ADR-0106 D7), its write-side twin {@link ISecurityService.getWritableFields} + * and its query-side twin {@link ISecurityService.getQueryableFields} read the + * same two empty answers the same way. The query-side twin is the one whose + * ABSENCE is not soft: its consumer gates a query rather than a presentation, + * so its fallback fails closed (see that method). * - **Verdicts fail to ABSTENTION.** {@link ISecurityService.checkAuthoredRowWrite} * answers a question a composing caller may use to WIDEN, so its failure mode * is the one that changes nothing: `abstain`. It never reports `admit` for a @@ -342,6 +345,49 @@ export interface ISecurityService { */ getMetadataReadableFields?(object: string, context?: SecurityContext): Promise; + /** + * [#20935] The field names `context` may QUERY ON in `object` — filter, sort, + * group or aggregate by — as far as field-level security decides. The + * query-side twin of {@link getReadableFields}, for the doors that compile + * their own statement and so never reach the engine middleware's field + * guards (the analytics raw-SQL path is the one in the tree). + * + * **Why a second answer, and why the read projection is not it.** A field + * whose `maskingRule` applies to this caller is SERVED — the key stays in the + * row and its value is replaced by the mask — so it IS in + * {@link getReadableFields}. It is not queryable: a filter on it reconstructs + * the masked span one probe at a time (row presence is the oracle), and a + * group key or an aggregate over it returns the unmasked value outright. The + * engine refuses both, so a door that narrows by the read projection alone + * answers exactly the queries the engine refuses. + * + * Computed by the SAME derivation the engine middleware's predicate guard and + * aggregate-input guard refuse with: the returned set is the exact complement + * of the fields those guards refuse when a query names them. It is therefore a + * subset of {@link getReadableFields} — every field that is not readable is + * not queryable either — and the difference between the two is exactly the + * fields this caller sees masked. + * + * **Fails SOFT, with the same two distinct empty answers as + * {@link getReadableFields}:** `undefined` is "no answer" (e.g. the object + * schema could not be resolved); `[]` is the real answer that this caller may + * query on NO field. A system context bypasses and yields the full field set. + * + * **OPTIONAL, and absence is a defined state — but the fallback is NOT + * {@link getReadableFields} alone.** A security service that predates this + * method omits it, and consumers feature-detect + * (`typeof svc.getQueryableFields === 'function'`). ⛔ A consumer that cannot + * get this answer — the method is absent, or it answered `undefined` — must + * treat every field that declares a `maskingRule` as NOT queryable, whoever + * the caller is (only a system context is exempt): the older reader cannot + * say for whom a rule is lifted, and the read projection reports a masked + * field as readable. Falling back to the read projection alone fails OPEN on + * precisely the fields this method exists for. Declaring it optional keeps + * that degradation a property of the type: the unguarded call does not + * compile, so a consumer cannot skip its fallback by accident. + */ + getQueryableFields?(object: string, context?: SecurityContext): Promise; + /** * The field names `context` may WRITE on `object` as far as field-level * security decides — the write-side twin of {@link getReadableFields}. From ab27e8a112b7166c681956b0468bfb3c66637e24 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:32:06 +0000 Subject: [PATCH 2/5] test(security,analytics): pin readable-but-not-queryable on both analytics strategies A door-level pin over the real SecurityPlugin, ObjectQL and SqlDriver: a field the caller is served masked, as a group or a filter member, answers the engine's own refusal on both strategies before any strategy runs, and a caller the masking rule is lifted for is the control. plugin-security pins that getQueryableFields equals the middleware's two query guards field for field; the analytics unit pins cover the gate, the bridge's fail-closed fallback and the construction-time warning. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../src/analytics-masked-field-gate.test.ts | 366 ++++++++++++++++++ .../field-query-admission-gate.test.ts | 300 ++++++++++++++ 2 files changed, 666 insertions(+) create mode 100644 packages/rest/src/analytics-masked-field-gate.test.ts create mode 100644 packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts diff --git a/packages/rest/src/analytics-masked-field-gate.test.ts b/packages/rest/src/analytics-masked-field-gate.test.ts new file mode 100644 index 00000000000..8f55d6c76f2 --- /dev/null +++ b/packages/rest/src/analytics-masked-field-gate.test.ts @@ -0,0 +1,366 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20935] A field the caller is served MASKED is readable and NOT queryable: + * as a member an analytics query groups or filters by, it answers the engine's + * own refusal — `403 PERMISSION_DENIED`, in the engine's words — whichever + * strategy would have served the cube, and before either strategy runs. + * + * The field-level gate at the analytics door (`field-read-admission.ts`) asks + * the `security` service's `getQueryableFields` beside its `getReadableFields`. + * The read projection alone counts a masked field readable — it is a served + * column, its value replaced — so a door that judged by it admitted a masked + * field as a group key or a filter, which the engine refuses. + * + * 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. 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. + * + * Two callers, one field: the member, for whom each field's masking rule + * applies, and an unmasker, who holds the capability that lifts it — the same + * field, queryable for one and not for the other. 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 grouped + * member, `engine.find` filtering on it for a filtered one. Code, status and + * message must all equal it, and neither the engine's aggregate nor its raw + * statement runs for a refused query: the verdict is the door's. + * + * SQLite only, and the fixtures are synthetic. The gate answers before a + * strategy is selected, so no statement is compiled for a refused query and 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_mask_ledger'; +const OWNER = 'rest_an_mask_owner'; +const AUTHORED = 'rest_an_mask_authored'; +const CAPABILITY = 'view_masked_codes'; + +const SYS_CTX = { isSystem: true, userId: 'usr_system' }; + +/** Every authenticated caller's baseline: every object readable, no field rules. */ +const MEMBER_SET = PermissionSetSchema.parse({ + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, +}); +/** The capability both masking rules name as their unmask gate. */ +const UNMASK_SET = PermissionSetSchema.parse({ + name: 'rest_an_mask_unmasker', + label: 'Unmasker', + objects: { '*': { allowRead: true } }, + systemPermissions: [CAPABILITY], +}); + +const MEMBER_CTX = { userId: 'usr_member', positions: [], permissions: [MEMBER_SET.name], posture: 'MEMBER' }; +const UNMASKER_CTX = { userId: 'usr_unmasker', positions: [], permissions: [UNMASK_SET.name], posture: 'MEMBER' }; + +const OWNERS = [ + { id: 'u1', region: 'r1', masked_region: 'north' }, + { id: 'u2', region: 'r2', masked_region: 'south' }, +]; +const ROWS = [ + { id: 'd1', title: 't1', masked_code: 'AA11', owner: 'u1', [OWNER]: 'u1' }, + { id: 'd2', title: 't2', masked_code: 'BB22', owner: 'u2', [OWNER]: 'u2' }, + { id: 'd3', title: 't3', masked_code: 'AA11', 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' } }, + dimensions: { + title: { type: 'string', sql: 'title', label: 'Title' }, + alias_code: { type: 'string', sql: 'masked_code', label: 'Code' }, + alias_owner_region: { type: 'string', sql: 'owner.masked_region', label: 'Owner region' }, + }, + joins: { owner: { name: OWNER } }, +}; + +/** The inline dataset the dataset door queries. */ +const DATASET = { + name: 'mask_gate_inline', + label: 'Masked field gate inline', + object: OBJECT, + include: ['owner'], + dimensions: [ + { name: 'title_dim', field: 'title', type: 'string' }, + { name: 'code_dim', field: 'masked_code', type: 'string' }, + ], + 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; +} + +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) }); + +type Ctx = typeof MEMBER_CTX; + +interface Harness { + engine: ObjectQL; + service: AnalyticsService; + /** How many times the engine's aggregate or its raw statement ran since boot. */ + strategyReads: () => number; + dataset: (ctx: Ctx, 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-masked-field-gate-20935', + name: 'Analytics masked field gate', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + { + name: OWNER, + label: 'Owner', + sharingModel: 'public_read_write', + fields: { + region: { name: 'region', type: 'text' }, + masked_region: { + name: 'masked_region', type: 'text', maskingRule: { keepHead: 1, keepTail: 0 }, requiredPermissions: [CAPABILITY], + }, + }, + }, + { + name: OBJECT, + label: 'Ledger', + sharingModel: 'public_read_write', + fields: { + title: { name: 'title', type: 'text' }, + masked_code: { + name: 'masked_code', type: 'text', maskingRule: { keepHead: 1, keepTail: 1 }, requiredPermissions: [CAPABILITY], + }, + 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, UNMASK_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 aggregateSpy = vi.spyOn(engine, 'aggregate'); + const executeSpy = vi.spyOn(engine, 'execute'); + const strategyReads = () => aggregateSpy.mock.calls.length + executeSpy.mock.calls.length; + + 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, + ); + let caller: Ctx = MEMBER_CTX; + (rest as any).resolveExecCtx = async () => caller; + 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 (as: Ctx, selection: Record) => { + caller = as; + const res = makeRes(); + const body = JSON.parse(JSON.stringify({ dataset: DATASET, selection })); + await route!.handler({ method: 'POST', params: {}, headers: {}, body, query: {} } as any, res); + return { status: res.statusCode, body: res.body }; + }; + return { engine, service, strategyReads, dataset }; +} + +/** The engine's answer for a member it GROUPS by, as the caller. */ +async function engineGrouped(engine: ObjectQL, object: string, field: string, context: Ctx) { + return engine + .aggregate(object, { groupBy: [field], aggregations: [{ function: 'count', alias: 'n' }], context } as never) + .then(() => null, (e: Thrown) => refusalOf(e)); +} + +/** The engine's answer for a member it FILTERS on, as the caller. */ +async function engineFiltered(engine: ObjectQL, object: string, field: string, value: string, context: Ctx) { + return engine + .find(object, { where: { [field]: value }, context } 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(); + +/** Each masked position: the query, how the engine is asked for the reference, and on what. */ +const CASES: ReadonlyArray, 'grouped' | 'filtered', string, string, string]> = [ + ['a masked field as a group member', { cube: OBJECT, measures: ['count'], dimensions: ['masked_code'] }, 'grouped', OBJECT, 'masked_code', ''], + ['a masked field as a filter member', { cube: OBJECT, measures: ['count'], where: { masked_code: 'AA11' } }, 'filtered', OBJECT, 'masked_code', 'AA11'], + ['an authored alias over a masked field as a group member', { cube: AUTHORED, measures: ['count'], dimensions: ['alias_code'] }, 'grouped', OBJECT, 'masked_code', ''], + ['an authored alias over a masked field as a filter member', { cube: AUTHORED, measures: ['count'], where: { alias_code: 'AA11' } }, 'filtered', OBJECT, 'masked_code', 'AA11'], + ['a joined masked field as a group member', { cube: OBJECT, measures: ['count'], dimensions: [`${OWNER}.masked_region`] }, 'grouped', OWNER, 'masked_region', ''], + ['a joined masked field as a filter member', { cube: OBJECT, measures: ['count'], where: { [`${OWNER}.masked_region`]: 'north' } }, 'filtered', OWNER, 'masked_region', 'north'], +]; + +for (const strategy of ['native', 'objectql'] as const) { + describe(`[#20935] a member naming a field the caller is served masked 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 masked field to the member, grouped and filtered, and serves it to the unmasker', async () => { + for (const [object, field, value] of [[OBJECT, 'masked_code', 'AA11'], [OWNER, 'masked_region', 'north']] as const) { + expect(await engineGrouped(h.engine, object, field, MEMBER_CTX), `${object}.${field} grouped, member`).toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + expect(await engineFiltered(h.engine, object, field, value, MEMBER_CTX), `${object}.${field} filtered, member`).toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + expect(await engineGrouped(h.engine, object, field, UNMASKER_CTX), `${object}.${field} grouped, unmasker`).toBeNull(); + expect(await engineFiltered(h.engine, object, field, value, UNMASKER_CTX), `${object}.${field} filtered, unmasker`).toBeNull(); + } + }); + + it('the cube read and the SQL echo refuse the member a masked field as a group or a filter member, before any strategy ran', async () => { + for (const [label, body, role, object, field, value] of CASES) { + const reference = role === 'grouped' + ? await engineGrouped(h.engine, object, field, MEMBER_CTX) + : await engineFiltered(h.engine, object, field, value, MEMBER_CTX); + expect(reference, `${label}: the engine's reference`).toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + const query = body as never; + const before = h.strategyReads(); + 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 }); + expect(h.strategyReads() - before, `${label}: no strategy ran`).toBe(0); + } + }); + + it('the dataset door refuses the member a masked field as a group or a filter member — never rows beside the refusal', async () => { + const cases: ReadonlyArray, Thrown]> = [ + ['a group member', { measures: ['row_count'], dimensions: ['code_dim'] }, await engineGrouped(h.engine, OBJECT, 'masked_code', MEMBER_CTX)], + ['a filter member', { measures: ['row_count'], dimensions: ['title_dim'], runtimeFilter: { masked_code: 'AA11' } }, await engineFiltered(h.engine, OBJECT, 'masked_code', 'AA11', MEMBER_CTX)], + ]; + for (const [label, selection, reference] of cases) { + const before = h.strategyReads(); + const ds = await h.dataset(MEMBER_CTX, selection); + 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(); + expect(h.strategyReads() - before, `${label}: no strategy ran`).toBe(0); + } + }); + + it('the control: the unmasker is answered for the same members exactly as the system caller is', async () => { + const normalized = (a: { answered?: { rows?: unknown }; refused?: unknown }) => + (a.answered ? { rows: sortRows((a.answered.rows ?? []) as Record[]) } : a); + for (const [label, body] of CASES) { + const query = body as never; + const unmasker = await answerOf(() => h.service.query(query, UNMASKER_CTX as never)); + const system = await answerOf(() => h.service.query(query, SYS_CTX as never)); + expect(normalized(unmasker), `${label}: the unmasker`).toEqual(normalized(system)); + // The ObjectQL strategy serves no cross-object filter to anyone — the + // engine cannot join in an aggregate — so that one case is answered by + // the strategy's own refusal, for the system caller too. Every other + // case is served rows. + const joinedFilter = strategy === 'objectql' && label === 'a joined masked field as a filter member'; + if (!joinedFilter) expect((unmasker as { answered?: { rows: unknown[] } }).answered?.rows.length, `${label}: rows`).toBeGreaterThan(0); + } + const ds = await h.dataset(UNMASKER_CTX, { measures: ['row_count'], dimensions: ['code_dim'] }); + expect(ds.status, JSON.stringify(ds.body)).toBe(200); + expect(ds.body.rows.length).toBeGreaterThan(0); + }); + + it('the control: the member is answered for the fields it may query on', 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 }])); + }); + }); +} diff --git a/packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts b/packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts new file mode 100644 index 00000000000..638dfb1026f --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts @@ -0,0 +1,300 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20935] READABLE IS NOT QUERYABLE — the query-side half of the field-level + * gate at the analytics door. + * + * Every member an analytics query names is a query position: a group key, an + * aggregate input, a filter or a sort key. A field the caller is served MASKED + * is readable (its key stays in the row, its value replaced), so the read + * projection alone admits it — and as a group key it would hand back the + * unmasked value, as a filter it rebuilds the masked span. The gate therefore + * asks a second reader beside the read projection + * (`AnalyticsServiceConfig.getQueryableFields`; the plugin bridges it to the + * `security` service's `getQueryableFields`) and admits a member only when both + * carry its field. Every refusal below runs through both strategy paths from + * one table, and asserts that nothing executed. + * + * The plugin half pins the bridge's fail direction: a security service that + * cannot give this answer — it predates the method, or it answers `undefined` — + * leaves every field that declares a `maskingRule` NOT queryable, never open. + * + * The route-level half, over the real `SecurityPlugin`, `ObjectQL` and + * `SqlDriver`, compares each refusal with the engine's own answer: + * `packages/rest/src/analytics-masked-field-gate.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +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 = 'fq_ledger'; +const OWNER = 'fq_owner'; + +/** Each object's declared fields; `masked_code` / `masked_region` declare a masking rule. */ +const FIELDS: Readonly> = { + [LEDGER]: ['title', 'masked_code', 'owner'], + [OWNER]: ['region', 'masked_region'], +}; +const MASKED: Readonly> = { + [LEDGER]: ['masked_code'], + [OWNER]: ['masked_region'], +}; + +/** The read projection: the masked fields are SERVED (masked), so they are readable. */ +const READABLE: Readonly> = { + [LEDGER]: ['id', 'title', 'masked_code', 'owner'], + [OWNER]: ['id', 'region', 'masked_region'], +}; +/** The query answer for a caller the rules apply to: the masked fields are not in it. */ +const QUERYABLE: Readonly> = { + [LEDGER]: ['id', 'title', 'owner'], + [OWNER]: ['id', 'region'], +}; + +/** An authored cube: aliases over the masked fields, and a declared join to the owner. */ +const AUTHORED: Cube = { + name: 'fq_authored', + title: 'Authored ledger', + sql: LEDGER, + public: true, + measures: { count: { type: 'count', sql: '*', label: 'Count' } }, + dimensions: { + title: { type: 'string', sql: 'title', label: 'Title' }, + alias_code: { type: 'string', sql: 'masked_code', label: 'Code' }, + alias_owner_region: { type: 'string', sql: 'owner.masked_region', label: 'Owner region' }, + }, + joins: { owner: { name: OWNER } }, +} as Cube; + +const CALLER = { userId: 'u_member', tenantId: 'org_a' } as ExecutionContext; +const SYSTEM = { isSystem: true } as ExecutionContext; + +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 FieldsReader = (object: string, context?: ExecutionContext) => + readonly string[] | undefined | Promise; + +function makeService(opts: { + capabilities: () => { nativeSql: boolean; objectqlAggregate: boolean; inMemory: boolean }; + getQueryableFields?: FieldsReader; + logger?: Record; +}) { + const executed: string[] = []; + const service = new AnalyticsService({ + cubes: [AUTHORED], + queryCapabilities: opts.capabilities, + getReadableFields: (object: string) => READABLE[object], + getQueryableFields: opts.getQueryableFields, + getObjectFieldNames: (object: string) => FIELDS[object], + ...(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 }; +} + +/** The first sentence of each of the engine's two refusals — the words it answers a masked field with. */ +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; + field: string; +} + +const REFUSED: readonly RefusalCase[] = [ + { label: 'a masked field as a group member', query: { cube: LEDGER, measures: ['count'], dimensions: ['masked_code'] }, role: 'aggregate', object: LEDGER, field: 'masked_code' }, + { label: 'a masked field as a filter member', query: { cube: LEDGER, measures: ['count'], where: { masked_code: 'x' } }, role: 'predicate', object: LEDGER, field: 'masked_code' }, + { label: 'a masked field as an order key', query: { cube: LEDGER, measures: ['count'], dimensions: ['title'], order: { masked_code: 'asc' } }, role: 'predicate', object: LEDGER, field: 'masked_code' }, + { label: 'an authored alias over a masked field as a group member', query: { cube: 'fq_authored', measures: ['count'], dimensions: ['alias_code'] }, role: 'aggregate', object: LEDGER, field: 'masked_code' }, + { label: 'a joined masked field as a group member', query: { cube: 'fq_authored', measures: ['count'], dimensions: ['alias_owner_region'] }, role: 'aggregate', object: OWNER, field: 'masked_region' }, + { label: 'a joined masked field as a filter member', query: { cube: 'fq_authored', measures: ['count'], where: { 'owner.masked_region': 'r' } }, role: 'predicate', object: OWNER, field: 'masked_region' }, +]; + +async function refusalOf(run: () => Promise) { + return run().then(() => null, (e: unknown) => e as Record); +} + +describe('[#20935] analytics — a field the caller is served masked is readable and NOT queryable', () => { + 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, field }) => { + const { service, executed } = makeService({ capabilities, getQueryableFields: (o) => QUERYABLE[o] }); + const sentence = role === 'aggregate' ? aggregateSentence(object, [field]) : predicateSentence(object, [field]); + for (const run of [() => service.query(query as never, CALLER), () => service.generateSql(query as never, CALLER)]) { + const refusal = await refusalOf(run); + expect(refusal).toMatchObject({ code: 'PERMISSION_DENIED', status: 403, object, fields: [field] }); + expect(String(refusal?.message).startsWith(sentence), String(refusal?.message)).toBe(true); + } + expect(executed).toEqual([]); + }); + + it('the control: a reader the rules do not apply to is served the same members, and the queryable reader is asked with the caller\'s context', async () => { + const getQueryableFields = vi.fn((object: string) => READABLE[object]); + const { service, executed } = makeService({ capabilities, getQueryableFields }); + await service.query({ cube: 'fq_authored', measures: ['count'], dimensions: ['alias_code', 'alias_owner_region'], where: { alias_code: 'x' } } as never, CALLER); + expect(executed.length).toBeGreaterThan(0); + expect(getQueryableFields.mock.calls.map((c) => c[0]).sort()).toEqual([LEDGER, OWNER]); + for (const call of getQueryableFields.mock.calls) expect(call[1]).toBe(CALLER); + }); + + it('fails closed: a queryable 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, + getQueryableFields: () => { 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('each reader is judged on its own: a queryable "no answer" leaves the read verdict intact', async () => { + const { service, executed } = makeService({ capabilities, getQueryableFields: () => undefined }); + await service.query({ cube: LEDGER, measures: ['count'], dimensions: ['title'] } as never, CALLER); + expect(executed.length).toBeGreaterThan(0); + }); + }); + + it('says so once, at construction, when the read reader is wired without the queryable one', () => { + const warn = vi.fn(); + const logger = { debug() {}, info() {}, warn, error() {}, child() { return logger; } }; + makeService({ capabilities: nativeSqlOnly, logger }); + expect(warn.mock.calls.map((c) => String(c[0])).join('\n')).toMatch(/getReadableFields is configured without getQueryableFields/); + const quiet = vi.fn(); + const both = { debug() {}, info() {}, warn: quiet, error() {}, child() { return both; } }; + makeService({ capabilities: nativeSqlOnly, getQueryableFields: (o) => QUERYABLE[o], logger: both }); + expect(quiet.mock.calls.map((c) => String(c[0])).join('\n')).not.toMatch(/getQueryableFields/); + }); +}); + +// ── 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 []; + }, + // The registry's declarations: the masked fields carry a `maskingRule`. + getObject: (name: string) => + FIELDS[name] + ? { + fields: Object.fromEntries(FIELDS[name].map((f) => [ + f, + f === 'owner' + ? { type: 'lookup', reference: OWNER } + : MASKED[name].includes(f) ? { type: 'text', maskingRule: 'name' } : { 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({ cubes: [AUTHORED], 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, and its read projection. */ +const readHalves = { + getReadFilter: async () => undefined, + canReadObject: async () => true, + getReadableFields: async (object: string, context?: ExecutionContext) => + (context?.isSystem ? ['id', ...FIELDS[object]] : READABLE[object]), +}; +const groupedMasked = { cube: LEDGER, measures: ['count'], dimensions: ['masked_code'] }; +const filteredMasked = { cube: LEDGER, measures: ['count'], where: { masked_code: 'x' } }; +const groupedPlain = { cube: LEDGER, measures: ['count'], dimensions: ['title'] }; + +describe('[#20935] analytics plugin — the query-side half of the "security" bridge', () => { + it('asks the security service\'s getQueryableFields with the caller\'s context: a masked member is refused, a plain one served', async () => { + const getQueryableFields = vi.fn(async (object: string) => QUERYABLE[object]); + const { service, reads } = await bootPlugin(() => ({ ...readHalves, getQueryableFields })); + for (const query of [groupedMasked, filteredMasked]) { + await expect(service.query(query as never, CALLER)).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403, object: LEDGER, fields: ['masked_code'] }); + } + expect(reads).toEqual([]); + expect(getQueryableFields).toHaveBeenCalledWith(LEDGER, CALLER); + await service.query(groupedPlain as never, CALLER); + expect(reads).toHaveLength(1); + }); + + it('serves a caller the security service reports the rule lifted for — the bridge adds no rule of its own', async () => { + const { service, reads } = await bootPlugin(() => ({ ...readHalves, getQueryableFields: async (object: string) => READABLE[object] })); + await service.query(groupedMasked as never, CALLER); + await service.query(filteredMasked as never, CALLER); + expect(reads).toHaveLength(2); + }); + + it('fails CLOSED for a security service that predates getQueryableFields: every field declaring a masking rule is not queryable', async () => { + const { service, reads } = await bootPlugin(() => ({ ...readHalves })); + for (const query of [groupedMasked, filteredMasked, { cube: 'fq_authored', measures: ['count'], dimensions: ['alias_owner_region'] }]) { + await expect(service.query(query as never, CALLER)).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 }); + } + expect(reads).toEqual([]); + // A field that declares no rule is judged by the read projection alone, as before. + await service.query(groupedPlain as never, CALLER); + expect(reads).toHaveLength(1); + // The contract's system bypass holds on the fallback too. + await service.query(groupedMasked as never, SYSTEM); + expect(reads).toHaveLength(2); + }); + + it('fails CLOSED the same way when getQueryableFields answers "no answer" (undefined)', async () => { + const { service, reads } = await bootPlugin(() => ({ ...readHalves, getQueryableFields: async () => undefined })); + await expect(service.query(groupedMasked as never, CALLER)).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403, fields: ['masked_code'] }); + expect(reads).toEqual([]); + }); + + it('applies no field-level gate when no security service is registered at all', async () => { + const { service, reads } = await bootPlugin(undefined); + await service.query(groupedMasked as never, CALLER); + expect(reads).toHaveLength(1); + }); +}); From 8864a76455257e3c5fabc5ba1e736446cdca09a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:32:45 +0000 Subject: [PATCH 3/5] chore(changeset): the queryable-fields contract member, its implementation and the analytics narrowing Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- ...35-analytics-masked-field-not-queryable.md | 37 +++++++++++++++++++ .../20935-plugin-security-queryable-fields.md | 5 +++ ...20935-security-service-queryable-fields.md | 11 ++++++ 3 files changed, 53 insertions(+) create mode 100644 .changeset/20935-analytics-masked-field-not-queryable.md create mode 100644 .changeset/20935-plugin-security-queryable-fields.md create mode 100644 .changeset/20935-security-service-queryable-fields.md diff --git a/.changeset/20935-analytics-masked-field-not-queryable.md b/.changeset/20935-analytics-masked-field-not-queryable.md new file mode 100644 index 00000000000..323f697162e --- /dev/null +++ b/.changeset/20935-analytics-masked-field-not-queryable.md @@ -0,0 +1,37 @@ +--- +'@objectstack/service-analytics': minor +--- + +fix(service-analytics)!: a field the caller is served masked is refused as a group key, an aggregate input, a filter or a sort key on every analytics face, whichever strategy serves the cube (#20935) + +Clause-②: yes (narrowing) + + + +**BREAKING for analytics queries on a SQL deployment that group, aggregate, filter or sort by a field the caller may only see masked.** + +**What changed.** The field-level gate on `POST /api/v1/analytics/query`, +`POST /api/v1/analytics/sql` and `POST /api/v1/analytics/dataset/query` judged +each member by the caller's readable fields. A field whose `maskingRule` applies +to the caller is readable (its values are served masked), so the gate admitted +it, and the native-SQL strategy then grouped or filtered by the stored value. +The gate now also asks which fields the caller may query on, and refuses a +member naming a masked field with `403 PERMISSION_DENIED`, in the words the +engine uses for the same field. The ObjectQL strategy and the data API already +refused these queries. + +**What is not affected.** A caller who holds the capability that lifts a +field's masking rule queries the field as before. A system context is +unaffected. A query that names no masked field answers as before. + +**New hook.** `AnalyticsServiceConfig.getQueryableFields(object, context)` +supplies the answer. `AnalyticsServicePlugin` wires it to the `security` +service's `getQueryableFields`. When that service predates the method, or +answers "no answer", the plugin treats every field that declares a +`maskingRule` as not queryable, for every caller but a system one. A host that +constructs `AnalyticsService` itself with `getReadableFields` and without +`getQueryableFields` is warned once at construction. + +**If a widget stopped answering for some users,** it groups or filters by a +field those users see masked. Give the users who need it the capability the +field's `requiredPermissions` names, or build the widget on fields they can query. diff --git a/.changeset/20935-plugin-security-queryable-fields.md b/.changeset/20935-plugin-security-queryable-fields.md new file mode 100644 index 00000000000..669d718c86a --- /dev/null +++ b/.changeset/20935-plugin-security-queryable-fields.md @@ -0,0 +1,5 @@ +--- +'@objectstack/plugin-security': minor +--- + +The `security` service implements `getQueryableFields(object, context)` (#20935). A field is in the answer exactly when a query naming it as a filter, a sort key, a group key or an aggregate input passes the engine's field guards: the answer is read from the one field map the predicate guard and the aggregate-input guard now share (permission sets, field grants, the `requiredPermissions` check, the on-behalf-of delegator intersection, and every field whose masking rule applies to the caller). The two guards refuse exactly what they refused before. diff --git a/.changeset/20935-security-service-queryable-fields.md b/.changeset/20935-security-service-queryable-fields.md new file mode 100644 index 00000000000..f0c57ca38b5 --- /dev/null +++ b/.changeset/20935-security-service-queryable-fields.md @@ -0,0 +1,11 @@ +--- +'@objectstack/spec': minor +--- + +`ISecurityService` (`@objectstack/spec/contracts`) gains an optional `getQueryableFields(object, context)`: the field names field-level security lets the caller filter, sort, group or aggregate by on the object, the query-side twin of `getReadableFields` (#20935). + +Clause-②: yes (widening) + +- It is the exact complement of the fields the engine's field guards refuse when a query names them as a filter, a sort key, a group key or an aggregate input. It is a subset of `getReadableFields`, and the two differ by exactly the fields the caller is served masked: a field whose `maskingRule` applies to the caller is readable (served, its value replaced) and not queryable. +- It fails soft like `getReadableFields`: `undefined` means no answer, `[]` means no field is queryable. A system context gets every field. +- It is optional. A consumer checks `typeof svc.getQueryableFields === 'function'`. When the method is missing, or answers `undefined`, the consumer must treat every field that declares a `maskingRule` as not queryable (a system context excepted). Falling back to `getReadableFields` alone would admit exactly the masked fields. From 20c56c9810541c549bf6f3526149990bb604602b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:48:04 +0000 Subject: [PATCH 4/5] test(service-analytics): type the queryable reader mock with its context parameter Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../src/__tests__/field-query-admission-gate.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts b/packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts index 638dfb1026f..8225d39b2ff 100644 --- a/packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts +++ b/packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts @@ -149,7 +149,7 @@ describe('[#20935] analytics — a field the caller is served masked is readable }); it('the control: a reader the rules do not apply to is served the same members, and the queryable reader is asked with the caller\'s context', async () => { - const getQueryableFields = vi.fn((object: string) => READABLE[object]); + const getQueryableFields = vi.fn((object: string, _context?: ExecutionContext) => READABLE[object]); const { service, executed } = makeService({ capabilities, getQueryableFields }); await service.query({ cube: 'fq_authored', measures: ['count'], dimensions: ['alias_code', 'alias_owner_region'], where: { alias_code: 'x' } } as never, CALLER); expect(executed.length).toBeGreaterThan(0); From fc844c433ed2e8e175e4568db4e03a4e58b0c5c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 22:26:08 +0000 Subject: [PATCH 5/5] fix(service-analytics): the queryable fallback reads no caller property, so it refuses masked-rule fields for every caller The fallback for a security service that cannot answer getQueryableFields exempted a system context by reading its system bit, a new elevation read site. It cannot say for whom a masking rule is lifted, so it now refuses every caller alike; the contract text and both changesets say so. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- .../20935-analytics-masked-field-not-queryable.md | 2 +- .../20935-security-service-queryable-fields.md | 2 +- .../src/__tests__/field-query-admission-gate.test.ts | 7 ++++--- .../service-analytics/src/analytics-service.ts | 3 ++- packages/services/service-analytics/src/plugin.ts | 12 ++++++------ packages/spec/src/contracts/security-service.ts | 5 ++--- 6 files changed, 16 insertions(+), 15 deletions(-) diff --git a/.changeset/20935-analytics-masked-field-not-queryable.md b/.changeset/20935-analytics-masked-field-not-queryable.md index 323f697162e..6442bc50669 100644 --- a/.changeset/20935-analytics-masked-field-not-queryable.md +++ b/.changeset/20935-analytics-masked-field-not-queryable.md @@ -28,7 +28,7 @@ unaffected. A query that names no masked field answers as before. supplies the answer. `AnalyticsServicePlugin` wires it to the `security` service's `getQueryableFields`. When that service predates the method, or answers "no answer", the plugin treats every field that declares a -`maskingRule` as not queryable, for every caller but a system one. A host that +`maskingRule` as not queryable, for every caller. A host that constructs `AnalyticsService` itself with `getReadableFields` and without `getQueryableFields` is warned once at construction. diff --git a/.changeset/20935-security-service-queryable-fields.md b/.changeset/20935-security-service-queryable-fields.md index f0c57ca38b5..059381101e2 100644 --- a/.changeset/20935-security-service-queryable-fields.md +++ b/.changeset/20935-security-service-queryable-fields.md @@ -8,4 +8,4 @@ Clause-②: yes (widening) - It is the exact complement of the fields the engine's field guards refuse when a query names them as a filter, a sort key, a group key or an aggregate input. It is a subset of `getReadableFields`, and the two differ by exactly the fields the caller is served masked: a field whose `maskingRule` applies to the caller is readable (served, its value replaced) and not queryable. - It fails soft like `getReadableFields`: `undefined` means no answer, `[]` means no field is queryable. A system context gets every field. -- It is optional. A consumer checks `typeof svc.getQueryableFields === 'function'`. When the method is missing, or answers `undefined`, the consumer must treat every field that declares a `maskingRule` as not queryable (a system context excepted). Falling back to `getReadableFields` alone would admit exactly the masked fields. +- It is optional. A consumer checks `typeof svc.getQueryableFields === 'function'`. When the method is missing, or answers `undefined`, the consumer must treat every field that declares a `maskingRule` as not queryable, whoever the caller is. Falling back to `getReadableFields` alone would admit exactly the masked fields. diff --git a/packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts b/packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts index 8225d39b2ff..e6d22d2d2e6 100644 --- a/packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts +++ b/packages/services/service-analytics/src/__tests__/field-query-admission-gate.test.ts @@ -281,9 +281,10 @@ describe('[#20935] analytics plugin — the query-side half of the "security" br // A field that declares no rule is judged by the read projection alone, as before. await service.query(groupedPlain as never, CALLER); expect(reads).toHaveLength(1); - // The contract's system bypass holds on the fallback too. - await service.query(groupedMasked as never, SYSTEM); - expect(reads).toHaveLength(2); + // The fallback reads no caller property — it cannot say for whom a rule is + // lifted — so it refuses every caller, a system one included. + await expect(service.query(groupedMasked as never, SYSTEM)).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403, fields: ['masked_code'] }); + expect(reads).toHaveLength(1); }); it('fails CLOSED the same way when getQueryableFields answers "no answer" (undefined)', async () => { diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 0103a70079e..b1bcbaa1610 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -731,7 +731,8 @@ export interface AnalyticsServiceConfig { * The plugin auto-bridges this to the `security` service's * `getQueryableFields`, and when that service predates the method, or * answers `undefined`, it fails CLOSED: every field that declares a - * `maskingRule` is treated as not queryable. MAY be async; a THROW refuses + * `maskingRule` is treated as not queryable, whoever the caller is. MAY be + * async; a THROW refuses * the query. A host that wires {@link getReadableFields} and not this judges * masked fields by the read projection alone, which admits them — the * service says so once, at construction. diff --git a/packages/services/service-analytics/src/plugin.ts b/packages/services/service-analytics/src/plugin.ts index 690e72d73ac..9aea975a1e3 100644 --- a/packages/services/service-analytics/src/plugin.ts +++ b/packages/services/service-analytics/src/plugin.ts @@ -814,11 +814,12 @@ export class AnalyticsServicePlugin implements Plugin { // `undefined`. The fallback is NOT the read projection alone, which counts a // masked field readable and would admit exactly the queries this half // exists to refuse. It fails CLOSED: the read projection less every field - // whose declaration carries a `maskingRule`, for every caller but a system - // one (the contract's own bypass). That over-refuses a caller the rule is - // lifted for, which is the safe direction and the only one available: an - // older reader cannot say for whom a rule is lifted, and deciding that here - // would be a second copy of the masking rule. + // whose declaration carries a `maskingRule`, whoever the caller is. That + // over-refuses a caller the rule is lifted for — a system one included — + // which is the safe direction and the only one available: an older reader + // cannot say for whom a rule is lifted, and deciding that here (reading the + // caller's capabilities, or its system bit) would be a second copy of the + // masking rule. interface SecurityQueryableFields extends SecurityReadableFields { getQueryableFields?(object: string, context?: ExecutionContext): Promise; } @@ -858,7 +859,6 @@ export class AnalyticsServicePlugin implements Plugin { } const readable = await svc.getReadableFields(object, context); if (readable === undefined) return undefined; - if ((context as { isSystem?: unknown } | undefined)?.isSystem === true) return readable; const masked = maskingRuleFields(object); return readable.filter((f) => !masked.has(f)); }; diff --git a/packages/spec/src/contracts/security-service.ts b/packages/spec/src/contracts/security-service.ts index b581dd6ef06..68e4c38530f 100644 --- a/packages/spec/src/contracts/security-service.ts +++ b/packages/spec/src/contracts/security-service.ts @@ -379,9 +379,8 @@ export interface ISecurityService { * (`typeof svc.getQueryableFields === 'function'`). ⛔ A consumer that cannot * get this answer — the method is absent, or it answered `undefined` — must * treat every field that declares a `maskingRule` as NOT queryable, whoever - * the caller is (only a system context is exempt): the older reader cannot - * say for whom a rule is lifted, and the read projection reports a masked - * field as readable. Falling back to the read projection alone fails OPEN on + * the caller is: the older reader cannot say for whom a rule is lifted, and + * the read projection reports a masked field as readable. Falling back to the read projection alone fails OPEN on * precisely the fields this method exists for. Declaring it optional keeps * that degradation a property of the type: the unguarded call does not * compile, so a consumer cannot skip its fallback by accident.