Repository navigation
fix(plugin-security): explain composes a lifecycle verb's row-level security as the door does - #22570
Conversation
…ecurity as the door does The by-id write pre-image gate (step 2.7) composes transfer and restore as update and purge as delete. explain composed the raw verb, which the RLS compiler sends to select, so a transfer was explained against no update-class policy and no ownership floor. The mapping now lives once, in lifecycle-verb-rls-operation.ts. Step 2.7 reads it in place of its inline expression (byte-identical), and explain reads it for both of its RLS compositions: the object-level rls layer and the record's row story. Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
…he transfer door Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
…RLS composition Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
…nwalled object The walled object's object-level rls verdict reads 'narrows' for every operation (the tenant predicate), so it cannot tell the composed class apart; an object with no tenant column can. Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
…fecycle-verb-rls-mapping
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
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 f3dbd9fd0c99d94ad016b80c2e2a059ae96d2f4f && git checkout f3dbd9fd0c99d94ad016b80c2e2a059ae96d2f4f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fbb065fd4b52dce103776e49fefd90f0138059a7 47ff3aee73861de4382d56e813500d0b8b31738d && git checkout -B drift-repro fbb065fd4b52dce103776e49fefd90f0138059a7 && git merge --no-ff 47ff3aee73861de4382d56e813500d0b8b31738d
node scripts/docs-audit/affected-docs.mjs --json fbb065fd4b52dce103776e49fefd90f0138059a7
|
Fixes #22550
Clause-②: no
POST /api/v1/security/explainnow composes a lifecycle verb's row-level security for the operation the door composes it for:transferandrestoreasupdate,purgeasdelete. Before, explain composed the raw verb, whichRLSCompiler.mapOperationToRLSsends toselect, so no update-class policy and no ownership floor reached a transfer's verdict.The mapping is declared once, in a new module-internal helper (
lifecycle-verb-rls-operation.ts,rlsOperationForVerb). It is not exported fromindex.ts, soClause-②: noand the changeset ispatch. Read onorigin/main4638625e0; line numbers below are at this PR's head.The measurement (before / after)
Before, on the card's own shape (the dogfood pin below, against the raw-verb composition; this is ablation A): a
public_read_writecpg_boardrow namedclosed, which an app-authored update policy (name == 'open') excludes, for anorg_memberholding edit and transfer.PATCH /data/cpg_board/IDwritingowner_id(the transfer door)PERMISSION_DENIEDPERMISSION_DENIED(unchanged)transfer,recordvisible: true,decidedBy: 'object_crud'visible: false,decidedBy: 'rls'(equal to explainupdate)transfer,rlslayer's recordnot_evaluated("No business RLS policy applies")excludedopenboard,PATCH owner_idtransferon theopenboard,rlsrecordnot_evaluatedadmitted,visible: trueThe per-verb enumeration: the door's composed operation and explain's
transferupdate: step 2.7,security-plugin.ts:3176(rlsOperation) intocomputeRlsFilter:3209. The live door is thePATCHwritingowner_id, anupdateselect(rls-compiler.ts:1238default)updaterestoreupdate, same site. The object gate refuses it to every principal first (permission-evaluator.tsDESTRUCTIVE_OPERATIONS), so it never reaches step 2.7 todayselectupdatepurgedelete, same site, same object-gate refusalselectdeleteread/exportfindfindfind(unchanged)create/update/deleteexplain reads the helper at
explain-engine.ts:1814(rlsOp) and uses it for both of its RLS compositions:computeLayeredRlsFilterat:1372;rlslayer,computeRlsFilterat:2186(and the delegator leg at:2195).Every other row judgement keeps the verb as asked: the sharing write gate (
:1489), the master-detail check (MASTER_CHECKED_BY_ID_WRITES) and its prose, andfetchRecord.What changed
lifecycle-verb-rls-operation.ts(new).LIFECYCLE_VERB_RLS_OPERATION(frozen: transfer to update, restore to update, purge to delete) andrlsOperationForVerb. Every other verb is returned unchanged, including names an object literal inherits (constructor,toString).security-plugin.ts, step 2.7.const rlsOperation = rlsOperationForVerb(opCtx.operation)replaces the inline ternary. It is byte-identical in behaviour, pinned over 16 operations against the old expression verbatim.security-plugin.ts, step 3 (about:4279). Comment only. The verb stays RAW there by design, under the invariantpackages/objectql/src/engine-middleware-operation-vocabulary.test.tspins. The comment now namesrlsOperationForVerbas the mapping that site adopts if the invariant ever breaks. No behaviour change.security-plugin.ts, explain wiring (recordWriteFloorOptions,:5467). Comment only. It now receives the mapped operation, so a transfer arrives asupdate.explain-engine.ts.rlsOpis computed once and passed to both compositions. The deps docblocks say what operation the two RLS deps are asked for..changeset/22550-explain-lifecycle-verb-rls-operation.md:@objectstack/plugin-securitypatch.mapOperationToRLS's default is untouched (triage: it serves the compiler's other callers).PM mechanism assumptions, measured
packages/non-test sources for the lifecycle comparisons hits onlysecurity-plugin.ts:3174–:3175before this change. Byte-identity is pinned inlifecycle-verb-rls-operation.test.ts.computeLayeredRlsFilter(..., engineOp, ...)was the cause of the record verdict. explain also composed the object-levelrlslayer for the raw verb (:2186/:2195), so both now read the helper.rlsOperationtoresolvePreImageFloorDropandcomputeRlsFilter, andmasterGateCoversOperationreads it. explain hands its deprlsOp, andrecordWriteFloorOptionsandmasterGateCoversOperationread that.controlled_by_parentupdate floor in play, handed over by the vouch onupdate. Before, it never applied to a transfer at all.restoreandpurgeare refused at the object gate for every principal, and explain saysobject_crudfor them. To reach the door's step 2.7 composition, the unit pin lifts only that gate, the way the lifecycle batch's rows will: restore asks the update grant and purge the delete grant. Every row-level input is left as it is. A separate cell shows the gate standing, with the same composition under it.Pins
explain-write-verdict-inputs.test.ts:532, a newdescribeat:518LIFECYCLE_STEWARD's update policy admits onlySharedand its delete policy onlyOwned, so the admitted rows name the composed class. For each oftransfer/restore/purge, on the row its class excludes and on the one it admits: the by-id verb through the real security and sharing middleware (step 2.7) admits or refuses (403PERMISSION_DENIED), and explain'srecordand itsrlsrecord outcome match it:558readcomposes nothing (not_applicable) and each lifecycle verb's object-levelrlslayernarrowswith its class's policy. On the walled object that verdict isnarrowsfor every operation (the tenant predicate), so it cannot tell the classes apart:572updatewritingowner_id: 403 onOwned, ownership untouched; 200 onShared, ownership moved. explaintransfermatches both:592restore/purge: explainobject_crud, the object gate 403, and therlsrecord already reports the update / delete classlifecycle-verb-rls-operation.test.tspackages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts:372cpg_boardrows above, through better-auth members, the REST explain route and/data. The board steward's set and itsassertArmeddeclaration are added to the fixture's bootAblations
Each leg went through
scripts/ablation-replace.mjs: the anchor hit once and went 1 to 0, and the blob changed. The restore was proven as blob equal toHEADwith an emptygit diff HEAD, under an outertrap ... EXIT INT TERM, andgit status --porcelainwas empty afterwards. The unit suites importsrc/, so they need no build. For the dogfood leg,plugin-securitywas rebuilt, andscripts/ablation-dist-preflight.mjsread the marker in both runtime bundles (dist/index.js,dist/index.mjs) before the run. After the restore it was rebuilt, and the preflight with--absentread the marker absent from all 6 built files, with the tree clean. The unit legs ran from the committed head1465c4928.explain-engine.ts:1372:rlsOpback toengineOpOwnedcell, both grants-retired cells). Dogfood 2 red / 13 green: the closed board readvisible: true,decidedBy: 'object_crud'beside the door's 403, which is the card's measurement. The open board'srlsrecord readnot_evaluated:2186:rlsOpback todataOpexpected 'not_applicable' to be 'narrows'per verbsecurity-plugin.ts:3176:rlsOperationForVerb(opCtx.operation)back toopCtx.operationtransfer×Owned,restore×Owned,purge×Shared, each "the by-id VERB through the middleware (written): expected true to be false"The mutated build in leg A failed its DTS step (
TS6133: 'rlsOp' is declared but its value is never read). The JS bundles were emitted, and the preflight above confirmed the marker in both before the dogfood run read them.Verification
Run at the merged head
47ff3aee7(origin/main6a3f82efamerged in; nothing there touchesplugin-security), on a shared box:pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2: 193 files, 4064 passed, 45 skipped.pnpm --filter @objectstack/plugin-security typecheck: exit 0 (tsc --noEmit, the scripts project, andcheck:test-typecheck: the test layer compiles, 0 debt).pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/cbp-explain-master-write.dogfood.test.ts: 15 passed (13 before this PR, plus 2).pnpm --filter @objectstack/dogfood typecheck: exit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 71 commands at47ff3aee7. All 71 were run serially, each exit code captured before any pipe, and all 71 exited 0.--ranreconciliation: "71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero: every line records its exit code). The derivation's CI-only families (shard attestations, path-scheduled jobs, the workspace type-check lanes) are NOT MEASURED here; they are CI's.eslint --no-inline-config --format jsonon the 6 changed.tsfiles, at47ff3aee7: 6 files, 0 errors, 0 warnings, none reported ignored. The population is read fromeslint.config.mjsitself. Invariance: that config enables no type-aware linting (noparserOptions.project,eslint.config.mjs:327), so this diff cannot move any untouched file's verdict. The repo-widepnpm lintis CI's.Acceptance notes
Out of scope, reported for the seat (measured): explain
transferdoes not answer the read question the transfer door asks. Measured on this branch at1465c4928, with a scratch dogfood probe on thecpgfixture that was deleted after the run. The board steward's set had aselectpolicyname == 'open'and anupdatepolicyname != 'nothing', on a board namedclosed:GETandPATCH owner_idboth answer 404RECORD_NOT_FOUND;updateanswers{ visible: false };transferanswersvisible: true,decidedBy: 'rls'.The read-absent rewrite in
explainAccess(explain-engine.ts:2254) keys onoperation === 'update' || operation === 'delete'. It is a separate mechanism from this card's composition, so it is not changed here.Dormant, not changed: explain's sharing write gate for
purgeiscanEdit(:1489keys onengineOp === 'delete'). The door's step 2.7 asks the sharing service's delete verdict for a purge's floor decision (resolveSharingWriteVerdict('delete')), and plugin-sharing's own middleware gates onlyupdate/delete. Nobody can purge today (the object gate), and it is not an RLS composition, so it is left for the batch that returns the grant.Read-only inference, not measured: the object-level
rlsverdict recognises a deny-all sentinel only at the top of the composed filter (isDenyAll). On a walled object,andComposeLayersnests a business-RLS deny under$andwith the tenant predicate, so that layer may reportnarrows(andallowed: true) where business RLS denies every row. The record path judges Layer 1 on its own and is unaffected.Generated by Claude Code