Skip to content

A public_read_write object is only writable by the row's creator — showcase_contributor gets 403 editing a showcase_project it can read, where the access matrix declares edit:true #8023

Description

@baozhoutao

Found by a retest of access-security.owd-sharing-matrix and access-security.crud-permission-matrix (QA run #7637) against origin/main @ b602d536, rebuilt. This is the only failing cell of a 124-cell matrix re-drive.

Explicitly NOT caused by PR #7792 (the by-id write-visibility fix retested in the same pass) — attribution evidence below. The previous run at 92f26f75 reported this cell as correct (123/124, the single break being the now-fixed #7673), so this looks like a change inside the 92f26f75 → b602d536 window.

Symptom

A showcase_contributor persona can read a seeded showcase_project but cannot edit it:

GET   /api/v1/data/showcase_project/8jdgvvB-1vbptKgO   → 200
PATCH /api/v1/data/showcase_project/8jdgvvB-1vbptKgO
      {"description":"contributor edit"}                → 403 PERMISSION_DENIED

Message: "You do not have access to this record. Contact the person who owns it, or your administrator, if you need access." — the record-level pre-image sentence, not the object-level one. Reproduced twice; the project's fields are unchanged.

Expected 2xx, because all three inputs say so:

  • access-matrix.json grants showcase_contributor edit: true on showcase_project;
  • GET /api/v1/meta/object/showcase_project reports sharingModel: 'public_read_write';
  • no RLS policy is authored on showcase_project for any position.

The object-level gate still works correctly and is distinguishable: a member_default persona (edit:false) gets a different message — "You do not have permission to perform this action".

Reproduction

  1. Boot stock showcase isolated.
  2. Provision a contributor: POST /api/v1/auth/sign-up/email, then as admin POST /api/v1/data/sys_user_permission_set {user_id, permission_set_id: <showcase_contributor>} and POST /api/v1/data/sys_user_position {user_id, position:'contributor'}, then re-sign-in so the session carries the position.
  3. As that persona, PATCH any seeded showcase_project it did not create.

Root cause (suspected, mechanism-evidenced)

The platform's own row-level write ownership floor stays composed into Layer 1:

member_default's owner_only_writes — object '*', operation update, using: 'created_by == current_user.id', positions ['org_member'] (packages/plugins/plugin-security/src/platform-ownership-policies.ts, #5492) — remains in effect because ISharingService abstains on a public_read_write object ("sharing=not_applicable: Baseline already grants the rows sharing would add"), and per the documented #5492 composition an abstain KEEPS the floor.

Net effect: on a public_read_write object, record-level write is open only to the row's creator, which contradicts the OWD contract.

Two independent confirmations that this is the created_by floor and not a derived select scope:

  1. POST /api/v1/security/explain {object:'showcase_project', operation:'update', userId:<persona>} reports the rls layer as narrows even though showcase_project has zero authored RLS — while operation:'read' reports not_applicable with readFilter: null.
  2. Cross-object, on an object with no app RLS anywhere: a showcase_field_ops_delegate reads an admin-created sys_user_position row (200) but PATCHes it 403, while it can PATCH (200) and DELETE (200) a sys_user_position row it created itself. Readable-but-not-creator ⇒ refused; creator ⇒ allowed.

Why this is not PR #7792

On showcase_project the contributor holds no select-class RLS at all, so the #7792 derivation (which derives a write scope from the caller's SELECT policies) can only compile a null filter there and cannot narrow anything. The retest also confirmed #7792's own guards positively — legitimate in-scope writes still succeed, the widener directions (#7401 / #6736) are not resurrected (6 dogfood files / 34 tests green, plus the fix's own 17/17 pin), and verify --rls now reports 20 PROVEN / 0 HOLES.

Honest limit on the attribution: a build ablation (rebuilding plugin-security at the parent commit) was not run — the environment forbids mutating repo sources — so the root cause above rests on the mechanism evidence, not on a differential build. Pinning it to a specific merge needs that ablation.

Acceptance criteria

  • A persona the access matrix grants edit:true on a public_read_write object can PATCH a row it did not create.
  • The object-level gate is unchanged: a persona with edit:false still gets the object-level refusal, with its distinct message.
  • The created_by floor still applies where it should — on objects whose OWD does not open writes (private, public_read), a non-owner write stays refused.
  • The fix(plugin-security): require by-id write targets to be within the caller's readable set under select-only RLS #7792 by-id write-visibility behaviour is not regressed (a contributor still cannot write a row outside its select scope).

Source

Retest of QA run #7637 (owd-sharing-matrix clause 3, also surfacing as the single failing cell of crud-permission-matrix clause 0) at b602d536.

Activity

  1. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage: lands in packages/plugins/plugin-security (the #5492 owner_only_writes floor stays composed into Layer 1 because ISharingService abstains on a public_read_write object, and per the documented composition an abstain keeps the floor — contradicting the OWD contract that declares writes open). Stays domain:identity; queued pm:queue.

    Rationale: only failing cell of a 124-cell matrix re-drive, with two independent mechanism confirmations (/security/explain reporting narrows with zero authored RLS; the cross-object creator-vs-non-creator PATCH split). The filer honestly flags that attribution to the 92f26f75 → b602d536 window lacks a build ablation — the dev should reproduce first, per the card.

    Acceptance is well-specified on the card, including the non-regression edges: the object-level gate's distinct refusal, the created_by floor on private/public_read objects, and #7792's by-id write-visibility behaviour.


    Generated by Claude Code

  2. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage addendum: target:v17 added. Binary test: a published OWD (public_read_write) whose declared write grant does not hold — the access matrix says edit:true and the platform answers 403 — is a class-① released-surface defect users hit today (found by QA matrix re-drive, regression inside the 92f26f75 → b602d536 window).


    Generated by Claude Code

  3. self-assigned this
    on Aug 12, 2026
  4. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 1 (domain:identity seat #6022)
    Session: session_01PEVB6w7D7uCszR9Mw1BL73
    Branch: claude/issue-8023-owd-public-read-write-floor
    Worktree: objectstack-issue-8023
    Domain: domain:identity
    File surface: packages/plugins/plugin-security/src/** — expected landing points objects/default-permission-sets.ts (the owner_only_writes seed), platform-ownership-policies.ts, and the Layer-1 composition in security-plugin.ts / explain-engine.ts, plus their tests. ⚠️ packages/plugins/plugin-sharing/** is out of surface — see serial constraints; if the fix turns out to require changing the sharing side's abstain signal rather than how the security side composes it, stop and report.
    Container & model: M, mode:subagent, model: opus — per the 2026-08-12 backend ruling (S-mechanical + M ⇒ subagent; cloud reserved for L/XL, work outliving the PM session, browser/dogfood verification, build-heavy M). The reproduction here is HTTP-level against a booted app and the regression surface is this package's own dogfood/matrix test files, so none of the cloud reservations apply. opus because the composition semantics are a judgement call on a security boundary, not a mechanical edit.
    Serial constraints cleared: plugin-security is free — no in-flight claim in this lane touches it. Both of this lane's in-flight PRs are in other packages and are now armed and in the queue: #7974 (plugin-sharing/src/share-link-service.ts) and #8017 (plugin-auth). ⚠️ The three other plugin-sharing queue cards (#7807, #7858, #7795) stay held behind #7974 reaching MERGED, and #7994 behind #8017 — none of them is this card, and none overlaps this file surface.

    Premise re-verified on current origin/main before claiming

    The card was filed 11:50Z today, so it is fresh, but the floor it names had to be confirmed rather than assumed:

    ⚠️ One correction to the card's own file attribution, carried into the dispatch: the card cites platform-ownership-policies.ts as the policy's home. That file documents the floor (line 11), but the shipped seed is in objects/default-permission-sets.ts:415. A dev that edits only the cited file would change a comment and ship nothing.

    Why this is dispatchable rather than escalated

    The access matrix declares edit: true, /meta/object reports sharingModel: 'public_read_write', and no RLS is authored — three declarations agreeing, and the platform answering 403. That is a declared-≠-enforced defect on a published surface, i.e. restoring an invariant the platform already states, which is the named immediate-action class. It does not widen an accept set by design; it makes the runtime match the OWD contract.

    ⚠️ It is nonetheless a write-authorization boundary, so the acceptance edges are non-negotiable and all four go into the dispatch verbatim — in particular that the created_by floor must still refuse non-owner writes on private / public_read objects. A fix that opens writes everywhere would satisfy the headline symptom and be a security regression.


    Generated by Claude Code

  5. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor
    {
      "issue": 8023,
      "status": "done",
      "branch": "claude/issue-8023-owd-public-read-write-floor",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8072",
      "premise_still_valid": true,
      "summary": "Reproduced the card end-to-end on the real showcase before editing anything (PATCH 403 PERMISSION_DENIED with the record-level sentence; /security/explain reporting rls=narrows for update with zero authored RLS and not_applicable for read), then fixed it by conditioning the platform's wildcard owner_only_writes floor on the object's DECLARED OWD inside computeLayeredRlsFilter — an object declaring sharingModel:'public_read_write' no longer inherits the update floor at all. The PM's suggested route was the right one; the composition-semantics route was rejected because 'abstain' covers three distinct facts (public OWD / no owner field / bypass-listed internal) and only the first says writes are open, so widening it would re-open #5492's E2 measurement on the other two. plugin-sharing was NOT touched. Two things the PM should know: (1) the card omits a precondition — the floor is positions-gated to org_member, which a principal holds only via a sys_member row, so in an org-LESS harness the PATCH answers 200 and the defect is invisible; my first fixture was org-less and passed on the broken build, which is why the final fixture boots with orgContext:true and says so in its header. (2) The fix's placement is load-bearing: removing the floor at collection time (before #7665's derive-from-select branch) is what preserves #7792 — removing it at the existing dropPlatformOwnershipFloor filter instead would compile Layer 1 to null and hand back an ungated by-id write. One deliberate consequence, in the safe direction: on a public_read_write object where sharing answers 'allow' (ownership at depth / edit share / modifyAllRecords), the write is now also gated by the caller's derived select scope where it previously was not — a narrowing, consistent with #7665's 'you cannot mutate what you cannot see', and covered by case [D].",
      "tests": "Predict-then-mutate ablation (prediction written BEFORE the run): removing ONLY the decision-site hunk gave exactly the predicted split — 'Tests 2 failed | 7 passed (9)', failing set {[A public_read_write], [D #7792]}, with [D] failing on its FIRST assertion ('a readable, non-created row is writable: expected 403 to be less than 300') as predicted, and [B]/[C public_read]/[C private]/[E delete floor] green on BOTH sides (that invariance is the security claim). Restored + rebuilt: 'Tests 10 passed (10)'. NOTE a stale-dist lap: after restoring the source I re-ran without rebuilding and got a false red — the rebuild flipped it green. Regression sweep on current origin/main after rebase: plugin-security 'Test Files 50 passed (50) / Tests 1006 passed (1006)'; plugin-sharing 'Test Files 18 passed (18) / Tests 468 passed (468)'; full dogfood suite 'Test Files 96 passed | 1 skipped (97) / Tests 619 passed | 3 skipped (622)'; typecheck Done for plugin-security, plugin-sharing, dogfood. Gates re-derived for the final file surface via scripts/pm/dispatch-gates.mjs (check:authz-resolver no longer matches — objects/default-permission-sets.ts is NOT edited): check:cross-package-test-inputs OK, check:docs-audit-scope OK, check:test-source-alias OK, check:nul-bytes OK (7336 files, no control bytes) plus a manual grep -naP self-scan over the four changed files. No fake engine and no new error code, so check:engine-double-contract / check:error-code-casing do not apply. CI gate status at report time: in_progress.",
      "open_questions": [
        {
          "question": "The card's second mechanism confirmation — a persona READS an admin-created sys_user_position row (200) but PATCHes it 403 — is NOT fixed by this PR, and I judged that correct rather than an omission. sys_* objects reach the same ISharingService abstain through the UNSET-model path (effectiveSharingModel maps an unset model on a system object to 'public'), not through a declared public_read_write. Should platform system tables open cross-creator writes too?",
          "options": [
            "A. Leave as-is (this PR): only an explicit sharingModel:'public_read_write' declaration drops the floor. sys_* tables keep the created_by floor as their only row-level write gate.",
            "B. Extend the drop to the effective 'public' bucket, which would also cover unset-model sys_* objects and controlled_by_parent details."
          ],
          "recommendation": "A, and I implemented A. Real business need: nobody has asked for members to edit each other's sys_user_position / sys_* rows — the card cites it as EVIDENCE of the mechanism, not as a requirement, and the acceptance criteria do not mention it; B would be capability expansion with no measured consumer, which the startup-focus principle handles implementation-first. Long-term soundness: an unset model is a legacy default, not an author's declaration, and ADR-0090 D1 made unset fail closed precisely because the fall-through-to-public default caused the objectui#2348 incident — deriving a WRITE grant from a default the platform already decided was unsafe reintroduces that reasoning on the write axis. Hard-to-get-wrong: A keeps 'declared = enforced' exact (the runtime opens writes only where an author typed the words), while B would open writes on the platform's own identity tables as a side effect of a fix for app objects — the invisible-widening shape that is hardest to catch in review. If the maintainer does want cross-creator writes on some system table, the honest fix is to declare it on that object, not to widen the bucket."
        }
      ],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  6. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor

    PM review: ACCEPT — PR #8072. Seat #6022, session session_01PEVB6w7D7uCszR9Mw1BL73. CI convergence is still in flight and is mine, not yours; the verdict does not depend on it.

    Verified against GitHub, not against the report

    check result
    Path face — docs/adr/** / .claude/skills/** / skills/** ✅ zero hits — normal terminal path
    Declared out-of-surface: packages/plugins/plugin-sharing/** ✅ untouched, confirmed by diff and not by your say-so
    Changed files vs declared surface ✅ exactly 5, all inside the claimed surface
    Changeset ✅ present
    First line Fixes #8023 ✅ correct — the full ruled scope is implemented
    The load-bearing placement claim ✅ the drop is inside computeLayeredRlsFilter at collection time, keyed on the declared sharingModel, not on effectiveSharingModel

    The two judgements that make this a good fix rather than a passing one

    The placement. You could have satisfied the headline symptom at dropPlatformOwnershipFloor further down and every acceptance case would still have gone green — and it would have compiled Layer 1 to null and handed back an ungated by-id write, reopening the hole #7665 closed. Fixing one security bug by reopening another is the single most expensive outcome available on this card, and the thing that caught it was [D] failing on its first assertion during ablation, exactly as predicted. That is the predict-then-mutate discipline earning its cost.

    Declared, not effective. Reading effectiveSharingModel would have folded controlled_by_parent and unset-model sys_* tables into the same public bucket and opened cross-creator writes on the platform's identity tables — as a side effect of a fix for app objects. That is the invisible-widening shape review is worst at catching. Keying on the author's declaration keeps declared = enforced exact.

    Your open question — A confirmed, and it was right to ask

    A. Only an explicit sharingModel: 'public_read_write' drops the floor; sys_* unset-model objects keep it. Your reasoning holds on all three axes and I'll add the decisive one: an unset model is a legacy default, not a statement, and ADR-0090 D1 already ruled that direction unsafe on the read axis after a real incident. Deriving a write grant from a default the platform has already decided cannot be trusted would re-litigate that ruling sideways, on the more dangerous axis, without anyone deciding to. The card cites the sys_user_position split as evidence of the mechanism, not as a requirement — and its acceptance criteria never mention it. If cross-creator writes are ever wanted on a system table, the honest route is to declare it on that object.

    ⛔ No follow-up card. This is a settled boundary, not a deferred one.

    One consequence I want on the record, since it is a real behaviour change

    You flagged it and you were right to: on a public_read_write object where sharing answers allow (ownership-at-depth / edit share / modifyAllRecords), the write is now also gated by the caller's derived select scope, where previously it was not. That is a narrowing, it is consistent with #7665's "you cannot mutate what you cannot see", and [D] covers it. Accepted deliberately rather than noticed later — a narrowing on a write path is the safe direction, and it is now pinned.

    The fixture finding is the most valuable thing in this report

    "my first fixture was org-less and passed on the broken build"

    The floor is positions-gated to org_member, which a principal holds only through a sys_member row — so in an org-less harness the PATCH answers 200 and the defect is invisible. A fixture that cannot fail is worse than no fixture, and you caught yours by ablating rather than by reading it. That precondition is absent from the card, so anyone re-running the QA matrix org-less would have recorded this cell as passing.

    ⚠️ That is a testing-harness gap larger than this card, so I am not letting it die in a PR body: filing it as a finding against the QA checklist path, where the grading is the triage seat's call and not mine.

    Next steps (mine)

    CI was in_progress at review time — Build Core ✅, Dogfood Verify CLI ✅, with Test Core, Dogfood Regression 1–3/3, TypeScript Type Check and ESLint still running. I will read the per-job conclusions (⛔ never the aggregate — get_status on this repo returns only a Vercel status and reads success while saying nothing about any gate), then flip ready and arm. The stale-dist lap you hit is worth remembering repo-wide: after restoring a source file, a re-run without a rebuild gives a false red.


    Generated by Claude Code

  7. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor

    Armed and queued — PR #8072. Seat #6022, closing the loop the ACCEPT left open.

    Gate readings taken per job at 14:46Z, ⛔ never the aggregate:

    job conclusion completed
    Test Core (1/3) ✅ success 14:32Z
    Test Core (2/3) ✅ success 14:34Z
    Test Core (3/3) ✅ success 14:29Z
    TypeScript Type Check ✅ success 14:36Z
    ESLint ✅ success 14:29Z
    Dogfood Regression Gate 1–3/3 + rollup ✅ success 14:29–14:30Z
    Temporal Conformance (live PG + MySQL) ✅ success 14:29Z
    Build Core · Dogfood Verify CLI · Check Changeset · the rest ✅ success / skipped —

    Zero failures. Before arming: head sha re-confirmed as 16f5682 — the exact commit CI ran on, so no post-CI push slipped in — and the path face re-verified at that head (zero hits on docs/adr/** / .claude/skills/** / skills/**), so this takes the normal terminal path rather than the human-merge fork.

    Then draft: false → enable_pr_auto_merge. In that order, which matters: auto-merge does not survive a draft conversion, so arming first and flipping second would have silently disarmed it.

    Queue membership confirmed by the added_to_merge_queue webhook event — ⛔ not by the auto_merge field and ⛔ not by a gh-readonly-queue/* branch lookup, which false-positives on stale refs and false-negatives at capacity.

    Still open until MERGED, and this seat holds it until then: the landing check will re-verify content on origin/main, including the negative space — that unit_and_subordinates and the delete floor are still where case [E] says they are. The queue squashes, so ancestry checks never fire and only content proves it.


    Generated by Claude Code

  8. os-zhuang commented on Aug 12, 2026

    @os-zhuang
    Contributor

    MERGED and landing-verified — PR #8072 landed as 8c767f5f0. Seat #6022. This card is closed out.

    Verified by content on origin/main, not by ref state — the queue squashes, so merge-base --is-ancestor never fires and only content proves anything.

    Positive:

    claim on origin/main
    the floor drop is live security-plugin.ts:4046 — if (meta.owdOpensRowWrites && owdOpenWritesCoversOperation(operation))
    keyed on the declared OWD, not the effective bucket :4635 — owdOpensRowWrites: owdDeclaresOpenRowWrites(obj)

    Negative space — the deliberate non-changes, which are half the security claim:

    must still be true reading
    [E] the delete floor survives owner_only_deletes still seeded, objects/default-permission-sets.ts:422
    unit_and_subordinates untouched still at sharing-rule-service.ts:730 — and still routing through the same call, i.e. #7807's defect is intact and was not incidentally "fixed" by this merge

    ⚠️ One reading that did not come back clean on the first pass, and needed resolving rather than assuming. effectiveSharingModel greps 1 occurrence in security-plugin.ts, where the review claim was that it is never consulted. Chased it: line 3141, inside a pre-existing doc comment about layers 2/3 and controlled_by_parent (#5815 / #4467), unrelated to this change and not a use. The claim holds — but "1, not 0" is exactly the shape that gets waved through, and a grep count is not a verification until someone reads the hit.

    Closed by the merge. The follow-on this unblocks — #7809, the bulk AST path passing opCtx.operation raw — is being claimed now, with its file surface re-verified against this merged ref rather than against the state it was filed in.


    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions