Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-security): a check-only write policy no longer disables both write-side row gates (#8059) - #8099
Conversation
…th write-side row gates (#8059) On the shipped showcase a `contributor` persona got GET 404 and PATCH 200 with the row actually changed, on `showcase_invoice` and its `controlled_by_parent` lines: a caller who could not read a record could write it by id. One authoring shape — an update-class policy declaring `check` and no `using` — switched off both write-side belts at once. Site 1: the #7665 write-visibility floor triggered on an EMPTY applicable set. A check-only policy is applicable (object + positions + operation) but carries no `using`, so the derivation was skipped while the policy compiled to no row filter — Layer 1 null, and the by-id pre-image gate, the controlled_by_parent master check and the bulk AST injection all no-ops again. The trigger now asks whether a write-scope PREDICATE applies, per #7665 criterion 5; a `check` clause is post-image validation (ADR-0058 D4), not a scope predicate. Site 2: `computeWriteCheckFilter` withheld `heldPositions`, so the ADR-0090 P2 applicability domain was evaluated against `[]` and every policy declaring `positions` was dropped from the post-image check for EVERY caller — a position-scoped `check` clause was inert (ADR-0049 enforce-or-remove). Consequence of site 1, fixed with it: `checkAuthoredRowWrite` reached `abstain` for check-only policies only because Layer 1 was null. It now requires an authored policy that declares a row scope, so a derived scope cannot masquerade as an authored admission (#5493 / #7281) — preserving the previous verdict. `policyDeclaresClause` in `rls-compiler.ts` is the single definition of "declares a clause", the same test the compiler applies per policy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 12 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
hotlong
commented
Aug 12, 2026
PM review — |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8059
The exposure
On the shipped showcase, on stock
main, a persona holding the plaincontributorposition:GET /data/showcase_invoice/:id→ 404 —invoice_own_rowscorrectly hides an invoice it does not own, andPATCH /data/showcase_invoice/:id→ 200, and the row actually changes (re-read as admin).Same on
showcase_invoice_line, which derives access from that invoice viacontrolled_by_parent. A caller who cannot read a record can write it by id — including reassigning its owner to itself. The #1994 class, reopened for this authoring shape one issue after #7665 closed it for the select-only shape.Not showcase-specific: any app authoring a
usingnarrowing for a position plus any update-class policy — even one that only validates the post-image — loses by-id write scoping on that object. The showcase is the shipped example of the recommended authoring style.Both sites move, because one authoring shape disabled both belts
The showcase's
invoice_owner_immutableisoperation: 'update'with acheckclause and nousing.Site 1 —
computeLayeredRlsFilter, the write-visibility floor.#7665's derivation triggered oncollected.length === 0. A check-only policy is fully applicable (object,positions, operation all match), so the set was non-empty and the derivation never ran — while that same policy compiled to no row filter at all, having nousingfor the compiler to read. Layer 1 was null and every write-side row gate composed from it went back to being a no-op: the by-id pre-image gate, thecontrolled_by_parentmaster check, the bulk AST injection. The trigger now asks whether a write-scope predicate applies, which is what #7665 criterion 5 says: acheckclause is post-image validation (ADR-0058 D4), not a scope predicate. The predicate test is a strict superset of the emptiness test, so every path #7665 pinned is reached on the same inputs as before.Site 2 —
computeWriteCheckFilter, the post-image belt.heldPositionswas not passed, sogetApplicablePoliciesevaluated the ADR-0090 P2 applicability domain against[]and dropped every policy declaringpositionsfrom the check, for every caller — holder and non-holder alike. A position-scopedcheckclause was inert (ADR-0049 enforce-or-remove; the class #3539 closed for org-scoped policies). Positions are now threaded through exactly as every othercollectRLSPoliciescall site threads them, and the domain still decides: a non-holder is still outside it.Third site, a direct consequence of site 1 and fixed with it.
checkAuthoredRowWritereached itsabstainverdict for check-only policies only because Layer 1 happened to be null. With a scope now derived in exactly that case, the derived readable-set scope would have begun answeringadmit— which the by-id widener uses to widen. Its authored-set pre-check now requires a policy that actually declares a row scope, so a derived scope still cannot masquerade as an authored admission (#5493 / #7281). This preserves the previous verdict rather than changing it.policyDeclaresClauseinrls-compiler.tsis the single definition of "declares a clause" — the same test the compiler already applies per policy when it skips a policy carrying no predicate for the clause being compiled.verify --rlson the showcase: 2 holes → 0 holesThe base probe persona holds no positions, so this class is unreachable for it —
verify --rlson this branch alone reports 0 holes, and so does stockmain. That number proves nothing, so the real signal was measured with #8067's position personas, composed locally with this fix in one tree, toggling only this commit:contributorpersonaThe ablated run reproduces #8067's reported before-state exactly, down to the sentence:
Both objects flip to proven and consistent — the proven count rises 33 → 35, so they became proofs, not skips.
✓ verify passed. Nothing inpackages/verify, the probe, or the showcase's policies was touched; the composition was local and never pushed.Tests
packages/plugins/plugin-security/src/check-only-write-scope.test.ts— 21 new pins on the showcase's exact invoice shape (select-onlyusing+ a check-onlyupdatepolicy declaringpositions,public_read_writeOWD, themember_defaultfloor present and out of domain).Every refusal is asserted on its ADR-0112 envelope, never on the bare fact of a refusal, because the two belts overlap on the obvious probe: revert site 1 and a "the PATCH was refused" assertion stays green, since the check gate refuses it instead (the caller cannot read the pre-image, so the post-image is missing
ownerand fails the check by accident). The belts have distinct envelopes —record_access_denied/ "(row-level security)" for the row gate,record_change_not_allowed/ "would violate a row-level CHECK" for the check gate — so a refusal migrating between them reddens.Site 1 is additionally pinned where the check gate provably cannot stand in for it:
owner: itself, a record steal that belt 2 passes by design, so only the derived row scope can refuse it;Every refusal is followed by an admin re-read of the row through the full middleware chain plus the stored row — a refusal that still wrote is the defect.
Also pinned: Layer 1 really derives (explain reports rls
narrows, notnot_applicable; the derived scope admits the caller's own rows rather than being a deny sentinel);heldPositionsreaches the check filter for a holder and not for a non-holder; nothing over-blocks (in-scope update, detail update, detail insert and delete by a legitimate holder all still land); and #7665 criterion 5 is unchanged where a real update-scopeusingis authored — the widener still widens and its boundary still refuses.Independent reverse verification, each site ablated alone from the committed state:
expected a refusal, got a completed update); the ordinary PATCH was still refused but by the wrong belt ('You are not allowed to save this record with the values you entered...'instead of'You do not have access to this record...'); thecontrolled_by_parentline update and insert both completed; the bulk update rewrote the unreadable row; explain reportednot_applicable. Every site-2 pin stayed green.expected a refusal, got a completed update(the owner reassignment landed). Every site-1 pin stayed green.Verification
pnpm --filter @objectstack/plugin-security test— 51 files, 1022 tests, all pass (1001 before this PR, +21 new).pnpm --filter '...@objectstack/plugin-security' typecheck— 26 packages clean. That filter is the prefix / downstream-consumer direction; the dependency closure was built first (turbo run build --filter='...@objectstack/plugin-security', 66 tasks).pnpm check:type-check-debt— OK, "none above its recorded number". Not raised: plugin-security's test layer measures 11 against a recorded ceiling of 21.pnpm --filter @objectstack/dogfood test— 624 passed, 3 skipped, 0 failed.node scripts/check-nul-bytes.mjs— OK.Generated by Claude Code