@@ -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