Skip to content

Commit 412f6ca

Browse files
committed
fix(plugin-security): an on-behalf-of by-id write never exceeds the delegator's own write
ADR-0090 D10: a delegated principal never exceeds its delegator. Each delegated row gate of a by-id write (the step 2.7 pre-image, the step 3.6 post-image check and the master gate's delegator pass) now ANDs the delegator's own direct composition of that write beside the composition the delegated path already used. The AND only narrows. Explain's record verdict composes the same two filters for the delegator. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
1 parent 795ff70 commit 412f6ca

2 files changed

Lines changed: 175 additions & 12 deletions

File tree

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

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import { superuserBypassBitForOperation } from './permission-evaluator.js';
5757
import { rlsOperationForVerb } from './lifecycle-verb-rls-operation.js';
5858
import { ExplainObjectNotFoundError } from './errors.js';
5959
import { RLS_DENY_FILTER, compiledObjectDefaultRowScopeOf, compiledPolicyNameOf } from './rls-compiler.js';
60+
import { andComposeLayers } from './tenant-layer.js';
6061
import { declaredComparisonColumns } from './declared-comparison-columns.js';
6162
import {
6263
declaredJsonStoredColumns,
@@ -1414,6 +1415,41 @@ interface RecordAttributionContext {
14141415
delegator?: { sets: PermissionSet[]; context: any };
14151416
}
14161417

1418+
/**
1419+
* [ADR-0090 D10] The delegator's write filter an on-behalf-of by-id write's
1420+
* pre-image re-read (step 2.7) ANDs beside the principal's, as the door
1421+
* composes it — two compositions of the delegator's own sets and context,
1422+
* ANDed:
1423+
*
1424+
* - {@link ExplainEngineDeps.computeRlsFilter}: the delegator's filter as the
1425+
* delegated path composes it (the floor standing);
1426+
* - {@link ExplainEngineDeps.computeLayeredRlsFilter}: the delegator's OWN
1427+
* record-grained write composition — the one the delegator's own
1428+
* explanation of this record is judged on, carrying the floor decision the
1429+
* delegator's own by-id write of the record gets.
1430+
*
1431+
* So an on-behalf-of record write verdict never admits a row the delegator's
1432+
* own record write verdict refuses. The AND only narrows. An engine that
1433+
* cannot split the layers composes the first alone, as its record story does
1434+
* for every principal.
1435+
*/
1436+
async function delegatorRecordWriteFilter(
1437+
deps: ExplainEngineDeps,
1438+
delegator: { sets: PermissionSet[]; context: any },
1439+
object: string,
1440+
rlsOp: string,
1441+
): Promise<Record<string, unknown> | null> {
1442+
const parts: Record<string, unknown>[] = [];
1443+
const delegated = await deps.computeRlsFilter(delegator.sets, object, rlsOp, delegator.context);
1444+
if (delegated) parts.push(delegated);
1445+
const own = deps.computeLayeredRlsFilter
1446+
? await deps.computeLayeredRlsFilter(delegator.sets, object, rlsOp, delegator.context)
1447+
: undefined;
1448+
const ownFilter = own ? andComposeLayers(own.layer0, own.layer1) : null;
1449+
if (ownFilter) parts.push(ownFilter);
1450+
return parts.length === 0 ? null : parts.length === 1 ? parts[0] : { $and: parts };
1451+
}
1452+
14171453
/**
14181454
* [C2 / ADR-0095] Fill the per-record row story onto the pipeline. Prepends the
14191455
* `tenant_isolation` Layer 0, tags every layer with its `kernelTier`, attaches a
@@ -1478,13 +1514,13 @@ async function applyRecordAttribution(
14781514
const layer1 = layered?.layer1;
14791515
// [#24646 / ADR-0090 D10] An on-behalf-of by-id write may only touch a row the
14801516
// delegator could touch: step 2.7 composes the DELEGATOR's write filter (its
1481-
// own sets, its own context, no floor option) and ANDs it into the same
1482-
// pre-image re-read as the principal's. Composed here by that same call, for
1483-
// the verbs step 2.7 judges, and judged on the `rls` layer with the
1517+
// own sets, its own context) and ANDs it into the same pre-image re-read as
1518+
// the principal's — {@link delegatorRecordWriteFilter} is that composition,
1519+
// for the verbs step 2.7 judges, judged on the `rls` layer with the
14841520
// principal's Layer 1. A rejection fails the write, so it is reported fail
14851521
// closed, as a rejection of the principal's own composition is.
14861522
const delegatorWriteOrFault = delegator && recordExists && (rlsOp === 'update' || rlsOp === 'delete')
1487-
? await settle(deps.computeRlsFilter(delegator.sets, object, rlsOp, delegator.context))
1523+
? await settle(delegatorRecordWriteFilter(deps, delegator, object, rlsOp))
14881524
: undefined;
14891525
const delegatorWriteFault = delegatorWriteOrFault === DEPENDENCY_FAULT;
14901526
const delegatorWriteFilter = delegatorWriteOrFault === DEPENDENCY_FAULT ? undefined : (delegatorWriteOrFault ?? undefined);

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

Lines changed: 135 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2571,6 +2571,12 @@ export class SecurityPlugin implements Plugin {
25712571
// floor on exactly the terms its `where` scope does: step 3 composes the
25722572
// bulk scope with no floor options either.
25732573
let preImageFloorOpts: RlsFilterOptions | null = null;
2574+
// [ADR-0090 D10] Its twin for the DELEGATOR of an on-behalf-of write: the
2575+
// floor decision and coverage vouch the delegator's OWN direct by-id write
2576+
// of this row would get ({@link delegatorOwnWriteFloorOptions}). Null on
2577+
// the ordinary path and until step 2.7 runs; step 3.6 reads it so the
2578+
// post-image is held to the delegator's own check as well.
2579+
let delegatorPreImageFloorOpts: RlsFilterOptions | null = null;
25742580

25752581
// [ADR-0066 D2/D3] Resolve the object's security posture (private flag,
25762582
// platform-global flag, capability contract) once for the checks below.
@@ -3046,7 +3052,9 @@ export class SecurityPlugin implements Plugin {
30463052
// would drop one principal's floor and not the other's: a half-state
30473053
// no measurement on this card covers. The delegated by-id write keeps
30483054
// BOTH floors, exactly as before, and that residual is recorded on the
3049-
// card rather than resolved by a guess.
3055+
// card rather than resolved by a guess. What bounds it by the
3056+
// delegator is the delegator's own composition ANDed below, never a
3057+
// dropped floor here.
30503058
const masterGateCoversThisWrite = !delegatorSets;
30513059
preImageFloorOpts = { dropPlatformOwnershipFloor, masterGateCoversThisWrite };
30523060
const writeFilter = await this.computeRlsFilter(
@@ -3064,7 +3072,37 @@ export class SecurityPlugin implements Plugin {
30643072
const delWriteFilter = delegatorSets
30653073
? await this.computeRlsFilter(delegatorSets, opCtx.object, rlsOperation, delegatorContext)
30663074
: null;
3067-
const writeParts = [writeFilter, delWriteFilter].filter(Boolean) as Record<string, unknown>[];
3075+
// [ADR-0090 D10] ...and the delegator's OWN direct by-id write of this
3076+
// row must admit it as well. The filter above is composed with the
3077+
// delegated path's floor exclusion, which is not the composition the
3078+
// delegator's own write of this row gets, so it alone does not bound
3079+
// the on-behalf-of write by what the delegator may write. The
3080+
// delegator's own composition — its own floor decision and coverage
3081+
// vouch, {@link delegatorOwnWriteFloorOptions} — is ANDed beside it:
3082+
// the row must satisfy both, so this only ever narrows, and an
3083+
// on-behalf-of write never admits a row the delegator's own write
3084+
// refuses. The vouch holds on this path too: step 2.8 asks the master
3085+
// gate for the delegator below.
3086+
if (delegatorSets) {
3087+
delegatorPreImageFloorOpts = await this.delegatorOwnWriteFloorOptions(
3088+
rlsOperation,
3089+
opCtx.object,
3090+
String(targetId),
3091+
delegatorContext,
3092+
delegatorSets,
3093+
true,
3094+
);
3095+
}
3096+
const delOwnWriteFilter = delegatorSets && delegatorPreImageFloorOpts
3097+
? await this.computeRlsFilter(
3098+
delegatorSets,
3099+
opCtx.object,
3100+
rlsOperation,
3101+
delegatorContext,
3102+
delegatorPreImageFloorOpts,
3103+
)
3104+
: null;
3105+
const writeParts = [writeFilter, delWriteFilter, delOwnWriteFilter].filter(Boolean) as Record<string, unknown>[];
30683106
// [#21771] Ruling A on the write doors: a row the caller cannot READ
30693107
// is a row that does not exist. For the by-id write the caller
30703108
// addressed (see {@link addressedByIdWriteId}), "can you read it?" is
@@ -3603,7 +3641,21 @@ export class SecurityPlugin implements Plugin {
36033641
const delCheckFilter = delegatorSets
36043642
? await this.computeWriteCheckFilter(delegatorSets, opCtx.object, opCtx.operation, delegatorContext)
36053643
: null;
3606-
const checkParts = [checkFilter, delCheckFilter].filter(Boolean) as Record<string, unknown>[];
3644+
// [ADR-0090 D10] ...and the check the delegator's OWN direct by-id write
3645+
// of this row is held to, on the floor decision its own pre-image got
3646+
// (step 2.7, `delegatorPreImageFloorOpts`). ANDed beside the check
3647+
// above, so it only narrows: an on-behalf-of write never stores a row
3648+
// the delegator's own write could not.
3649+
const delOwnCheckFilter = delegatorSets && delegatorPreImageFloorOpts
3650+
? await this.computeWriteCheckFilter(
3651+
delegatorSets,
3652+
opCtx.object,
3653+
opCtx.operation,
3654+
delegatorContext,
3655+
delegatorPreImageFloorOpts,
3656+
)
3657+
: null;
3658+
const checkParts = [checkFilter, delCheckFilter, delOwnCheckFilter].filter(Boolean) as Record<string, unknown>[];
36073659
if (checkParts.length > 0) {
36083660
// The ONE refusal, shared by every shape — so a write judged inside
36093661
// the engine (an insert, a predicate update) and a by-id update
@@ -4985,8 +5037,12 @@ export class SecurityPlugin implements Plugin {
49855037
// delegated principal at its agent-leg depth, then, if that admits, once
49865038
// for the delegator at the delegator's own depth. The delegator's own
49875039
// write filter, which step 2.7 ANDs into the pre-image re-read, is composed
4988-
// by the engine. The floor decision still excludes this path, as the gate
4989-
// does. The delegated READ depths are not modelled on explain's record
5040+
// by the engine, as the gate composes it: once with the floor standing
5041+
// (`computeRlsFilter`), once as the delegator's own record composition
5042+
// (`computeLayeredRlsFilter` below, asked with the delegator's context, so
5043+
// its floor decision is the delegator's own). The floor decision still
5044+
// excludes the delegated context itself, as the gate's does. The delegated
5045+
// READ depths are not modelled on explain's record
49905046
// path: the read filter is asked with the delegated context as it stands.
49915047
const actsOnBehalfOf = (c: any): boolean => !!c?.onBehalfOf?.userId;
49925048
const recordWriteFloorOptions = async (
@@ -5413,7 +5469,9 @@ export class SecurityPlugin implements Plugin {
54135469
* delegated context (`hasWriteBypass`: "no D10 delegator intersection on
54145470
* this path", ADR-0111 D2), so composing here could only produce a verdict
54155471
* resolved against the wrong identity. The delegated write keeps both
5416-
* principals' floors, exactly as before.
5472+
* principals' floors, exactly as before — and beside the delegator's
5473+
* floor-standing filter it also ANDs the delegator's own composition,
5474+
* asked here AS the delegator ({@link delegatorOwnWriteFloorOptions}).
54175475
*/
54185476
private async resolvePreImageFloorDrop(
54195477
rlsOperation: string,
@@ -5438,6 +5496,48 @@ export class SecurityPlugin implements Plugin {
54385496
);
54395497
}
54405498

5499+
/**
5500+
* [ADR-0090 D10] The composition knobs the DELEGATOR's own direct by-id
5501+
* write of `recordId` would get: its floor decision
5502+
* ({@link resolvePreImageFloorDrop}, asked as the delegator — its own sets,
5503+
* its own context, not delegated) and the coverage vouch the caller makes
5504+
* for that write.
5505+
*
5506+
* An on-behalf-of principal never exceeds its delegator. Every delegated
5507+
* row gate composes the delegator's filter with the floor standing (the
5508+
* delegated path's exclusion), which is a different composition from the one
5509+
* the delegator's own write of the same row gets, so on its own it does not
5510+
* bound the delegated write by what the delegator may write. Each such gate
5511+
* therefore also composes the delegator's filter with THESE options and ANDs
5512+
* the two: the by-id pre-image (step 2.7), its post-image check (step 3.6)
5513+
* and the master gate's delegator pass ({@link assertMasterRowEditable}).
5514+
* The AND only ever narrows — a row passes only where today's composition
5515+
* AND the delegator's own both admit it — so nothing refused before is
5516+
* admitted by it.
5517+
*
5518+
* `masterGateCoversThisWrite` is the vouch the caller's own direct
5519+
* composition makes, passed rather than assumed, because coverage is a fact
5520+
* about the call site ({@link RlsFilterOptions.masterGateCoversThisWrite}).
5521+
*/
5522+
private async delegatorOwnWriteFloorOptions(
5523+
rlsOperation: string,
5524+
object: string,
5525+
recordId: string,
5526+
delegatorContext: any,
5527+
delegatorSets: PermissionSet[],
5528+
masterGateCoversThisWrite: boolean,
5529+
): Promise<RlsFilterOptions> {
5530+
const dropPlatformOwnershipFloor = await this.resolvePreImageFloorDrop(
5531+
rlsOperation,
5532+
object,
5533+
recordId,
5534+
delegatorContext,
5535+
delegatorSets,
5536+
false,
5537+
);
5538+
return { dropPlatformOwnershipFloor, masterGateCoversThisWrite };
5539+
}
5540+
54415541
/**
54425542
* [#5493 / ADR-0105 D3] `ISecurityService.checkAuthoredRowWrite` — does an
54435543
* APP-AUTHORED row-level policy admit this row for this write operation, on
@@ -9028,7 +9128,34 @@ export class SecurityPlugin implements Plugin {
90289128
dropPlatformOwnershipFloor,
90299129
masterGateCoversThisWrite: !delegatedWrite && this.declaresControlledByParent(rel.master),
90309130
});
9031-
if (masterWriteFilter) {
9131+
// [ADR-0090 D10] On the DELEGATOR's pass of a delegated write — `context`
9132+
// is the delegator's own (no delegation link) while `opCtx` carries the
9133+
// link — the master must also be editable under the composition the
9134+
// delegator's OWN direct write of this detail gets on this hop: its own
9135+
// floor decision on the master row, and the vouch the direct hop makes
9136+
// ({@link delegatorOwnWriteFloorOptions}). ANDed beside the filter above,
9137+
// so it only narrows: an on-behalf-of detail write never passes a master
9138+
// the delegator's own detail write is refused on. The agent's pass is
9139+
// unchanged.
9140+
const delegatorPass = delegatedWrite && !context?.onBehalfOf?.userId;
9141+
const delegatorOwnMasterWriteFilter = delegatorPass
9142+
? await this.computeRlsFilter(
9143+
permissionSets,
9144+
rel.master,
9145+
'update',
9146+
context,
9147+
await this.delegatorOwnWriteFloorOptions(
9148+
'update',
9149+
rel.master,
9150+
String(masterId),
9151+
context,
9152+
permissionSets,
9153+
this.declaresControlledByParent(rel.master),
9154+
),
9155+
)
9156+
: null;
9157+
const masterWriteParts = [masterWriteFilter, delegatorOwnMasterWriteFilter].filter(Boolean) as Record<string, unknown>[];
9158+
if (masterWriteParts.length > 0) {
90329159
let visible: unknown = null;
90339160
// [#7505] This catch STAYS, and the difference from the existence probe
90349161
// above is the whole per-caller distinction the ruling asks for. That
@@ -9041,7 +9168,7 @@ export class SecurityPlugin implements Plugin {
90419168
// not as a site to change.
90429169
try {
90439170
visible = await this.ql.findOne(rel.master, {
9044-
where: { $and: [{ id: masterId }, masterWriteFilter] },
9171+
where: { $and: [{ id: masterId }, ...masterWriteParts] },
90459172
context,
90469173
});
90479174
} catch {

0 commit comments

Comments
 (0)