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
assertControlledByParentWrite's master write-RLS leg keeps the platform ownership floor, so a principal who may PATCH a master by id is refused on its children (#8679 divergence, sibling leg) #8865
Found while implementing #8757 (the controlled_by_parent detail's own ownership floor). Not fixed there — it is a different object's floor in a different leg of a different gate, and #8757's ruling explicitly bounds that PR to the detail's own floor. Recorded rather than ridden along.
What was measured
assertControlledByParentWrite resolves master-edit access in two legs. Leg 2 (record sharing) learned to DEFER before hard-refusing in #8679 / PR #8758. Leg 1 — the master's own write RLS — still runs the platform ownership floor undropped:
No dropPlatformOwnershipFloor. The by-id write pre-image gate (step 2.7) computes the same filter for the same object with that knob set whenever ISharingService answers allow, so the master's owner_only_writes floor is dropped there and stands here.
One principal, one master row, one operation, two answers — the same shape #8679 closed, in the sibling leg:
step
verdict
PATCH the master camp_mkt directly, by id
allowed
UPDATE a child of camp_mkt
refused — 403 … requires edit access to its master record (master 'crm_campaign' not editable by this user (row-level security))
The principal holds modifyAllRecords: true on the master, which is exactly what makes step 2.7's sharing verdict allow and drops the floor on the direct path. It did not create camp_mkt, so the undropped floor (created_by == current_user.id) refuses it on the derived path.
Reproduction
Unit, in-memory, no server. packages/plugins/plugin-security/src/controlled-by-parent-detail-write-authority.test.ts (landing with #8757) pins this as a labelled residual, including the witness row: RESIDUAL (#8798): card line 2 is refused ONE GATE LATER now. Master crm_campaign (private, owner_id), detail crm_campaign_member (controlled_by_parent), an admin_set permission set carrying viewAllRecords/modifyAllRecords on both objects, and the shipped member_default seed for the floor.
Why it looks wrong
The floor is the widener-blind second implementation of ownership that #5492 already made defer on the by-id path; #6684 landed the knob for exactly that. This call site never got it, so every widening mechanism the platform declares — write DEPTH, an edit-level sys_record_share, modifyAllRecords — is inert for children while it works for the master itself. An app author sees a master they can edit and children they cannot.
Note leg 1 is also CONDITIONAL (if (masterWriteFilter)), so on a master with no applicable write policy the leg is skipped entirely and leg 2 carries the decision alone — which is why this went unnoticed: it only bites when a floor policy applies, i.e. for ordinary org_member principals.
Possible directions (not a recommendation — needs triage)
Narrower: keep leg 1 as-is and let leg 2's existing checkAuthoredRowWrite deferral retract leg 1's refusal too. Smaller diff, but leaves two compositions.
Accept and document that derived writes are stricter than direct ones.
Direction 1 changes a permission boundary (it widens who may write children) and should not be taken without a maintainer ruling.
Serial note
⚠️assertControlledByParentWrite is a hot file. #8688 is already queued against its insert leg, and #8757 is in flight in the same method. Sequence this behind both.
Found while implementing #8757 (the
controlled_by_parentdetail's own ownership floor). Not fixed there — it is a different object's floor in a different leg of a different gate, and #8757's ruling explicitly bounds that PR to the detail's own floor. Recorded rather than ridden along.What was measured
assertControlledByParentWriteresolves master-edit access in two legs. Leg 2 (record sharing) learned to DEFER before hard-refusing in #8679 / PR #8758. Leg 1 — the master's own write RLS — still runs the platform ownership floor undropped:No
dropPlatformOwnershipFloor. The by-id write pre-image gate (step 2.7) computes the same filter for the same object with that knob set wheneverISharingServiceanswersallow, so the master'sowner_only_writesfloor is dropped there and stands here.One principal, one master row, one operation, two answers — the same shape #8679 closed, in the sibling leg:
camp_mktdirectly, by idcamp_mkt403 … requires edit access to its master record (master 'crm_campaign' not editable by this user (row-level security))The principal holds
modifyAllRecords: trueon the master, which is exactly what makes step 2.7's sharing verdictallowand drops the floor on the direct path. It did not createcamp_mkt, so the undropped floor (created_by == current_user.id) refuses it on the derived path.Reproduction
Unit, in-memory, no server.
packages/plugins/plugin-security/src/controlled-by-parent-detail-write-authority.test.ts(landing with #8757) pins this as a labelled residual, including the witness row:RESIDUAL (#8798): card line 2 is refused ONE GATE LATER now. Mastercrm_campaign(private,owner_id), detailcrm_campaign_member(controlled_by_parent), anadmin_setpermission set carryingviewAllRecords/modifyAllRecordson both objects, and the shippedmember_defaultseed for the floor.Why it looks wrong
The floor is the widener-blind second implementation of ownership that #5492 already made defer on the by-id path; #6684 landed the knob for exactly that. This call site never got it, so every widening mechanism the platform declares — write DEPTH, an
edit-levelsys_record_share,modifyAllRecords— is inert for children while it works for the master itself. An app author sees a master they can edit and children they cannot.Note leg 1 is also CONDITIONAL (
if (masterWriteFilter)), so on a master with no applicable write policy the leg is skipped entirely and leg 2 carries the decision alone — which is why this went unnoticed: it only bites when a floor policy applies, i.e. for ordinaryorg_memberprincipals.Possible directions (not a recommendation — needs triage)
dropPlatformOwnershipFloorcomposition here that step 2.7 uses: askresolveSharingWriteVerdict('update', rel.master, masterId, context, permissionSets)and drop the floor onallow. Makes the two paths one composition, which is the Row-level write gate consults neithermodifyAllRecordsnorsys_record_share.access_level— both declared write-widening mechanisms are inert #5492 / controlled_by_parent children: the master-editability check still refuses an RLS-widened master, so #6909's fix covers the by-id write but not the derived-write path (17.0.0 GA) #8679 direction.checkAuthoredRowWritedeferral retract leg 1's refusal too. Smaller diff, but leaves two compositions.Direction 1 changes a permission boundary (it widens who may write children) and should not be taken without a maintainer ruling.
Serial note
assertControlledByParentWriteis a hot file. #8688 is already queued against its insert leg, and #8757 is in flight in the same method. Sequence this behind both.Filed unassigned and unlabeled for triage. Related: #8757, #8679 (PR #8758), #5492, #6684.
Generated by Claude Code