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

fix(plugin-security): a public_read_write OWD opens row-level writes, not just the creator's (#8023)#8072
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 codeand status, never a bare throw):

criterioncase
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

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

1 participant

@os-zhuang