Skip to content

fix(plugin-security): a public_read_write OWD opens row-level writes, not just the creator's (#8023) - #8072

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-8023-owd-public-read-write-floor
Aug 12, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-8023-owd-public-read-write-floor

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #8023

An object declaring sharingModel: 'public_read_write' promised "everyone can see and edit" and delivered "everyone can see, only the creator can edit".

Premise: reproduced, with one correction to the card

The card's mechanism is confirmed, and so is the PM's correction of its file attribution (the shipped seed is objects/default-permission-sets.ts:415; platform-ownership-policies.ts only documents the floor). Reproduced end-to-end against the real showcase app before any edit:

positions: ["org_member","contributor","everyone"]
GET   /api/v1/data/showcase_project/:id   -> 200
PATCH /api/v1/data/showcase_project/:id   -> 403 PERMISSION_DENIED
  "You do not have access to this record. Contact the person who owns it, or your administrator, if you need access."
server log: [Security] Access denied: not permitted to update this 'showcase_project' record (row-level security)

POST /api/v1/security/explain reproduced the card's other measurement exactly — the rls layer reporting narrows for operation: 'update' on an object with zero authored RLS, while read reported not_applicable.

One precondition the card does not state, and it matters for anyone re-running this. The floor is positions-gated to org_member, which a principal holds only through a sys_member row. In an org-less harness a fresh sign-up gets positions: ['everyone'], the floor never applies, and the PATCH answers 200 — the defect is invisible. My first fixture was org-less and passed on the broken build; that is recorded in the test file's header, because a fixture that cannot fail is worse than no fixture. The reproduction above stands up the default organization the membership reconciler binds new users to (ADR-0093 D1), which is what a real deployment boots into.

The cause

member_default ships owner_only_writes (object '*', operation update, created_by == current_user.id, positions ['org_member']). The by-id write pre-image gate lets ISharingService's tri-state verdict replace that floor, but only on a positive allow — and on a public object the service abstains, correctly, because record sharing does not enforce there. An abstain keeps the floor, so the floor became the object's only row-level write gate and silently overrode its OWD.

The fix, and why it is where it is

An object whose author declared public_read_write no longer inherits the wildcard update floor at collection time, inside computeLayeredRlsFilter.

The placement is the fix, not a style choice. Removing the floor there leaves the update class empty, so #7665's derive-from-select branch then supplies the write scope from the caller's SELECT narrowing — which is exactly what preserves #7792's by-id write visibility. Removing it at the existing dropPlatformOwnershipFloor filter further down would instead compile Layer 1 to null and hand back an ungated by-id write: the hole #7665 closed, reopened by a fix for a different bug.

Three boundaries are deliberate, each with a control case in the test:

  • delete is unchanged. public_read_write is "see and edit"; the legacy full alias that also covered transfer/delete was refused a mechanical conversion for being wider than it (ADR-0090 D4, spec/conversions/registry.ts). owner_only_deletes still refuses a non-creator delete.
  • Only the declared OWD qualifies. The author's declaration is read, never plugin-sharing's effectiveSharingModel, which folds controlled_by_parent and an unset model on a system object into the same 'public' bucket. A detail derives access from its master; an unset model on a sys_* table is a legacy default, not a statement. Opening either would have handed members cross-creator writes on the platform's identity tables. An unresolvable schema fails closed.
  • Only the platform's floor. Provenance decides, so an app-authored policy spelling the identical predicate still reaches the compiler and still refuses (ADR-0049).

I did not take the composition-semantics route (reinterpreting what an abstain means): abstain covers three distinct facts — public OWD, no owner field, bypass-listed internal — and only the first says anything about writes being open. Widening the abstain would re-open #5492's E2 measurement on the other two. plugin-sharing is untouched.

Verification

Acceptance evidence is at the layer the harm is at — HTTP PATCH through the real REST stack — in a new dogfood fixture whose three objects are byte-identical apart from their OWD.

Predict-then-mutate ablation. The predicted red/green split was written down before running, then the decision-site hunk alone was removed (helper and plumbing left in place). Measured result matched the prediction exactly: 2 failed / 7 passed, failing set {[A public_read_write], [D #7792]}, with [D] failing on its first assertion (the in-scope PATCH — the narrowed persona is a non-creator too), precisely as predicted. [C public_read], [C private], [B object gate] and [E delete floor] stayed green on both sides; that invariance is the security claim rather than a coverage gap.

Acceptance criteria, each with a positive pin (rejection cases assert code and status, never a bare throw):

criterion case
an edit:true persona PATCHes a row it did not create [A] — 403 -> 2xx, value persisted
object-level gate unchanged, distinct message [B] — 403 PERMISSION_DENIED, asserts the object-level sentence is present and the record-level one is not
floor still refuses on private / public_read [C] x2 — 403 PERMISSION_DENIED, record-level sentence, nothing persisted
#7792 not regressed [D] — out-of-select-scope row unreadable and unwritable; in-scope row writable
(boundary) delete not opened [E] — non-creator DELETE still refused, row survives
(second consumer) [F] — explain update flips narrows -> not_applicable, while delete on the same object still reports narrows
plugin-security   50 files, 1006 tests   PASS
plugin-sharing    18 files,  468 tests   PASS
dogfood (full)    96 files,  619 tests   PASS
typecheck         plugin-security, plugin-sharing, dogfood   PASS
gates             check:cross-package-test-inputs, check:docs-audit-scope,
                  check:test-source-alias, check:nul-bytes   PASS

Gates re-derived for the final file surface; check:authz-resolver from the dispatch no longer matches because objects/default-permission-sets.ts is not edited.

Not settled here

The regression window 92f26f75 -> b602d536 is not repeated as fact — no build ablation at the parent commit was run, so the attribution remains the card's mechanism evidence rather than a differential build.

The card's cross-object confirmation (a sys_user_position row readable but not PATCHable by a non-creator) is not fixed: sys_* objects reach the same abstain through the unset-model path, which this change deliberately leaves closed. Whether platform system tables should open cross-creator writes is a separate contract question and would be a widening, not a restoration.


Generated by Claude Code

… not just the creator's (#8023)

The platform's wildcard `owner_only_writes` floor (object '*', operation
update, `created_by == current_user.id`, positions ['org_member']) stayed
composed into Layer 1 on objects declaring `sharingModel: 'public_read_write'`,
because the by-id write pre-image gate only lets ISharingService REPLACE the
floor on a positive `allow` and a public object makes the service abstain.
Net effect: the OWD declared 'everyone reads and writes' and the runtime
enforced 'only the creator writes' — a declared-but-unenforced security
property on a published surface.

The floor is now conditioned on the object's DECLARED OWD at collection time,
before #7665's derive-from-select branch, so the write class falls through to
the caller's select narrowing and by-id write visibility (#7792) is preserved.
Only `update` is opened (owner_only_deletes survives), only the canonical
`public_read_write` spelling qualifies (controlled_by_parent and an unset
model on a system object do not), and only the PLATFORM's floor is dropped —
an app-authored policy with the same predicate still reaches the compiler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 12, 2026 2:25pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security.

12 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via @objectstack/plugin-security)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/plugin-security)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/access-recipes.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/authorization.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/explain.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/permissions-matrix.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/plugin-security)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-security)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-security)
  • content/docs/ui/audience-based-interfaces.mdx (via packages/plugins/plugin-security)
  • content/docs/ui/dashboards.mdx (via @objectstack/plugin-security)

⛔ 1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-security)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants