fix(plugin-security): checkAuthoredRowWrite answers the declaration, not the caller's read scope (#7281) - #7400
Conversation
…r an elevated scope (#7281) The verdict answers "does the declared, app-authored widener admit this row", which is a question about the row and the policy. It was resolved by re-reading the row through the caller's own execution context, so plugin-sharing's READ filter applied and a `private`-OWD cross-owner row was invisible to the question asked about it — `abstain` for a row the declaration names. The by-id widener surface was structurally dead on the posture #5493 built it for. Ruled by the maintainer on 2026-08-10 (reading 2): resolve the probe read under a scope that can see the row; the write decision stays with the pre-image gate. The predicate — {id} AND layer0(tenant wall) AND layer1(authored policies) — still carries the whole question and still compiles from the caller's own permission sets and tenant, so nothing about the ANSWER widens with the scope. The read is projected to `id`, so the probe can learn existence and nothing else. Test half, landed independently and first per the ruling: a real-stack pin (packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts) that measures the verdict on both OWD postures, plus six no-leak cases; and the fake-engine unit file's end-to-end assertion — which asserted a `private` cross-owner write LANDING, which the real stack refuses — removed, with the double's blind spot written into the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 109 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
|
PM review — accepted. Verified from the diff rather than from the report. The elevation is confined, which is the only thing that made this landable. Also correct, and easy to have missed: the old JSDoc paragraph claiming the caller-scoped read was "the non-widening direction" is deleted, not left standing beside its own refutation. That sentence is exactly the kind of stale prose three cards in this lane were spent removing today. The finding is the most valuable thing in this PRThe report does not claim the ruling achieved its purpose, and it is right not to. Measured: on So the ruling's premise holds exactly (the verdict was answering the wrong question and now answers the right one), while its implied consequence — that fixing the probe revives the by-id widener on Not extending the fix into that gate was the right call twice over: it is outside the ruled scope, and it answers a different public-contract question — may you write a row you cannot read — which the ruling never put to the maintainer. Filed as #7401 with both readings, the measurement, and today's behaviour pinned so either direction needs a deliberate change with a red test. I have escalated the gap on #7281 as well, so it does not live only inside a merged PR. What I checked on the no-leak setThe claim under review was the maintainer's — "no leak either way: the final write gate still enforces" — and it is now measured rather than inherited. The ones that carry weight are the two that go red against the pre-fix producer ( On the fake-engine assertionThe disposition is right and the reasoning is the part I want on record. The end-to-end write assertion was removed rather than corrected, because Two retained cases that cannot bite on this change are flagged in the test file, not only in the report. CI: 27 runs, all Marking ready and landing through the merge queue. Landing this does not close the widener story on Generated by Claude Code |
Fixes #7281
Implements the maintainer's 2026-08-10 ruling (reading 2) in the order it directs: the test half first and standing on its own, then the fix.
Branch point:
d53bd0ba9bbe603632e6e9f55c2f202f06a7d541. Anchors re-derived there (the triage anchors still hold;security-plugin.tsmoved in #7346 without disturbing them):d53bd0ba9checkAuthoredRowWriteservice registrationsecurity-plugin.ts:749/:754security-plugin.ts:2660security-plugin.ts:2703-2705security-plugin.ts:1301-1306sharing-plugin.ts:933-939row-write-widener-composition.test.ts:5211. The test half
row-write-widener-composition.test.tsassertedcheckAuthoredRowWrite(...) === 'admit'and then that the write lands, oncrm_opportunity— declaredsharingModel: 'private', with a row owned and created by somebody else. ItsmakeEngine()fake implementsfindas a direct row filter and registers no middleware, so no nested re-read in that file is ever scoped byplugin-sharing's read filter. Both of those nested reads — the probe's, and the pre-image gate's — decide their verdict on exactly that scoping. The double was looser than the producer on the one axis the outcome turns on.What I did with it, and why. The real-stack measurement (below) says a
privatecross-owner by-id write is refused, so the{ ok: true }end-to-end assertion measured behaviour the producer does not have and could not be corrected in place — that fake cannot represent read scoping at all. So:checkEdit→deny,checkAuthoredRowWrite→admit) — a fake can legitimately pin provenance composition against the realmember_defaultseed;admitassertion is flagged in the file as scope-blind — it is true on the real stack now, but it is green here for the fake's reason and would stay green if the producer regressed;plugin-sharing's deferral test, whose header named this file as "the REAL composition measured end-to-end", now points at the real-stack pin instead.The replacement is
packages/qa/dogfood/test/authored-row-write-scope.dogfood.test.ts, in thebulk-widener-probe.dogfood.test.tsidiom: realbootStack, realSecurityPlugin, realSharingServicePlugin, real ObjectQL engine, real middleware chain. Two objects identical in every respect except the OWD, one widener text, one principal, one cross-owner row shape.2. The measurement
wscope_notepublic_readadmitadmitwscope_secretprivateabstainadmitControl, ruling out an inert declaration: on that same
privateobject the caller's own admitted row answersadmiton both sides.3. The fix
The probe read moves to an elevated, principal-less context and is projected to
id. The predicate is unchanged and is the whole of the question:{id} AND layer0(tenant wall) AND layer1(app-authored policies), both layers still compiled from the caller's own permission sets and tenant before the read. Elevating the scope therefore cannot widen the answer — it only stops a READ decision from being folded into a question about a declaration.packages/spec'sISecurityServicedoc listed "the row is unreadable" among theabstaincases; that clause is the conflation the ruling removed, so it is corrected. Documentation only — no signature, shape or vocabulary change, still optional, still fail-closed.privateThe ruling's stated goal — "otherwise the by-id widener is structurally dead on
private" — is not reached by the ruled fix alone, and the reason is a second gate with the identical shape. The by-id write pre-image gate (security-plugin.ts:1301-1306) performs its ownfindOneunderopCtx.context, the caller's context, and is blind to the same cross-owner row for the same reason the probe was. Measured refusal onprivate, before and after:That is the row-level gate's envelope, not the sharing middleware's
FORBIDDEN: insufficient privileges— so on this posture the security gate refuses first and the deferral this verdict feeds is never even reached. (This also refines the card's own mechanism sketch, which attributed the 403 to the sharing middleware's refusal standing.)I did not extend the fix to that gate. Its caller-scoped read is deliberate and documented in place ("a read denial … is itself a 'cannot touch this row' signal"), and changing it means answering may you write a row you cannot read — a public-contract question the ruling did not put to the maintainer. Filed as #7401 (unassigned, for triage) and raised in the report; both behaviours are pinned here so changing it later is a deliberate act with a red test to justify.
The no-leak claim, measured rather than assumed
"No leak either way: the final write gate still enforces" is the maintainer's argument; these are the measurements behind it. All are green on both sides of the change — that invariance is the claim.
abstain, on both postures — the elevated read never turns "invisible" into "permitted".abstain; and still refused end-to-end with the ADR-0112 envelope.isSystem, carries no principal, and is not the caller's own context object; the caller's context is deep-compared before/after and is unmutated; and during a real by-id UPDATE through the middleware every read of the target row still carries the caller'suserIdand no elevation.['id'], so no column of a row the caller may not read is ever materialised.layer0from the composed predicate turns the existing cross-tenant case red (expected 'admit' to be 'abstain').Reverse verification — direction predicted in writing before the first mutation
Predictions were committed as
7d7c358f3before the producer was touched (file removed in the final commit; the commit stands as the timestamp). Predicted: the new real-stack pin is red against today's producer, green after; every no-leak case and both controls green on both sides; the end-to-endprivatePATCH still 403 after the fix, refused by the pre-image gate — "if it lands 2xx after, my reading of the pre-image gate is wrong and I must re-derive."Measured, producer taken out with
git checkout origin/main -- security-plugin.ts(nevergit stash), dist rebuilt each way:Two further mutations, since a case that cannot bite is not coverage:
Prediction and measurement agree case for case, including the one that matters most: the
privatePATCH is still 403 after the fix. Two retained cases cannot bite on this change — "the caller's context is not mutated" and "the elevation does not reach the write path" — and both are flagged as guards in the test file itself, not only here.Gates
pnpm --filter @objectstack/dogfood exec vitest run test/authored-row-write-scope.dogfood.test.ts— 12 passedpnpm --filter @objectstack/plugin-security test— 882 passed (43 files)pnpm --filter @objectstack/plugin-sharing test— 418 passed (16 files)typecheck— clean forspec,plugin-security,plugin-sharing,dogfoodeslinton all six changed files — cleancheck:nul-bytes,check:authz-resolver,check:engine-double-contract,check:error-code-casing,check:route-envelope,check:query-options-erasure,check:verify-stand-in— all greenChangeset added (
patchonplugin-security+spec): behaviour changes in a permission path, but only in the non-widening direction and with no end-to-end effect on any posture — nothing an app can observe as a new capability, and no contract shape moves.