diff --git a/.changeset/23009-rls-deny-sentinel-composed-readable-ids.md b/.changeset/23009-rls-deny-sentinel-composed-readable-ids.md new file mode 100644 index 00000000000..5bd26623bbd --- /dev/null +++ b/.changeset/23009-rls-deny-sentinel-composed-readable-ids.md @@ -0,0 +1,13 @@ +--- +"@objectstack/spec": minor +"@objectstack/plugin-security": patch +--- + +The RLS deny sentinel and the composed `ReadableIds` leaf are declared once in `@objectstack/spec/data`, and every place in `@objectstack/plugin-security` that handed out a copy of the sentinel now returns the object itself (ADR-0139 D2) + +Clause-②: yes (widening: new exported members of the published spec `data` entry) + +- **`RLS_DENY_FILTER` moves into the spec.** FROM `@objectstack/plugin-security`'s own frozen literal TO `RLS_DENY_FILTER` exported by `@objectstack/spec/data`. The value is unchanged: `{ id: '__rls_deny__:00000000-0000-0000-0000-000000000000' }`, an `id` equality no record satisfies, so a consumer that never heard of it still reads zero rows. `@objectstack/plugin-security` keeps exporting `RLS_DENY_FILTER` under the same name and type; it is now the spec's object, not a second copy. Nothing to change in code that imports it from either package. +- **One object, compared by identity.** `getReadFilter` (`ISecurityService`) now returns the sentinel itself, not a copy, for a principal-less caller, a sharing scope that cannot be resolved, an on-behalf-of caller, a caller with no permission set and a resolution failure. It also returns the sentinel itself when the only layer that constrains is the tenant wall's deny, the RLS compiler's deny (every applicable policy dropped), or the deny of a `controlled_by_parent` object whose master cannot be resolved. Before this release most of those returned a spread copy, which matched the same zero rows but was a different object. Not every zero-row answer is the sentinel: a deny ANDed with another layer is returned as the composed `$and`, with the sentinel inside it, and the record-sharing layer's own deny, `{ id: '__deny_all__' }`, is returned unchanged when sharing is the only layer that constrains. ADR-0139 D2 step 2 has the engine's `ReadableIds` composition recognise the deny by `scope === RLS_DENY_FILTER` and answer it `$in: []`; any other zero-row answer composes like any scope and still matches no row. The object is frozen: code that edited the filter `getReadFilter` returned now gets a `TypeError` instead of silently changing a deny. +- **The composed leaf.** `ReadableIds.compose(leaf, scope)` builds the frozen value `{ object, scope }` the engine hands a driver in place of a `ReadableIds` leaf (D2 step 3). It is branded under `COMPOSED_READABLE_IDS_BRAND` (`Symbol.for('objectstack.filter.readableIds.composed')`), so a JSON round-trip, a structured clone or a spread drops the brand. `isComposedReadableIds(value)` recognises it. The type `ComposedReadableIds` is exported. `scope` is `undefined` for "no row restriction"; `compose` throws a `TypeError` for anything but a `ReadableIds.of` leaf, for a scope that is neither `undefined` nor a non-null, non-array object, and for `RLS_DENY_FILTER` itself, which D2 step 2 answers `$in: []`. +- **`isReadableIds` answers as before.** It answers `false` for a composed leaf, so it still means "an uncomposed leaf", and a driver refuses on it. The comparand-shape door's `admitReadableIds` admits the uncomposed leaf only: a composed leaf handed to the engine from outside is refused `INVALID_FILTER` / 400 as a non-list comparand. diff --git a/packages/plugins/plugin-security/src/rls-compiler.ts b/packages/plugins/plugin-security/src/rls-compiler.ts index ebab98d8268..fee12defce9 100644 --- a/packages/plugins/plugin-security/src/rls-compiler.ts +++ b/packages/plugins/plugin-security/src/rls-compiler.ts @@ -49,6 +49,11 @@ import { booleanComparandRefusalMessage, type BooleanComparandDoorFieldMeta, } from '@objectstack/spec/data'; +// [ADR-0139 D2 step 2] The RLS deny sentinel is declared ONCE, in the spec's +// `data` entry, because the engine recognises it by identity and cannot import +// this plugin. This file holds no second literal: it returns the spec's object +// and exports it under the name every consumer here already imports. +import { RLS_DENY_FILTER as SPEC_RLS_DENY_FILTER } from '@objectstack/spec/data'; /** * Why a policy's predicate produced no filter — the compiler's OWN answer, @@ -674,12 +679,15 @@ function rlsLowering(fieldGuard: RlsFieldGuard | undefined): FilterLoweringOptio * handle a thrown `PermissionDeniedError` for what is conceptually an * empty result set. * - * Exposed for the SecurityPlugin's optional short-circuit path and for - * tests; see {@link RLSCompiler.compileFilter}. + * [ADR-0139 D2 step 2] It IS `@objectstack/spec/data`'s `RLS_DENY_FILTER` — + * the same object, not a copy — because the engine's composition recognises + * it by identity. Bound under this plugin's published name and type, so the + * package's export reads exactly as it did before the declaration moved. + * Every producer in this plugin returns this reference + * ({@link RLSCompiler.compileFilter}, the tenant wall's deny, `getReadFilter`'s + * fail-closed branches); ⛔ never spread it. */ -export const RLS_DENY_FILTER: Record = Object.freeze({ - id: '__rls_deny__:00000000-0000-0000-0000-000000000000', -}); +export const RLS_DENY_FILTER: Record = SPEC_RLS_DENY_FILTER; /** * [#20355] Which policy each compiled policy filter came from — recorded by diff --git a/packages/plugins/plugin-security/src/rls-deny-sentinel-identity.test.ts b/packages/plugins/plugin-security/src/rls-deny-sentinel-identity.test.ts new file mode 100644 index 00000000000..d59eb539f98 --- /dev/null +++ b/packages/plugins/plugin-security/src/rls-deny-sentinel-identity.test.ts @@ -0,0 +1,221 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0139 D2 step 2] Every deny `getReadFilter` answers IS the spec's + * `RLS_DENY_FILTER` — the same object, by reference — so the engine's + * composition can recognise it by identity and answer `$in: []`. + * + * The sentinel is declared once, in `@objectstack/spec/data`; this plugin + * re-exports it under the name every consumer here imports and holds no second + * literal. Each case below drives one deny return through the public method and + * pins it with `toBe` against the spec's export, never `toEqual`: a spread copy + * carries the same value and matches zero rows too, which is exactly why a + * value assertion cannot tell the two apart, and why the copies stood for so + * long. + * + * The returns, by where they are produced: + * + * - in `getReadFilter` itself: a principal-less context, a sharing-scope + * resolution failure, an on-behalf-of context, a caller who resolves no + * permission set, and a permission-resolution throw; + * - in a layer whose answer comes up unchanged when it is the only one that + * constrains: the tenant wall's deny (Layer 0, `tenantLayer0FilterOf`), the + * "every applicable policy dropped for a missing column" deny (Layer 1), + * the RLS compiler's own deny, and the unresolvable-master deny of a + * `controlled_by_parent` object. + * + * Plus the one downstream property the by-reference return leans on: the CRUD + * middleware injects the frozen sentinel into `ast.where` without mutating it. + * + * Fixtures are synthetic. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { RLS_DENY_FILTER as SPEC_RLS_DENY_FILTER } from '@objectstack/spec/data'; +import { SecurityPlugin } from './security-plugin.js'; +import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js'; +import { RLS_DENY_FILTER as PACKAGE_RLS_DENY_FILTER } from './index.js'; +import { computeTenantLayer0Filter, tenantLayer0FilterOf } from './tenant-layer.js'; + +const CRUD = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }; + +/** Object access everywhere, and no row-level policy: no Layer 1 at all. */ +const OPEN_SET = { name: 'member_default', label: 'Synthetic member', objects: { '*': CRUD } } as unknown as PermissionSet; + +/** The platform's wildcard tenant policy shape, which needs `organization_id`. */ +const TENANT_POLICY_SET = { + name: 'member_default', + label: 'Synthetic member', + objects: { '*': CRUD }, + rowLevelSecurity: [ + { name: 'tenant_isolation', object: '*', operation: 'all', using: 'organization_id == current_user.organization_id' }, + ], +} as unknown as PermissionSet; + +/** A policy the compiler cannot evaluate for a member: a `current_user` key nothing resolves. */ +const UNRESOLVABLE_POLICY_SET = { + name: 'member_default', + label: 'Synthetic member', + objects: { '*': CRUD }, + rowLevelSecurity: [ + { name: 'region_scope', object: 'task', operation: 'select', using: 'owner_id == current_user.synthetic_unresolved_key' }, + ], +} as unknown as PermissionSet; + +const MEMBER = { userId: 'u1', tenantId: 'org-1', positions: [], permissions: [] }; + +interface Deployment { + sets: PermissionSet[]; + fields?: string[]; + schemaExtra?: Record; + orgScoping?: boolean; + sharing?: unknown; + noBaseline?: boolean; +} + +async function boot(deployment: Deployment) { + const fields: Record = {}; + for (const f of deployment.fields ?? ['id', 'organization_id', 'owner_id', 'name']) fields[f] = { name: f, type: 'text' }; + const schema = { name: 'task', fields, ...(deployment.schemaExtra ?? {}) }; + let middleware: ((opCtx: any, next: () => Promise) => Promise) | undefined; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: { + registerMiddleware: (mw: any) => { if (!middleware) middleware = mw; }, + getSchema: () => schema, + // No row is ever read here: none of the deny paths reaches the store. + find: vi.fn(async () => [] as Record[]), + }, + metadata: { get: async () => schema, list: async () => deployment.sets }, + }; + if (deployment.orgScoping) services['org-scoping'] = { name: 'com.objectstack.org-scoping' }; + if (deployment.sharing) services['sharing'] = deployment.sharing; + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const ctx: any = { + logger, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin( + deployment.noBaseline + ? { defaultPermissionSets: [], fallbackPermissionSet: null } + : { fallbackPermissionSet: 'member_default' }, + ); + await plugin.init(ctx); + await plugin.start(ctx); + if (!middleware) throw new Error('SecurityPlugin registered no middleware'); + return { plugin, middleware, logger }; +} + +describe('[ADR-0139 D2 step 2] one sentinel in the tree: this plugin re-exports the spec object', () => { + it('rls-compiler and the package entry export the spec\'s object itself, not a copy', () => { + expect(RLS_DENY_FILTER).toBe(SPEC_RLS_DENY_FILTER); + expect(PACKAGE_RLS_DENY_FILTER).toBe(SPEC_RLS_DENY_FILTER); + expect(Object.isFrozen(RLS_DENY_FILTER)).toBe(true); + }); + + it('the tenant wall\'s deny is the spec object, from the projection and from the computation', () => { + expect(tenantLayer0FilterOf({ kind: 'deny' })).toBe(SPEC_RLS_DENY_FILTER); + const walledWithoutOrganization = computeTenantLayer0Filter({ + tenancyPosture: 'isolated', + organizationId: undefined, + objectHasOrgIdField: true, + tenancyDisabled: false, + posturePermitsCrossTenant: false, + isPlatformAdmin: false, + }); + expect(walledWithoutOrganization).toBe(SPEC_RLS_DENY_FILTER); + // CONTROL: a wall with an organization is a fresh predicate, never the sentinel. + expect(tenantLayer0FilterOf({ kind: 'organization', organizationId: 'org-1' })).toEqual({ organization_id: 'org-1' }); + }); + + it('the RLS compiler\'s "every applicable policy dropped" deny is the spec object', () => { + const compiler = new RLSCompiler(); + compiler.setLogger({ warn: vi.fn() }); + const policy = { name: 'region_scope', object: 'task', operation: 'select', using: 'owner_id == current_user.synthetic_unresolved_key' }; + expect(compiler.compileFilter([policy as never], { userId: 'u1' } as never)).toBe(SPEC_RLS_DENY_FILTER); + }); +}); + +describe('[ADR-0139 D2 step 2] every getReadFilter deny return is the spec sentinel BY REFERENCE', () => { + it('a principal-less context (ADR-0096 D5)', async () => { + const { plugin } = await boot({ sets: [OPEN_SET] }); + expect(await plugin.getReadFilter('task', { positions: [], permissions: [] })).toBe(SPEC_RLS_DENY_FILTER); + }); + + it('a sharing (OWD) scope that cannot be resolved', async () => { + const sharing = { buildReadFilter: vi.fn(async () => { throw new Error('synthetic sharing outage'); }) }; + const { plugin, logger } = await boot({ sets: [OPEN_SET], sharing }); + expect(await plugin.getReadFilter('task', { ...MEMBER })).toBe(SPEC_RLS_DENY_FILTER); + expect(sharing.buildReadFilter).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); + }); + + it('an on-behalf-of context (the D10 intersection is not on this path)', async () => { + const { plugin } = await boot({ sets: [OPEN_SET] }); + expect(await plugin.getReadFilter('task', { ...MEMBER, onBehalfOf: { userId: 'u_delegator' } })).toBe(SPEC_RLS_DENY_FILTER); + }); + + it('a caller who resolves no permission set (ADR-0056 D2 deny baseline)', async () => { + const { plugin } = await boot({ sets: [], noBaseline: true }); + expect(await plugin.getReadFilter('task', { ...MEMBER })).toBe(SPEC_RLS_DENY_FILTER); + }); + + it('a permission-resolution throw', async () => { + const { plugin } = await boot({ sets: [OPEN_SET] }); + (plugin as any).permissionEvaluator.resolvePermissionSets = async () => { + throw new Error('synthetic metadata outage'); + }; + expect(await plugin.getReadFilter('task', { ...MEMBER })).toBe(SPEC_RLS_DENY_FILTER); + }); + + it('the tenant wall\'s deny, when it is the only layer that constrains', async () => { + const { plugin } = await boot({ sets: [OPEN_SET], orgScoping: true }); + expect(await plugin.getReadFilter('task', { ...MEMBER, tenantId: undefined })).toBe(SPEC_RLS_DENY_FILTER); + // CONTROL: the same caller WITH an organization is walled, not denied. + expect(await plugin.getReadFilter('task', { ...MEMBER })).toEqual({ organization_id: 'org-1' }); + }); + + it('every applicable policy dropped for a column the object lacks (Layer 1)', async () => { + const { plugin } = await boot({ sets: [TENANT_POLICY_SET], fields: ['id', 'owner_id', 'name'], orgScoping: true }); + expect(await plugin.getReadFilter('task', { ...MEMBER })).toBe(SPEC_RLS_DENY_FILTER); + }); + + it('the RLS compiler\'s deny, when every applicable policy fails to compile', async () => { + const { plugin } = await boot({ sets: [UNRESOLVABLE_POLICY_SET] }); + expect(await plugin.getReadFilter('task', { ...MEMBER })).toBe(SPEC_RLS_DENY_FILTER); + }); + + it('a controlled_by_parent object whose master cannot be resolved', async () => { + const { plugin } = await boot({ sets: [OPEN_SET], schemaExtra: { sharingModel: 'controlled_by_parent' } }); + expect(await plugin.getReadFilter('task', { ...MEMBER })).toBe(SPEC_RLS_DENY_FILTER); + }); + + it('CONTROL: an open member with no wall and no policy has no row restriction', async () => { + const { plugin } = await boot({ sets: [OPEN_SET] }); + expect(await plugin.getReadFilter('task', { ...MEMBER })).toBeUndefined(); + }); +}); + +describe('[ADR-0139 D2 step 2] the CRUD middleware carries the frozen sentinel without mutating it', () => { + it('a walled read with no organization injects the spec object itself as the where, and leaves it unedited', async () => { + const { middleware } = await boot({ sets: [OPEN_SET], orgScoping: true }); + const opCtx: any = { + object: 'task', + operation: 'find', + context: { ...MEMBER, tenantId: undefined }, + options: {}, + ast: { where: undefined }, + }; + let ran = false; + await middleware(opCtx, async () => { ran = true; }); + expect(ran).toBe(true); + expect(opCtx.ast.where).toBe(SPEC_RLS_DENY_FILTER); + expect(SPEC_RLS_DENY_FILTER).toEqual({ id: expect.stringMatching(/^__rls_deny__:/) }); + expect(Object.keys(SPEC_RLS_DENY_FILTER)).toEqual(['id']); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 0986060dcc5..42e9fa777bd 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -656,19 +656,6 @@ function callerHasOrganizationScope(context: any, posture: TenancyPosture): bool return tenantId != null && tenantId !== ''; } -/** - * [ADR-0123 D2] Is this Layer 0 filter the fail-closed DENY sentinel — i.e. "a - * walled posture on a tenant object, and the context carries no organization - * scope to enforce with"? - * - * Identity is decided on the sentinel's own value, not on shape: `RLS_DENY_FILTER` - * is a single `id` equality against a string no record can carry, and - * `computeTenantLayer0Filter` SPREADS it (`{ ...RLS_DENY_FILTER }`) rather than - * returning the frozen object, so a reference check would silently answer `false` - * for every real denial. Every other Layer 0 return is a real tenant predicate - * (`organization_id = …` / `organization_id $in […]`) or `null`, so no legitimate - * wall can collide with this test. - */ /** * [#21771] Ruling A's read question, judged on the answer of a caller-context * by-id read: is the row ABSENT to this caller? @@ -786,10 +773,21 @@ function copyOfCallerPredicate(where: unknown): unknown { } } +/** + * [ADR-0123 D2] Is this Layer 0 filter the fail-closed DENY sentinel — i.e. "a + * walled posture on a tenant object, and the context carries no organization + * scope to enforce with"? + * + * [ADR-0139 D2 step 2] Decided BY IDENTITY, the way the engine's composition + * decides it: the wall's `deny` returns `RLS_DENY_FILTER` itself — the spec's + * frozen object, never a spread copy (`tenantLayer0FilterOf`) — so the + * reference is the whole test, and no value or shape is compared. Every other + * Layer 0 return is a real tenant predicate (`organization_id = …` / + * `organization_id $in […]`) or `null`, which no reference check can confuse + * with it. + */ function isTenantWallDenial(filter: Record | null | undefined): boolean { - if (!filter) return false; - const keys = Object.keys(filter); - return keys.length === 1 && keys[0] === 'id' && filter.id === RLS_DENY_FILTER.id; + return filter === RLS_DENY_FILTER; } const SYSTEM_ROW_PROVENANCE: Record< @@ -5838,6 +5836,14 @@ export class SecurityPlugin implements Plugin { * ({@link isPrincipalLessContext}, ADR-0096 D5 strict mode), which the * middleware refuses before it resolves anything: it no longer keeps the * sharing predicate alone. + * + * [ADR-0139 D2 step 2] Every deny this method answers is `RLS_DENY_FILTER` + * ITSELF — `@objectstack/spec/data`'s frozen object, by reference — whether + * it is returned here or comes up unchanged from a layer (the tenant wall's + * deny, the RLS compiler's, the dropped-policy and unresolvable-master + * denies below): the engine's composition recognises it by identity and + * answers `$in: []`. ⛔ Never return a spread copy; a copy still matches zero + * rows, but it is no longer the sentinel. */ async getReadFilter( object: string, @@ -5848,7 +5854,7 @@ export class SecurityPlugin implements Plugin { // [#21908] ADR-0096 D5 strict mode — the middleware's principal-less // refusal, mirrored: zero rows, read off the same predicate, before any // layer resolves. This used to answer the sharing predicate alone. - if (isPrincipalLessContext(context)) return { ...RLS_DENY_FILTER }; + if (isPrincipalLessContext(context)) return RLS_DENY_FILTER; // [#4467] The OWD/sharing predicate is resolved for EVERY non-system caller, // ahead of the RLS branches below, because it is a SEPARATE middleware in // the chain this method mirrors: none of the RLS stand-downs below is a @@ -5864,7 +5870,7 @@ export class SecurityPlugin implements Plugin { `the engine middleware never runs without the owner and share scope a direct read applies)`, e instanceof Error ? e : new Error(String(e)), ); - return { ...RLS_DENY_FILTER }; + return RLS_DENY_FILTER; } // [#2852] D10 delegator intersection is NOT implemented on this path. // The engine middleware (find/count/aggregate) intersects an on-behalf-of @@ -5886,7 +5892,7 @@ export class SecurityPlugin implements Plugin { `implemented on the read-scope path; denying (fail-closed: a delegated read is never scoped ` + `wider than its delegator's own)`, ); - return { ...RLS_DENY_FILTER }; + return RLS_DENY_FILTER; } try { const permissionSets = await this.resolvePermissionSetsForContext(context); @@ -5897,7 +5903,7 @@ export class SecurityPlugin implements Plugin { // from the layers instead, it was the sharing predicate alone — every // row of an object whose sharing model is not private. Not a failure, // so nothing is logged. - if (permissionSets.length === 0) return { ...RLS_DENY_FILTER }; + if (permissionSets.length === 0) return RLS_DENY_FILTER; const filter = await this.computeRlsFilter(permissionSets, object, 'find', context); // [#5815] ADR-0055 — the SECOND thing the middleware ANDs into `ast.where` // for a read. Resolved from the sets already resolved above, exactly as @@ -5924,7 +5930,7 @@ export class SecurityPlugin implements Plugin { `(user ${context?.userId ?? 'unknown'}) — denying (fail-closed)`, e instanceof Error ? e : new Error(String(e)), ); - return { ...RLS_DENY_FILTER }; + return RLS_DENY_FILTER; } } @@ -8185,9 +8191,10 @@ export class SecurityPlugin implements Plugin { } : undefined, ); - // Every applicable policy dropped for a missing field → deny sentinel. + // Every applicable policy dropped for a missing field → deny sentinel, + // by reference (ADR-0139 D2 step 2: recognised by identity). if (layer1 == null && dropped > 0) { - layer1 = { ...RLS_DENY_FILTER }; + layer1 = RLS_DENY_FILTER; } } } @@ -8618,7 +8625,7 @@ export class SecurityPlugin implements Plugin { // header names those doors). So here the alias is genuinely REACHABLE. // • And here a miss is not a quiet wrong answer, it is a DENIAL. // `resolveCbpRelation` returning null is FAIL-CLOSED: the read leg - // answers `{ ...RLS_DENY_FILTER }` (zero rows for every non-admin + // answers `RLS_DENY_FILTER` (zero rows for every non-admin // caller) and the write leg throws `MasterDetailRelationMissingError`. // Narrowing this reader would take a raw-registered, alias-spelled // `controlled_by_parent` object from "access derived from its master" @@ -8839,7 +8846,8 @@ export class SecurityPlugin implements Plugin { if (sharingModel !== 'controlled_by_parent') return null; const rel = this.resolveCbpRelation(object); - if (!rel) return { ...RLS_DENY_FILTER }; + // No resolvable master: the deny sentinel, by reference (ADR-0139 D2 step 2). + if (!rel) return RLS_DENY_FILTER; // [ADR-0055 amendment] Chain guards, BEFORE any store work. Both answer with the empty // master set — the same shape the #5386 sharing-resolution failure answers diff --git a/packages/plugins/plugin-security/src/tenant-layer.test.ts b/packages/plugins/plugin-security/src/tenant-layer.test.ts index a292ffc3e42..f8057beb015 100644 --- a/packages/plugins/plugin-security/src/tenant-layer.test.ts +++ b/packages/plugins/plugin-security/src/tenant-layer.test.ts @@ -348,10 +348,10 @@ describe('computeTenantLayer0Verdict — what the wall decided, per exit (#15813 } }); - it('projecting `deny` spreads the sentinel — equal by value, never the frozen object itself', () => { + it('[ADR-0139 D2 step 2] projecting `deny` returns the frozen sentinel itself, by reference — never a spread copy', () => { const projected = tenantLayer0FilterOf({ kind: 'deny' }); - expect(projected).toEqual(RLS_DENY_FILTER); - expect(projected).not.toBe(RLS_DENY_FILTER); + expect(projected).toBe(RLS_DENY_FILTER); + expect(Object.isFrozen(projected)).toBe(true); }); it('projecting `organizations` copies the set (a later mutation cannot reach the compiled filter)', () => { diff --git a/packages/plugins/plugin-security/src/tenant-layer.ts b/packages/plugins/plugin-security/src/tenant-layer.ts index f33e0947e6c..8794bbbc995 100644 --- a/packages/plugins/plugin-security/src/tenant-layer.ts +++ b/packages/plugins/plugin-security/src/tenant-layer.ts @@ -162,17 +162,19 @@ export function computeTenantLayer0Verdict(input: TenantLayer0Input): TenantLaye * is `tenantLayer0FilterOf(computeTenantLayer0Verdict(input))` and nothing * else. * - * `deny` SPREADS {@link RLS_DENY_FILTER} rather than returning the frozen - * object (`isTenantWallDenial` in `security-plugin.ts` compares by value for - * exactly this reason); `organizations` copies the set so a later mutation of - * the verdict cannot reach a compiled filter. + * [ADR-0139 D2 step 2] `deny` returns {@link RLS_DENY_FILTER} itself — the + * spec's frozen object, BY REFERENCE, never a spread copy — because the + * engine's composition and `isTenantWallDenial` in `security-plugin.ts` + * recognise the sentinel by identity; frozen, no holder can edit it. + * `organizations` copies the set so a later mutation of the verdict cannot + * reach a compiled filter. */ export function tenantLayer0FilterOf(verdict: TenantLayer0Verdict): Record | null { switch (verdict.kind) { case 'none': return null; case 'deny': - return { ...RLS_DENY_FILTER }; + return RLS_DENY_FILTER; case 'organization': return { organization_id: verdict.organizationId }; case 'organizations': diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 1bc71a3972c..352e1dc29f4 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -86,6 +86,7 @@ "CALENDAR_DATE_TYPES (const)", "CANONICAL_CREDENTIAL_KEYS (const)", "CLOCK_TIME_TYPES (const)", + "COMPOSED_READABLE_IDS_BRAND (const)", "COMPUTED_VALUE_TYPES (const)", "CONTEXT_TOKENS (const)", "CONTEXT_TOKEN_DESCRIPTIONS (const)", @@ -109,6 +110,7 @@ "ComparandTypeRow (interface)", "ComparisonOperatorSchema (const)", "Compatibility (type)", + "ComposedReadableIds (interface)", "ConditionalValidation (type)", "ConditionalValidationParsed (type)", "ConditionalValidationSchema (const)", @@ -608,6 +610,7 @@ "REFERENCE_VALUE_TYPES (const)", "RETIRED_FILTER_OPERATORS (const)", "RETIRED_SUB_DAY_INTERVALS (const)", + "RLS_DENY_FILTER (const)", "RPC_QUERY_ALIAS_SLOTS (const)", "RUNTIME_OWNED_FIELD_TYPES (const)", "RangeOperatorSchema (const)", @@ -868,6 +871,7 @@ "isApiPrimitive (function)", "isAppResolvedDefaultToken (function)", "isCompatible (function)", + "isComposedReadableIds (function)", "isContextToken (function)", "isCurrentUserDefaultToken (function)", "isDateMacroToken (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 63a37f65bc5..b0bb55da9d1 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -83,6 +83,7 @@ "CALENDAR_DATE_TYPES": "src/data/field-value.zod.ts#CALENDAR_DATE_TYPES (const)", "CANONICAL_CREDENTIAL_KEYS": "src/data/driver/common.zod.ts#CANONICAL_CREDENTIAL_KEYS (const)", "CLOCK_TIME_TYPES": "src/data/field-value.zod.ts#CLOCK_TIME_TYPES (const)", + "COMPOSED_READABLE_IDS_BRAND": "src/data/readable-ids.ts#COMPOSED_READABLE_IDS_BRAND (const)", "COMPUTED_VALUE_TYPES": "src/data/field-value.zod.ts#COMPUTED_VALUE_TYPES (const)", "CONTEXT_TOKENS": "src/data/context-tokens.zod.ts#CONTEXT_TOKENS (const)", "CONTEXT_TOKEN_DESCRIPTIONS": "src/data/context-tokens.zod.ts#CONTEXT_TOKEN_DESCRIPTIONS (const)", @@ -106,6 +107,7 @@ "ComparandTypeRow": "src/data/filter-comparand-type-conformance.ts#ComparandTypeRow (interface)", "ComparisonOperatorSchema": "src/data/filter.zod.ts#ComparisonOperatorSchema (const)", "Compatibility": "src/data/type-compat.ts#Compatibility (type)", + "ComposedReadableIds": "src/data/readable-ids.ts#ComposedReadableIds (interface)", "ConditionalValidation": "src/data/validation.zod.ts#ConditionalValidation (type)", "ConditionalValidationParsed": "src/data/validation.zod.ts#ConditionalValidationParsed (type)", "ConditionalValidationSchema": "src/data/validation.zod.ts#ConditionalValidationSchema (const)", @@ -597,6 +599,7 @@ "REFERENCE_VALUE_TYPES": "src/data/field-value.zod.ts#REFERENCE_VALUE_TYPES (const)", "RETIRED_FILTER_OPERATORS": "src/data/filter.zod.ts#RETIRED_FILTER_OPERATORS (const)", "RETIRED_SUB_DAY_INTERVALS": "src/data/analytics.zod.ts#RETIRED_SUB_DAY_INTERVALS (const)", + "RLS_DENY_FILTER": "src/data/readable-ids.ts#RLS_DENY_FILTER (const)", "RPC_QUERY_ALIAS_SLOTS": "src/data/data-engine.zod.ts#RPC_QUERY_ALIAS_SLOTS (const)", "RUNTIME_OWNED_FIELD_TYPES": "src/data/field.zod.ts#RUNTIME_OWNED_FIELD_TYPES (const)", "RangeOperatorSchema": "src/data/filter.zod.ts#RangeOperatorSchema (const)", @@ -854,6 +857,7 @@ "isApiPrimitive": "src/data/api-derivation.ts#isApiPrimitive (function)", "isAppResolvedDefaultToken": "src/data/default-value-tokens.ts#isAppResolvedDefaultToken (function)", "isCompatible": "src/data/type-compat.ts#isCompatible (function)", + "isComposedReadableIds": "src/data/readable-ids.ts#isComposedReadableIds (function)", "isContextToken": "src/data/context-tokens.zod.ts#isContextToken (function)", "isCurrentUserDefaultToken": "src/data/default-value-tokens.ts#isCurrentUserDefaultToken (function)", "isDateMacroToken": "src/data/date-macros.zod.ts#isDateMacroToken (function)", diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index ec11876efba..a557a552558 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -45,6 +45,10 @@ export * from './filter-subtree-provenance'; // operator, so no request can spell it; its brand is a `Symbol.for` key on the // provenance mark's device, so a copy that crossed JSON or a structured clone // arrives unbranded and the comparand-shape door refuses it as a non-list. +// [ADR-0139 D2] Beside it, the two things the engine's composition reads: the +// RLS deny sentinel (`RLS_DENY_FILTER`, recognised by identity, D2 step 2) and +// the composed leaf `{ object, scope }` (`ReadableIds.compose`, +// `isComposedReadableIds`, D2 step 3). export * from './readable-ids'; // [#8371] The FILTER axis' dotted-path verdict — ONE head-type classification // consumed by both doors (metadata-protocol's ingress gate and objectql's diff --git a/packages/spec/src/data/readable-ids.test.ts b/packages/spec/src/data/readable-ids.test.ts index cd6779ea4c4..d985100cd22 100644 --- a/packages/spec/src/data/readable-ids.test.ts +++ b/packages/spec/src/data/readable-ids.test.ts @@ -18,7 +18,15 @@ import { describe, it, expect } from 'vitest'; import { StandardErrorCode } from '../api/errors.zod'; import { FieldOperatorsSchema, FilterConditionSchema, parseFilterAST } from './filter.zod'; import { assertListComparandShapes } from './filter-comparand-shape'; -import { READABLE_IDS_BRAND, ReadableIds, isReadableIds } from './readable-ids'; +import { filterSubtreeProvenanceOf, markFilterSubtreeProvenance } from './filter-subtree-provenance'; +import { + COMPOSED_READABLE_IDS_BRAND, + READABLE_IDS_BRAND, + RLS_DENY_FILTER, + ReadableIds, + isComposedReadableIds, + isReadableIds, +} from './readable-ids'; type Refusal = Error & { code?: string; status?: number }; @@ -165,3 +173,155 @@ describe('the comparand-shape door admits a branded leaf only for a composing ca expect(() => assertListComparandShapes(where, undefined, 'where', ADMIT)).not.toThrow(); }); }); + +// ── [ADR-0139 D2] What the engine's composition reads ──────────────────────── + +describe('RLS_DENY_FILTER — the deny sentinel, declared once and recognised by identity', () => { + it('is the published value, frozen, and an ordinary zero-row predicate to anyone who never heard of it', () => { + // The value is published (explain's readFilter, a bound SQL parameter in + // analytics), so it is pinned; what D2 step 2 reads is the reference. + expect(RLS_DENY_FILTER).toEqual({ id: '__rls_deny__:00000000-0000-0000-0000-000000000000' }); + expect(Object.keys(RLS_DENY_FILTER)).toEqual(['id']); + expect(Object.isFrozen(RLS_DENY_FILTER)).toBe(true); + expect(() => { (RLS_DENY_FILTER as { id: string }).id = 'widened'; }).toThrow(TypeError); + expect(RLS_DENY_FILTER.id).toBe('__rls_deny__:00000000-0000-0000-0000-000000000000'); + }); + + it('no read-scope merge boundary can stamp a provenance mark on it — frozen, so it stays unmarked, which withholds', () => { + expect(markFilterSubtreeProvenance(RLS_DENY_FILTER, 'author')).toBe(RLS_DENY_FILTER); + expect(filterSubtreeProvenanceOf(RLS_DENY_FILTER)).toBeNull(); + }); + + it('a copy carries the same value and is NOT the sentinel — the reason every producer returns the reference', () => { + const copy = { ...RLS_DENY_FILTER }; + expect(copy).toEqual(RLS_DENY_FILTER); + expect(copy).not.toBe(RLS_DENY_FILTER); + }); +}); + +describe('ReadableIds.compose — the composed leaf of D2 step 3', () => { + const scope = { owner_id: 'u1' }; + + it('builds a frozen leaf carrying { object, scope }, the scope by reference, branded under its own global-registry symbol, invisibly to enumeration and JSON', () => { + const composed = ReadableIds.compose(ReadableIds.of('cpz_doc'), scope); + expect(isComposedReadableIds(composed)).toBe(true); + expect(composed.object).toBe('cpz_doc'); + expect(composed.scope).toBe(scope); + expect(Object.isFrozen(composed)).toBe(true); + expect(COMPOSED_READABLE_IDS_BRAND).toBe(Symbol.for('objectstack.filter.readableIds.composed')); + expect(COMPOSED_READABLE_IDS_BRAND).not.toBe(READABLE_IDS_BRAND); + expect(Object.getOwnPropertyDescriptor(composed, COMPOSED_READABLE_IDS_BRAND)).toEqual({ + value: true, enumerable: false, writable: false, configurable: false, + }); + expect(Object.keys(composed)).toEqual(['object', 'scope']); + expect(JSON.stringify(composed)).toBe('{"object":"cpz_doc","scope":{"owner_id":"u1"}}'); + }); + + it('composes "no row restriction" as an own scope key holding undefined', () => { + const composed = ReadableIds.compose(ReadableIds.of('cpz_doc'), undefined); + expect(isComposedReadableIds(composed)).toBe(true); + expect(Object.prototype.hasOwnProperty.call(composed, 'scope')).toBe(true); + expect(composed.scope).toBeUndefined(); + }); + + it('a holder of the reference cannot re-point the object or swap the scope', () => { + const composed = ReadableIds.compose(ReadableIds.of('cpz_doc'), scope); + expect(() => { (composed as { object: string }).object = 'sys_user'; }).toThrow(TypeError); + expect(() => { (composed as { scope: unknown }).scope = undefined; }).toThrow(TypeError); + expect(composed.object).toBe('cpz_doc'); + expect(composed.scope).toBe(scope); + }); + + it('refuses to compose anything but a branded ReadableIds leaf', () => { + const leaf = ReadableIds.of('cpz_doc'); + for (const bad of [{ ...leaf }, { object: 'cpz_doc' }, ReadableIds.compose(leaf, undefined), null, 'cpz_doc'] as unknown[]) { + expect(() => ReadableIds.compose(bad as never, undefined), String(bad)).toThrow(TypeError); + } + }); + + it('refuses a scope that is neither undefined nor a plain filter object', () => { + const leaf = ReadableIds.of('cpz_doc'); + for (const bad of [null, [], [{ owner_id: 'u1' }], 'owner_id = u1', 0, false] as unknown[]) { + expect(() => ReadableIds.compose(leaf, bad as never), JSON.stringify(bad)).toThrow(TypeError); + } + }); + + it('refuses the deny sentinel BY IDENTITY: D2 step 2 answers it $in: [], never a composed leaf', () => { + const leaf = ReadableIds.of('cpz_doc'); + expect(() => ReadableIds.compose(leaf, RLS_DENY_FILTER)).toThrow(TypeError); + expect(() => ReadableIds.compose(leaf, RLS_DENY_FILTER)).toThrow(/\$in: \[\] \(ADR-0139 D2 step 2\)/); + // Identity, not value: a copy is an ordinary scope (one that matches zero + // rows), exactly as D2 step 2 would see it — ⛔ no structural recognition. + const copy = { ...RLS_DENY_FILTER }; + expect(ReadableIds.compose(leaf, copy).scope).toBe(copy); + }); +}); + +describe('isComposedReadableIds — disjoint from isReadableIds, and every copy answers false', () => { + const leaf = ReadableIds.of('cpz_doc'); + const composed = ReadableIds.compose(leaf, { owner_id: 'u1' }); + + it('the two recognisers are disjoint: a composed leaf is not a ReadableIds leaf, and an uncomposed one is not composed', () => { + expect(isComposedReadableIds(composed)).toBe(true); + expect(isReadableIds(composed)).toBe(false); + expect(isReadableIds(leaf)).toBe(true); + expect(isComposedReadableIds(leaf)).toBe(false); + }); + + it('a hand-built value carrying BOTH brands is not composed: it reads as an uncomposed leaf, whichever recogniser is asked first', () => { + // Both keys are global-registry symbols, so in-process code can build this. + const both = { object: 'x', scope: undefined }; + Object.defineProperty(both, READABLE_IDS_BRAND, { value: true }); + Object.defineProperty(both, COMPOSED_READABLE_IDS_BRAND, { value: true }); + expect(isComposedReadableIds(both)).toBe(false); + expect(isReadableIds(both)).toBe(true); + // The comparand door, as measured: it admits the value as an UNCOMPOSED leaf + // for the composing caller (so the engine composes it from the caller's + // access) and refuses it for every other caller. + expect(() => assertListComparandShapes({ record_id: { $in: both } }, undefined, 'where', ADMIT)).not.toThrow(); + const err = refusalOf(() => assertListComparandShapes({ record_id: { $in: both } })); + expect(envelopeOf(err)).toEqual(ENVELOPE); + }); + + it('a JSON round-trip, a structured clone, a spread and Object.assign all drop the brand', () => { + const copies = { + json: JSON.parse(JSON.stringify(composed)), + structuredClone: structuredClone(composed), + spread: { ...composed }, + assign: Object.assign({}, composed), + }; + for (const [how, copy] of Object.entries(copies)) { + expect(copy, how).toEqual({ object: 'cpz_doc', scope: { owner_id: 'u1' } }); + expect(isComposedReadableIds(copy), how).toBe(false); + expect(isReadableIds(copy), how).toBe(false); + } + }); + + it('a brand with any value but true, an empty object name, a missing or malformed scope, inherited, or on an array is not a composed leaf', () => { + const branded = (value: unknown, fields: Record) => + Object.defineProperty({ ...fields }, COMPOSED_READABLE_IDS_BRAND, { value }); + expect(isComposedReadableIds(branded(true, { object: 'cpz_doc', scope: undefined })), 'the declared shape, built by hand').toBe(true); + expect(isComposedReadableIds(branded(true, { object: 'cpz_doc', scope: { a: 1 } })), 'the declared shape, built by hand').toBe(true); + expect(isComposedReadableIds(branded('true', { object: 'cpz_doc', scope: undefined }))).toBe(false); + expect(isComposedReadableIds(branded(true, { object: '', scope: undefined }))).toBe(false); + expect(isComposedReadableIds(branded(true, { object: 7, scope: undefined }))).toBe(false); + expect(isComposedReadableIds(branded(true, { object: 'cpz_doc' })), 'no scope key at all').toBe(false); + expect(isComposedReadableIds(branded(true, { object: 'cpz_doc', scope: null }))).toBe(false); + expect(isComposedReadableIds(branded(true, { object: 'cpz_doc', scope: [] }))).toBe(false); + expect(isComposedReadableIds(branded(true, { object: 'cpz_doc', scope: 'owner_id = u1' }))).toBe(false); + expect(isComposedReadableIds(Object.create(composed))).toBe(false); + expect(isComposedReadableIds(Object.defineProperty([], COMPOSED_READABLE_IDS_BRAND, { value: true }))).toBe(false); + for (const value of [{ object: 'cpz_doc', scope: undefined }, null, undefined, 'cpz_doc', 0, true, []]) { + expect(isComposedReadableIds(value), String(value)).toBe(false); + } + }); +}); + +describe('the comparand-shape door never admits a composed leaf — not even for the caller that composes', () => { + it('a composed leaf handed to the engine from outside is refused 400 as a non-list comparand, never trusted as a resolved scope', () => { + const composed = ReadableIds.compose(ReadableIds.of('cpz_doc'), undefined); + const err = refusalOf(() => assertListComparandShapes({ record_id: { $in: composed } }, undefined, 'where', ADMIT)); + expect(envelopeOf(err)).toEqual(ENVELOPE); + expect(err.message).toMatch(/^Operator "\$in" on field "record_id" requires an ARRAY of values\. Received object/); + }); +}); diff --git a/packages/spec/src/data/readable-ids.ts b/packages/spec/src/data/readable-ids.ts index 6f1880a82ac..d7c5fcde3a9 100644 --- a/packages/spec/src/data/readable-ids.ts +++ b/packages/spec/src/data/readable-ids.ts @@ -55,8 +55,29 @@ * Like `./filter-subtree-provenance.ts`, the module is tolerant of bare, * un-parsed input: {@link isReadableIds} accepts `unknown` and answers `false` * for every degraded shape. + * + * ## Two more declarations the composition reads (ADR-0139 D2) + * + * The engine composes each leaf from the parent object's read scope, + * `getReadFilter(P, caller)`, immediately before the driver call. Two things + * that composition needs live here, beside the leaf, because the engine, the + * security service and the drivers all import this package and none of them + * can import each other: + * + * - {@link RLS_DENY_FILTER}, the deny sentinel `getReadFilter` answers when + * the caller may read no row of `P`. D2 step 2 answers it `$in: []`, and it + * recognises it BY IDENTITY, so every producer returns this one object. + * - {@link ComposedReadableIds}, the composed leaf of D2 step 3, + * `{ object: P, scope }`, built only by {@link ReadableIds.compose} and + * told apart from the uncomposed leaf by {@link isComposedReadableIds}. The + * two recognisers are DISJOINT: {@link isReadableIds} answers `false` for a + * composed leaf, so "an uncomposed leaf reached this driver" is the one + * question `isReadableIds` asks, and a driver refuses on it (D2's last + * paragraph, D3). */ +import type { FilterCondition } from './filter.zod'; + /** * The symbol key the `ReadableIds` brand lives under. * @@ -96,9 +117,130 @@ export function isReadableIds(value: unknown): value is ReadableIds { return typeof object === 'string' && object.length > 0; } +/** + * [ADR-0139 D2 step 2] The RLS deny sentinel — the read scope that means "this + * caller may read no row of this object", declared once for the whole tree. + * + * **Its reader is ADR-0139 D2 step 2.** When the engine composes a + * {@link ReadableIds} leaf for parent object `P`, it asks + * `getReadFilter(P, caller)` (`../contracts/security-service.ts`); an answer + * that IS this object is answered `$in: []` — FALSE on every driver — and never + * becomes a {@link ComposedReadableIds} ({@link ReadableIds.compose} refuses + * it). The engine recognises it **by identity** (`scope === RLS_DENY_FILTER`), + * never by comparing its value or its shape. + * + * **So every producer returns this object by reference.** The security service + * answers it from `getReadFilter`'s fail-closed branches, from the tenant wall's + * deny (Layer 0) and from the RLS compiler's "every applicable policy dropped" + * path; `@objectstack/plugin-security` re-exports it under the same name and + * holds no second literal. ⛔ Never spread or clone it (`{ ...RLS_DENY_FILTER }`): + * a copy still matches zero rows, so nothing visibly breaks, but it is no + * longer the sentinel, and D2 step 2 then composes a subquery that matches + * nothing instead of answering `$in: []`. + * + * **It is also an ordinary predicate, and that is the fail direction.** The + * value is an `id` equality against a UUID-shaped string no record carries, so + * a consumer that never heard of the sentinel — the analytics raw-SQL compiler, + * a driver, the explain engine's record pass — evaluates it to zero rows. The + * value is published (the explain engine's `readFilter`, a bound SQL parameter + * in analytics) and is unchanged by this declaration. + * + * Frozen, so no holder of the reference can edit it, and so no read-scope merge + * boundary can stamp a provenance mark on it: a frozen subtree stays unmarked, + * which withholds + * (`packages/spec/src/data/filter-subtree-provenance.ts#markFilterSubtreeProvenance`). + */ +export const RLS_DENY_FILTER: Readonly<{ id: string }> = Object.freeze({ + id: '__rls_deny__:00000000-0000-0000-0000-000000000000', +}); + +/** + * The symbol key the {@link ComposedReadableIds} brand lives under — a key of + * its own, never {@link READABLE_IDS_BRAND}, so the composed and the uncomposed + * leaf are told apart by which brand they carry. + * + * Exported for the same reason as {@link READABLE_IDS_BRAND}. Everything else + * asks {@link isComposedReadableIds}. + */ +export const COMPOSED_READABLE_IDS_BRAND: unique symbol = Symbol.for('objectstack.filter.readableIds.composed'); + +/** + * [ADR-0139 D2 step 3] A COMPOSED `ReadableIds` leaf: "the ids of the records + * of {@link ComposedReadableIds.object} matching + * {@link ComposedReadableIds.scope}", which is what the engine hands a driver + * in place of the {@link ReadableIds} leaf it composed. + * + * Built only by {@link ReadableIds.compose}, at the engine's executor boundary, + * after the comparand doors have run; no door admits it (the comparand-shape + * door's `admitReadableIds` admits the uncomposed leaf only), so a composed + * leaf handed to the engine from outside is refused as a non-list comparand, + * never trusted as a scope someone else already resolved. + * + * Still branded, as D2 says, under {@link COMPOSED_READABLE_IDS_BRAND}: a JSON + * round-trip, a structured clone or a spread produce the unbranded + * `{ object, scope }`, which no driver may evaluate as a composed leaf. + * + * **The tenant wall is not in `scope`** (D2 step 3): the driver applies its own + * tenant scope to `object`'s table with the outer read's options (D3). + */ +export interface ComposedReadableIds { + /** The machine name of the parent object whose ids the leaf stands for. */ + readonly object: string; + /** + * The parent's read scope, lowered and marked `'policy'` by the engine + * (D2 step 2), or `undefined`: the caller has no row restriction on + * `object`. Never the {@link RLS_DENY_FILTER} sentinel, which D2 step 2 + * answers `$in: []` instead. + */ + readonly scope: FilterCondition | undefined; + /** The brand. Non-enumerable at runtime, so a copy of the leaf drops it. */ + readonly [COMPOSED_READABLE_IDS_BRAND]: true; +} + +/** + * Is `value` a branded {@link ComposedReadableIds} leaf? + * + * Own symbol key only, and only the declared value `true`; no own + * {@link READABLE_IDS_BRAND} beside it; `object` a non-empty string; `scope` + * an own key holding `undefined` or a non-null, non-array object. Every copy + * that lost the brand, every hand-built shape that breaks one of those, an + * uncomposed {@link ReadableIds} leaf, `null` and every primitive answer + * `false`. + * + * Disjoint from {@link isReadableIds}: no value answers `true` to both. A + * value that carries both brands answers `false` here, so it is never a + * composed leaf: when its uncomposed brand is valid, {@link isReadableIds} + * answers `true` and the value is refused as an uncomposed leaf, whichever of + * the two recognisers is asked first. + */ +export function isComposedReadableIds(value: unknown): value is ComposedReadableIds { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + if (!Object.prototype.hasOwnProperty.call(value, COMPOSED_READABLE_IDS_BRAND)) return false; + if ((value as Record)[COMPOSED_READABLE_IDS_BRAND] !== true) return false; + if (Object.prototype.hasOwnProperty.call(value, READABLE_IDS_BRAND)) return false; + const object = (value as { object?: unknown }).object; + if (typeof object !== 'string' || object.length === 0) return false; + if (!Object.prototype.hasOwnProperty.call(value, 'scope')) return false; + return isComposableScope((value as { scope?: unknown }).scope); +} + +/** `undefined` (no restriction) or a non-null, non-array object — the scope's two declared spellings. */ +function isComposableScope(scope: unknown): boolean { + return scope === undefined || (scope !== null && typeof scope === 'object' && !Array.isArray(scope)); +} + +/** How a refused argument is named in a factory's `TypeError`. */ +function describeArgument(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'an array'; + if (typeof value === 'string') return value.length === 0 ? 'an empty string' : 'a string'; + return typeof value; +} + /** * The `ReadableIds` factory. `ReadableIds.of(object)` builds the frozen, - * branded leaf for `object`. + * branded leaf for `object`; `ReadableIds.compose(leaf, scope)` builds the + * composed leaf the engine hands a driver in its place. */ export const ReadableIds: { /** @@ -110,6 +252,26 @@ export const ReadableIds: { * names no object can never be composed. */ readonly of: (object: string) => ReadableIds; + /** + * [ADR-0139 D2 step 3] The composed leaf for `leaf` under the parent's read + * scope: frozen, branded under {@link COMPOSED_READABLE_IDS_BRAND}, carrying + * `{ object: leaf.object, scope }`. + * + * The engine's executor boundary is the caller (ADR-0139 D2); a driver's + * conformance suite (D3) builds one the same way. The function judges its + * arguments and nothing else: it asks no security service, lowers nothing + * and marks nothing. + * + * @param leaf the uncomposed leaf being composed; its `object` is the + * composed leaf's `object`. + * @param scope the parent's lowered read scope, or `undefined` for "no row + * restriction" (`getReadFilter`'s own spelling of it). + * @throws TypeError when `leaf` is not a branded {@link ReadableIds} leaf; + * when `scope` is neither `undefined` nor a non-null, non-array object; + * or when `scope` IS {@link RLS_DENY_FILTER}, which D2 step 2 + * answers `$in: []` and never composes. + */ + readonly compose: (leaf: ReadableIds, scope: FilterCondition | undefined) => ComposedReadableIds; } = Object.freeze({ of(object: string): ReadableIds { if (typeof object !== 'string' || object.length === 0) { @@ -128,4 +290,32 @@ export const ReadableIds: { }); return Object.freeze(leaf) as ReadableIds; }, + compose(leaf: ReadableIds, scope: FilterCondition | undefined): ComposedReadableIds { + if (!isReadableIds(leaf)) { + throw new TypeError( + `ReadableIds.compose() needs a leaf built by ReadableIds.of(), received ${describeArgument(leaf)}` + + `${leaf !== null && typeof leaf === 'object' ? ' without the ReadableIds brand' : ''}.`, + ); + } + if (!isComposableScope(scope)) { + throw new TypeError( + `ReadableIds.compose() needs the read scope of '${leaf.object}' as a filter object, or undefined for ` + + `no row restriction, received ${describeArgument(scope)}.`, + ); + } + if ((scope as unknown) === RLS_DENY_FILTER) { + throw new TypeError( + `ReadableIds.compose() was handed the RLS deny sentinel as the read scope of '${leaf.object}'. A caller ` + + 'who may read no row of the parent is answered $in: [] (ADR-0139 D2 step 2), never a composed leaf.', + ); + } + const composed = { object: leaf.object, scope }; + Object.defineProperty(composed, COMPOSED_READABLE_IDS_BRAND, { + value: true, + enumerable: false, + writable: false, + configurable: false, + }); + return Object.freeze(composed) as ComposedReadableIds; + }, });