Repository navigation
fix(plugin-security): explain answers a transfer of a row the caller cannot read as missing, as its door does - #22610
Conversation
…ed verb through the door's mapping, so a transfer of an unreadable row answers missing The #21771 ruling-A rewrite was guarded on the raw explained verb (update or delete). The door asks the read question of the by-id update and delete the caller addresses, and a transfer's door is the PATCH that writes owner_id, an update. The guard now reads rlsOperationForVerb, the one mapping step 2.7 reads, so a transfer of a row the caller cannot read is explained with the missing-record shape the door's 404 is. Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
…ite verb the mapping names, beside the engine door, on both axes Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
…read-absent and no-policy rows (cpg fixture) Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
…bsent verdict Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 16 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 557b1f6f00110eec00f5287cbf8630ead57057e4 && git checkout 557b1f6f00110eec00f5287cbf8630ead57057e4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d748ae80afe6ed63d1c61a51a69f4fb18280c844 abf492af0f71068c5afff3f7902180e4f184a2d7 && git checkout -B drift-repro d748ae80afe6ed63d1c61a51a69f4fb18280c844 && git merge --no-ff abf492af0f71068c5afff3f7902180e4f184a2d7
node scripts/docs-audit/affected-docs.mjs --json d748ae80afe6ed63d1c61a51a69f4fb18280c844 |
Fixes #22571
Clause-②: no
POST /api/v1/security/explainnow explains a transfer of a row the caller cannot read with the missing-record shape:record: { recordId, visible: false }, with nodecidedBy. That is what the transfer door's404 RECORD_NOT_FOUNDsays. The #21771 ruling-A rewrite inexplain-engine.tschecked the explained verb as asked (updateordelete). It now reads the verb throughrlsOperationForVerb, the one mapping the door's step 2.7 already reads, so every write verb whose door is a by-id update or delete is asked the read question.ExplainEngineDepsis unchanged and nothing is added toindex.ts, so this isClause-②: nowith apatchchangeset. Measured onorigin/main76bc1e03a. Line numbers are at this PR's headabf492af0unless marked.Step 1: the structural option, measured first (triage
6093130738)The question: can
explaintake the write verdict by calling the door's own gate functions instead of re-modelling them?What explain already calls. These are door-owned functions, wired in the explain block of
security-plugin.ts(:5492-:5573):requiredCapsForOperationdeps.requiredCapsPermissionEvaluator.checkObjectPermissiondeps.evaluatorcomputeLayeredRlsFilter,resolvePreImageFloorDrop(:3180)deps.computeLayeredRlsFilterwithrecordWriteFloorOptions(:5472)callerCannotReadAddressedRow(:7774), a caller-contextreadRowByIddeps.recordAbsentToCaller(:5560), the sameabsentUnderCallerReadover the same readassertControlledByParentWritedeps.checkControlledByParentWrite(:5569)canEdit/canDeletedeps.canEditRecord/deps.canDeleteRecordWhat explain re-models. Two things:
addressedByIdWriteId:update(:7749) anddelete(:7755).ql.findOnefor the id AND the write filter under the caller's context (:3243). explain matches a system-read row in memory (matchUnderDeclaredColumns).Every member of this family slipped on (1), never on a gate's own logic: #22514, #22530, #22550, and this card.
Verdict: the structural route does not fit this card. The gap: taking the verdict whole means extracting step 2.7 into one function of (object, id, verb, context) and handing it to the engine as a new
ExplainEngineDepskey. Step 2.7 reads middleware-local state that has no caller-free form today:preImageFloorOpts, written at :3208 and consumed later by step 3.6's post-image check;nestedInOperationandreferentialFieldClearWrite, whichaddressedByIdWriteIdtakes;extractSingleId,opCtx.dataandopCtx.options.That route would rewrite the security middleware's by-id write path and widen a published contract (Clause-② yes, contract-review tier). Both are larger than this card and outside its file surface.
Route taken: the per-axis fix, with
ExplainEngineDepsunchanged. For every cell this card measures, it is as correct as the structural route. The orchestration the rewrite needs is read off the mapping the door already reads, not restated. No second verb list is added: the pair the guard compares is the door's own two read-question operations (addressedByIdWriteId), which the guard already named before this change.Mechanism assumptions, measured
updateanddeleteonly. This isaddressedByIdWriteId(:7747-:7760). Its doc says "Update and delete only — the two verbs the ruling names". Step 2.7's pre-image runs for five verbs (:3163), but it asks the read question only for those two. A transfer reaches it because its door is thePATCHthat writesowner_id, an engineupdate; the engine dispatches notransfer.rlsOperationForVerbstates that same relation (transfer → update, restore → update, purge → delete). That is the pin's domain: the five verbs the mapping sends to update or delete. The assumption that step 2.7 asks the question for every by-id write it gates is partly falsified: a by-id enginetransfer, which nothing dispatches today, would not be asked (see Acceptance notes).main. explain update of an unreadable row gives the missing shape beside the 404. This holds in the single form and in the batch (recordIds) form. The pin still carries the cell.computeLayeredRlsFilter, :8257-:8266). explain composes the same scope through the same function. Onmain, the only divergence on this axis was the transfer verb's shape: the door answers 404, and explain answeredvisible: false, decidedBy: 'rls', which is a 403's shape. The same guard fixes it.?top=0answerstotal: 0, andsecurity/explainallows updates the write door refuses #22588 item 3(b) reading does not reproduce at record level; see the last table.clm_adminholder also holdsclm_requester, whosecontract_requester_edit_windowupdate policy names no positions. It therefore applies to that holder, andmodifyAllRecordsdoes not bypass business RLS on an ordinary posture (ADR-0066 ①).allowedanswers the object question by contract. The record verdict is inrecords[i].Verb × axis, door beside explain (cpg fixture,
public_read_writecpg_board,org_membercallers)The door's 404 is
security-plugin.ts:3257 / :3300 (recordNotFoundError). Its 403 is :3286 (record_access_denied). explain's missing shape isexplain-engine.ts:2269-:2279. Itsrlsdecider isapplyRecordAttribution'srlsExcludedbranch.76bc1e03a)owner_only_deletes)Exactly the two bold cells moved. A scratch probe on the same fixture read every other cell byte-identical before and after; the probe was never committed.
#22588 item 3(b) shape, measured on
main, unchanged by this PR. The caller held a set withviewAllRecords+modifyAllRecordsand no RLS, plus a second set with an update policyname == 'draft'. The row was namedapprovedand readable.recordIds)records[0]: visible false, decidedBy rls (top-levelallowed: trueis the object question)Pins and ablations
Ablation: the old guard was put back, keyed on the raw verb. Every leg went through
scripts/ablation-replace.mjsWRAP mode, with the mutation, the restore and the blob proof on disk. Both legs were run from committed HEAD9fefec732.packages/plugins/plugin-security/src/explain-read-absent-every-write-verb.test.ts(unit, realSecurityPluginover realObjectQL+ SQL driver, engine-level door)visible: true, decidedBy: 'rls', and no-policy transfer gavevisible: false, decidedBy: 'rls', each against the expected{ recordId, visible: false }1d8f2ff143a5== HEAD,git diff HEADemptypackages/qa/dogfood/test/explain-write-door-parity.dogfood.test.ts(REST, cpg fixture,org_member,assertArmed)ablation-dist-preflightfound the marker in 2 builtdist/files before the run. 2 failed / 21 passed, the same two cellsThe dogfood pin resolves
@objectstack/plugin-securitythroughdist/, so both of its legs were rebuilt and preflighted. The ablation spelling['update', 'delete'].includes(operation)is semantically the old guard and appears nowhere else indist/. The old spellingoperation === "update" || operation === "delete"already appears 3 times indist/, so it could not serve as a marker.Tests (at
abf492af0)pnpm --filter @objectstack/plugin-security typecheck: exit 0. The new test file is in thetsconfig.test.jsonprogram (--listFilescount 1), which the script'scheck:test-typecheckcompiles.pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2: 194 files, 4093 passed, 45 skipped.pnpm --filter @objectstack/dogfood typecheck: exit 0. The new pin is in the program (--listFilescount 1).git grep -il explain packages/qa/dogfood/test, 15 test files, the new one included): 15 files, 212 tests passed, in two batches of 8 and 7.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 70 commands. Each ran with its exit code recorded, and--ranreconciles: "70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN".check:dual-build-cjs-loadsfirst answered PREREQUISITE NOT MET because 8 unrelated packages had nodist/. After building them it answered exit 0, and that is the recorded code.eslint --no-inline-config --format jsonread 4 files, 0 errors, 0 warnings.eslint --print-configexits 0 for each file, so none is ignored.eslint.config.mjsenables no type-aware linting for any file (its own statement at :327-:328), so this diff cannot move an untouched file's verdict.pnpm lintis CI's.Acceptance notes
The by-id write verb set is spelled inline in several places.
security-plugin.ts: step 2.6 at :3084, step 2.7 at :3163, and withinsertat :3321, :7333 and :7455.MASTER_CHECKED_BY_ID_WRITESatexplain-engine.ts:964.delegated-admin-gate.tsat :151 and :382.None of these is this card's axis, and each matches the door today. The structural gap in Step 1, with step 2.7 as a door function, would retire the explain-side copy. Carrier: none.
Dormant: the read question is asked only for engine
updateanddelete(addressedByIdWriteId). If the lifecycle batch ever dispatches a by-id enginetransfer,restoreorpurge, the door would answer an unreadable row 403 while explain answers missing. Nothing dispatches those today;restoreandpurgeare refused at the object gate for everyone. Carrier: the lifecycle (M2) batch that re-grants them.explain's re-read is an in-memory match of a system-read row, while the door re-reads through the store under the caller's context (Step 1, item 2). No divergence was measured here. Carrier: none.
The batch explain response carries the object-level
allowedbeside per-recordrecords[i], which is the documented contract. The runtime (17.7.0): three server-side defects seen from a metadata app — hook refusals reach users with an internal prefix,?top=0answerstotal: 0, andsecurity/explainallows updates the write door refuses #22588 item 3 reading tookallowed: trueas the write verdict. Not a defect; noted for the seat that triages runtime (17.7.0): three server-side defects seen from a metadata app — hook refusals reach users with an internal prefix,?top=0answerstotal: 0, andsecurity/explainallows updates the write door refuses #22588.Generated by Claude Code