Skip to content

Commit 2ffe7d2

Browse files
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 <noreply@anthropic.com>
1 parent 62bc129 commit 2ffe7d2

2 files changed

Lines changed: 161 additions & 55 deletions

File tree

‎packages/objectql/src/engine.ts‎

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6140,11 +6140,11 @@ export class ObjectQL implements IObjectQLEngine {
61406140
*
61416141
* Per parent object `P`, once per call, in this order:
61426142
*
6143-
* 1. **D5's three classes are refused, by name** (`INTERNAL_ERROR` / 500),
6144-
* before the security service is asked anything: a delegated
6145-
* (on-behalf-of) caller; a parent an object-scoped read middleware
6146-
* narrows ({@link hasObjectMiddleware}); a parent bound to another
6147-
* datasource than `object` ({@link resolveDatasourceBinding}).
6143+
* 1. **D5's two remaining classes are refused, by name** (`INTERNAL_ERROR`
6144+
* / 500), before the security service is asked anything: a parent an
6145+
* object-scoped read middleware narrows ({@link hasObjectMiddleware}); a
6146+
* parent bound to another datasource than `object`
6147+
* ({@link resolveDatasourceBinding}).
61486148
* 2. **A parent the registry does not hold** ⇒ `$in: []`, unasked.
61496149
* 3. **The security service** — absent or unusable ⇒ `$in: []`.
61506150
* 4. **`canReadObject(P)`** (its `explain` fallback when the method is
@@ -6155,6 +6155,17 @@ export class ObjectQL implements IObjectQLEngine {
61556155
* `'policy'` and otherwise passed exactly as `P`'s own read passes its
61566156
* injected scope. The tenant wall is the driver's (D3).
61576157
*
6158+
* [ADR-0139 D5.1, as amended] A **delegated (on-behalf-of) caller** is
6159+
* composed, not refused: both halves are asked with the caller's context
6160+
* exactly as it stands — `onBehalfOf` intact — and the security service
6161+
* answers each half for the delegated principal, which under ADR-0090 D10
6162+
* is the INTERSECTION of the principal's leg and the delegator's. The
6163+
* engine composes what it is answered and derives no second intersection
6164+
* of its own: it has one context, the delegated one, and the delegator's
6165+
* grants are the security service's to resolve. A service that cannot
6166+
* answer that context answers the deny sentinel, and step 5 turns it into
6167+
* `$in: []`, closed.
6168+
*
61586169
* Nothing is memoised across calls. `aggregate`'s `having` and its
61596170
* aggregations' own filters are judged here too, so a leaf a middleware
61606171
* wrote there is refused rather than evaluated in memory.
@@ -6174,7 +6185,7 @@ export class ObjectQL implements IObjectQLEngine {
61746185
}
61756186
const sites = collectComposableReadableIds(object, operation, ast.where, 'where');
61766187
if (sites.length === 0) return ast;
6177-
const parents = this.refuseUncomposableReadableIdsClasses(object, operation, sites, context);
6188+
const parents = this.refuseUncomposableReadableIdsClasses(object, operation, sites);
61786189

61796190
const security = this.resolveReadableIdsSecurity();
61806191
if (security.kind !== 'usable' && !this.readableIdsSecurityReported.has(security.kind)) {
@@ -6202,17 +6213,18 @@ export class ObjectQL implements IObjectQLEngine {
62026213
}
62036214

62046215
/**
6205-
* [ADR-0139 D5] Refuse D5's three classes, by name, and return the distinct
6206-
* parent objects of `sites` the security service is asked about, in walk
6207-
* order (every one but a parent the registry does not hold). Reads the
6208-
* context, the registry and the middleware registrations only — no security
6209-
* call.
6216+
* [ADR-0139 D5] Refuse D5's two remaining classes (D5.2 and D5.3), by name,
6217+
* and return the distinct parent objects of `sites` the security service is
6218+
* asked about, in walk order (every one but a parent the registry does not
6219+
* hold). Reads the registry and the middleware registrations only — no
6220+
* security call, and no reading of the caller's context: a delegated
6221+
* (on-behalf-of) caller is no longer a class of its own (D5.1, as amended),
6222+
* it is composed like any other through the security service's answers.
62106223
*/
62116224
private refuseUncomposableReadableIdsClasses(
62126225
object: string,
62136226
operation: string,
62146227
sites: readonly ComposableReadableIdsSite[],
6215-
context: ExecutionContext | undefined,
62166228
): string[] {
62176229
const firstSite = new Map<string, ComposableReadableIdsSite>();
62186230
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 {
62226234
`such leaf ${why}. The query was NOT run and no driver was asked. Read this class through its bounded ` +
62236235
'probe, chosen before the leaf is written.',
62246236
);
6225-
const delegator = (context as { onBehalfOf?: { userId?: unknown } } | undefined)?.onBehalfOf?.userId;
6226-
if (delegator) {
6227-
throw refuse(sites[0], 'for a delegated (on-behalf-of) caller: the parent\'s read scope does not carry the ' +
6228-
'delegator intersection, so the composed leaf would not answer what a direct read answers');
6229-
}
62306237
const outer = this.resolveDatasourceBinding(object)?.datasource;
62316238
const composable: string[] = [];
62326239
for (const [parent, site] of firstSite) {

‎packages/plugins/plugin-security/src/security-plugin.ts‎

Lines changed: 137 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)