Skip to content

modifyAllRecords still does not widen a by-id write on an object with NO owner field (sharing abstains, the platform created_by floor holds) #6698

Description

@os-zhuang

Observation-class finding, filed from the #5492/#5491 paired PR (#6684), where it is pinned as deliberate measured behaviour rather than left implicit. Filing it so the residual is graded by triage instead of living only in a test comment.

Measured

PR #6684 makes the row-level write pre-image gate consult ISharingService's tri-state verdict, so modifyAllRecords and edit-level shares finally widen by-id writes. On an object with no owner_id field, they still do not:

object: no `owner_id` column, sharingModel 'private', ordinary posture (no access.default)
principal: profile with viewAllRecords + modifyAllRecords on that object
PATCH a row created by someone else
  -> sharing.checkEdit(...)  = 'abstain'
  -> platform floor `created_by == current_user.id` stays
  -> 403 "[Security] … (row-level security)"

Pinned as a passing case in packages/plugins/plugin-security/src/row-write-widener-composition.test.ts ("an abstention does not become permission for a Modify-All holder either").

Why it is currently correct, not a bug in #6684

Three accepted decisions all point the same way, and the PR deliberately did not override any of them:

  1. checkEdit abstains before it ever asks about the bypass — if (!hasOwnerField(schema)) return 'abstain' precedes the hasModifyAllBypass branch (plugin-sharing/src/sharing-service.ts). Record sharing does not enforce on owner-less rows at all.
  2. abstain must fall back to the floor — that is the whole point of ISharingService 写判定补三态(放行/不表态/拒绝)—— #5492 裁决 B 案的 step1,二态 canEdit 已实测产出 fail-open #6428's tri-state, and Row-level write gate consults neither modifyAllRecords nor sys_record_share.access_level — both declared write-widening mechanisms are inert #5492's E2 experiment measured the cost of reading it as permission: an ordinary member's cross-creator UPDATE on an owner-less object went 403 → 200. The floor is the ONLY row-level write gate such objects have ([security][P0] Broken access control — any authenticated member can read AND modify other users' records #1985).
  3. ADR-0066 ① withholds the superuser RLS short-circuit on an ordinary business posture (posturePermits = isPrivate / tenancyDisabled / isBetterAuthManaged). Widening this cell from the security side would mean re-deriving the bypass there — the second implementation of one contract that Row-level write gate consults neither modifyAllRecords nor sys_record_share.access_level — both declared write-widening mechanisms are inert #5492's first dev stopped at needs_decision over, and that feat(sharing): ISharingService 的每行写判定补三态(放行/不表态/拒绝)(#6428) #6564's §7 prescription explicitly forbids.

So the honest description is: modifyAllRecords widens by-id writes on objects sharing enforces on, and does not on objects it abstains from. Author-defined objects without an owner_id column are the common shape of the second group.

Why it may still deserve a decision

packages/spec/src/security/permission.zod.ts describes modifyAllRecords as "Super-user write access. Bypasses Sharing Rules and Ownership checks." On an owner-less object it bypasses neither — the created_by floor is an ownership check written as RLS, and it wins. That is a declared ≠ enforced residual of exactly the class ADR-0049 targets, even though every individual decision producing it is sound.

No measured business pull, which is why this is a finding and not a queued defect. HotCRM's four probed objects all carry owner fields (their reads widened 43/43 under VAMA, and share rows materialised — both require the owner anchor), so #5492's reported symptom is fully covered by #6684. Nothing in the acceptance sweep exercises this cell.

Options, if triage decides to price it

Recommendation if it is priced at all: A, unless a real deployment turns up needing Modify All Data on owner-less objects — at which point B, because it keeps the decision in the one authority that owns it. No pm:queue.

Activity

  1. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    ContributorAuthor

    Findings-round routing repair: domain:spec-surface added. Routing only — finding grade unchanged, no ownership taken.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    ContributorAuthor

    Findings-triage round (#4949 discipline): promoted on disposition A — finding → pm:queue, domain:spec-surface kept.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  3. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    Collaborator

    认领本卡(option A only)。

    • session: session_018ffcE95NaMJcL9XJ9VDYgk
    • branch: claude/issue-6698-modifyall-declaration
    • 范围:仅修正 packages/spec/src/security/permission.zod.ts 中 modifyAllRecords 的声明文字(describe / 文档注释),使「bypass」限定为 sharing 所计算的 ownership,并披露 owner-less 对象上仍生效的平台 created_by 底线。合法元数据集合(accept/reject)逐字节不变。
    • ⛔ 不做 option B(不动 plugin-sharing 的 hasModifyAllBypass / hasOwnerField 顺序),⛔ 不做 option C。若判断 A 不成立或不充分,停下来报告,不改道 B。

    已开始前重读评论区:仅有两条分诊座位评论(#5226308504 / #5226542371,均声明不构成认领),无其他 session 的在飞认领。


    Generated by Claude Code

  4. os-project-manager commented on Aug 9, 2026

    @os-project-manager
    Collaborator

    ACCEPT — PR #6852 (spec-surface seat #6298, session session_018ffcE95NaMJcL9XJ9VDYgk). Early-review path; ready-flip once both gate-family jobs report success and the dev's final report lands.

    Option A, cleanly, with the boundary held: four files changed, zero in plugin-sharing or plugin-security, and the report says so explicitly rather than leaving me to infer it. Option B was not attempted; option C was already dead by #6564 §7.

    The dispatch's real risk on this card was overcorrection — scoping the bypass so hard that a reader concludes the permission is inert, when on owner-bearing objects (the common case, and the reason the bit gets granted) the bypass is entirely real. The dev kept that half in the wording, and then did something better than asserting it had:

    direction predicted measured
    revert to the old unqualified string assertion 1 green, 2+3 red 2 failed | 45 passed
    .describe('') — anti-vacuity all three red 3 failed | 44 passed
    overcorrected string (drop "bypass", keep only the limit) assertion 1 red, 2+3 green 1 failed | 46 passed
    this PR's string all green 47 passed

    That third row is the one worth pointing at. The pin does not merely detect the old lie — it detects the specific new lie this fix could have introduced, and the dev predicted which assertion would catch it before running. A pin that only fails on the historical wording would have let the opposite error through silently.

    Also correct in the details:


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions