Repository navigation
fix(plugin-security): explain's read verdict on a controlled_by_parent record takes the read door's answer on the master leg - #22813
Conversation
…t record takes the read door's answer on the master leg A record-grained read (and export) of a controlled_by_parent record now asks the read door's own by-id read, with the explained context, where every leg the report models admits the record. A record the door withholds is reported not visible on the sharing layer; a record it returns leaves the report unchanged; a rejection is reported fail-closed. Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
…d verb beside the by-id read door The family's per-verb table classifies read against its by-id GET door on a controlled_by_parent detail of a private master: a record under a master the principal cannot read, a record under one it reads, and an id no row carries, each beside that door's answer. Adds the patch changeset. Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d01a7e489b763ae7b35517f0565e684e9846a7f5 && git checkout d01a7e489b763ae7b35517f0565e684e9846a7f5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 14f4912390d7761a4b91a6e2ed1f1ca1fb95adfe eb427425180b98dc0cc31de7cdb052dc68ac9ffe && git checkout -B drift-repro 14f4912390d7761a4b91a6e2ed1f1ca1fb95adfe && git merge --no-ff eb427425180b98dc0cc31de7cdb052dc68ac9ffe
node scripts/docs-audit/affected-docs.mjs --json 14f4912390d7761a4b91a6e2ed1f1ca1fb95adfe
|
Part of #22792
This PR delivers item 1 of the card; item 2 (ruling A on #22795) stays on the card, and #22792 remains open for item 2.
Clause-②: no
What this changes
Class: explain-versus-door parity, the
readverb. The family's triage record on #22530 pinned every write verb; this widens that pin toread. Position:applyRecordAttribution'sreadbranch inpackages/plugins/plugin-security/src/explain-engine.ts.For a
controlled_by_parentrecord, the read door scopes the find by the record's master as well as by its own row-level security (ADR-0055). explain'sreadbranch modelled the record's own row-level security only, so its record verdict could disagree with the read door. It now asks the read door's own by-id read for that leg, and its verdict equals the door's answer:read(orexport, which streams the same find) of a record that exists, on acontrolled_by_parentobject, past the capability and object gates, where every leg the report already models (the tenant wall, the record's business row-level security, the sharing read filter) admits the record.ExplainEngineDeps.recordAbsentToCaller, the caller-context by-id read the write doors already ask, with the EXPLAINED context. No second copy of the master derivation.visible: false, decided by thesharinglayer (the layer the write verbs' master check names), with that layer's recordexcluded. A record the door returns leaves the report byte-identical. A rejection is reported fail-closed (not_evaluated, no predicate), as every other dependency fault on the record path is.controlled_by_parent, every write verb (the read-absent path of the by-id writes is not touched), object-level reports,allowed, and the answer for an id no row carries. The verdict keeps a decider; whether it should take the nonexistent-id shape is item 2's round, not this PR.Why the existing dependency and not a new one
The PM route pointed at the read door's master-aware read (the security plugin's master-derived read filter). Reaching it directly would add an optional key to the exported
ExplainEngineDepstype. On this family's own precedent (the update half, which addedcheckControlledByParentWrite) that is a widening of a published type,Clause-②: yes (widening)with aminorchangeset. The claim and this dispatch declareClause-②: nowith apatch.recordAbsentToCalleris the read door itself, so asking it is parity by construction, and no public type moves. Only its docblock changes, to say it is now asked for such a read too.Pins
packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.tsreada by-id read door (GET) on the fixture's private-master detail object. Three cells, each beside that door: a record under a master the principal cannot read (door404 RECORD_NOT_FOUND, explainvisible: false, decided bysharing); CONTROL, a record under a master the principal reads (door200, explainvisible: true); CONTROL, an id no row carries (door404 RECORD_NOT_FOUND, explain unchanged, no decider). The table stays total againstExplainOperationSchema.packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.tsreadandexportare classifiedread_door_asked. For each: a withheld record is not visible onsharing; a returned one is byte-identical to the report without the question; a rejection is fail-closed with no predicate; asked once with the explained context, and the write check is not asked; not asked where the record's own RLS or the tenant wall already decides; not asked on aprivateorpublic_read_writeobject, nor for an object-level request or a missing record;createis not asked.No new test file. The registered-service enumeration (
controlled-by-parent-write-member.test.ts) is left as it was; see Acceptance notes.Measurement and ablation
ObjectQL, SQL driver andSharingService, on179f7bf6c, which equals680a86b4cforplugin-security): the read door withheld the record and explain'sreadverdict disagreed with it. PM assumption 1 holds. After (same harness, the changed source): the verdict equals the door for the subject and both controls.scripts/ablation-replace.mjsin wrap mode, anchor 1 to 0, blob changed, under an outertraprestoring toHEADon EXIT, INT and TERM): the read master leg switched off behind a marker. Thenpnpm --filter @objectstack/plugin-security build, thenscripts/ablation-dist-preflight.mjsread the marker in 2 built files. Predicted: the engine's withheld, rejection and asked-once cells red forreadandexport, and the REST subject cell red, with every control green. Observed: engine 6 red, 67 green; REST 1 red (the subject cell), 17 green. Restore leg: blob equalsHEAD,git diff HEADempty, rebuilt, and the preflight with--absentread the marker absent from all 6 built files with the tree clean.Local verification (head
eb4274251, after mergingorigin/main098481744)pnpm --filter @objectstack/plugin-security exec vitest run: 199 files, 4053 passed, 45 skipped (run on5ad8ac3f5, before the merge, which touched no file of this package). Oneb4274251: the three explain and master-check files, 107 passed; the widened dogfood file, 18 passed.pnpm --filter @objectstack/plugin-security typecheck: exit 0.pnpm --filter @objectstack/dogfood typecheck: exit 0, after a full workspace build.node scripts/pm/dispatch-gates.mjs --commands: 70 derived commands, all run oneb4274251, all exit 0. Two first answeredPREREQUISITE NOT MET(exit 3, not a measurement) and were re-run green after the full build.--ran: 70 derived, 70 run, 0 NOT-MEASURED.--print-configresolves each), the changeset is outside itsfilesglobs, and--format jsoncounts 3 files with 0 errors and 0 warnings.eslint.config.mjsenables no type-aware linting, so this diff cannot move the verdict on any file it does not touch.pnpm lint, the full dogfood suite (only the touched file ran), and the path-scheduled CI jobs.Acceptance notes
controlled-by-parent-write-member.test.ts,VERB_ROWS) still classifiesreadasno_by_id_write, which stays literally true. Its fixture has no private master, so a read-door row there needs a fixture change. The family's REST and engine enumerations carryread. Carrier: none.readFilteron areadexplanation is therlslayer's artifact (computeRlsFilter). The service'sgetReadFilteralso ANDs the master-derived scope and the sharing filter. This is an observation from reading code, not measured at a door, and it is the object-level question, not this card's record verdict. Not filed. Carrier: none.recordAbsentToCallerwiring insecurity-plugin.tsstill calls it the write path's read question. It is accurate, but it no longer covers every caller. Left alone to keep the diff inside the declared territory.Generated by Claude Code