Skip to content

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

Description

@qq9340100

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:

const masterWriteFilter = await this.computeRlsFilter(permissionSets, rel.master, 'update', context);

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 refused403 … 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)

  1. Pass the same dropPlatformOwnershipFloor composition here that step 2.7 uses: ask resolveSharingWriteVerdict('update', rel.master, masterId, context, permissionSets) and drop the floor on allow. Makes the two paths one composition, which is the Row-level write gate consults neither modifyAllRecords nor sys_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.
  2. 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.
  3. 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.


Filed unassigned and unlabeled for triage. Related: #8757, #8679 (PR #8758), #5492, #6684.

Generated by Claude Code

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions