From 2ffe7d2632d966ec255470613a00360e197c3b63 Mon Sep 17 00:00:00 2001 From: "objectstack-fleet[bot]" <332303061+objectstack-fleet[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:10:50 +0000 Subject: [PATCH 1/8] wip(objectql,plugin-security): the ReadableIds leaf composes for a delegated caller; getReadFilter threads the D10 intersection Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude --- packages/objectql/src/engine.ts | 41 ++-- .../plugin-security/src/security-plugin.ts | 175 ++++++++++++++---- 2 files changed, 161 insertions(+), 55 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index dd71823aff7..2879fdae7f2 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -6140,11 +6140,11 @@ export class ObjectQL implements IObjectQLEngine { * * Per parent object `P`, once per call, in this order: * - * 1. **D5's three classes are refused, by name** (`INTERNAL_ERROR` / 500), - * before the security service is asked anything: a delegated - * (on-behalf-of) caller; a parent an object-scoped read middleware - * narrows ({@link hasObjectMiddleware}); a parent bound to another - * datasource than `object` ({@link resolveDatasourceBinding}). + * 1. **D5's two remaining classes are refused, by name** (`INTERNAL_ERROR` + * / 500), before the security service is asked anything: a parent an + * object-scoped read middleware narrows ({@link hasObjectMiddleware}); a + * parent bound to another datasource than `object` + * ({@link resolveDatasourceBinding}). * 2. **A parent the registry does not hold** ⇒ `$in: []`, unasked. * 3. **The security service** — absent or unusable ⇒ `$in: []`. * 4. **`canReadObject(P)`** (its `explain` fallback when the method is @@ -6155,6 +6155,17 @@ export class ObjectQL implements IObjectQLEngine { * `'policy'` and otherwise passed exactly as `P`'s own read passes its * injected scope. The tenant wall is the driver's (D3). * + * [ADR-0139 D5.1, as amended] A **delegated (on-behalf-of) caller** is + * composed, not refused: both halves are asked with the caller's context + * exactly as it stands — `onBehalfOf` intact — and the security service + * answers each half for the delegated principal, which under ADR-0090 D10 + * is the INTERSECTION of the principal's leg and the delegator's. The + * engine composes what it is answered and derives no second intersection + * of its own: it has one context, the delegated one, and the delegator's + * grants are the security service's to resolve. A service that cannot + * answer that context answers the deny sentinel, and step 5 turns it into + * `$in: []`, closed. + * * 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. @@ -6174,7 +6185,7 @@ 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, context); + const parents = this.refuseUncomposableReadableIdsClasses(object, operation, sites); const security = this.resolveReadableIdsSecurity(); if (security.kind !== 'usable' && !this.readableIdsSecurityReported.has(security.kind)) { @@ -6202,17 +6213,18 @@ export class ObjectQL implements IObjectQLEngine { } /** - * [ADR-0139 D5] Refuse D5's three classes, by name, and return the distinct - * parent objects of `sites` the security service is asked about, in walk - * order (every one but a parent the registry does not hold). Reads the - * context, the registry and the middleware registrations only — no security - * call. + * [ADR-0139 D5] Refuse D5's two remaining classes (D5.2 and D5.3), by name, + * and return the distinct parent objects of `sites` the security service is + * asked about, in walk order (every one but a parent the registry does not + * hold). Reads the registry and the middleware registrations only — no + * security call, and no reading of the caller's context: a delegated + * (on-behalf-of) caller is no longer a class of its own (D5.1, as amended), + * it is composed like any other through the security service's answers. */ private refuseUncomposableReadableIdsClasses( object: string, operation: string, sites: readonly ComposableReadableIdsSite[], - context: ExecutionContext | undefined, ): string[] { const firstSite = new Map(); for (const site of sites) if (!firstSite.has(site.leaf.object)) firstSite.set(site.leaf.object, site); @@ -6222,11 +6234,6 @@ export class ObjectQL implements IObjectQLEngine { `such leaf ${why}. The query was NOT run and no driver was asked. Read this class through its bounded ` + 'probe, chosen before the leaf is written.', ); - const delegator = (context as { onBehalfOf?: { userId?: unknown } } | undefined)?.onBehalfOf?.userId; - if (delegator) { - throw refuse(sites[0], 'for a delegated (on-behalf-of) caller: the parent\'s read scope does not carry the ' + - 'delegator intersection, so the composed leaf would not answer what a direct read answers'); - } const outer = this.resolveDatasourceBinding(object)?.datasource; const composable: string[] = []; for (const [parent, site] of firstSite) { diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index f871777f30a..25896c46cf1 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -5421,6 +5421,14 @@ export class SecurityPlugin implements Plugin { object: string, context: any, resolvedSets?: PermissionSet[], + /** + * [ADR-0090 D10] The depth to stamp instead of the one `getEffectiveScope` + * answers for `resolvedSets` — what step 2.6 stamps on a DELEGATED read, + * where the agent leg carries the intersected depth and the delegator leg + * its own ({@link computeDelegatedReadFilter}). Absent on every + * non-delegated call, which resolves the depth here as before. + */ + depth?: string, ): Promise | null> { const sharing = this.resolveKernelService?.('sharing') as | { buildReadFilter?: (o: string, c: any) => Promise } @@ -5432,23 +5440,25 @@ export class SecurityPlugin implements Plugin { // [#5386] `resolvedSets` lets an in-middleware caller (the // controlled_by_parent derivation) pass the sets it already resolved for // THIS identity, instead of re-resolving them from the context. - let readScope: string | undefined; - try { - const permissionSets = resolvedSets ?? (await this.resolvePermissionSetsForContext(context)); - if (permissionSets.length > 0) { - const meta = await this.getObjectSecurityMeta(object); - readScope = this.permissionEvaluator.getEffectiveScope( - 'read', - object, - permissionSets, - { isPrivate: meta.isPrivate }, - ); + let readScope: string | undefined = depth; + if (readScope === undefined) { + try { + const permissionSets = resolvedSets ?? (await this.resolvePermissionSetsForContext(context)); + if (permissionSets.length > 0) { + const meta = await this.getObjectSecurityMeta(object); + readScope = this.permissionEvaluator.getEffectiveScope( + 'read', + object, + permissionSets, + { isPrivate: meta.isPrivate }, + ); + } + } catch { + // Depth is a WIDENING input: failing to resolve it leaves the owner-match + // at its narrowest ('own'), which is the safe direction. The sharing call + // below still runs — and its own failure still denies. + readScope = undefined; } - } catch { - // Depth is a WIDENING input: failing to resolve it leaves the owner-match - // at its narrowest ('own'), which is the safe direction. The sharing call - // below still runs — and its own failure still denies. - readScope = undefined; } const filter = await sharing.buildReadFilter(object, { ...context, @@ -5861,6 +5871,14 @@ export class SecurityPlugin implements Plugin { * 3. {@link resolveSharingReadFilter} — plugin-sharing's OWD / record-share * visibility filter, contributed by the sibling middleware. * + * [ADR-0090 D10] On a DELEGATED (on-behalf-of) context the chain composes + * each of the three twice — once for the delegated principal's leg, once for + * the delegator's — and so does this method ({@link computeDelegatedReadFilter}): + * the delegated read is scoped to the intersection, exactly as a delegated + * `find` is. ADR-0139 D5.1 named that intersection reaching this method as + * the condition under which the engine composes its `ReadableIds` leaf for a + * delegated caller; it does, so the engine does. + * * Each layer has been missing here at some point, and each absence had the * same shape: a caller who cannot read a row through `/data` could still * `COUNT(*)` / `GROUP BY` it through `/analytics`. Layer 3 was #4467; layer 2 @@ -5899,6 +5917,28 @@ export class SecurityPlugin implements Plugin { // 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; + // [ADR-0090 D10 / ADR-0139 D5.1 as amended] A DELEGATED (on-behalf-of) + // context is scoped to the intersection of its own leg and its delegator's, + // composed as the engine middleware chain composes a delegated find + // ({@link computeDelegatedReadFilter}). This path used to answer the deny + // sentinel for every such context, because the intersection was threaded + // through the chain only; the engine's `ReadableIds` composition asks this + // method with the delegated context as it stands, so the intersection + // reaches here now. Fails CLOSED on every throw: a delegated read is never + // scoped wider than its delegator's own, and never served with a layer + // dropped. + if (context?.onBehalfOf?.userId) { + try { + return await this.computeDelegatedReadFilter(object, context); + } catch (e) { + this.logger.error?.( + `[security] getReadFilter failed for object '${object}' (principal ${context?.userId ?? 'unknown'} ` + + `on behalf of ${context.onBehalfOf.userId}) — denying (fail-closed)`, + e instanceof Error ? e : new Error(String(e)), + ); + 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 @@ -5916,28 +5956,6 @@ export class SecurityPlugin implements Plugin { ); 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 - // read with the DELEGATOR's own RLS (resolveDelegatorContext, ~L689), but - // getReadFilter — the read-scope provider bound by the analytics/raw-SQL - // path — resolves only the CALLER's ceiling. Computing a filter here for a - // delegated (agent) context would therefore SILENTLY WIDEN the read past - // the delegator's scope (the confused-deputy widening D10 prevents on the - // CRUD path). Until the intersection is threaded through computeRlsFilter - // (tracked with #2920 B1 / ADR-0095 D1), FAIL CLOSED: a delegated read on - // this path denies rather than under-scopes. System on-behalf-of already - // returned above; today no agent surface reaches analytics, so this is a - // latent-invariant guard, not a live-traffic change. - if (context?.onBehalfOf?.userId) { - this.logger.error?.( - `[security] getReadFilter received an on-behalf-of context for object ` + - `'${object}' (agent ${context?.userId ?? 'unknown'} on behalf of ` + - `${context.onBehalfOf.userId}) — the D10 delegator intersection is not ` + - `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; - } try { const permissionSets = await this.resolvePermissionSetsForContext(context); // [#21079] ADR-0056 D2 — the deny baseline at the row scope. A caller who @@ -5978,6 +5996,87 @@ export class SecurityPlugin implements Plugin { } } + /** + * [ADR-0090 D10 / ADR-0139 D5.1 as amended] The read scope of a DELEGATED + * (on-behalf-of) principal: the INTERSECTION of the principal's own leg and + * the delegator's — never the union — composed here exactly as the engine + * middleware chain composes a delegated `find` of `object`, layer for layer: + * + * 1. {@link computeRlsFilter} over the principal's sets, with the delegated + * context AS IT STANDS — {@link collectRLSPolicies} reads that context as + * the delegated principal's own leg (`isDelegatedPrincipalLeg`), so the + * ceiling's silence contributes no object default row scope; + * 2. {@link computeControlledByParentFilter} for the principal; + * 3. {@link computeRlsFilter} over the DELEGATOR's sets, with the context + * {@link resolveDelegatorContext} rebuilds for them (the middleware's + * D10 half, step 3); + * 4. {@link computeControlledByParentFilter} for the delegator; + * 5. plugin-sharing's visibility filter for the principal's identity, at + * the agent-leg depth step 2.6 stamps as `__readScope` + * (`intersectDelegatedScope` of the ceiling's DECLARED depth and the + * delegator's effective one); + * 6. the same filter re-run under the DELEGATOR's identity at the + * delegator's own depth — plugin-sharing's second call, with `userId` + * swapped to the delegator and no delegation link. + * + * Fails CLOSED where the chain refuses, with the deny sentinel by identity: + * a principal that resolves no set (ADR-0056 D2); a delegator the link names + * who does not exist (the chain's `PERMISSION_DENIED`; `canReadObject`'s + * `false`); a delegator who resolves no set (object admission refuses them); + * a sharing scope that cannot be resolved. A throw anywhere propagates to the + * caller's catch, which denies. + * + * ⛔ Not a second intersection rule: every leg is the primitive the + * middleware calls, in the middleware's spelling, and the delegator's context + * comes from the one resolver the middleware and `canReadObject` share. + */ + private async computeDelegatedReadFilter( + object: string, + context: any, + ): Promise | undefined> { + const permissionSets = await this.resolvePermissionSetsForContext(context); + if (permissionSets.length === 0) return RLS_DENY_FILTER; + const del = await resolveDelegatorContext(this.ql, context); + if (del.kind !== 'resolved') { + this.logger.error?.( + `[security] getReadFilter received an on-behalf-of context for object '${object}' whose delegator ` + + `'${String(context?.onBehalfOf?.userId)}' does not exist — denying (fail-closed: ADR-0090 D10, a ` + + `delegated read with no delegator to intersect against is never scoped)`, + ); + return RLS_DENY_FILTER; + } + const delegatorContext = del.context; + const delegatorSets = await this.resolvePermissionSetsForContext(delegatorContext); + if (delegatorSets.length === 0) return RLS_DENY_FILTER; + + // Step 2.6's two depth stamps, from the same two evaluator calls. + const meta = await this.getObjectSecurityMeta(object); + const delegatorScope = this.permissionEvaluator.getEffectiveScope('read', object, delegatorSets, { isPrivate: meta.isPrivate }); + const declared = this.permissionEvaluator.getDeclaredScope('read', object, permissionSets, { isPrivate: meta.isPrivate }); + const agentLegScope = intersectDelegatedScope(declared, delegatorScope).agentLegScope; + + // Layers 5 and 6 — the sibling middleware's two calls. Resolved first, as + // the non-delegated path resolves its one sharing layer first: a sharing + // outage denies before any RLS layer is composed. + const sharingFilter = await this.resolveSharingReadFilter(object, context, permissionSets, agentLegScope); + const delegatorSharingFilter = await this.resolveSharingReadFilter( + object, + { ...context, userId: delegatorContext.userId, onBehalfOf: undefined }, + delegatorSets, + delegatorScope, + ); + + // Layers 1 to 4 — the security middleware's own four, in its order. + const filter = await this.computeRlsFilter(permissionSets, object, 'find', context); + const cbpFilter = await this.computeControlledByParentFilter(permissionSets, object, context); + const delFilter = await this.computeRlsFilter(delegatorSets, object, 'find', delegatorContext); + const delCbpFilter = await this.computeControlledByParentFilter(delegatorSets, object, delegatorContext); + + const securityLayers = andComposeLayers(andComposeLayers(filter, cbpFilter), andComposeLayers(delFilter, delCbpFilter)); + const sharingLayers = andComposeLayers(sharingFilter, delegatorSharingFilter); + return andComposeLayers(securityLayers, sharingLayers) ?? undefined; + } + /** * [#3547] Query surface: the field names the caller MAY READ on `object` * under `context`. This is the authoritative column projection for a From cff39e9d150a077988d689405993313b7cf293ea Mon Sep 17 00:00:00 2001 From: "objectstack-fleet[bot]" <332303061+objectstack-fleet[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:31:12 +0000 Subject: [PATCH 2/8] wip(objectql,plugin-security,adr): the inherited pins and the ADR-0139 revision draft, taken over unverified The delegated-compose pins beside the leaf tests (objectql) and the real-stack test in plugin-security, the three plugin-security pins respelled for a delegator who does not exist, and the ADR-0139 draft (O5 answered, D5.1 amended). Committed as found in the interrupted run's worktree; verified in the commits that follow. Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude --- ...rnal-semi-join-leaf-readable-parent-ids.md | 67 ++- .../engine-readable-ids-composition.test.ts | 167 +++++++- .../src/controlled-by-parent-sharing.test.ts | 10 +- .../readable-ids-delegated-compose.test.ts | 399 ++++++++++++++++++ .../src/rls-deny-sentinel-identity.test.ts | 6 +- .../src/security-plugin.test.ts | 14 +- 6 files changed, 619 insertions(+), 44 deletions(-) create mode 100644 packages/plugins/plugin-security/src/readable-ids-delegated-compose.test.ts diff --git a/docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md b/docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md index e3b436c0379..5e33eb30a9a 100644 --- a/docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md +++ b/docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md @@ -5,8 +5,14 @@ D2's position sentence names the executor boundary — after the middleware chain and the before-hooks, immediately before each driver call — in place of the seam where `lowerRelationConditions` runs, which precedes the chain D4's emitters write into (ruling [`6121741984`](https://github.com/objectstack-ai/objectstack/issues/22861#issuecomment-6121741984) -on [#22861](https://github.com/objectstack-ai/objectstack/issues/22861), letter A). **This record declares; it -implements nothing.** It became **Accepted** on the maintainer's approval (`docs/adr/**` is Tier H, Prime +on [#22861](https://github.com/objectstack-ai/objectstack/issues/22861), letter A). **Amended** (2026-10-21): O5 is +answered — `controlled_by_parent` becomes the seventh consumer, in the end-state form — and D5.1 is amended from a +refusal to a composition: the engine composes the leaf for a delegated (on-behalf-of) caller, the scope being the +ADR-0090 D10 delegator intersection `getReadFilter` now answers for that context (ruling +[`6303721609`](https://github.com/objectstack-ai/objectstack/issues/24932#issuecomment-6303721609) on +[#24932](https://github.com/objectstack-ai/objectstack/issues/24932), letter B; the engine card +[#25009](https://github.com/objectstack-ai/objectstack/issues/25009)). D6 and the execution plan carry the rest of that +ruling. **This record declares; it implements nothing.** It became **Accepted** on the maintainer's approval (`docs/adr/**` is Tier H, Prime Directive #14); the execution cards under [Execution plan](#execution-plan-after-acceptance) are cut after acceptance and ⛔ never before it. Until then the platform stayed in the ruling's letter D state, and letter C (a located 400 at the bound, after objectui#12081 item 8) is the `domain:services` lane's interim on @@ -26,7 +32,8 @@ keys on), [ADR-0090](./0090-permission-model-v2-concept-convergence.md) D10 (del D5 (principal-less refusal), [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove: why no capability bit returns), [ADR-0112](./0112-error-code-vocabulary-and-ledger.md) (the refusal envelope), [ADR-0057](./0057-system-data-lifecycle-and-retention.md) (the `telemetry` datasource split that makes a parent federated). -**Leaves unchanged**: ADR-0055 §2's chosen mechanism (b) for `controlled_by_parent`, its `rlsMembership` IN-form and +**Leaves unchanged**: ADR-0055 §2's chosen mechanism (b) for `controlled_by_parent` (until the plugin-security switch +the 2026-10-21 amendment of D6 orders lands, in its own Tier H edit of that record), its `rlsMembership` IN-form and the fail-closed RLS compiler and its closed pushdown form set (historically four, broader today); ADR-0053 D-D1 (drivers receive lowered input) for every form but this one leaf. **Consumers**: `@objectstack/spec` (the leaf's declaration), `@objectstack/objectql` (composition and push-down), @@ -198,16 +205,31 @@ restriction". `managedBy: 'append-only'`, so the backfill goes through the migration door, never the CRUD write path). A row the backfill cannot judge keeps the fail-closed answer it has today. -### D5 — Three classes stay on the bounded fail-closed probe, by name - -Each is recognised by the engine, which **refuses to compose a leaf for it** (loud, as D3's envelope), and routed by the -consumer to today's probe before the leaf is written. ⛔ No silent fallback: the probe is chosen up front, never -reached by catching a refusal. - -1. **A delegated (on-behalf-of) caller** (ADR-0090 D10). `packages/plugins/plugin-security/src/security-plugin.ts#getReadFilter` - answers the deny sentinel for such a context because the delegator intersection is not threaded through the - read-scope path, while the CRUD middleware intersects it; a composed leaf would serve a delegated reader nothing. - The probe, which reads through the middleware, stays until the intersection reaches `getReadFilter`. +### D5 — Two classes stay on the bounded fail-closed probe, by name; the delegated caller composes (amended 2026-10-21) + +Each of the two remaining classes is recognised by the engine, which **refuses to compose a leaf for it** (loud, as D3's +envelope), and routed by the consumer to today's probe before the leaf is written. ⛔ No silent fallback: the probe is +chosen up front, never reached by catching a refusal. + +1. **A delegated (on-behalf-of) caller** (ADR-0090 D10) — **composed, not refused** (amended 2026-10-21; as accepted + this class was refused "until the intersection reaches `getReadFilter`", and it has). The engine's boundary + (`packages/objectql/src/engine.ts#composeReadableIdsAtBoundary`) asks both halves — `canReadObject` and + `getReadFilter` — with the delegated context exactly as it stands, `onBehalfOf` intact, and composes what it is + answered; it reads no delegation link of its own + (`packages/objectql/src/engine.ts#refuseUncomposableReadableIdsClasses` judges the two classes below and nothing + about the caller). The security service answers each half for the delegated principal, which under ADR-0090 D10 is + the INTERSECTION of the principal's leg and the delegator's: `canReadObject` already intersected the object half; + `packages/plugins/plugin-security/src/security-plugin.ts#getReadFilter` now answers the row half through + `packages/plugins/plugin-security/src/security-plugin.ts#computeDelegatedReadFilter`, the six layers the engine + middleware chain ANDs into a delegated `find` of P — the principal's RLS and `controlled_by_parent` derivation, the + delegator's two (over the context `packages/plugins/plugin-security/src/explain-engine.ts#resolveDelegatorContext` + rebuilds), and plugin-sharing's visibility filter under each identity at its own depth. So a delegated read through + the leaf serves exactly the rows a delegated direct read of P serves (measured: `find`, `count`, one statement per + read, the parent inside the subquery). The closed doors are the chain's: a delegator who does not exist, one the + parent object does not admit, a principal that resolves no set, and any resolution failure answer `false` or the + deny sentinel, and the leaf is `$in: []`. ⛔ The engine derives no second intersection: it has one context, the + delegated one, and the delegator's grants are the security service's to resolve. The six consumers' up-front + `'delegated-caller'` probe routing (D4) is now a residue of the refusal, retired per consumer on its own card. 2. **A parent whose rows are narrowed by an object-scoped read middleware** (`packages/objectql/src/engine.ts#registerMiddleware` with an `object`): `sys_attachment`, `sys_comment`, `sys_comment_reaction`, `sys_approval_request`, and the two gated objects themselves. Their WHERE is built at read time and is no part of `getReadFilter`, so a leaf over them @@ -226,11 +248,28 @@ compiled output of the RLS compiler, whose closed pushdown form set (historicall IN-form and deliberate lack of a subquery form are unchanged (`packages/plugins/plugin-security/src/rls-compiler.ts#RLSCompiler`, `#compileExpression`: a subquery shape compiles to `null` and fails closed). §2's mechanism (b) for -`controlled_by_parent` stands, set-size ceiling included; whether that id set becomes a seventh consumer is ⛔ not +`controlled_by_parent` stands, set-size ceiling included; whether that id set becomes a seventh consumer was ⛔ not decided here. ADR-0056's non-goal stands as written for the compiler and is revisited only as the engine-side leaf. Each record carries one pointer to this one, landed with this record — ADR-0055's status line, ADR-0056's non-goal bullet; nothing else in either moves. +**Amended 2026-10-21 — O5 is decided, by the maintainer's ruling +[`6303721609`](https://github.com/objectstack-ai/objectstack/issues/24932#issuecomment-6303721609) on +[#24932](https://github.com/objectstack-ai/objectstack/issues/24932) (letter B), recorded here verbatim:** + +> O5 is answered yes, in the end-state form. `controlled_by_parent` becomes the seventh consumer of the engine-internal +> semi-join leaf, once for every shape: the engine first composes the leaf for a delegated (on-behalf-of) caller (the +> exit condition D5.1 itself names) and for a nested master chain; then plugin-security switches the caller, the +> delegator intersection and `getReadFilter` in one change, and the id-list pre-scan retires. + +The order the ruling binds: the delegated composition (D5.1 as amended, this revision's card), then the nested master +chain (a leaf inside a composed scope is composed; its own `domain:engine` card), then the plugin-security switch, +"which in the same Tier H edit corrects ADR-0055's 'cached within the request' sentence to what the code does". Two +things the ruling ⛔ does not take: an interim with two spellings of one scope, and "interim removal of the predicate for +an unrestricted master scope: it would expose rows with a null or dangling foreign key (measured)". ADR-0055 §2's +mechanism (b) therefore stands until the switch lands, and ADR-0055's own text moves only in that switch's edit, never +here. + ## Consequences - **Positive.** A reader gets every row they may see and a `total` that is true, on any table size; the pre-scan, diff --git a/packages/objectql/src/engine-readable-ids-composition.test.ts b/packages/objectql/src/engine-readable-ids-composition.test.ts index 0e05c00cbec..5ce354099c0 100644 --- a/packages/objectql/src/engine-readable-ids-composition.test.ts +++ b/packages/objectql/src/engine-readable-ids-composition.test.ts @@ -15,8 +15,12 @@ * * Refused by name, `INTERNAL_ERROR` / 500, before any driver is asked: a write * verb, `having`, an aggregation's filter, any position but a `$in` - * comparand, a composed leaf the engine did not compose, and D5's three - * classes (a delegated caller, a middleware-gated parent, a federated parent). + * comparand, a composed leaf the engine did not compose, and D5's two + * remaining classes (a middleware-gated parent, a federated parent). A + * delegated (on-behalf-of) caller composes like any other (D5.1 as amended): + * both halves are asked with the delegated context as it stands, and what the + * security service answers for that context — under ADR-0090 D10 the + * intersection of the two legs — is the scope. * * The recording driver is this package's witness shape (the in-memory * driver's test consumers are a closed census); evaluating the composed leaf @@ -87,18 +91,27 @@ const leafOf = (object = 'rid_parent') => ReadableIds.of(object); const branch = (object = 'rid_parent') => ({ object_name: object, record_id: { $in: leafOf(object) } }); const COUNT_AGG = [{ function: 'count', alias: 'n' }]; -/** A security service double that records which half it was asked, per object. */ +/** A security service double that records which half it was asked, per object, and the context each half received. */ function makeSecurity(opts: { - canRead?: (object: string) => boolean | Promise; - scope?: (object: string) => unknown; + canRead?: (object: string, context?: unknown) => boolean | Promise; + scope?: (object: string, context?: unknown) => unknown; } = {}) { const asked: string[] = []; + const contexts: unknown[] = []; const svc: any = { - async canReadObject(object: string) { asked.push(`canReadObject:${object}`); return opts.canRead ? opts.canRead(object) : true; }, - async getReadFilter(object: string) { asked.push(`getReadFilter:${object}`); return opts.scope ? opts.scope(object) : undefined; }, + async canReadObject(object: string, context?: unknown) { + asked.push(`canReadObject:${object}`); + contexts.push(context); + return opts.canRead ? opts.canRead(object, context) : true; + }, + async getReadFilter(object: string, context?: unknown) { + asked.push(`getReadFilter:${object}`); + contexts.push(context); + return opts.scope ? opts.scope(object, context) : undefined; + }, async explain() { asked.push('explain'); return { allowed: false }; }, }; - return { svc, asked }; + return { svc, asked, contexts }; } /** The `$in` comparand the driver received at `where.record_id`, under any `$and` the chain wrapped around it. */ @@ -344,6 +357,133 @@ describe('[ADR-0139 D2] the executor boundary composes the ReadableIds leaf', () }); }); +describe('[ADR-0139 D5.1, as amended] a delegated (on-behalf-of) caller composes — both halves asked with the delegated context as it stands', () => { + let engine: ObjectQL; + let calls: SeenCall[]; + /** The shape the OAuth door assembles: `userId` stays the human, the link names them as the delegator. */ + const DELEGATED = { userId: 'usr_owner', principalKind: 'agent', onBehalfOf: { userId: 'usr_owner', principalKind: 'human' } }; + const delegated = () => ({ ...DELEGATED, onBehalfOf: { ...DELEGATED.onBehalfOf } }) as any; + + 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, OTHER, GATED, FAR]) engine.registry.registerObject(def as any, 'test'); + engine.registerMiddleware(async (_ctx, next) => next(), { object: 'rid_gated' }); + calls.length = 0; + }); + + const delegatedReads = (where: Record) => [ + ['find', () => engine.find('rid_child', { where, context: delegated() })], + ['findOne', () => engine.findOne('rid_child', { where, context: delegated() })], + ['count', () => engine.count('rid_child', { where, context: delegated() })], + ['aggregate', () => engine.aggregate('rid_child', { where, aggregations: COUNT_AGG as any, context: delegated() })], + ] as const; + + it('each read verb asks both halves with the delegation link intact and hands its driver ONE call carrying the composed leaf — scope by reference, marked policy', async () => { + // What the service answers for the delegated context stands for the + // ADR-0090 D10 intersection; the engine composes it, it derives nothing. + const scope = { $and: [{ owner: 'usr_owner' }, { title: { $ne: 'secret' } }] }; + const { svc, asked, contexts } = makeSecurity({ scope: () => scope }); + wire(engine, () => svc); + for (const [verb, run] of delegatedReads(branch())) { + 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']); + // Both halves received the caller's context as it stands: the link the + // service intersects on is there, and the principal is the delegated one + // — never a context the engine rebuilt for the delegator. + for (const ctx of contexts as Array>) { + expect(ctx.onBehalfOf, verb).toEqual(DELEGATED.onBehalfOf); + expect(ctx.userId, verb).toBe(DELEGATED.userId); + expect(ctx.principalKind, verb).toBe('agent'); + } + const composed = inComparandOf(calls[0].ast.where) as any; + expect(isComposedReadableIds(composed), verb).toBe(true); + expect(composed.object, verb).toBe('rid_parent'); + expect(composed.scope, verb).toBe(scope); + } + expect(filterSubtreeProvenanceOf(scope)).toBe('policy'); + }); + + it('a leaf written by an object-scoped middleware on the child is composed for a delegated caller too', async () => { + const scope = { owner: 'usr_owner' }; + const { svc, contexts } = makeSecurity({ scope: () => scope }); + 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' }); + for (const [verb, run] of delegatedReads({ note: 'x' })) { + calls.length = 0; + contexts.length = 0; + await run(); + const composed = inComparandOf(calls[0].ast.where) as any; + expect(isComposedReadableIds(composed), verb).toBe(true); + expect(composed.scope, verb).toBe(scope); + expect(contexts.map((c) => (c as any)?.onBehalfOf?.userId), verb).toEqual(['usr_owner', 'usr_owner']); + } + }); + + it('the closed doors stay closed under a delegated context: canReadObject false, the deny sentinel, a throw on either half ⇒ $in: []', async () => { + const doors: ReadonlyArray]> = [ + ['canReadObject false', makeSecurity({ canRead: () => false, scope: () => ({ owner: 'usr_owner' }) })], + ['the deny sentinel, by identity', makeSecurity({ scope: () => RLS_DENY_FILTER })], + ['canReadObject throws', (() => { + const s = makeSecurity({ scope: () => ({ owner: 'usr_owner' }) }); + s.svc.canReadObject = async () => { throw new Error('canReadObject is down'); }; + return s; + })()], + ['getReadFilter throws', (() => { + const s = makeSecurity(); + s.svc.getReadFilter = async () => { throw new Error('getReadFilter is down'); }; + return s; + })()], + ]; + for (const [label, { svc }] of doors) { + wire(engine, () => svc); + for (const [verb, run] of delegatedReads(branch())) { + calls.length = 0; + await run(); + expect(calls.map((c) => c.verb), `${label}: ${verb}`).toEqual([verb]); + expect(inComparandOf(calls[0].ast.where), `${label}: ${verb}`).toEqual([]); + } + } + }); + + it('D5.2 and D5.3 keep their refusal under a delegated context: code and status, no driver call, the service unasked', async () => { + const { svc, asked } = makeSecurity(); + wire(engine, () => svc); + const gated = await refusalOf(engine.count('rid_child', { where: branch('rid_gated'), context: delegated() })); + expect(envelopeOf(gated)).toEqual(REFUSED); + expect(gated!.message).toContain("'rid_gated', whose rows an object-scoped read middleware narrows"); + const far = await refusalOf(engine.aggregate('rid_child', { where: branch('rid_far'), aggregations: COUNT_AGG as any, context: delegated() })); + expect(envelopeOf(far)).toEqual(REFUSED); + expect(far!.message).toContain("'rid_far', which is bound to datasource 'far'"); + expect(calls).toHaveLength(0); + expect(asked).toEqual([]); + }); + + it('CONTROL — the same read without the delegation link composes the same way, so the link is not what the engine keys on', async () => { + const scope = { owner: 'usr_owner' }; + const { svc, asked, contexts } = makeSecurity({ scope: () => scope }); + wire(engine, () => svc); + await engine.find('rid_child', { where: branch(), context: { userId: 'usr_owner', principalKind: 'human' } as any }); + expect(asked).toEqual(['canReadObject:rid_parent', 'getReadFilter:rid_parent']); + expect(contexts.map((c) => (c as any)?.onBehalfOf)).toEqual([undefined, undefined]); + expect((inComparandOf(calls[0].ast.where) as any).scope).toBe(scope); + }); +}); + describe('[ADR-0139 D1, D5] what the engine never composes is refused by name — no driver call', () => { let engine: ObjectQL; let calls: SeenCall[]; @@ -365,17 +505,6 @@ describe('[ADR-0139 D1, D5] what the engine never composes is refused by name calls.length = 0; }); - it('D5 — a delegated (on-behalf-of) caller', async () => { - const err = await refusalOf(engine.find('rid_child', { - where: branch(), - context: { userId: 'usr_agent', onBehalfOf: { userId: 'usr_owner' } } as any, - })); - expect(envelopeOf(err)).toEqual(REFUSED); - expect(err!.message).toContain('on-behalf-of'); - expect(calls).toHaveLength(0); - expect(asked, 'D5 is decided before the security service is asked').toEqual([]); - }); - it('D5 — a parent an object-scoped read middleware narrows', async () => { const err = await refusalOf(engine.count('rid_child', { where: branch('rid_gated') })); expect(envelopeOf(err)).toEqual(REFUSED); diff --git a/packages/plugins/plugin-security/src/controlled-by-parent-sharing.test.ts b/packages/plugins/plugin-security/src/controlled-by-parent-sharing.test.ts index 0466fda8c59..cac1868247c 100644 --- a/packages/plugins/plugin-security/src/controlled-by-parent-sharing.test.ts +++ b/packages/plugins/plugin-security/src/controlled-by-parent-sharing.test.ts @@ -665,11 +665,13 @@ describe('[#5815] getReadFilter enforces the same read scope as the engine middl expect(reads).not.toContain('crm_contact'); }); - it('a delegated (on-behalf-of) read still denies outright — unchanged by the added layer', async () => { - // [#2852] The D10 delegator intersection is not implemented on this path, - // so it fails closed BEFORE any layer is composed. Pinned here because the + it('a delegated (on-behalf-of) read whose delegator does not exist still denies outright — unchanged by the added layer', async () => { + // [ADR-0090 D10] getReadFilter composes the delegator intersection for a + // delegated context (ADR-0139 D5.1 as amended). This fixture stores no + // `sys_user` row, so the link names a delegator who does not exist and the + // read fails closed BEFORE any layer is composed. Pinned here because the // added layer must not become a reason to compute a scope for a context - // this method refuses to scope at all. + // whose delegator cannot be resolved. const h = await boot({ shareLevel: 'edit' }); const delegated = { ...h.repContext(), onBehalfOf: { userId: OTHER } }; expect(await h.analyticsVisibleContacts(delegated)).toEqual([]); diff --git a/packages/plugins/plugin-security/src/readable-ids-delegated-compose.test.ts b/packages/plugins/plugin-security/src/readable-ids-delegated-compose.test.ts new file mode 100644 index 00000000000..042c9a6325d --- /dev/null +++ b/packages/plugins/plugin-security/src/readable-ids-delegated-compose.test.ts @@ -0,0 +1,399 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0139 D5.1, as amended / ADR-0090 D10] The engine composes its + * `ReadableIds` leaf for a DELEGATED (on-behalf-of) caller, and the scope it is + * composed with is the delegator intersection: `getReadFilter` answers the same + * six layers the engine middleware chain ANDs into a delegated `find` of the + * parent — the principal's RLS and `controlled_by_parent` derivation, the + * delegator's two, and plugin-sharing's visibility filter for each identity at + * its own depth — so a child read through the leaf serves exactly the children + * whose parent BOTH legs admit. + * + * Measured on the real stack: `ObjectQL` over better-sqlite3, the real + * `SecurityPlugin` with the real `SharingService` behind it, and the delegated + * context assembled by `resolveAuthzContext` and `assembleExecutionContext` as + * the OAuth door assembles it — `userId` the human, the link naming them as the + * delegator, the agent's ceiling set as its only grant. + * + * The two legs are made to disagree on purpose: + * + * - the CEILING admits a parent row by its content (`name == 'open'`) and is + * silent on depth, so its leg carries no owner narrowing (the agent leg's + * depth is `org` for a silent ceiling, `intersectDelegatedScope`); + * - the DELEGATOR's own set is silent on rows, so their leg is plugin-sharing's + * owner-match on a `private` parent plus the one record shared to them. + * + * So `p_caller_only` (open, owned by someone else) is admitted by the caller + * leg alone, `p_del_only` (secret, the delegator's own) by the delegator leg + * alone, and only `p_both` and `p_shared` by both. The closed doors are + * measured beside it: a delegator who does not exist, a delegator the parent + * object does not admit, and the two D5 classes that still refuse. + */ + +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_del'; + +/** The parent the leaf is over: `private`, so plugin-sharing's owner-match scopes it per identity. */ +const PARENT = 'qa_rid_del_parent'; +/** A child pointing at the parent; a consumer-shaped middleware writes the leaf into its reads. */ +const CHILD = 'qa_rid_del_child'; +/** A parent an object-scoped read middleware narrows: D5.2, still refused. */ +const GATED = 'qa_rid_del_gated'; + +/** The delegator: the human an agent acts for, and the delegated context's own `userId`. */ +const HUMAN = 'u_rid_del_human'; +/** Owns the rows the delegator does not. */ +const OTHER = 'u_rid_del_other'; +/** A delegator whose grants do not admit the parent object. */ +const NO_PARENT_READ = 'u_rid_del_no_parent_read'; + +const SET = { + /** The agent's ceiling: object read on both, a content rule on the parent's rows, silent on depth. */ + ceiling: 'qa_rid_del_ceiling', + /** The delegator's own set: object read on both, silent on rows. */ + human: 'qa_rid_del_human', + /** A set that admits the child but not the parent object. */ + childOnly: 'qa_rid_del_child_only', +} 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) } }); + +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-delegated-compose', + name: 'The ReadableIds leaf composes for a delegated caller with the delegator intersection', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPosition, SysUserPermissionSet, + SysOrganization, SysUser, SysMember, SysRecordShare, + { + name: PARENT, + label: 'Parent', + 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: CHILD, + label: 'Child', + sharingModel: 'public_read_write', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + note: { name: 'note', type: 'text' }, + parent: { name: 'parent', type: 'text' }, + }, + }, + { + name: GATED, + label: 'Gated parent', + sharingModel: 'public_read_write', + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', 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_PARENT_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.ceiling, + objects: { [PARENT]: READ, [CHILD]: READ, [GATED]: READ }, + rowLevelSecurity: [ + { name: 'qa_rid_del_ceiling_open_only', object: PARENT, operation: 'select', using: "record.name == 'open'" }, + ], + }), + PermissionSetSchema.parse({ name: SET.human, objects: { [PARENT]: READ, [CHILD]: READ, [GATED]: READ } }), + PermissionSetSchema.parse({ name: SET.childOnly, objects: { [CHILD]: READ } }), + ]; + // The delegators hold their sets through stored grants, as a user does. + for (const name of [SET.human, SET.childOnly]) { + 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_PARENT_READ, SET.childOnly]] as const) { + await e.insert('sys_user_permission_set', { id: `ups_${user}`, user_id: user, permission_set: set, organization_id: ORG }, SYS); + } + + // The parent rows, named for the leg(s) that admit them. + await e.insert(PARENT, { id: 'p_both', name: 'open', owner_id: HUMAN }, SYS); + await e.insert(PARENT, { id: 'p_del_only', name: 'secret', owner_id: HUMAN }, SYS); + await e.insert(PARENT, { id: 'p_caller_only', name: 'open', owner_id: OTHER }, SYS); + await e.insert(PARENT, { id: 'p_neither', name: 'secret', owner_id: OTHER }, SYS); + await e.insert(PARENT, { id: 'p_shared', name: 'open', owner_id: OTHER }, SYS); + await e.insert('sys_record_share', { + id: 'shr_rid_del_1', object_name: PARENT, record_id: 'p_shared', recipient_type: 'user', recipient_id: HUMAN, access_level: 'read', + }, SYS); + 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_dangling', 'p_missing'], ['c_null', null], + ] as const) { + await e.insert(CHILD, { id, note: '', parent }, SYS); + } + await e.insert(GATED, { id: 'g_1', name: 'open' }, SYS); + + 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); + // What `ObjectQLPlugin.init` does on a kernel: put the SOURCE of the + // `security` service in the engine's slot, read at every composition. The + // key is `Symbol.for`, the one slot every bundle of the engine names. + (engine as unknown as Record)[Symbol.for('objectstack.objectql.securityServiceSource')] = () => security; + 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 CHILD writes + // the leaf over the 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 }); + // D5.2's shape: an object-scoped read middleware on a parent. + engine.registerMiddleware(async (_opCtx, next) => next(), { object: GATED }); + 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, + read: async (object: string, context: unknown, where?: Record) => + ids(await engine.find(object, { ...(where ? { where } : {}), 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)), + /** The ids `getReadFilter`'s scope admits, read with it as the only predicate under a system context. */ + readThroughReadFilter: async (object: string, context: unknown) => { + const scope = await security!.getReadFilter(object, { ...(context as object) }); + return ids(await engine.find(object, { where: scope ?? {}, context: { isSystem: true } } as never)); + }, + readFilter: (object: string, context: unknown) => security!.getReadFilter(object, { ...(context as object) }), + 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).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' }); + +describe('[ADR-0139 D5.1 as amended] a delegated read through the leaf serves the delegator intersection', () => { + let rig: Rig; + beforeAll(async () => { rig = await boot(); }); + afterAll(async () => { await rig.teardown(); }); + + it("the legs, measured apart: the delegator's own read, and the ceiling's rule on a principal who owns the other rows", async () => { + // The delegator's own leg: owner-match plus the one share, every name. + expect(await rig.read(PARENT, member(HUMAN, SET.human))).toEqual(['p_both', 'p_del_only', 'p_shared']); + // The ceiling's content rule, held directly by the owner of the other rows: + // `open` only. Its leg under delegation carries no owner narrowing at all + // (a silent ceiling's depth is `org`), so it admits `p_caller_only` too. + expect(await rig.read(PARENT, member(OTHER, SET.ceiling))).toEqual(['p_caller_only', 'p_shared']); + }); + + it("the delegated read of the parent itself is the intersection: the delegator's rows the ceiling admits", async () => { + const context = await rig.onBehalfOf(HUMAN); + expect(await rig.read(PARENT, context)).toEqual(['p_both', 'p_shared']); + }); + + it('a delegated read through the leaf serves exactly the children whose parent both legs admit — find, count', async () => { + const context = await rig.onBehalfOf(HUMAN); + // `p_caller_only` is admitted by the caller leg alone, `p_del_only` by the + // delegator leg alone: neither child is served. A dangling or null pointer + // is excluded as before (the subquery finds no such id). + expect(await rig.read(CHILD, context)).toEqual(['c_both', 'c_shared']); + expect(await rig.count(CHILD, context)).toBe(2); + }); + + it("CONTROL — the delegator's own read through the leaf serves their own rows' children, the ceiling not applied", async () => { + expect(await rig.read(CHILD, member(HUMAN, SET.human))).toEqual(['c_both', 'c_del_only', 'c_shared']); + }); + + it('getReadFilter answers the delegated context the same scope the delegated find runs under (parity, by rows)', async () => { + const context = await rig.onBehalfOf(HUMAN); + expect(await rig.readThroughReadFilter(PARENT, context)).toEqual(['p_both', 'p_shared']); + expect(await rig.readThroughReadFilter(PARENT, context)).toEqual(await rig.read(PARENT, context)); + }); + + it('one statement touches the parent table per delegated child find: the subquery, no pre-scan of the parent', async () => { + const context = await rig.onBehalfOf(HUMAN); + const statements = await rig.statementsDuring(() => rig.engine.find(CHILD, { context: { ...context } } as never)); + const touchingParent = statements.filter((sql) => sql.includes(PARENT)); + expect(touchingParent, statements.join('\n')).toHaveLength(1); + // The one statement is the child's own select, the parent inside its subquery. + expect(touchingParent[0]).toMatch(new RegExp(`from .${CHILD}.`, 'i')); + expect(touchingParent[0]).toMatch(/in \(select/i); + }); +}); + +describe('[ADR-0139 D5.1 as amended] the closed doors stay closed on a delegated read', () => { + let rig: Rig; + beforeAll(async () => { rig = await boot(); }); + afterAll(async () => { await rig.teardown(); }); + + it('a delegator who does not exist: every read of the principal is refused at admission, and both halves of the parent deny', async () => { + const context = { ...(await rig.onBehalfOf(HUMAN)), onBehalfOf: { userId: 'u_rid_del_missing', principalKind: 'human' } }; + // The chain refuses the principal before any leaf is written (the child + // read included), `canReadObject` answers false, and `getReadFilter` is + // the deny sentinel BY IDENTITY — the answer the engine turns into `$in: []`. + expect(await rig.readOutcome(PARENT, context)).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); + expect(await rig.readOutcome(CHILD, context)).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); + expect(await rig.canReadObject(PARENT, context)).toBe(false); + expect(await rig.readFilter(PARENT, context)).toBe(RLS_DENY_FILTER); + }); + + it('a delegator the parent object does not admit: the parent read is refused, canReadObject is false, the leaf serves nothing', async () => { + const context = await rig.onBehalfOf(NO_PARENT_READ); + expect(await rig.readOutcome(PARENT, context)).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); + // The object half decides (D2 step 1): the leaf is `$in: []` before + // `getReadFilter` is asked, which — as for a non-delegated principal — + // answers rows, not object admission. + expect(await rig.canReadObject(PARENT, context)).toBe(false); + expect(await rig.read(CHILD, context)).toEqual([]); + expect(await rig.count(CHILD, context)).toBe(0); + }); + + it('D5.2 — a leaf over a parent an object-scoped read middleware narrows is still refused, INTERNAL_ERROR / 500, under a delegated context', async () => { + const context = await rig.onBehalfOf(HUMAN); + const got = await outcomeOf(rig.engine.find(CHILD, { + where: { parent: { $in: ReadableIds.of(GATED) } }, + context: { ...context }, + } as never)); + expect(got).toMatchObject({ kind: 'refused', code: 'INTERNAL_ERROR', status: 500 }); + }); + + it('CONTROL — a system context reads every child, the leaf composed with no restriction', async () => { + expect(await rig.read(CHILD, { isSystem: true })).toEqual(['c_both', 'c_caller_only', 'c_del_only', 'c_neither', 'c_shared']); + }); +}); 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 index d59eb539f98..7062744ccfc 100644 --- a/packages/plugins/plugin-security/src/rls-deny-sentinel-identity.test.ts +++ b/packages/plugins/plugin-security/src/rls-deny-sentinel-identity.test.ts @@ -155,7 +155,11 @@ describe('[ADR-0139 D2 step 2] every getReadFilter deny return is the spec senti expect(logger.error).toHaveBeenCalled(); }); - it('an on-behalf-of context (the D10 intersection is not on this path)', async () => { + it('an on-behalf-of context whose delegator does not exist (the D10 intersection fails closed on a dangling link)', async () => { + // This rig's engine double answers no `sys_user`, so the delegator the link + // names is `missing`: the intersection cannot be composed, and the deny is + // the sentinel by reference. A delegator who exists is scoped to the + // intersection instead (`readable-ids-delegated-compose.test.ts`). const { plugin } = await boot({ sets: [OPEN_SET] }); expect(await plugin.getReadFilter('task', { ...MEMBER, onBehalfOf: { userId: 'u_delegator' } })).toBe(SPEC_RLS_DENY_FILTER); }); diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 2ab67120d06..e60fb3a3503 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -1657,12 +1657,14 @@ describe('SecurityPlugin', () => { expect(filter).toEqual(RLS_DENY_FILTER); }); - it('[#2852] fail-closed on an on-behalf-of (delegated) context — no D10 intersection on this path', async () => { - // getReadFilter resolves only the CALLER's ceiling; the delegator RLS - // intersection the engine middleware applies is absent here. A delegated - // read must DENY rather than silently return the agent's (wider) own - // scope. Latent today (no agent surface reaches analytics) but the - // invariant is enforced regardless. + it('[ADR-0090 D10] fail-closed on an on-behalf-of (delegated) context whose delegator does not exist', async () => { + // getReadFilter composes the D10 intersection for a delegated context + // (ADR-0139 D5.1 as amended; `readable-ids-delegated-compose.test.ts` + // measures it on the real stack). This harness answers no `sys_user` + // row, so the link names a delegator who does not exist: there is no + // leg to intersect against, and the read DENIES rather than serve the + // agent's own (wider) scope — the dangling-link rule every D10 gate + // shares, logged as the failure it is. const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet], orgScoping: true }); await plugin.init(harness.ctx); From 46b4360cdb09c8d0abf1fcf3b33406c19b76b63c Mon Sep 17 00:00:00 2001 From: "objectstack-fleet[bot]" <332303061+objectstack-fleet[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:36:24 +0000 Subject: [PATCH 3/8] feat(objectql,plugin-security): the ReadableIds leaf composes for a delegated caller with the delegator intersection; the changeset, the ADR E2 row and the dangling-delegator report line The missing-delegator refusal in computeDelegatedReadFilter carries the D10 invariant sentence the report-sink pin reads; the two bare-instance pins' docblocks say which refusal they now reach. The changeset for @objectstack/objectql and @objectstack/plugin-security (minor, as the earlier ADR-0139 composition cards). ADR-0139's E2 row notes D5.1's amendment beside its historical "three classes". Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude --- .../25009-readable-ids-delegated-compose.md | 19 +++++++++++++++++++ ...rnal-semi-join-leaf-readable-parent-ids.md | 2 +- .../src/default-report-sink.test.ts | 6 ++++-- .../plugin-security/src/security-plugin.ts | 5 +++-- .../src/start-logger-binding.test.ts | 12 +++++++----- 5 files changed, 34 insertions(+), 10 deletions(-) create mode 100644 .changeset/25009-readable-ids-delegated-compose.md diff --git a/.changeset/25009-readable-ids-delegated-compose.md b/.changeset/25009-readable-ids-delegated-compose.md new file mode 100644 index 00000000000..e2af54e0452 --- /dev/null +++ b/.changeset/25009-readable-ids-delegated-compose.md @@ -0,0 +1,19 @@ +--- +'@objectstack/objectql': minor +'@objectstack/plugin-security': minor +--- + +feat(objectql, plugin-security): the engine composes its `ReadableIds` leaf for a delegated (on-behalf-of) caller, scoped to the delegator intersection, instead of refusing it (ADR-0139 D5.1 as amended; O5 answered) + +Clause-②: no + +**Before.** A read carrying an on-behalf-of link whose final `where` held a `ReadableIds` leaf was refused `INTERNAL_ERROR` / 500 before any driver was asked (ADR-0139 D5.1 as accepted), and `getReadFilter` answered the deny sentinel for every delegated context, so the leaf's consumers routed such a caller to their bounded, fail-closed probe. + +**Now.** + +- `@objectstack/objectql`: the executor boundary composes the leaf for a delegated caller exactly as for any other. It asks `canReadObject` and `getReadFilter` with the delegated context as it stands (`onBehalfOf` intact) and composes what the security service answers; it derives no intersection of its own. The two remaining D5 classes, a parent an object-scoped read middleware narrows and a parent bound to another datasource, are still refused by name with the same envelope. +- `@objectstack/plugin-security`: `getReadFilter` answers a delegated context the ADR-0090 D10 intersection, the six layers the engine middleware ANDs into a delegated `find` of the same object: the principal's row-level security and `controlled_by_parent` derivation, the delegator's two over the delegator's own rebuilt context, and the sharing visibility filter under each identity at its own depth. A delegated read through the leaf serves exactly the rows a delegated direct read of the parent serves. It fails closed as the chain does: a delegator who does not exist, a delegator the object does not admit, a principal or a delegator that resolves no permission set, and any resolution failure answer the deny sentinel, and the leaf is `$in: []`. + +**What a reader sees.** No shipped consumer writes a leaf for a delegated caller yet (each still routes it to its probe; that routing retires per consumer on its own card), so a delegated read of `sys_activity`, `sys_audit_log`, `sys_comment`, `sys_comment_reaction` or `sys_attachment` is served as before. A door that binds `getReadFilter` directly, such as the analytics raw-SQL path, scopes a delegated context to the intersection now; as accepted it denied that context outright. + +**Unchanged.** `controlled_by_parent`'s own read scope (ADR-0055 §2 mechanism (b), the pre-resolved id set) and the nested master chain, which keep their own cards; the delegated `find`, `findOne`, `count` and `aggregate` through the engine middleware, which already applied the intersection. diff --git a/docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md b/docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md index 5e33eb30a9a..595013d8e5a 100644 --- a/docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md +++ b/docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md @@ -323,7 +323,7 @@ and none changes `packages/spec` before it. | Card | Decisions | What lands | Pins (red before, green after) | Order | |:--|:--|:--|:--|:--| | **E1** — the leaf | D1 | `ReadableIds` in `packages/spec/src/data/`; the comparand door admits the branded value and refuses the unbranded one | a JSON round-trip of a `where` holding the leaf is refused; the plain-object spelling is refused 400 | first | -| **E2** — composition | D2, D5 | the engine seam composes the leaf, refuses it on write verbs and for D5's three classes, marks the scope `'policy'`; the conformance case-set | `canReadObject` false ⇒ no row; deny sentinel ⇒ no row; a delegated, middleware-gated or federated parent ⇒ the loud refusal | after E1 | +| **E2** — composition | D2, D5 | the engine seam composes the leaf, refuses it on write verbs and for D5's three classes as accepted (D5.1 amended 2026-10-21: the delegated caller composes), marks the scope `'policy'`; the conformance case-set | `canReadObject` false ⇒ no row; deny sentinel ⇒ no row; a delegated, middleware-gated or federated parent ⇒ the loud refusal | after E1 | | **E3** — SQL family | D3 | `driver-sql` compiles the leaf inside `packages/drivers/driver-sql/src/sql-driver.ts#applyTenantScope`; wasm and local Turso inherit; the remote transport compiles it | a reader with more than 2,000 readable candidates gets every visible row; an unreadable row stays hidden; a deleted parent is excluded; `count` equals the rows served | after E2 | | **E4** — memory and mongodb | D3 | memory implements; mongodb refuses with the envelope | the conformance table records both | with E3 | | **E5** — the activity and ledger switch | D4, D5 | `packages/plugins/plugin-audit/src/parent-record-read-gate.ts#computeParentRecordFilter` emits leaves, no pre-scan; D5's classes keep the probe; letter C's refusal retired from these two | the measured scenario answers 1,808 of 1,808; the exemption capability unchanged; the outside class as a predicate | after E3 | diff --git a/packages/plugins/plugin-security/src/default-report-sink.test.ts b/packages/plugins/plugin-security/src/default-report-sink.test.ts index 11cd651754e..a31d907dccb 100644 --- a/packages/plugins/plugin-security/src/default-report-sink.test.ts +++ b/packages/plugins/plugin-security/src/default-report-sink.test.ts @@ -22,8 +22,10 @@ * Case 1 drives a REAL report site on a bare instance, so the pin is about what * an operator sees rather than about a field's contents. `getReadFilter` with an * on-behalf-of context is the one refusal reachable with no `ql`, no `metadata` - * and no `start()`: it fails closed on the unimplemented D10 delegator - * intersection (#2852) and reports through `this.logger.error?.()`. + * and no `start()`: the ADR-0090 D10 intersection it composes (ADR-0139 D5.1 as + * amended) needs the delegator's `sys_user` row, which a bare instance cannot + * read, so the link names a delegator who does not exist and the read fails + * closed on that dangling link, reporting through `this.logger.error?.()`. * * Case 2 pins the channel the LEDGER row is about. `error` stays optional by * #9754's own ruling (hosts inject reduced sinks); `warn` is the channel a diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 91c7995fe72..b3af6bf43f2 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -6040,8 +6040,9 @@ export class SecurityPlugin implements Plugin { if (del.kind !== 'resolved') { this.logger.error?.( `[security] getReadFilter received an on-behalf-of context for object '${object}' whose delegator ` + - `'${String(context?.onBehalfOf?.userId)}' does not exist — denying (fail-closed: ADR-0090 D10, a ` + - `delegated read with no delegator to intersect against is never scoped)`, + `'${String(context?.onBehalfOf?.userId)}' does not exist — denying (fail-closed: a delegated read is ` + + `never scoped wider than its delegator's own, and with no delegator to intersect against it is not ` + + `scoped at all; ADR-0090 D10)`, ); return RLS_DENY_FILTER; } diff --git a/packages/plugins/plugin-security/src/start-logger-binding.test.ts b/packages/plugins/plugin-security/src/start-logger-binding.test.ts index f24cdf9a4b9..117e5be3491 100644 --- a/packages/plugins/plugin-security/src/start-logger-binding.test.ts +++ b/packages/plugins/plugin-security/src/start-logger-binding.test.ts @@ -77,11 +77,13 @@ const sinkOf = (plugin: SecurityPlugin) => (plugin as any).logger; * Drive a REAL report through `this.logger` and return whether it landed. * * `getReadFilter` is a public instance method whose on-behalf-of refusal - * (`this.logger.error?.(…)`, the ADR-0095/#2852 fail-closed branch) depends on - * NONE of the handles captured below the bail-outs — no `this.ql`, no - * `this.metadata`. That makes it the one report site that is genuinely - * reachable on a bailed-out instance, which is what makes it the right probe - * for "the sink RECEIVES something". + * (`this.logger.error?.(…)`, the ADR-0090 D10 fail-closed branch for a + * delegator who does not exist — on a bailed-out instance the delegator's + * `sys_user` row cannot be read, so the link dangles) depends on NONE of the + * handles captured below the bail-outs — no `this.ql`, no `this.metadata`. + * That makes it the one report site that is genuinely reachable on a + * bailed-out instance, which is what makes it the right probe for "the sink + * RECEIVES something". * * Pre-fix this call is silent: `{}.error` is `undefined` and `?.()` no-ops. */ From 4c31b7cc93dc2ca485f32bc3c17f1c09b0f3164b Mon Sep 17 00:00:00 2001 From: "objectstack-fleet[bot]" <332303061+objectstack-fleet[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:37:25 +0000 Subject: [PATCH 4/8] fix(plugin-security): the dangling-delegator report line keeps the D10 invariant sentence verbatim, as the report-sink pin reads it Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude --- packages/plugins/plugin-security/src/security-plugin.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index b3af6bf43f2..caf66c649f1 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -6041,8 +6041,8 @@ export class SecurityPlugin implements Plugin { this.logger.error?.( `[security] getReadFilter received an on-behalf-of context for object '${object}' whose delegator ` + `'${String(context?.onBehalfOf?.userId)}' does not exist — denying (fail-closed: a delegated read is ` + - `never scoped wider than its delegator's own, and with no delegator to intersect against it is not ` + - `scoped at all; ADR-0090 D10)`, + `never scoped wider than its delegator's own) — with no delegator to intersect against it is not ` + + `scoped at all (ADR-0090 D10)`, ); return RLS_DENY_FILTER; } From 2a8d0d5b4132ea6dacecbb0bdb73f1dfc59b5ad1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 02:45:02 +0000 Subject: [PATCH 5/8] docs(adr): ADR-0139 marks O5 answered and the E2 pins follow the amended D5.1; the ancestors docblock counts six call sites - Open items: O5 is marked answered, pointing at D6 where the ruling is recorded. - E2 row, Pins cell: a delegated caller composes (both halves asked with the link intact, the closed doors still `$in: []`); a middleware-gated or federated parent keeps the loud refusal, a delegated context included. - security-plugin.ts: computeControlledByParentFilter's `ancestors` docblock counts the six call sites (five with empty ancestors, the recursive hop the sixth). Comment only. Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude --- ...9-engine-internal-semi-join-leaf-readable-parent-ids.md | 5 +++-- packages/plugins/plugin-security/src/security-plugin.ts | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md b/docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md index 595013d8e5a..aed87191b9e 100644 --- a/docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md +++ b/docs/adr/0139-engine-internal-semi-join-leaf-readable-parent-ids.md @@ -308,7 +308,8 @@ here. - **O3** — `driver-mongodb`: the cost of a pipeline form is unprototyped; it refuses until measured. - **O4** — `packages/plugins/plugin-sharing/src/sharing-service.ts` reads a caller's record-share grants at `limit: 5000` with no warning; that scope rides into the subquery unchanged. A separate finding, not this record's. -- **O5** — whether `controlled_by_parent`'s id set becomes a seventh consumer (D6). +- **O5** — whether `controlled_by_parent`'s id set becomes a seventh consumer (D6). **Answered** (2026-10-21): the + ruling is recorded verbatim in D6, as amended. ## Acceptance criteria @@ -323,7 +324,7 @@ and none changes `packages/spec` before it. | Card | Decisions | What lands | Pins (red before, green after) | Order | |:--|:--|:--|:--|:--| | **E1** — the leaf | D1 | `ReadableIds` in `packages/spec/src/data/`; the comparand door admits the branded value and refuses the unbranded one | a JSON round-trip of a `where` holding the leaf is refused; the plain-object spelling is refused 400 | first | -| **E2** — composition | D2, D5 | the engine seam composes the leaf, refuses it on write verbs and for D5's three classes as accepted (D5.1 amended 2026-10-21: the delegated caller composes), marks the scope `'policy'`; the conformance case-set | `canReadObject` false ⇒ no row; deny sentinel ⇒ no row; a delegated, middleware-gated or federated parent ⇒ the loud refusal | after E1 | +| **E2** — composition | D2, D5 | the engine seam composes the leaf, refuses it on write verbs and for D5's three classes as accepted (D5.1 amended 2026-10-21: the delegated caller composes), marks the scope `'policy'`; the conformance case-set | `canReadObject` false ⇒ no row; deny sentinel ⇒ no row; a delegated caller composes (both halves asked with the delegation link intact, the closed doors still `$in: []`); a middleware-gated or federated parent ⇒ the loud refusal, a delegated context included | after E1 | | **E3** — SQL family | D3 | `driver-sql` compiles the leaf inside `packages/drivers/driver-sql/src/sql-driver.ts#applyTenantScope`; wasm and local Turso inherit; the remote transport compiles it | a reader with more than 2,000 readable candidates gets every visible row; an unreadable row stays hidden; a deleted parent is excluded; `count` equals the rows served | after E2 | | **E4** — memory and mongodb | D3 | memory implements; mongodb refuses with the envelope | the conformance table records both | with E3 | | **E5** — the activity and ledger switch | D4, D5 | `packages/plugins/plugin-audit/src/parent-record-read-gate.ts#computeParentRecordFilter` emits leaves, no pre-scan; D5's classes keep the probe; letter C's refusal retired from these two | the measured scenario answers 1,808 of 1,808; the exemption capability unchanged; the outside class as a predicate | after E3 | diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index caf66c649f1..ae6ea4afab5 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -8743,9 +8743,10 @@ export class SecurityPlugin implements Plugin { context: any, /** * [ADR-0055 amendment] The `controlled_by_parent` objects already being resolved on - * this branch of the walk, outermost first. Empty at every real call site - * — the four are the CRUD middleware (caller and D10 delegator) and - * `getReadFilter` — and grown by one on each recursive hop. + * this branch of the walk, outermost first. Empty at five of the six call + * sites — the CRUD middleware (caller and D10 delegator), `getReadFilter` + * and `computeDelegatedReadFilter` (principal and delegator) — and grown by + * one at the sixth, the recursive hop. */ ancestors: readonly string[] = [], ): Promise | null> { From 46905c05d13cb6616cdb8d07cfda32f3d58bd480 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 04:06:35 +0000 Subject: [PATCH 6/8] =?UTF-8?q?feat(objectql):=20the=20ReadableIds=20leaf?= =?UTF-8?q?=20inside=20a=20composed=20scope=20is=20composed=20too=20?= =?UTF-8?q?=E2=80=94=20the=20nested=20master=20chain=20(WIP:=20pins=20and?= =?UTF-8?q?=20real-stack=20measurement=20unrun)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 --- .../25010-readable-ids-nested-compose.md | 15 + ...ne-readable-ids-nested-composition.test.ts | 409 +++++++++++++++ packages/objectql/src/engine.ts | 159 +++++- .../objectql/src/filter-comparand-shape.ts | 11 +- .../src/readable-ids-nested-compose.test.ts | 475 ++++++++++++++++++ 5 files changed, 1056 insertions(+), 13 deletions(-) create mode 100644 .changeset/25010-readable-ids-nested-compose.md create mode 100644 packages/objectql/src/engine-readable-ids-nested-composition.test.ts create mode 100644 packages/plugins/plugin-security/src/readable-ids-nested-compose.test.ts 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..86d42dcfcf6 --- /dev/null +++ b/packages/objectql/src/engine-readable-ids-nested-composition.test.ts @@ -0,0 +1,409 @@ +// 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; + }); + + const readVerbs = (where: Record, context?: unknown) => [ + ['find', () => engine.find('rid_child', { where, context } as any)], + ['findOne', () => engine.findOne('rid_child', { where, context } as any)], + ['count', () => engine.count('rid_child', { where, context } as any)], + ['aggregate', () => engine.aggregate('rid_child', { where, aggregations: COUNT_AGG as any, 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 } = chainSecurity(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 2879fdae7f2..455faa95a5f 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..f74f0a08e45 --- /dev/null +++ b/packages/plugins/plugin-security/src/readable-ids-nested-compose.test.ts @@ -0,0 +1,475 @@ +// 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' }); +const touchesChain = (sql: string) => sql.includes(PARENT) || sql.includes(GRAND); + +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, the parent and the grandparent inside nested subqueries — 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 chain = statements.filter(touchesChain); + expect(chain, `${verb}: ${statements.join('\n')}`).toHaveLength(1); + expect(chain[0], verb).toMatch(new RegExp(`from .${CHILD_LEAF}.`, 'i')); + expect(chain[0].match(/in \(select/gi) ?? [], `${verb}: ${chain[0]}`).toHaveLength(2); + expect(chain[0], verb).toMatch(new RegExp(`from .${PARENT}.`, 'i')); + expect(chain[0], verb).toMatch(new RegExp(`from .${GRAND}.`, 'i')); + } + // Today's path pre-scans the grandparent and then the parent under a + // system context before the child's own statement: more than one statement + // touches the chain per find, and none is a subquery. + const today = (await rig.statementsDuring(() => rig.engine.find(CHILD_CBP, { context: { ...context } } as never))).filter(touchesChain); + expect(today.length).toBeGreaterThan(1); + expect(today.some((sql) => /in \(select/i.test(sql))).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', + ]); + }); +}); From 9cee19a5dd177052175190be205cf9e37278d238 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 04:20:21 +0000 Subject: [PATCH 7/8] test(objectql, plugin-security): the nested-chain pins read the composed read's own statement and spell an undefined grandparent scope explicitly Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 --- ...ne-readable-ids-nested-composition.test.ts | 2 +- .../src/readable-ids-nested-compose.test.ts | 49 +++++++++++++------ 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/packages/objectql/src/engine-readable-ids-nested-composition.test.ts b/packages/objectql/src/engine-readable-ids-nested-composition.test.ts index 86d42dcfcf6..0066531d161 100644 --- a/packages/objectql/src/engine-readable-ids-nested-composition.test.ts +++ b/packages/objectql/src/engine-readable-ids-nested-composition.test.ts @@ -209,7 +209,7 @@ describe('[ADR-0139 D6 as amended] a leaf inside a composed scope is composed to }); 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 } = chainSecurity(undefined); + 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; 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 index f74f0a08e45..cce5a12e950 100644 --- a/packages/plugins/plugin-security/src/readable-ids-nested-compose.test.ts +++ b/packages/plugins/plugin-security/src/readable-ids-nested-compose.test.ts @@ -368,7 +368,10 @@ async function boot() { type Rig = Awaited>; const member = (userId: string, set: string) => ({ userId, positions: [], permissions: [set], posture: 'MEMBER' }); -const touchesChain = (sql: string) => sql.includes(PARENT) || sql.includes(GRAND); +/** 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; @@ -417,26 +420,44 @@ describe('[ADR-0139 D6 as amended] a two-level controlled_by_parent chain compos 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, the parent and the grandparent inside nested subqueries — against the pre-scans of today\'s path', async () => { + 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 chain = statements.filter(touchesChain); - expect(chain, `${verb}: ${statements.join('\n')}`).toHaveLength(1); - expect(chain[0], verb).toMatch(new RegExp(`from .${CHILD_LEAF}.`, 'i')); - expect(chain[0].match(/in \(select/gi) ?? [], `${verb}: ${chain[0]}`).toHaveLength(2); - expect(chain[0], verb).toMatch(new RegExp(`from .${PARENT}.`, 'i')); - expect(chain[0], verb).toMatch(new RegExp(`from .${GRAND}.`, 'i')); + 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 pre-scans the grandparent and then the parent under a - // system context before the child's own statement: more than one statement - // touches the chain per find, and none is a subquery. - const today = (await rig.statementsDuring(() => rig.engine.find(CHILD_CBP, { context: { ...context } } as never))).filter(touchesChain); - expect(today.length).toBeGreaterThan(1); - expect(today.some((sql) => /in \(select/i.test(sql))).toBe(false); + // 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); }); }); From 549f0bcc89068b9dcf81ff8efc403d64f35b43f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 05:08:13 +0000 Subject: [PATCH 8/8] test(objectql): the nested-chain pins cast the context value, not the options object (the query-options-erasure ratchet counts an erased options argument) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 --- .../src/engine-readable-ids-nested-composition.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/objectql/src/engine-readable-ids-nested-composition.test.ts b/packages/objectql/src/engine-readable-ids-nested-composition.test.ts index 0066531d161..4b1413bdc4c 100644 --- a/packages/objectql/src/engine-readable-ids-nested-composition.test.ts +++ b/packages/objectql/src/engine-readable-ids-nested-composition.test.ts @@ -144,11 +144,13 @@ describe('[ADR-0139 D6 as amended] a leaf inside a composed scope is composed to 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 } as any)], - ['findOne', () => engine.findOne('rid_child', { where, context } as any)], - ['count', () => engine.count('rid_child', { where, context } as any)], - ['aggregate', () => engine.aggregate('rid_child', { where, aggregations: COUNT_AGG as any, context } as any)], + ['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. */