Uh oh!
There was an error while loading. Please reload this page.
fix(security): controlled_by_parent master-editability consults the same authored write widener the by-id path does (#8679) - #8758
Conversation
…uthored write widener (#8679) `assertControlledByParentWrite`'s record-sharing leg hard-refused on `canEdit === false` without asking whether an app-authored RLS update-widener admits the master row. The by-id write path has asked exactly that since #5493 (PR #6909), where the deferral was installed on the sharing middleware's refusal branch — so one principal, one master record and one operation got two different answers depending on whether the question was asked directly or on behalf of a child. The gate now consults the SAME composition: `checkAuthoredRowWrite`, the method `SharingService.probeAuthoredRowWrite` passes straight through to, asked for `update` to match the two legs already above it. `admit` retracts only this leg's refusal; the object-level grant and the master's write-RLS leg still refuse on their own terms, and every other verdict leaves the refusal byte-for- byte untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NaS1PAHJcPfAA2acnV53Tn
…trolled-by-parent-master-rls
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 12 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8679
The divergence
crm_campaign_member-shaped objects — ADR-0055controlled_by_parentdetails — route every insert/update/delete throughassertControlledByParentWrite, which asks whether the caller may EDIT the master. That gate's record-sharing leg hard-refused oncanEdit === falsewithout ever asking whether an app-authored RLS update-widener admits the master row. The by-id write path has asked exactly that since #5493 (merged as PR #6909), where the deferral was installed on the sharing middleware's refusal branch — and deliberately only there, because folding it intocanEditwould make the two authorities read each other in a circle.So one principal, one master record and one operation got two different answers depending on who was asking: the master itself, allowed; a child of it,
403 ... (record sharing)— naming the very layer #6909 had already taught to defer.The isolation, reproduced
Earned before implementing, on current
main, driving the REALSecurityPluginmiddleware and the REAL sharing middleware over the REALSharingService, wired through the same late binding the kernel uses. One variable: who created the master.Measured verdict on the unfixed tree, verbatim:
The first line is the witness that makes the second a divergence rather than a policy question: the platform already permits this principal to edit that exact master row.
The fix
The record-sharing leg now defers before it hard-refuses, consulting the SAME composition:
checkAuthoredRowWrite— the methodSharingService.probeAuthoredRowWritepasses straight through to — so the answer here is byte-for-byte the one a direct by-id write of that master would get. There is no second copy to drift, which matters because a duplicated permission composition is how these two paths diverged in the first place;update, matching the two legs already above it. This gate's subject is edit access to the master, never the detail's own verb (deleting a child does not delete the master, andinsertis not anAuthoredRowWriteOperationat all);admitretracts only this leg's refusal. The object-levelupdategrant and the master's own write-RLS leg run first and still refuse on their own terms — mirroring the by-id path, where anadmithands the row to the pre-image gate rather than authorizing anything;abstain, no authored policy, acheck-only policy, a principal-less or delegated context, a throwing probe — leaves the refusal byte-for-byte untouched. The method is fail-closed in theabstaindirection and swallows its own throws, so no failure mode here can widen access.This is not a relaxation, and the tests are built so it cannot become one. Ruling: reaching parity by widening what counts as master-editability beyond #6909's composition would be a permission-boundary expansion. Nothing here widens that definition — the same question is asked at a second call site.
Acceptance pins both directions
16 cases on one fixture. Every positive is mirrored by a negative on the same route with the same payload, differing only in the app-authored declaration:
select-only authored policy admits no write, so the refusal stands (this separates "consults the authored WRITE widener" from "notices the caller authored something");abstain— Layer 0 still stands under the widest authored predicate;update.A transferred master (created by the caller, since handed to someone else) keeps the record-sharing leg itself pinned live: its write RLS admits via the platform ownership floor while record sharing refuses because the owner is another user, so that refusal can only come from the leg this PR edits. Deleting that leg outright would otherwise leave the whole suite green. An
edit-level share admits the same row; aread-level share still refuses it.Reverse verification
The expected direction was stated before running: reverting the fix should turn red exactly the cases that depend on the deferral, and leave every negative green. Observed exactly that — 4 red, 12 green, and precisely the 4 predicted:
The negatives passing in BOTH states is the point: they pin the refusal, not the fix.
Scope
packages/plugins/plugin-security/src/**only, as dispatched. No file inpackages/core,plugin-auth,plugin-sharingorpackages/specis touched —plugin-sharingwas read to mirror the composition, never edited.security/explainis deliberately unchanged: the card uses it as the platform's own contradicting verdict, so it is the witness, not the defect.Verification
mainmoved twice during this card; it was merged and everything below re-measured at the merged head rather than reported green over a stale tree.All of the following ran at
075477ee2, the head of this branch, after the final commit.The dogfood suite resolves the code under test from each package's built
dist/, so the full workspace closure was rebuilt at the merged head before it ran — the run covers this change rather than a pre-merge artifact.Gate union, all PASS at
075477ee2:The families were re-derived against this PR's actual changed paths with
scripts/pm/dispatch-gates.mjs;check:engine-double-contractandcheck:error-code-casingare convention-triggered additions run beyond the dispatch list. The new test file declares a read-only engine double with no write verb, so it adds nothing to the double ledger.Out of scope, filed separately
While isolating row 3 I measured a second, independent refusal on this route: the detail's OWN
owner_only_writesownership floor refuses a by-id update of acontrolled_by_parentchild created by another user, at the pre-image gate, before the master gate runs — and it is not droppable there, becausecheckEditreturnsabstainfor acontrolled_by_parentobject before it ever reaches themodifyAllRecordsbranch. A Modify-All-Data admin is refused too. That is a different gate and a different guard, so it is not touched here; it is filed rather than fixed. The acceptance cases above hold the detail's floor constant so this PR's verdicts are attributable to the master gate alone.Filed as #8757 (unassigned, unlabeled for triage). #8757 is not addressed here.
Out of scope and untouched: #8688 remains open — it is this card's hard serial (same file, same guard, its own acceptance test), and nothing in this PR moves the missing-master envelope it is about.
Generated by Claude Code
Generated by Claude Code