You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[finding] The bulk AST write path passes opCtx.operation raw, so purge / transfer / restore derive no row scope while update / delete now do (#7665 asymmetry) #7809
Found by the PM while reviewing PR #7792 (#7665). Not a regression — the behaviour described here is the same before and after that PR — but #7665 creates an asymmetry between the two write paths that deserves a deliberate answer rather than an accident.
#7665 makes an empty write-class policy collection derive its row scope from the caller's select narrowing, inside computeLayeredRlsFilter. The derivation is written for operation === 'update' || operation === 'delete'.
On the by-id write pre-image gate (step 2.7) that is complete coverage, because the gate normalises first (security-plugin.ts:1487-1489):
security-plugin.ts:2133 — the ADR-0090 D10 delegator half, same shape
A bulk write arriving as purge, transfer or restore therefore collects no policies for that literal operation name, derives nothing, and has no row scope AND-ed into its AST — while a bulk update or delete on the same select-only object now does.
Why it is worth answering rather than assuming
Two questions, and they have different answers:
Can those verbs reach the AST path at all? If opCtx.ast is only ever populated for find / update / delete, the asymmetry is theoretical and the right fix is a comment saying so. That is a reading job on the engine's dispatch, not a guess.
Note the two paths already normalise differently for a reason that may be legitimate — the 2.7 gate is asking "may this caller write THIS row", the AST path is scoping a set — so the fix may be "normalise there too" or may be "these verbs never take the AST path, and here is the assertion that says so".
PM review of PR #7792, verified against origin/main at the time of writing (:1487-1489 normalisation present; :2113 / :2133 raw). Filed unassigned and unqueued — recording, not claiming. Sizing note for whoever takes it: the @objectstack/verify harness gap (#7762) does not apply here, but the coverage would need a bulk-write fixture on a select-only object, which the #7665 suite (select-only-write-visibility.test.ts) already has the personas for.
Found by the PM while reviewing PR #7792 (#7665). Not a regression — the behaviour described here is the same before and after that PR — but #7665 creates an asymmetry between the two write paths that deserves a deliberate answer rather than an accident.
What #7665 changed, and where it stops
#7665 makes an empty write-class policy collection derive its row scope from the caller's
selectnarrowing, insidecomputeLayeredRlsFilter. The derivation is written foroperation === 'update' || operation === 'delete'.On the by-id write pre-image gate (step 2.7) that is complete coverage, because the gate normalises first (
security-plugin.ts:1487-1489):so all six write verbs reach the derivation as
updateordelete.On the bulk AST injection path (step 3) there is no such normalisation —
opCtx.operationis passed raw:security-plugin.ts:2113—computeRlsFilter(permissionSets, opCtx.object, opCtx.operation, opCtx.context)security-plugin.ts:2133— the ADR-0090 D10 delegator half, same shapeA bulk write arriving as
purge,transferorrestoretherefore collects no policies for that literal operation name, derives nothing, and has no row scope AND-ed into its AST — while a bulkupdateordeleteon the same select-only object now does.Why it is worth answering rather than assuming
Two questions, and they have different answers:
opCtx.astis only ever populated forfind/update/delete, the asymmetry is theoretical and the right fix is a comment saying so. That is a reading job on the engine's dispatch, not a guess.purge/transfer/restoreunscoped by row visibility while by-id and bulkupdate/deleteare scoped — the same class of defect [security] A by-id write is not gated by record visibility — a contributor mutates records they cannot read, when only select-scope RLS is authored #7665 fixed, on the verbs whose blast radius is largest (purgedestroys;transferreassigns ownership).Note the two paths already normalise differently for a reason that may be legitimate — the 2.7 gate is asking "may this caller write THIS row", the AST path is scoping a set — so the fix may be "normalise there too" or may be "these verbs never take the AST path, and here is the assertion that says so".
Related, deliberately not merged into this
pm:on-hold) coversallowRestore/allowPurgeRBAC — the object-level bit, a different layer from row scope. If that card is ever taken up, this finding belongs in the same sweep.privateeven once checkAuthoredRowWrite admits it #7401 / Authored RLS update-wideners are silently ineffective on the bulk write path — buildWriteFilter ANDs them away (fewer rows, no error) #6736 track the opposite direction (authored write wideners going dead). Any fix here must not derive when a write-class policy applies, exactly as [security] A by-id write is not gated by record visibility — a contributor mutates records they cannot read, when only select-scope RLS is authored #7665 does not.Provenance
PM review of PR #7792, verified against
origin/mainat the time of writing (:1487-1489normalisation present;:2113/:2133raw). Filed unassigned and unqueued — recording, not claiming. Sizing note for whoever takes it: the@objectstack/verifyharness gap (#7762) does not apply here, but the coverage would need a bulk-write fixture on a select-only object, which the #7665 suite (select-only-write-visibility.test.ts) already has the personas for.