diff --git a/.changeset/25010-readable-ids-nested-compose.md b/.changeset/25010-readable-ids-nested-compose.md new file mode 100644 index 00000000000..264bb167fed --- /dev/null +++ b/.changeset/25010-readable-ids-nested-compose.md @@ -0,0 +1,15 @@ +--- +'@objectstack/objectql': minor +--- + +feat(objectql): the engine composes a `ReadableIds` leaf that sits inside a composed read scope — the nested master chain (ADR-0139 D6 as amended) + +Clause-②: no + +**Before.** The executor boundary composed every `ReadableIds` leaf in a read's own `where` with the parent's read scope, and handed the driver that scope as answered. A scope that itself carried a leaf — a parent whose read scope is derived from ITS master, `{ master_fk: { $in: ReadableIds.of(G) } }`, the shape a two-level `controlled_by_parent` chain takes once its scope is spelled as a leaf — reached the driver uncomposed inside the parent's subquery, and the driver refused it, `INTERNAL_ERROR` / 500 (measured on driver-sql). + +**Now.** Every leaf a composed scope carries is composed the same way, before the leaf that encloses it — the same five steps, the same caller context at every level (a delegated context included), recursively. The driver receives one composed leaf whose scope holds composed leaves and compiles one statement with the subqueries nested; a two-level chain serves exactly the rows today's id-list pre-scan serves, for a direct and for a delegated caller (measured on the real stack: `find` and `count` agree, one statement per read). Each parent is asked once per read, at whichever level it first appears. The closed doors hold at every level: `canReadObject` false, the deny sentinel, a throw on either half, or a parent the registry does not hold answer `$in: []` inside the composed scope. Refused by name, `INTERNAL_ERROR` / 500, before any driver is asked: D5's two classes at a nested level (a middleware-gated or federated master), a nested leaf outside a `$in` comparand or a composed leaf the engine did not compose inside a scope, a chain that returns to a parent already on its branch, and a chain deeper than 8 scopes (the same cut as the `controlled_by_parent` derivation's). + +**What a reader sees.** No shipped security service answers a read scope carrying a leaf yet — `controlled_by_parent`'s scope is still the pre-resolved id list (ADR-0055 §2 mechanism (b)) until the plugin-security switch lands on its own card — so every shipped read is served as before. The six consumers' leaves compose exactly as they did: a scope with no leaf is composed by reference, one level asked. + +**Unchanged.** `controlled_by_parent`'s own read scope and the id-list pre-scan, ADR-0055, every consumer's probe routing, and the drivers' compilation of the composed leaf (driver-sql and driver-memory already compile a scope that holds a composed leaf, through their own filter path). diff --git a/packages/objectql/src/engine-readable-ids-nested-composition.test.ts b/packages/objectql/src/engine-readable-ids-nested-composition.test.ts new file mode 100644 index 00000000000..4b1413bdc4c --- /dev/null +++ b/packages/objectql/src/engine-readable-ids-nested-composition.test.ts @@ -0,0 +1,411 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0139 D6, as amended — the nested master chain] A `ReadableIds` leaf + * that sits INSIDE a composed scope — the scope `getReadFilter(P)` answers for + * a parent whose own read scope is derived from ITS master — is composed by + * the engine too, before the leaf that encloses it, with the same caller + * context at every level. The driver receives one composed leaf whose scope + * holds composed leaves, never an uncomposed leaf (which it would refuse, D3). + * + * What is pinned, against the recording driver (this package's witness shape): + * + * - the composed chain itself: each read verb hands its driver ONE call; the + * outer leaf's scope is the service's answer rewritten copy-on-write, its + * nested `$in` a composed leaf whose scope is the service's answer for the + * grandparent by reference, marked `'policy'`; each parent is asked once per + * read, outermost first, every half with the caller's context as it stands + * (a delegated context included); + * - the closed doors at the nested level — `canReadObject` false, the deny + * sentinel, a throw on either half, a parent the registry does not hold — + * answer `$in: []` INSIDE the composed scope, the outer leaf still composed; + * - D5's two classes at the nested level (a middleware-gated or federated + * grandparent) refuse by name — code and status — with no driver call; + * - a nested leaf outside a `$in` comparand, and a composed leaf the engine + * did not compose inside a scope, are refused by the same walk; + * - a chain that returns to a parent on its branch (a cycle), and one deeper + * than the bound, are refused by name, not composed partway; + * - a parent that appears at two levels is asked once, and its composed scope + * is reused by reference. + * + * Evaluating the composed chain — one statement, the subqueries nested — is + * measured on the real stack in `@objectstack/plugin-security` + * (`readable-ids-nested-compose.test.ts`). + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + ReadableIds, + RLS_DENY_FILTER, + isComposedReadableIds, + filterSubtreeProvenanceOf, +} from '@objectstack/spec/data'; +import { ObjectQL, SECURITY_SERVICE_SOURCE_SLOT } from './engine.js'; + +interface SeenCall { verb: string; object: string; ast: any } + +/** Put a security-service source in the engine's slot, as `ObjectQLPlugin.init` does. */ +function wire(engine: ObjectQL, source: unknown): void { + (engine as unknown as Record)[SECURITY_SERVICE_SOURCE_SLOT] = source; +} + +function makeRecordingDriver(name = 'recording') { + const calls: SeenCall[] = []; + const driver: any = { + name, version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(object: string, ast: any) { calls.push({ verb: 'find', object, ast }); return []; }, + async findOne(object: string, ast: any) { calls.push({ verb: 'findOne', object, ast }); return null; }, + async count(object: string, ast: any) { calls.push({ verb: 'count', object, ast }); return 0; }, + async aggregate(object: string, ast: any) { calls.push({ verb: 'aggregate', object, ast }); return []; }, + async create(object: string, data: Record) { calls.push({ verb: 'create', object, ast: data }); return data; }, + async update(object: string, _id: string, data: Record) { calls.push({ verb: 'update', object, ast: data }); return data; }, + async updateMany(object: string, ast: any) { calls.push({ verb: 'updateMany', object, ast }); return 0; }, + async delete(object: string) { calls.push({ verb: 'delete', object, ast: null }); return true; }, + async deleteMany(object: string, ast: any) { calls.push({ verb: 'deleteMany', object, ast }); return 0; }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, calls }; +} + +/** The chain: child → parent (by `record_id`) → grand (by the parent's `grand`). */ +const CHILD = { + name: 'rid_child', + label: 'ReadableIds child', + fields: { + object_name: { name: 'object_name', type: 'text' }, + record_id: { name: 'record_id', type: 'text' }, + note: { name: 'note', type: 'text' }, + }, +}; +const PARENT = { name: 'rid_parent', label: 'Parent', fields: { title: { name: 'title', type: 'text' }, grand: { name: 'grand', type: 'text' } } }; +const GRAND = { name: 'rid_grand', label: 'Grandparent', fields: { owner: { name: 'owner', type: 'text' } } }; +const OTHER = { name: 'rid_other', label: 'Other parent', fields: { title: { name: 'title', type: 'text' } } }; +const GATED = { name: 'rid_gated', label: 'Middleware-gated grandparent', fields: { title: { name: 'title', type: 'text' } } }; +const FAR = { name: 'rid_far', label: 'Federated grandparent', datasource: 'far', fields: { title: { name: 'title', type: 'text' } } }; + +type Thrown = (Error & { code?: string; status?: number }) | null; +const refusalOf = async (p: Promise): Promise => p.then(() => null, (e: any) => e); +const envelopeOf = (err: Thrown) => ({ code: err?.code, status: err?.status }); +const REFUSED = { code: 'INTERNAL_ERROR', status: 500 }; + +const branch = (object = 'rid_parent') => ({ object_name: object, record_id: { $in: ReadableIds.of(object) } }); +/** The scope a `controlled_by_parent` parent's read is derived from: a leaf over its master. */ +const derivedFrom = (master: string) => ({ grand: { $in: ReadableIds.of(master) } }); +const COUNT_AGG = [{ function: 'count', alias: 'n' }]; + +/** A security service double answering per object, recording which half it was asked and with which context. */ +function makeSecurity(opts: { + canRead?: (object: string, context?: unknown) => boolean | Promise; + scope?: (object: string, context?: unknown) => unknown; +} = {}) { + const asked: string[] = []; + const contexts: Array<{ half: string; object: string; context: unknown }> = []; + const svc: any = { + async canReadObject(object: string, context?: unknown) { + asked.push(`canReadObject:${object}`); + contexts.push({ half: 'canReadObject', object, context }); + return opts.canRead ? opts.canRead(object, context) : true; + }, + async getReadFilter(object: string, context?: unknown) { + asked.push(`getReadFilter:${object}`); + contexts.push({ half: 'getReadFilter', object, context }); + return opts.scope ? opts.scope(object, context) : undefined; + }, + }; + return { svc, asked, contexts }; +} + +/** The `$in` comparand the driver received at `where.record_id`, under any `$and` the chain wrapped around it. */ +function inComparandOf(where: any): unknown { + if (where?.record_id) return where.record_id.$in; + for (const arm of where?.$and ?? []) { + const found = inComparandOf(arm); + if (found !== undefined) return found; + } + return undefined; +} + +describe('[ADR-0139 D6 as amended] a leaf inside a composed scope is composed too — the nested master chain', () => { + let engine: ObjectQL; + let calls: SeenCall[]; + + beforeEach(async () => { + const rec = makeRecordingDriver(); + const farRec = makeRecordingDriver('far'); + calls = rec.calls; + engine = new ObjectQL(); + engine.registerDriver(rec.driver, true); + engine.registerDriver(farRec.driver); + await engine.init(); + for (const def of [CHILD, PARENT, GRAND, OTHER, GATED, FAR]) engine.registry.registerObject(def as any, 'test'); + engine.registerMiddleware(async (_ctx, next) => next(), { object: 'rid_gated' }); + calls.length = 0; + }); + + // The context value is the cast, never the options object: the + // query-options-erasure ratchet counts an erased options argument. + const readVerbs = (where: Record, context?: unknown) => [ + ['find', () => engine.find('rid_child', { where, context: context as any })], + ['findOne', () => engine.findOne('rid_child', { where, context: context as any })], + ['count', () => engine.count('rid_child', { where, context: context as any })], + ['aggregate', () => engine.aggregate('rid_child', { where, aggregations: COUNT_AGG as any, context: context as any })], + ] as const; + + /** The service of a two-level chain: the parent's scope carries a leaf over the grandparent, whose scope is plain. */ + const chainSecurity = (grandScope: unknown = { owner: 'u1' }) => makeSecurity({ + scope: (object) => (object === 'rid_parent' ? derivedFrom('rid_grand') : object === 'rid_grand' ? grandScope : undefined), + }); + + it('each read verb hands its driver ONE call whose composed leaf carries a composed leaf: the chain asked outermost first, each parent once, both halves with the caller context as it stands', async () => { + const grandScope = { owner: 'u1' }; + const context = { userId: 'u1', principalKind: 'human' }; + const { svc, asked, contexts } = chainSecurity(grandScope); + wire(engine, () => svc); + for (const [verb, run] of readVerbs(branch(), context)) { + calls.length = 0; + asked.length = 0; + contexts.length = 0; + await run(); + expect(calls.map((c) => c.verb), verb).toEqual([verb]); + expect(asked, verb).toEqual([ + 'canReadObject:rid_parent', 'getReadFilter:rid_parent', + 'canReadObject:rid_grand', 'getReadFilter:rid_grand', + ]); + // One context for the whole chain: every half at every level received + // the same object the outer level did, and it is the caller's. + expect((contexts[0].context as any).userId, verb).toBe('u1'); + for (const seen of contexts) expect(seen.context, `${verb}: ${seen.half}(${seen.object})`).toBe(contexts[0].context); + const outer = inComparandOf(calls[0].ast.where) as any; + expect(isComposedReadableIds(outer), verb).toBe(true); + expect(outer.object, verb).toBe('rid_parent'); + // The outer scope is the parent's answer rewritten copy-on-write: its + // nested `$in` is the composed leaf over the grandparent, whose scope is + // the grandparent's answer BY REFERENCE, marked policy — and the + // rewritten scope keeps the mark the parent's answer was given. + const nested = outer.scope.grand.$in; + expect(isComposedReadableIds(nested), verb).toBe(true); + expect(nested.object, verb).toBe('rid_grand'); + expect(nested.scope, verb).toBe(grandScope); + expect(filterSubtreeProvenanceOf(outer.scope), verb).toBe('policy'); + expect(filterSubtreeProvenanceOf(nested.scope), verb).toBe('policy'); + } + }); + + it('a delegated (on-behalf-of) caller: every level is asked with the delegated context as it stands, onBehalfOf intact', async () => { + const delegated = { userId: 'usr_owner', principalKind: 'agent', onBehalfOf: { userId: 'usr_owner', principalKind: 'human' } }; + const { svc, contexts } = chainSecurity(); + wire(engine, () => svc); + await engine.find('rid_child', { where: branch(), context: delegated as any }); + expect(contexts.map((c) => `${c.half}:${c.object}`)).toEqual([ + 'canReadObject:rid_parent', 'getReadFilter:rid_parent', 'canReadObject:rid_grand', 'getReadFilter:rid_grand', + ]); + for (const seen of contexts) { + expect((seen.context as any).onBehalfOf, `${seen.half}(${seen.object})`).toEqual(delegated.onBehalfOf); + expect((seen.context as any).userId, `${seen.half}(${seen.object})`).toBe('usr_owner'); + expect((seen.context as any).principalKind, `${seen.half}(${seen.object})`).toBe('agent'); + } + const outer = inComparandOf(calls[0].ast.where) as any; + expect(isComposedReadableIds(outer.scope.grand.$in)).toBe(true); + }); + + it("the grandparent's scope undefined ⇒ the nested leaf composes with no row restriction; the outer leaf's scope is still the rewritten copy", async () => { + const { svc } = makeSecurity({ scope: (object) => (object === 'rid_parent' ? derivedFrom('rid_grand') : undefined) }); + wire(engine, () => svc); + await engine.find('rid_child', { where: branch() }); + const outer = inComparandOf(calls[0].ast.where) as any; + const nested = outer.scope.grand.$in; + expect(isComposedReadableIds(nested)).toBe(true); + expect(nested.scope).toBeUndefined(); + }); + + it('a leaf written by an object-scoped middleware on the child composes the same chain', async () => { + const { svc, asked } = chainSecurity(); + wire(engine, () => svc); + engine.registerMiddleware(async (ctx, next) => { + if (ctx.ast && ['find', 'findOne', 'count', 'aggregate'].includes(ctx.operation)) { + ctx.ast.where = ctx.ast.where ? { $and: [ctx.ast.where, branch()] } : branch(); + } + await next(); + }, { object: 'rid_child' }); + await engine.count('rid_child', { where: { note: 'x' } }); + expect(asked).toEqual([ + 'canReadObject:rid_parent', 'getReadFilter:rid_parent', 'canReadObject:rid_grand', 'getReadFilter:rid_grand', + ]); + const outer = inComparandOf(calls[0].ast.where) as any; + expect(isComposedReadableIds(outer.scope.grand.$in)).toBe(true); + }); + + it('the closed doors at the nested level answer $in: [] INSIDE the composed scope — the outer leaf still composed, no row of the parent whose master is unreadable', async () => { + const doors: ReadonlyArray]> = [ + ['canReadObject(grand) false', makeSecurity({ + canRead: (object) => object !== 'rid_grand', + scope: (object) => (object === 'rid_parent' ? derivedFrom('rid_grand') : { owner: 'u1' }), + })], + ['the deny sentinel for grand, by identity', makeSecurity({ + scope: (object) => (object === 'rid_parent' ? derivedFrom('rid_grand') : RLS_DENY_FILTER), + })], + ['canReadObject throws for grand', makeSecurity({ + canRead: (object) => { if (object === 'rid_grand') throw new Error('canReadObject is down'); return true; }, + scope: (object) => (object === 'rid_parent' ? derivedFrom('rid_grand') : { owner: 'u1' }), + })], + ['getReadFilter throws for grand', makeSecurity({ + scope: (object) => { if (object === 'rid_grand') throw new Error('getReadFilter is down'); return derivedFrom('rid_grand'); }, + })], + ['a grandparent the registry does not hold', makeSecurity({ + scope: (object) => (object === 'rid_parent' ? derivedFrom('rid_unregistered') : { owner: 'u1' }), + })], + ]; + for (const [label, { svc, asked }] of doors) { + wire(engine, () => svc); + for (const [verb, run] of readVerbs(branch())) { + calls.length = 0; + asked.length = 0; + await run(); + expect(calls.map((c) => c.verb), `${label}: ${verb}`).toEqual([verb]); + const outer = inComparandOf(calls[0].ast.where) as any; + expect(isComposedReadableIds(outer), `${label}: ${verb}`).toBe(true); + expect(Object.keys(outer.scope), `${label}: ${verb}`).toEqual(['grand']); + expect(outer.scope.grand.$in, `${label}: ${verb}`).toEqual([]); + if (label === 'a grandparent the registry does not hold') { + expect(asked, `${label}: ${verb}`).toEqual(['canReadObject:rid_parent', 'getReadFilter:rid_parent']); + } + } + } + }); + + it("the parent's own door closed ⇒ $in: [] at the outer level, and the grandparent is never asked", async () => { + const { svc, asked } = makeSecurity({ + canRead: (object) => object !== 'rid_parent', + scope: (object) => (object === 'rid_parent' ? derivedFrom('rid_grand') : { owner: 'u1' }), + }); + wire(engine, () => svc); + await engine.find('rid_child', { where: branch() }); + expect(inComparandOf(calls[0].ast.where)).toEqual([]); + expect(asked).toEqual(['canReadObject:rid_parent']); + }); + + it('D5.2 and D5.3 at the nested level refuse by name — code and status, no driver call, the grandparent unasked', async () => { + for (const [grand, fragment] of [ + ['rid_gated', "'rid_gated', whose rows an object-scoped read middleware narrows"], + ['rid_far', "'rid_far', which is bound to datasource 'far'"], + ] as const) { + const { svc, asked } = makeSecurity({ scope: (object) => (object === 'rid_parent' ? derivedFrom(grand) : undefined) }); + wire(engine, () => svc); + calls.length = 0; + for (const [verb, run] of readVerbs(branch())) { + asked.length = 0; + const err = await refusalOf(run()); + expect(envelopeOf(err), `${grand}: ${verb}`).toEqual(REFUSED); + expect(err!.message, `${grand}: ${verb}`).toContain(`${verb}('rid_child'): where.record_id.$in.scope.grand.$in holds a ReadableIds leaf`); + expect(err!.message, `${grand}: ${verb}`).toContain(fragment); + expect(asked, `${grand}: ${verb}`).toEqual(['canReadObject:rid_parent', 'getReadFilter:rid_parent']); + } + expect(calls, grand).toHaveLength(0); + } + }); + + it('inside a scope, a leaf outside a $in comparand and a composed leaf the engine did not compose are refused by name', async () => { + const shapes: ReadonlyArray = [ + ['implicit equality', { grand: ReadableIds.of('rid_grand') }, 'where.record_id.$in.scope.grand holds a ReadableIds leaf'], + ['a $nin comparand', { grand: { $nin: ReadableIds.of('rid_grand') } }, 'where.record_id.$in.scope.grand.$nin holds a ReadableIds leaf'], + ['a list member', { grand: { $in: ['g1', ReadableIds.of('rid_grand')] } }, 'where.record_id.$in.scope.grand.$in[1] holds a ReadableIds leaf'], + ['a forged composed leaf', { grand: { $in: ReadableIds.compose(ReadableIds.of('rid_grand'), undefined) } }, 'where.record_id.$in.scope.grand.$in holds a COMPOSED ReadableIds leaf'], + ]; + for (const [label, parentScope, fragment] of shapes) { + const { svc } = makeSecurity({ scope: (object) => (object === 'rid_parent' ? parentScope : undefined) }); + wire(engine, () => svc); + calls.length = 0; + const err = await refusalOf(engine.find('rid_child', { where: branch() })); + expect(envelopeOf(err), label).toEqual(REFUSED); + expect(err!.message, label).toContain(fragment); + expect(calls, label).toHaveLength(0); + } + }); + + it('a chain that returns to a parent on its branch is refused by name — a cycle, not composed partway', async () => { + const cycles: ReadonlyArray unknown, string]> = [ + ['parent → grand → parent', (object) => (object === 'rid_parent' ? derivedFrom('rid_grand') : object === 'rid_grand' ? { grand: { $in: ReadableIds.of('rid_parent') } } : undefined), + "rid_child -> rid_parent -> rid_grand -> rid_parent returns to 'rid_parent', a cycle"], + ['parent → parent', (object) => (object === 'rid_parent' ? derivedFrom('rid_parent') : undefined), + "rid_child -> rid_parent -> rid_parent returns to 'rid_parent', a cycle"], + ]; + for (const [label, scope, fragment] of cycles) { + const { svc } = makeSecurity({ scope }); + wire(engine, () => svc); + calls.length = 0; + const err = await refusalOf(engine.find('rid_child', { where: branch() })); + expect(envelopeOf(err), label).toEqual(REFUSED); + expect(err!.message, label).toContain(fragment); + expect(calls, label).toHaveLength(0); + } + }); + + it('a chain deeper than the bound is refused by name; one at the bound composes whole', async () => { + // rid_l0 … rid_l8: each level's scope is a leaf over the next. The scope + // composed for rid_lk is the chain's scope number k + 1; the engine + // composes a chain at most 8 scopes deep, so a chain ending at rid_l7 + // composes whole (eight scopes) and one reaching rid_l8 is refused — the + // same cut as plugin-security's controlled_by_parent derivation: a detail + // with seven derived masters above it resolves, an eighth is refused. + const levels = Array.from({ length: 9 }, (_, k) => `rid_l${k}`); + for (const name of levels) engine.registry.registerObject({ name, label: name, fields: { next: { name: 'next', type: 'text' } } } as any, 'test'); + const chainTo = (last: number) => makeSecurity({ + scope: (object) => { + const k = levels.indexOf(object); + return k >= 0 && k < last ? { next: { $in: ReadableIds.of(levels[k + 1]) } } : undefined; + }, + }); + const whole = chainTo(7); + wire(engine, () => whole.svc); + await engine.find('rid_child', { where: { record_id: { $in: ReadableIds.of('rid_l0') } } }); + expect(whole.asked.filter((a) => a.startsWith('getReadFilter:'))).toEqual(levels.slice(0, 8).map((l) => `getReadFilter:${l}`)); + let leaf: any = calls[0].ast.where.record_id.$in; + for (let k = 0; k < 8; k++) { + expect(isComposedReadableIds(leaf), `level ${k}`).toBe(true); + expect(leaf.object, `level ${k}`).toBe(levels[k]); + leaf = leaf.scope?.next?.$in; + } + expect(leaf).toBeUndefined(); + + const tooDeep = chainTo(8); + wire(engine, () => tooDeep.svc); + calls.length = 0; + const err = await refusalOf(engine.find('rid_child', { where: { record_id: { $in: ReadableIds.of('rid_l0') } } })); + expect(envelopeOf(err)).toEqual(REFUSED); + expect(err!.message).toContain('rid_child -> rid_l0 -> rid_l1 -> rid_l2 -> rid_l3 -> rid_l4 -> rid_l5 -> rid_l6 -> rid_l7 -> rid_l8 is 9 scopes deep'); + expect(err!.message).toContain('composes a chain at most 8 scopes deep'); + expect(calls).toHaveLength(0); + // The ninth scope's parent was never asked: the bound is judged before the service is. + expect(tooDeep.asked).not.toContain('canReadObject:rid_l8'); + }); + + it('a parent that appears at the outer level and inside another parent\'s scope is asked once, its composed scope reused by reference', async () => { + const grandScope = { owner: 'u1' }; + const { svc, asked } = chainSecurity(grandScope); + wire(engine, () => svc); + await engine.find('rid_child', { where: { $or: [branch('rid_grand'), branch('rid_parent')] } }); + expect(asked).toEqual([ + 'canReadObject:rid_grand', 'getReadFilter:rid_grand', + 'canReadObject:rid_parent', 'getReadFilter:rid_parent', + ]); + const arms = calls[0].ast.where.$or; + const direct = inComparandOf(arms[0]) as any; + const viaParent = (inComparandOf(arms[1]) as any).scope.grand.$in; + expect(direct.object).toBe('rid_grand'); + expect(viaParent.object).toBe('rid_grand'); + expect(direct.scope).toBe(grandScope); + expect(viaParent.scope).toBe(grandScope); + }); + + it('CONTROL — a scope that carries no leaf is composed exactly as before: the service\'s answer by reference, one level asked', async () => { + const scope = { title: 'open' }; + const { svc, asked } = makeSecurity({ scope: () => scope }); + wire(engine, () => svc); + await engine.find('rid_child', { where: branch() }); + expect(asked).toEqual(['canReadObject:rid_parent', 'getReadFilter:rid_parent']); + const outer = inComparandOf(calls[0].ast.where) as any; + expect(outer.scope).toBe(scope); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 2527f2cc998..51393862e42 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -4628,6 +4628,49 @@ function stampObjectPackage(def: ServiceObject, packageId: string, packageVersio */ type ReadableIdsSecurity = Pick & Partial>; +/** The three resolutions of the `security` service (`ObjectQL#resolveReadableIdsSecurity`). */ +type ReadableIdsSecurityResolution = + | { kind: 'usable'; svc: ReadableIdsSecurity } + | { kind: 'absent' } + | { kind: 'unusable'; why: string }; + +/** + * [ADR-0139 D2, D6 as amended] One read's `ReadableIds` composition, carried + * through the chain of composed scopes (`ObjectQL#composeReadableIdsChain`): + * the read's object and verb (every refusal names them), the security + * resolution made once for the read, the caller's context handed to every + * level as it stands, and the answers — per parent object, `{ scope }` with + * the scope already composed, or `null` for `$in: []` — so a parent is asked + * once per read at whichever level it first appears. + */ +interface ReadableIdsChain { + readonly object: string; + readonly operation: 'find' | 'findOne' | 'count' | 'aggregate'; + readonly security: ReadableIdsSecurityResolution; + readonly context: ExecutionContext | undefined; + readonly answers: Map; +} + +/** + * [ADR-0139 D6, as amended — the nested master chain] How deep a chain of + * composed scopes the engine composes: the scope answered for a leaf in the + * read's own `where` is one scope deep, the scope answered for a leaf inside + * it is two, and so on down a master-detail chain whose every master is + * itself `controlled_by_parent`. A chain deeper than this is refused by name. + * + * A COST ceiling, not a semantic rule, and the same cut as the + * `controlled_by_parent` derivation's (`CBP_MAX_CHAIN_DEPTH`, + * `packages/plugins/plugin-security`): a detail with seven derived masters + * above it composes, an eighth derived master is refused there and here. + * Every authored chain in this repository is one hop, the deepest any consumer + * has asked for is two, and 8 is four times that. Termination needs no bound — + * the chain refuses a parent already on its branch — so what this caps is the + * statement the driver compiles, one nested subquery per level. At the bound + * the read is REFUSED, loud (D5's envelope), ⛔ never composed partway and + * never answered "no restriction". + */ +const READABLE_IDS_MAX_CHAIN_DEPTH = 8; + /** * [ADR-0139 D2] The engine slot where `ObjectQLPlugin` puts the SOURCE the * `ReadableIds` composition reads the registered `ISecurityService` from: a @@ -6102,10 +6145,7 @@ export class ObjectQL implements IObjectQLEngine { * nor `explain`; * - **usable** — ask it. */ - private resolveReadableIdsSecurity(): - | { kind: 'usable'; svc: ReadableIdsSecurity } - | { kind: 'absent' } - | { kind: 'unusable'; why: string } { + private resolveReadableIdsSecurity(): ReadableIdsSecurityResolution { // Read from the engine slot at use (see SECURITY_SERVICE_SOURCE_SLOT), never cached. const source = (this as unknown as Record)[SECURITY_SERVICE_SOURCE_SLOT]; if (source === undefined || source === null) return { kind: 'absent' }; @@ -6166,6 +6206,21 @@ export class ObjectQL implements IObjectQLEngine { * answer that context answers the deny sentinel, and step 5 turns it into * `$in: []`, closed. * + * [ADR-0139 D6, as amended — the nested master chain] A scope the service + * answers in step 5 may itself carry a leaf: a parent whose own read scope + * is derived from ITS master (a `controlled_by_parent` detail whose master + * is `controlled_by_parent` too) is scoped `{ master_fk: { $in: + * ReadableIds.of(G) } }`, and a leaf handed to a driver uncomposed is + * refused there (D3), never read as "no restriction". So every leaf a + * composed scope carries is composed the same way, BEFORE the leaf that + * encloses it — the same five steps, the same caller context at every level, + * recursively ({@link composeReadableIdsChain}). The driver then receives one + * composed leaf whose scope holds composed leaves, and compiles one statement + * with the subqueries nested. Each parent is asked once per read, at + * whichever level it first appears; a chain that returns to a parent already + * on its branch, or runs deeper than {@link READABLE_IDS_MAX_CHAIN_DEPTH} + * scopes, is refused by name (D5's envelope), not composed partway. + * * Nothing is memoised across calls. `aggregate`'s `having` and its * aggregations' own filters are judged here too, so a leaf a middleware * wrote there is refused rather than evaluated in memory. @@ -6185,7 +6240,6 @@ export class ObjectQL implements IObjectQLEngine { } const sites = collectComposableReadableIds(object, operation, ast.where, 'where'); if (sites.length === 0) return ast; - const parents = this.refuseUncomposableReadableIdsClasses(object, operation, sites); const security = this.resolveReadableIdsSecurity(); if (security.kind !== 'usable' && !this.readableIdsSecurityReported.has(security.kind)) { @@ -6197,19 +6251,100 @@ export class ObjectQL implements IObjectQLEngine { 'Reported once for this engine; later leaves are answered the same way.', ); } - const answers = new Map(); + const chain: ReadableIdsChain = { object, operation, security, context, answers: new Map() }; + const where = await this.composeReadableIdsChain(chain, ast.where, sites, []); + return { ...ast, where }; + } + + /** + * [ADR-0139 D2, D6 as amended] Compose the leaves at `sites` in `node` — + * the read's `where` at the top level, a parent's composed scope below it — + * and, first, every leaf a scope answered for one of their parents carries, + * recursively. Returns `node` rewritten copy-on-write, every leaf replaced + * by its composed form or `[]`. + * + * `ancestors` are the parents whose scopes enclose `node`, outermost first: + * empty for the read's own `where`, `[P]` inside P's scope, `[P, G]` inside + * the scope G answered within it. Per distinct parent of `sites`, in walk + * order: + * + * 1. D5's two classes are refused by name, exactly as at the top level + * ({@link refuseUncomposableReadableIdsClasses}): a nested parent an + * object-scoped middleware narrows, or one bound to another datasource + * than the read's object, is refused — the whole chain compiles into the + * read's one statement, on the read's datasource. + * 2. A parent already answered on this read — at any level — is not asked + * again: its composed scope is reused by reference. + * 3. A parent already on `ancestors` is a cycle (its own scope, or a scope + * inside it, reaches back to it); a parent whose scope would be the + * chain's scope number {@link READABLE_IDS_MAX_CHAIN_DEPTH} + 1 is past + * the bound. Both are refused by name, `INTERNAL_ERROR` / 500, before + * the service is asked about that parent: a chain the engine cannot + * compose whole is not composed partway, and ⛔ never answered "no + * restriction". + * 4. The parent is asked both halves ({@link readableIdsScopeOf}) with the + * read's context as it stands; a scope that carries leaves is composed + * through this method first, under `[...ancestors, parent]`, and the + * rewritten scope is what the parent's leaf is composed with. The + * provenance mark the scope carries (`'policy'`) survives the rewrite. + * + * A nested leaf outside a `$in` comparand, or a composed leaf the engine did + * not compose, inside an answered scope is refused by the same walk that + * refuses it in a caller's `where` ({@link collectComposableReadableIds}): + * the scope is the `where` of a read of its parent and is judged as one. + */ + private async composeReadableIdsChain( + chain: ReadableIdsChain, + node: N, + sites: readonly ComposableReadableIdsSite[], + ancestors: readonly string[], + ): Promise { + const { object, operation } = chain; + const parents = this.refuseUncomposableReadableIdsClasses(object, operation, sites); for (const parent of parents) { - answers.set(parent, security.kind === 'usable' - ? await this.readableIdsScopeOf(object, operation, parent, security.svc, context) - : null); + if (chain.answers.has(parent)) continue; + const site = sites.find((s) => s.leaf.object === parent)!; + const branch = [object, ...ancestors, parent]; + if (ancestors.includes(parent)) { + throw readableIdsRefusalError( + object, + `${operation}('${object}'): ${describeReadableIdsLeaf(site.at, site.leaf)}, inside the read scope of ` + + `'${ancestors[ancestors.length - 1]}', and the engine composes no such leaf: the chain of read scopes ` + + `${branch.join(' -> ')} returns to '${parent}', a cycle. The query was NOT run and no driver was asked. ` + + `A parent's read scope cannot be derived from its own readable ids; fix the read scope the security ` + + `service answers for '${ancestors[ancestors.length - 1]}'.`, + ); + } + if (ancestors.length >= READABLE_IDS_MAX_CHAIN_DEPTH) { + throw readableIdsRefusalError( + object, + `${operation}('${object}'): ${describeReadableIdsLeaf(site.at, site.leaf)}, and the engine composes no ` + + `such leaf: the chain of read scopes ${branch.join(' -> ')} is ${ancestors.length + 1} scopes deep, and the ` + + `engine composes a chain at most ${READABLE_IDS_MAX_CHAIN_DEPTH} scopes deep. The query was NOT run and ` + + `no driver was asked. A master-detail chain this deep is not one the engine composes; read '${parent}' as ` + + 'the caller first and match its ids with a list, or shorten the chain.', + ); + } + const answer = chain.security.kind === 'usable' + ? await this.readableIdsScopeOf(object, operation, parent, chain.security.svc, chain.context) + : null; + if (answer === null || answer.scope === undefined) { + chain.answers.set(parent, answer); + continue; + } + // The scope is the `where` of a read of `parent`: judged as one, and + // the leaves it carries are composed before this parent's leaf is. + const nested = collectComposableReadableIds(object, operation, answer.scope, 'where', `${site.at}.scope`); + chain.answers.set(parent, nested.length === 0 + ? answer + : { scope: await this.composeReadableIdsChain(chain, answer.scope, nested, [...ancestors, parent]) }); } // A parent with no answer (`null`, or never asked: not registered) is // `$in: []`. - const where = composeReadableIdsLeaves(ast.where, (leaf) => { - const answer = answers.get(leaf.object); + return composeReadableIdsLeaves(node, (leaf) => { + const answer = chain.answers.get(leaf.object); return answer ? ReadableIds.compose(leaf, answer.scope) : []; }); - return { ...ast, where }; } /** diff --git a/packages/objectql/src/filter-comparand-shape.ts b/packages/objectql/src/filter-comparand-shape.ts index b5a9e1aa8d9..218819faa03 100644 --- a/packages/objectql/src/filter-comparand-shape.ts +++ b/packages/objectql/src/filter-comparand-shape.ts @@ -181,12 +181,21 @@ const NO_SITES: readonly ComposableReadableIdsSite[] = Object.freeze([]); * self-referential in-process `where` instead of overflowing the stack. A * filter with no leaf walks its own keys and returns a shared empty list, so * the common path allocates nothing. + * + * `at` is the label the sites and the refusals name the root of `node` by; + * it defaults to `path`. The executor boundary passes the position of the + * enclosing leaf when it walks a COMPOSED SCOPE for the leaves it carries + * (ADR-0139 D6 as amended, the nested master chain): a scope is the `where` of + * a read of its parent, so it is judged with `path` `'where'`, and its sites + * read `where.record_id.$in.scope.master.$in` — the leaf, its scope, the + * field inside it. */ export function collectComposableReadableIds( object: string, operation: string, node: unknown, path = 'where', + at: string = path, ): readonly ComposableReadableIdsSite[] { const composable = READABLE_IDS_READ_VERBS.has(operation) && path === 'where'; let sites: ComposableReadableIdsSite[] | undefined; @@ -218,7 +227,7 @@ export function collectComposableReadableIds( onPath.delete(value); } }; - visit(node, path, false); + visit(node, at, false); return sites ?? NO_SITES; } diff --git a/packages/plugins/plugin-security/src/readable-ids-nested-compose.test.ts b/packages/plugins/plugin-security/src/readable-ids-nested-compose.test.ts new file mode 100644 index 00000000000..cce5a12e950 --- /dev/null +++ b/packages/plugins/plugin-security/src/readable-ids-nested-compose.test.ts @@ -0,0 +1,496 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0139 D6, as amended — the nested master chain] A `ReadableIds` leaf + * that sits INSIDE a composed scope is composed by the engine too, so a + * two-level `controlled_by_parent` chain — a child whose master is itself + * `controlled_by_parent` — compiles to ONE statement with the subqueries + * nested, and serves exactly the rows today's id-list path serves. + * + * Measured on the real stack: `ObjectQL` over better-sqlite3, the real + * `SecurityPlugin` with the real `SharingService` behind it, direct member + * contexts and the delegated context `resolveAuthzContext` and + * `assembleExecutionContext` assemble. The chain is + * child → PARENT (`controlled_by_parent`, its master GRAND) → GRAND (`private`, + * owner-scoped by plugin-sharing), read two ways over the SAME rows: + * + * - **today's id-list path**: a `controlled_by_parent` child of PARENT, read + * as the security middleware chain scopes it — `computeControlledByParentFilter` + * pre-scans GRAND and then PARENT under a system context and hands the + * driver `parent IN [ids]` (ADR-0055 §2 mechanism (b)); + * - **the leaf path**: a child of the same shape whose reads carry + * `{ parent: { $in: ReadableIds.of(PARENT) } }` (a consumer-shaped + * middleware writes it), with the engine's security source wrapped so that + * `getReadFilter(PARENT)` answers the real scope with its pre-resolved + * master id list replaced by the leaf `ReadableIds.of(GRAND)` — the + * spelling the plugin-security switch (ruling 6303721609, card (c)) will + * answer. The wrapper stands in for that switch and touches nothing else: + * `getReadFilter(GRAND)` and both `canReadObject` halves are the real + * service's, asked by the engine with the caller's context at each level. + * + * Before the engine composed the nested leaf, the leaf path answered + * `INTERNAL_ERROR` / 500: driver-sql refused the uncomposed leaf inside the + * parent's subquery (the #24932 dev report's measurement, reproduced here on + * the base before the change). The closed doors are measured beside the + * parity: a caller the grandparent object does not admit, a delegator who + * does not exist, and a delegator the grandparent object does not admit. + */ + +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resolveAuthzContext, assembleExecutionContext } from '@objectstack/core'; +import { ReadableIds, RLS_DENY_FILTER } from '@objectstack/spec/data'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; +import { SharingService, buildSharingMiddleware, SysRecordShare } from '@objectstack/plugin-sharing'; + +import { SysPosition, SysPermissionSet, SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { SecurityPlugin } from './security-plugin.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +const SYS = { context: { isSystem: true } } as never; +const ORG = 'org_rid_nest'; + +/** The top of the chain: `private`, so plugin-sharing's owner-match scopes it per identity. */ +const GRAND = 'qa_rid_nest_grand'; +/** The middle: `controlled_by_parent`, its master GRAND — its read scope is derived from GRAND's. */ +const PARENT = 'qa_rid_nest_parent'; +/** Today's path: a `controlled_by_parent` child of PARENT, scoped by the id-list pre-scan. */ +const CHILD_CBP = 'qa_rid_nest_child_cbp'; +/** The leaf path: the same rows, read through a leaf over PARENT that a consumer-shaped middleware writes. */ +const CHILD_LEAF = 'qa_rid_nest_child_leaf'; + +/** The delegator: the human an agent acts for, and the owner of two grandparents. */ +const HUMAN = 'u_rid_nest_human'; +/** Owns the other grandparents. */ +const OTHER = 'u_rid_nest_other'; +/** A caller whose grants do not admit the grandparent object. */ +const NO_GRAND_READ = 'u_rid_nest_no_grand_read'; + +const SET = { + /** Object read on the whole chain, silent on rows. */ + human: 'qa_rid_nest_human', + /** The agent's ceiling: object read on the whole chain, a content rule on GRAND's rows, silent on depth. */ + ceiling: 'qa_rid_nest_ceiling', + /** Object read on PARENT and the children, not on GRAND. */ + noGrand: 'qa_rid_nest_no_grand', +} as const; + +type Outcome = { kind: 'landed' } | { kind: 'refused'; code?: string; status?: number }; + +const outcomeOf = (p: Promise): Promise => + p.then( + () => ({ kind: 'landed' as const }), + (e: unknown) => { + const x = e as { code?: unknown; status?: unknown; statusCode?: unknown } | null; + const status = x?.statusCode ?? x?.status; + return { + kind: 'refused' as const, + code: x?.code == null ? undefined : String(x.code), + status: status == null ? undefined : Number(status), + }; + }, + ); + +const releaseAsyncContexts = (...owners: object[]): void => { + for (const owner of owners) { + for (const value of Object.values(owner)) if (value instanceof AsyncLocalStorage) value.disable(); + } +}; + +const ids = (rows: unknown): string[] => + (Array.isArray(rows) ? rows : [rows]) + .filter(Boolean) + .map((r) => String((r as { id: unknown }).id)) + .sort(); + +const leafBranch = () => ({ parent: { $in: ReadableIds.of(PARENT) } }); + +/** + * The real scope with every pre-resolved master id list on `fk` replaced by + * the leaf over `master` — `{ fk: { $in: [ids] } }` becomes + * `{ fk: { $in: ReadableIds.of(master) } }`, copy-on-write — and how many + * were replaced. The spelling the plugin-security switch will answer. + */ +function withLeafForMaster(scope: unknown, fk: string, master: string): { scope: unknown; replaced: number } { + let replaced = 0; + const rewrite = (node: unknown): unknown => { + if (node === null || typeof node !== 'object') return node; + if (Array.isArray(node)) return node.map(rewrite); + const out: Record = {}; + for (const [key, value] of Object.entries(node as Record)) { + if (key === fk && value !== null && typeof value === 'object' && Array.isArray((value as { $in?: unknown }).$in)) { + replaced++; + out[key] = { ...(value as Record), $in: ReadableIds.of(master) }; + } else { + out[key] = rewrite(value); + } + } + return out; + }; + return { scope: rewrite(scope), replaced }; +} + +async function boot() { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never, + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.readable-ids-nested-compose', + name: 'The ReadableIds leaf composes inside a composed scope: the nested master chain', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPosition, SysUserPermissionSet, + SysOrganization, SysUser, SysMember, SysRecordShare, + { + name: GRAND, + label: 'Grandparent', + sharingModel: 'private', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + owner_id: { name: 'owner_id', type: 'lookup', reference: 'sys_user' }, + }, + }, + { + name: PARENT, + label: 'Parent', + sharingModel: 'controlled_by_parent', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + grand: { name: 'grand', type: 'master_detail', reference: GRAND, required: true }, + }, + }, + { + name: CHILD_CBP, + label: 'Child (controlled by parent)', + sharingModel: 'controlled_by_parent', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + note: { name: 'note', type: 'text' }, + parent: { name: 'parent', type: 'master_detail', reference: PARENT, required: true }, + }, + }, + { + name: CHILD_LEAF, + label: 'Child (read through the leaf)', + sharingModel: 'public_read_write', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + note: { name: 'note', type: 'text' }, + parent: { name: 'parent', type: 'text' }, + }, + }, + ], + } as never); + await engine.syncSchemas(); + + const e = engine as unknown as { + insert: (o: string, row: Record, opts: never) => Promise; + getDriver: (o: string) => { create: (o: string, row: Record) => Promise; knex: { on: (ev: string, fn: (q: { sql?: unknown }) => void) => void; removeListener: (ev: string, fn: (q: { sql?: unknown }) => void) => void } }; + logger: { warn: () => void }; + }; + await e.insert('sys_organization', { id: ORG, name: ORG, slug: ORG }, SYS); + for (const id of [HUMAN, OTHER, NO_GRAND_READ]) { + await e.insert('sys_user', { id, name: id, email: `${id}@example.test` }, SYS); + await e.insert('sys_member', { id: `mem_${id}`, user_id: id, organization_id: ORG, role: 'member' }, SYS); + } + const READ = { allowRead: true } as const; + const sets = [ + ...defaultPermissionSets, + PermissionSetSchema.parse({ name: SET.human, objects: { [GRAND]: READ, [PARENT]: READ, [CHILD_CBP]: READ, [CHILD_LEAF]: READ } }), + PermissionSetSchema.parse({ + name: SET.ceiling, + objects: { [GRAND]: READ, [PARENT]: READ, [CHILD_CBP]: READ, [CHILD_LEAF]: READ }, + rowLevelSecurity: [ + { name: 'qa_rid_nest_ceiling_open_only', object: GRAND, operation: 'select', using: "record.name == 'open'" }, + ], + }), + PermissionSetSchema.parse({ name: SET.noGrand, objects: { [PARENT]: READ, [CHILD_CBP]: READ, [CHILD_LEAF]: READ } }), + ]; + // The delegators hold their sets through stored grants, as a user does. + for (const name of [SET.human, SET.noGrand]) { + await e.getDriver('sys_permission_set').create('sys_permission_set', { + id: `ps_${name}`, name, label: name, organization_id: null, managed_by: 'admin', active: true, + object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]), + }); + } + for (const [user, set] of [[HUMAN, SET.human], [OTHER, SET.human], [NO_GRAND_READ, SET.noGrand]] as const) { + await e.insert('sys_user_permission_set', { id: `ups_${user}`, user_id: user, permission_set: set, organization_id: ORG }, SYS); + } + + // The grandparents, named for the leg(s) of a delegated read that admit them. + await e.insert(GRAND, { id: 'g_both', name: 'open', owner_id: HUMAN }, SYS); + await e.insert(GRAND, { id: 'g_del_only', name: 'secret', owner_id: HUMAN }, SYS); + await e.insert(GRAND, { id: 'g_caller_only', name: 'open', owner_id: OTHER }, SYS); + await e.insert(GRAND, { id: 'g_neither', name: 'secret', owner_id: OTHER }, SYS); + await e.insert(GRAND, { id: 'g_shared', name: 'open', owner_id: OTHER }, SYS); + await e.insert('sys_record_share', { + id: 'shr_rid_nest_1', object_name: GRAND, record_id: 'g_shared', recipient_type: 'user', recipient_id: HUMAN, access_level: 'read', + }, SYS); + // The parents, one per grandparent, plus one with a dangling and one with a + // null master pointer (written through the driver: the required master is + // what the authoring door refuses, and these rows exist to be excluded). + for (const [id, grand] of [ + ['p_both', 'g_both'], ['p_del_only', 'g_del_only'], ['p_caller_only', 'g_caller_only'], + ['p_neither', 'g_neither'], ['p_shared', 'g_shared'], ['p_dangling', 'g_missing'], ['p_null', null], + ] as const) { + await e.getDriver(PARENT).create(PARENT, { id, name: id, grand }); + } + // The same children in both tables, so the two paths read the same rows. + for (const child of [CHILD_CBP, CHILD_LEAF]) { + for (const [id, parent] of [ + ['c_both', 'p_both'], ['c_del_only', 'p_del_only'], ['c_caller_only', 'p_caller_only'], + ['c_neither', 'p_neither'], ['c_shared', 'p_shared'], ['c_via_dangling', 'p_dangling'], ['c_via_null', 'p_null'], + ['c_dangling', 'p_missing'], ['c_null', null], + ] as const) { + await e.getDriver(child).create(child, { id, note: '', parent }); + } + } + + let security: { + getReadFilter: (o: string, c: unknown) => Promise | undefined>; + canReadObject: (o: string, c: unknown) => Promise; + } | undefined; + let sharing: SharingService | undefined; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (type: string, name: string) => + type === 'permission' ? sets.find((s: { name?: string }) => s.name === name) ?? null : engine.getSchema(name) ?? null, + list: async () => sets, + }, + tenancy: { posture: 'single' }, + get sharing() { return sharing; }, + }; + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: (name: string, svc: unknown) => { + if (name === 'security') security = svc as typeof security; + else services[name] = svc; + }, + hook: () => undefined, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + // The engine's security source (what `ObjectQLPlugin.init` puts in the + // slot): the real service, with PARENT's read scope answered in the + // spelling the plugin-security switch will answer — the pre-resolved master + // id list replaced by the leaf over GRAND. Every other answer is the real + // service's, untouched. + const replacedPerCall: number[] = []; + const wrapped = { + canReadObject: (object: string, context: unknown) => security!.canReadObject(object, context), + async getReadFilter(object: string, context: unknown) { + const real = await security!.getReadFilter(object, context); + if (object !== PARENT || real === undefined || real === RLS_DENY_FILTER) return real; + const { scope, replaced } = withLeafForMaster(real, 'grand', GRAND); + replacedPerCall.push(replaced); + return scope; + }, + }; + (engine as unknown as Record)[Symbol.for('objectstack.objectql.securityServiceSource')] = () => wrapped; + sharing = new SharingService({ engine: engine as never, securityService: () => security as never }); + engine.registerMiddleware(buildSharingMiddleware(sharing, ctx.logger) as never, { object: '*' }); + // The consumer shape: an object-scoped read middleware on the leaf-path + // child writes the leaf over PARENT into every read, after the security chain. + engine.registerMiddleware(async (opCtx, next) => { + if (opCtx.ast && ['find', 'findOne', 'count', 'aggregate'].includes(opCtx.operation)) { + opCtx.ast.where = opCtx.ast.where ? { $and: [opCtx.ast.where, leafBranch()] } : leafBranch(); + } + await next(); + }, { object: CHILD_LEAF }); + vi.spyOn(e.logger, 'warn').mockImplementation(() => undefined); + + /** A delegated context for `userId`, as the OAuth door assembles it, the agent holding `ceiling` as its only grant. */ + const onBehalfOf = async (userId: string, ceiling: string = SET.ceiling) => { + const authz = await resolveAuthzContext({ + userAuthGates: undefined, + apiKeyLifecycle: undefined, + ql: engine, + headers: {}, + getSession: async () => ({ + user: { id: userId }, + session: { id: `sess_${userId}`, userId, activeOrganizationId: ORG }, + }), + tenancyPosture: 'single', + } as never); + return assembleExecutionContext({ + authz, + oauth: { userId, scopes: ['data:read'], clientId: 'qa_agent_client', scopePermissions: [ceiling], delegatesActions: false }, + localization: undefined, + requestLocale: undefined, + accessToken: undefined, + } as never)!; + }; + + return { + engine, + onBehalfOf, + replacedPerCall, + read: async (object: string, context: unknown) => + ids(await engine.find(object, { context: { ...(context as object) } } as never)), + count: (object: string, context: unknown) => engine.count(object, { context: { ...(context as object) } } as never), + readOutcome: (object: string, context: unknown) => outcomeOf(engine.find(object, { context: { ...(context as object) } } as never)), + canReadObject: (object: string, context: unknown) => security!.canReadObject(object, { ...(context as object) }), + /** The SQL statements the driver ran during `run`, as their text. */ + statementsDuring: async (run: () => Promise): Promise => { + const seen: string[] = []; + const onQuery = (q: { sql?: unknown }) => { seen.push(String(q?.sql ?? '')); }; + const knex = e.getDriver(CHILD_LEAF).knex; + knex.on('query', onQuery); + try { await run(); } finally { knex.removeListener('query', onQuery); } + return seen; + }, + teardown: async () => { + try { await engine.destroy(); } catch { /* noop */ } + releaseAsyncContexts(engine, plugin); + }, + }; +} + +type Rig = Awaited>; + +const member = (userId: string, set: string) => ({ userId, positions: [], permissions: [set], posture: 'MEMBER' }); +/** The table a statement's FIRST `from` names — the table it reads, not one inside a subquery of it. */ +const fromTable = (sql: string): string | undefined => /^select .*? from `?([a-z0-9_]+)`?/i.exec(sql)?.[1]; +/** A statement that reads `table` — not one that reads another table with `table` inside a subquery. */ +const readsTable = (table: string) => (sql: string) => fromTable(sql) === table; + +describe('[ADR-0139 D6 as amended] a two-level controlled_by_parent chain composes and serves the rows the id-list path serves', () => { + let rig: Rig; + beforeAll(async () => { rig = await boot(); }); + afterAll(async () => { await rig.teardown(); }); + + it('the grandparents, measured apart: the owner-match plus the one share for each identity', async () => { + expect(await rig.read(GRAND, member(HUMAN, SET.human))).toEqual(['g_both', 'g_del_only', 'g_shared']); + expect(await rig.read(GRAND, member(OTHER, SET.human))).toEqual(['g_caller_only', 'g_neither', 'g_shared']); + }); + + it("a direct caller: the leaf path serves exactly the rows today's id-list path serves — find and count, both identities", async () => { + for (const [user, expected] of [ + [HUMAN, ['c_both', 'c_del_only', 'c_shared']], + [OTHER, ['c_caller_only', 'c_neither', 'c_shared']], + ] as const) { + const context = member(user, SET.human); + const today = await rig.read(CHILD_CBP, context); + const viaLeaf = await rig.read(CHILD_LEAF, context); + expect(today, user).toEqual([...expected]); + expect(viaLeaf, user).toEqual(today); + expect(await rig.count(CHILD_LEAF, context), user).toBe(expected.length); + expect(await rig.count(CHILD_CBP, context), user).toBe(expected.length); + } + // A dangling or null master pointer at either level is excluded on both + // paths (ruling 6303721609 item 4: never "no restriction" for them). + expect(await rig.read(CHILD_LEAF, member(HUMAN, SET.human))).not.toContain('c_via_dangling'); + expect(await rig.read(CHILD_LEAF, member(HUMAN, SET.human))).not.toContain('c_via_null'); + }); + + it("a delegated caller: the leaf path serves exactly the rows today's path serves — the delegator intersection at both levels", async () => { + const context = await rig.onBehalfOf(HUMAN); + const today = await rig.read(CHILD_CBP, context); + expect(today).toEqual(['c_both', 'c_shared']); + expect(await rig.read(CHILD_LEAF, context)).toEqual(today); + expect(await rig.count(CHILD_LEAF, context)).toBe(2); + expect(await rig.count(CHILD_CBP, context)).toBe(2); + }); + + it("the wrapper replaced PARENT's pre-resolved master id list with the leaf on every composed read: once per leg", async () => { + // The direct answer carries one `controlled_by_parent` predicate; the + // delegated answer carries one per leg (ADR-0090 D10). Either way, every + // list was replaced — none reached the driver as a bound id list. + expect(rig.replacedPerCall.length).toBeGreaterThan(0); + expect(rig.replacedPerCall.every((n) => n >= 1)).toBe(true); + expect(new Set(rig.replacedPerCall)).toEqual(new Set([1, 2])); + }); + + it('one statement per find and per count on the leaf path: the child\'s select with the parent and the grandparent inside nested subqueries, and no pre-scan of the parent — against the pre-scans of today\'s path', async () => { + const context = member(HUMAN, SET.human); + for (const [verb, run] of [ + ['find', () => rig.engine.find(CHILD_LEAF, { context: { ...context } } as never)], + ['count', () => rig.engine.count(CHILD_LEAF, { context: { ...context } } as never)], + ] as const) { + const statements = await rig.statementsDuring(run); + const all = `${verb}:\n${statements.join('\n')}`; + // The read is ONE statement: the child's, the parent inside its subquery + // and the grandparent inside that one. + const reads = statements.filter(readsTable(CHILD_LEAF)); + expect(reads, all).toHaveLength(1); + expect(reads[0].match(/in \(select/gi) ?? [], all).toHaveLength(2); + expect(reads[0], all).toMatch(new RegExp(`in \\(select .id. from .${PARENT}. where .grand. in \\(select .id. from .${GRAND}.`, 'i')); + // No statement reads the parent on its own: the leaf stands where the + // pre-resolved id list stood, so the parent is never pre-scanned. + expect(statements.filter(readsTable(PARENT)), all).toHaveLength(0); + // What else the driver ran is the security service's own answering, not + // the read: the real `getReadFilter(PARENT)` still derives PARENT's + // scope through `computeControlledByParentFilter`, whose grandparent + // pre-scan this rig's wrapper then REPLACES with the leaf (it retires + // with the plugin-security switch, card (c)), and plugin-sharing reads + // the share rows to answer the grandparent's scope. Neither is a read + // of the child, the parent or — as a read of its own — the grandparent + // by the composed statement. + for (const sql of statements.filter(readsTable(GRAND))) expect(sql, all).toMatch(/^select .id. from/i); + } + // Today's path: the grandparent and then the parent are pre-scanned under + // a system context (`select id from …`), and the child's own statement + // binds the parent ids as a list — no subquery anywhere. + const today = await rig.statementsDuring(() => rig.engine.find(CHILD_CBP, { context: { ...context } } as never)); + const todayAll = `today:\n${today.join('\n')}`; + expect(today.filter(readsTable(GRAND)), todayAll).toHaveLength(1); + expect(today.filter(readsTable(PARENT)), todayAll).toHaveLength(1); + const todayReads = today.filter(readsTable(CHILD_CBP)); + expect(todayReads, todayAll).toHaveLength(1); + expect(todayReads[0], todayAll).not.toMatch(/in \(select/i); + expect(today.some((sql) => /in \(select/i.test(sql)), todayAll).toBe(false); + }); +}); + +describe('[ADR-0139 D6 as amended] the closed doors stay closed at every level of the chain', () => { + let rig: Rig; + beforeAll(async () => { rig = await boot(); }); + afterAll(async () => { await rig.teardown(); }); + + it('a caller the grandparent object does not admit reads no child on either path: canReadObject(GRAND) is false, the nested leaf is $in: []', async () => { + const context = member(NO_GRAND_READ, SET.noGrand); + expect(await rig.readOutcome(GRAND, context)).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); + expect(await rig.canReadObject(GRAND, context)).toBe(false); + expect(await rig.read(CHILD_CBP, context)).toEqual([]); + expect(await rig.read(CHILD_LEAF, context)).toEqual([]); + expect(await rig.count(CHILD_LEAF, context)).toBe(0); + }); + + it('a delegator who does not exist: every read of the principal is refused at admission, on both paths', async () => { + const context = { ...(await rig.onBehalfOf(HUMAN)), onBehalfOf: { userId: 'u_rid_nest_missing', principalKind: 'human' } }; + expect(await rig.readOutcome(CHILD_CBP, context)).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); + expect(await rig.readOutcome(CHILD_LEAF, context)).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); + }); + + it('a delegator the grandparent object does not admit: the leaf path and today\'s path both serve nothing', async () => { + const context = await rig.onBehalfOf(NO_GRAND_READ); + expect(await rig.canReadObject(GRAND, context)).toBe(false); + expect(await rig.read(CHILD_CBP, context)).toEqual([]); + expect(await rig.read(CHILD_LEAF, context)).toEqual([]); + }); + + it('CONTROL — a system context reads every leaf-path child whose parent row exists, the chain composed with no restriction', async () => { + expect(await rig.read(CHILD_LEAF, { isSystem: true })).toEqual([ + 'c_both', 'c_caller_only', 'c_del_only', 'c_neither', 'c_shared', 'c_via_dangling', 'c_via_null', + ]); + }); +});