@@ -5421,6 +5421,14 @@ export class SecurityPlugin implements Plugin {
54215421 object: string,
54225422 context: any,
54235423 resolvedSets?: PermissionSet[],
5424+ /**
5425+ * [ADR-0090 D10] The depth to stamp instead of the one `getEffectiveScope`
5426+ * answers for `resolvedSets` — what step 2.6 stamps on a DELEGATED read,
5427+ * where the agent leg carries the intersected depth and the delegator leg
5428+ * its own ({@link computeDelegatedReadFilter}). Absent on every
5429+ * non-delegated call, which resolves the depth here as before.
5430+ */
5431+ depth?: string,
54245432 ): Promise<Record<string, unknown> | null> {
54255433 const sharing = this.resolveKernelService?.('sharing') as
54265434 | { buildReadFilter?: (o: string, c: any) => Promise<unknown | null> }
@@ -5432,23 +5440,25 @@ export class SecurityPlugin implements Plugin {
54325440 // [#5386] `resolvedSets` lets an in-middleware caller (the
54335441 // controlled_by_parent derivation) pass the sets it already resolved for
54345442 // THIS identity, instead of re-resolving them from the context.
5435- let readScope: string | undefined;
5436- try {
5437- const permissionSets = resolvedSets ?? (await this.resolvePermissionSetsForContext(context));
5438- if (permissionSets.length > 0) {
5439- const meta = await this.getObjectSecurityMeta(object);
5440- readScope = this.permissionEvaluator.getEffectiveScope(
5441- 'read',
5442- object,
5443- permissionSets,
5444- { isPrivate: meta.isPrivate },
5445- );
5443+ let readScope: string | undefined = depth;
5444+ if (readScope === undefined) {
5445+ try {
5446+ const permissionSets = resolvedSets ?? (await this.resolvePermissionSetsForContext(context));
5447+ if (permissionSets.length > 0) {
5448+ const meta = await this.getObjectSecurityMeta(object);
5449+ readScope = this.permissionEvaluator.getEffectiveScope(
5450+ 'read',
5451+ object,
5452+ permissionSets,
5453+ { isPrivate: meta.isPrivate },
5454+ );
5455+ }
5456+ } catch {
5457+ // Depth is a WIDENING input: failing to resolve it leaves the owner-match
5458+ // at its narrowest ('own'), which is the safe direction. The sharing call
5459+ // below still runs — and its own failure still denies.
5460+ readScope = undefined;
54465461 }
5447- } catch {
5448- // Depth is a WIDENING input: failing to resolve it leaves the owner-match
5449- // at its narrowest ('own'), which is the safe direction. The sharing call
5450- // below still runs — and its own failure still denies.
5451- readScope = undefined;
54525462 }
54535463 const filter = await sharing.buildReadFilter(object, {
54545464 ...context,
@@ -5861,6 +5871,14 @@ export class SecurityPlugin implements Plugin {
58615871 * 3. {@link resolveSharingReadFilter} — plugin-sharing's OWD / record-share
58625872 * visibility filter, contributed by the sibling middleware.
58635873 *
5874+ * [ADR-0090 D10] On a DELEGATED (on-behalf-of) context the chain composes
5875+ * each of the three twice — once for the delegated principal's leg, once for
5876+ * the delegator's — and so does this method ({@link computeDelegatedReadFilter}):
5877+ * the delegated read is scoped to the intersection, exactly as a delegated
5878+ * `find` is. ADR-0139 D5.1 named that intersection reaching this method as
5879+ * the condition under which the engine composes its `ReadableIds` leaf for a
5880+ * delegated caller; it does, so the engine does.
5881+ *
58645882 * Each layer has been missing here at some point, and each absence had the
58655883 * same shape: a caller who cannot read a row through `/data` could still
58665884 * `COUNT(*)` / `GROUP BY` it through `/analytics`. Layer 3 was #4467; layer 2
@@ -5899,6 +5917,28 @@ export class SecurityPlugin implements Plugin {
58995917 // refusal, mirrored: zero rows, read off the same predicate, before any
59005918 // layer resolves. This used to answer the sharing predicate alone.
59015919 if (isPrincipalLessContext(context)) return RLS_DENY_FILTER;
5920+ // [ADR-0090 D10 / ADR-0139 D5.1 as amended] A DELEGATED (on-behalf-of)
5921+ // context is scoped to the intersection of its own leg and its delegator's,
5922+ // composed as the engine middleware chain composes a delegated find
5923+ // ({@link computeDelegatedReadFilter}). This path used to answer the deny
5924+ // sentinel for every such context, because the intersection was threaded
5925+ // through the chain only; the engine's `ReadableIds` composition asks this
5926+ // method with the delegated context as it stands, so the intersection
5927+ // reaches here now. Fails CLOSED on every throw: a delegated read is never
5928+ // scoped wider than its delegator's own, and never served with a layer
5929+ // dropped.
5930+ if (context?.onBehalfOf?.userId) {
5931+ try {
5932+ return await this.computeDelegatedReadFilter(object, context);
5933+ } catch (e) {
5934+ this.logger.error?.(
5935+ `[security] getReadFilter failed for object '${object}' (principal ${context?.userId ?? 'unknown'} ` +
5936+ `on behalf of ${context.onBehalfOf.userId}) — denying (fail-closed)`,
5937+ e instanceof Error ? e : new Error(String(e)),
5938+ );
5939+ return RLS_DENY_FILTER;
5940+ }
5941+ }
59025942 // [#4467] The OWD/sharing predicate is resolved for EVERY non-system caller,
59035943 // ahead of the RLS branches below, because it is a SEPARATE middleware in
59045944 // the chain this method mirrors: none of the RLS stand-downs below is a
@@ -5916,28 +5956,6 @@ export class SecurityPlugin implements Plugin {
59165956 );
59175957 return RLS_DENY_FILTER;
59185958 }
5919- // [#2852] D10 delegator intersection is NOT implemented on this path.
5920- // The engine middleware (find/count/aggregate) intersects an on-behalf-of
5921- // read with the DELEGATOR's own RLS (resolveDelegatorContext, ~L689), but
5922- // getReadFilter — the read-scope provider bound by the analytics/raw-SQL
5923- // path — resolves only the CALLER's ceiling. Computing a filter here for a
5924- // delegated (agent) context would therefore SILENTLY WIDEN the read past
5925- // the delegator's scope (the confused-deputy widening D10 prevents on the
5926- // CRUD path). Until the intersection is threaded through computeRlsFilter
5927- // (tracked with #2920 B1 / ADR-0095 D1), FAIL CLOSED: a delegated read on
5928- // this path denies rather than under-scopes. System on-behalf-of already
5929- // returned above; today no agent surface reaches analytics, so this is a
5930- // latent-invariant guard, not a live-traffic change.
5931- if (context?.onBehalfOf?.userId) {
5932- this.logger.error?.(
5933- `[security] getReadFilter received an on-behalf-of context for object ` +
5934- `'${object}' (agent ${context?.userId ?? 'unknown'} on behalf of ` +
5935- `${context.onBehalfOf.userId}) — the D10 delegator intersection is not ` +
5936- `implemented on the read-scope path; denying (fail-closed: a delegated read is never scoped ` +
5937- `wider than its delegator's own)`,
5938- );
5939- return RLS_DENY_FILTER;
5940- }
59415959 try {
59425960 const permissionSets = await this.resolvePermissionSetsForContext(context);
59435961 // [#21079] ADR-0056 D2 — the deny baseline at the row scope. A caller who
@@ -5978,6 +5996,87 @@ export class SecurityPlugin implements Plugin {
59785996 }
59795997 }
59805998
5999+ /**
6000+ * [ADR-0090 D10 / ADR-0139 D5.1 as amended] The read scope of a DELEGATED
6001+ * (on-behalf-of) principal: the INTERSECTION of the principal's own leg and
6002+ * the delegator's — never the union — composed here exactly as the engine
6003+ * middleware chain composes a delegated `find` of `object`, layer for layer:
6004+ *
6005+ * 1. {@link computeRlsFilter} over the principal's sets, with the delegated
6006+ * context AS IT STANDS — {@link collectRLSPolicies} reads that context as
6007+ * the delegated principal's own leg (`isDelegatedPrincipalLeg`), so the
6008+ * ceiling's silence contributes no object default row scope;
6009+ * 2. {@link computeControlledByParentFilter} for the principal;
6010+ * 3. {@link computeRlsFilter} over the DELEGATOR's sets, with the context
6011+ * {@link resolveDelegatorContext} rebuilds for them (the middleware's
6012+ * D10 half, step 3);
6013+ * 4. {@link computeControlledByParentFilter} for the delegator;
6014+ * 5. plugin-sharing's visibility filter for the principal's identity, at
6015+ * the agent-leg depth step 2.6 stamps as `__readScope`
6016+ * (`intersectDelegatedScope` of the ceiling's DECLARED depth and the
6017+ * delegator's effective one);
6018+ * 6. the same filter re-run under the DELEGATOR's identity at the
6019+ * delegator's own depth — plugin-sharing's second call, with `userId`
6020+ * swapped to the delegator and no delegation link.
6021+ *
6022+ * Fails CLOSED where the chain refuses, with the deny sentinel by identity:
6023+ * a principal that resolves no set (ADR-0056 D2); a delegator the link names
6024+ * who does not exist (the chain's `PERMISSION_DENIED`; `canReadObject`'s
6025+ * `false`); a delegator who resolves no set (object admission refuses them);
6026+ * a sharing scope that cannot be resolved. A throw anywhere propagates to the
6027+ * caller's catch, which denies.
6028+ *
6029+ * ⛔ Not a second intersection rule: every leg is the primitive the
6030+ * middleware calls, in the middleware's spelling, and the delegator's context
6031+ * comes from the one resolver the middleware and `canReadObject` share.
6032+ */
6033+ private async computeDelegatedReadFilter(
6034+ object: string,
6035+ context: any,
6036+ ): Promise<Record<string, unknown> | undefined> {
6037+ const permissionSets = await this.resolvePermissionSetsForContext(context);
6038+ if (permissionSets.length === 0) return RLS_DENY_FILTER;
6039+ const del = await resolveDelegatorContext(this.ql, context);
6040+ if (del.kind !== 'resolved') {
6041+ this.logger.error?.(
6042+ `[security] getReadFilter received an on-behalf-of context for object '${object}' whose delegator ` +
6043+ `'${String(context?.onBehalfOf?.userId)}' does not exist — denying (fail-closed: ADR-0090 D10, a ` +
6044+ `delegated read with no delegator to intersect against is never scoped)`,
6045+ );
6046+ return RLS_DENY_FILTER;
6047+ }
6048+ const delegatorContext = del.context;
6049+ const delegatorSets = await this.resolvePermissionSetsForContext(delegatorContext);
6050+ if (delegatorSets.length === 0) return RLS_DENY_FILTER;
6051+
6052+ // Step 2.6's two depth stamps, from the same two evaluator calls.
6053+ const meta = await this.getObjectSecurityMeta(object);
6054+ const delegatorScope = this.permissionEvaluator.getEffectiveScope('read', object, delegatorSets, { isPrivate: meta.isPrivate });
6055+ const declared = this.permissionEvaluator.getDeclaredScope('read', object, permissionSets, { isPrivate: meta.isPrivate });
6056+ const agentLegScope = intersectDelegatedScope(declared, delegatorScope).agentLegScope;
6057+
6058+ // Layers 5 and 6 — the sibling middleware's two calls. Resolved first, as
6059+ // the non-delegated path resolves its one sharing layer first: a sharing
6060+ // outage denies before any RLS layer is composed.
6061+ const sharingFilter = await this.resolveSharingReadFilter(object, context, permissionSets, agentLegScope);
6062+ const delegatorSharingFilter = await this.resolveSharingReadFilter(
6063+ object,
6064+ { ...context, userId: delegatorContext.userId, onBehalfOf: undefined },
6065+ delegatorSets,
6066+ delegatorScope,
6067+ );
6068+
6069+ // Layers 1 to 4 — the security middleware's own four, in its order.
6070+ const filter = await this.computeRlsFilter(permissionSets, object, 'find', context);
6071+ const cbpFilter = await this.computeControlledByParentFilter(permissionSets, object, context);
6072+ const delFilter = await this.computeRlsFilter(delegatorSets, object, 'find', delegatorContext);
6073+ const delCbpFilter = await this.computeControlledByParentFilter(delegatorSets, object, delegatorContext);
6074+
6075+ const securityLayers = andComposeLayers(andComposeLayers(filter, cbpFilter), andComposeLayers(delFilter, delCbpFilter));
6076+ const sharingLayers = andComposeLayers(sharingFilter, delegatorSharingFilter);
6077+ return andComposeLayers(securityLayers, sharingLayers) ?? undefined;
6078+ }
6079+
59816080 /**
59826081 * [#3547] Query surface: the field names the caller MAY READ on `object`
59836082 * under `context`. This is the authoritative column projection for a
0 commit comments