Skip to content

fix(plugin-security): require by-id write targets to be within the caller's readable set under select-only RLS - #7792

Merged
huangyiirene merged 3 commits into
mainfrom
claude/issue-7665-by-id-write-visibility-gate
Aug 11, 2026
Merged

fix(plugin-security): require by-id write targets to be within the caller's readable set under select-only RLS#7792
huangyiirene merged 3 commits into
mainfrom
claude/issue-7665-by-id-write-visibility-gate

Conversation

@os-help

@os-help os-help commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7665

What was open

An object whose row narrowing is authored as operation: 'select' rules only had an open by-id write path. A contributor holding only select-scope narrowing could PATCH records they cannot read — 200, values persisted — on the master object and on a controlled_by_parent detail, while the read side correctly hid the same rows (GET 404, absent from list). QA run #7637 measured it on the stock showcase across three objects, twice each, and called it the strongest finding of the sweep.

The by-id write pre-image gate (2.7), the controlled_by_parent master check (2.8) and the bulk write filter all compose the RLS filter for the write operation. With no update-scope policy applicable to the caller, that filter compiled to a null Layer 1 and all three row gates became a no-op at once; OWD public_read_write then let resolveSharingCanEdit admit the write, and the detail derived its access from that same permissive master verdict.

The fix — option (A), one decision site

An empty write-class policy collection now derives its scope from the caller's select narrowing, inside computeLayeredRlsFilter — the same policies, compiled by the same compiler, that the read path enforces. "You cannot mutate what you cannot see" then holds by construction on the by-id gate, the master check and the bulk AST, and the explain engine reports the same narrowing for update/delete that it reports for read.

Deliberately not derived:

checkAuthoredRowWrite is structurally unreachable by derivation: its authored-set pre-check abstains whenever the authored set is empty, and the authored set is a subset of the collected set, so derivation can only fire on inputs that already abstained. A derived scope therefore can never masquerade as an authored admission (#5493 / #7281).

Refusal envelopes are the existing house answers, unchanged: PERMISSION_DENIED / 403 with the record_access_denied catalog sentence from the 2.7 gate, and the master-edit sentence from 2.8. Neither distinguishes "row absent" from "row invisible", so no existence oracle is opened.

Is this a behaviour change or a bug fix?

Both, and the honest split matters for review. It is migration-visible (the changeset says so plainly). But the platform's own conformance ledger already claimed the property: authz-conformance.matrix.ts:39 marks rls-by-id-write as state: 'enforced' and cites rls-fixture.dogfood.test.ts as its proof — while that very proof file pinned the select-only case as an rls-hole. The ledger and its own cited evidence contradicted each other; after this PR they agree. examples/app-showcase/src/security/permission-sets.ts makes the same promise in a comment about detail writes, also false as shipped.

One caveat, because it was raised and is worth stating accurately rather than claiming more than holds: content/docs/permissions/authorization.mdx:55 ("If no applicable policy compiles, the result is a deny-all sentinel") is weaker evidence than it first appears. In the code the sentinel fires only when applicable policies existed and all of them failed to compile — the field-existence net — and never when the applicable set is empty (security-plugin.ts:2599: "policies applied but none compiled"). Read literally as "the applicable set was empty", the sentence would also mean an object with no RLS at all denies every read, which has never been the behaviour and is not what this PR changes. The layer-5 row's fail-closed failure direction is the part that was genuinely not honoured on the write path.

Acceptance criteria

# Criterion Where
1 Select-only contributor cannot PATCH out-of-scope by id, master and controlled_by_parent detail select-only-write-visibility.test.ts (2 describes) + dogfood end-to-end
2 Probe persona holds object read+edit(+delete) grants, outside the record scope qa_contributor holds full CRUD on all three objects; the dogfood fixture member set grants read+edit+delete on rls_note
3 Read side unchanged (GET still 404s, list still omits) unit read pin + dogfood by-id read assertion
4 A legitimately in-scope write still succeeds unit pins on master and detail + dogfood "member edits their OWN note"
5 #7401 / #6736 widener directions preserved, both allow and deny qa_doc control (authored update rule) + the whole dogfood authored-row-write-scope suite

Verification

Ablation with the direction predicted before running (revert the enforcement to origin/main): 8 of 17 unit cases flip red — the 6 enforcement pins and both explain pins — while all 9 steady-state / guard / widener pins stay green. Exactly as predicted.

Two further mutations prove the criterion-5 guards are live rather than decorative:

End-to-end on a real HTTP stack (rls-fixture.dogfood.test.ts), measured both ways:

with the fix:     rls_note [rls-consistent] member B cannot read (GET 404) and could not mutate (PATCH 403, row unchanged)
without the fix:  rls_note [rls-hole]       member B cannot read it (GET 404) yet MUTATED it by id (PATCH 200)

Suites, all green on top of merged main: @objectstack/plugin-security 990, @objectstack/plugin-sharing 428, @objectstack/runtime 2018, @objectstack/http-conformance 72, @objectstack/verify 23, and the full @objectstack/dogfood suite 578 passed / 3 pre-existing skips. Typecheck clean. Gates run: check:nul-bytes, check:engine-double-contract, check:tenant-chokepoint, check:authz-resolver, check:error-code-casing, check:empty-changeset, check:doc-authoring, check:adr-links.

Docs

content/docs/permissions/rls.mdx described select as narrowing reads and the write classes as guarding "the matching write" — the exact mental model that produced this defect, and an understatement of enforcement after the fix. It now records that a select policy also bounds writes when no write-class policy applies, with the three boundaries (authored write predicate decides its own class; never insert; never a read-side superuser-bypass holder), plus a pointer from the operation property row.

Two corrections to the inherited draft

This card was carried over from a session terminated mid-flight; the preserved diff was reviewed as an untrusted draft and two defects were found by measurement:

  1. The explain pin was vacuous. It asserted the rls layer reports narrows for operation:'update', but the fixture ran with org scoping active, and the verdict is read off the composed layer0 AND layer1 — so the tenant predicate alone satisfied it. Measured: it stayed green under the full ablation. Rewritten to run at posture single (null Layer 0), which both makes the assertion able to fail and reproduces the card's measured signal, whose not_applicable is only reachable with a null Layer 0. Two cases added (delete narrows, create still not_applicable).
  2. The widener pin's green was over-readable. The fake engine's findOne does not re-enter the middleware chain, so the pre-image gate's caller-scoped read is not simulated. The case is sound at the layer this fix edits (filter composition) but does not establish the widener works end-to-end — that is The by-id write pre-image gate resolves the row under the caller's own read scope, so an app-authored widener is still dead on private even once checkAuthoredRowWrite admits it #7401's open question. Documented in place so a later reader cannot mistake it.

The dogfood planted-hole RED block was correctly repurposed as the #7665 end-to-end pin; detector liveness survives in rls-runner.test.ts, whose scripted stack still plants and classifies a hole and which was measured green in both ablation states, so this fix cannot switch its oracle off.


Generated by Claude Code

…ller's readable set under select-only RLS

An object whose row narrowing is authored as `operation: 'select'` rules only
had an open by-id write path. A contributor could PATCH records they could not
read — 200, values persisted — on the master object AND on a
`controlled_by_parent` detail, while the read side correctly hid the same rows
(GET 404, absent from list). Measured live on the stock showcase by QA run
#7637, on three objects, twice each.

The by-id write pre-image gate, the controlled_by_parent master check and the
bulk write filter all compose the RLS filter for the WRITE operation. With no
update-scope policy applicable to the caller that filter compiled to a null
Layer 1, and all three row gates became a no-op at once; OWD
`public_read_write` then let `resolveSharingCanEdit` admit the write, and the
detail derived its access from that same permissive master verdict.

An empty write-class policy collection now derives its scope from the caller's
SELECT narrowing, at the single decision site in `computeLayeredRlsFilter` —
the same policies, compiled by the same compiler, that the read path enforces.

Deliberately not derived: when any write-class policy applies (an authored
predicate or the in-domain platform ownership floor), so app-authored write
wideners keep deciding alone and the #7401 / #6736 directions are preserved
exactly; for `insert`, which has no pre-image to be visible; and when the
caller holds the read-side superuser bypass on a posture-permitting object,
whose readable set is unbounded — the mirror of the read path's own Layer 1
short-circuit, so a derived write scope can never be narrower than the read
scope it comes from.

Fixes #7665

Co-Authored-By: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 11, 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 11, 2026 3:25pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-security, @objectstack/dogfood.

13 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, packages/qa/dogfood)
  • content/docs/permissions/delegated-administration.mdx (via packages/qa/dogfood)
  • 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.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 11, 2026
claude added 2 commits August 11, 2026 15:10
…hen no write-class policy applies

The "Choosing `operation`" paragraph described `select` as narrowing reads and
the write classes as guarding "the matching write" — the exact mental model
that produced #7665, and now an understatement of what the platform enforces.

States the derivation and its three boundaries: it applies only when no
write-class policy applies to the caller (an authored write predicate keeps
deciding its class alone), never for `insert`, and never for a caller holding
the read-side superuser bypass. Adds a pointer from the `operation` property
row.

Co-Authored-By: Claude <noreply@anthropic.com>
@os-help
os-help marked this pull request as ready for review August 11, 2026 15:31
@os-help
os-help enabled auto-merge August 11, 2026 15:31
@os-help
os-help disabled auto-merge August 11, 2026 15:36
@huangyiirene
huangyiirene added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit b54aaab Aug 11, 2026
28 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-7665-by-id-write-visibility-gate branch August 11, 2026 20:11
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

Development

Successfully merging this pull request may close these issues.

[security] A by-id write is not gated by record visibility — a contributor mutates records they cannot read, when only select-scope RLS is authored

3 participants