Skip to content

Commit ea531f3

Browse files
committed
fix(plugin-security): explain's read-absent rewrite reads the explained 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>
1 parent 76bc1e0 commit ea531f3

2 files changed

Lines changed: 22 additions & 2 deletions

File tree

‎packages/plugins/plugin-security/src/explain-engine.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,11 @@ export interface ExplainEngineDeps {
413413
* `true` only for absence; a read-time policy refusal answers `false`, and a
414414
* store fault propagates — exactly as the write path asks it, so the
415415
* explanation cannot drift from the answer the write gets.
416+
*
417+
* [#22571] Asked for every explained verb whose door is a by-id update or
418+
* delete, read through {@link rlsOperationForVerb}: `update`, `delete`, and
419+
* the lifecycle verbs that map onto them (`transfer` and `restore` onto
420+
* update, `purge` onto delete).
416421
*/
417422
recordAbsentToCaller?: (object: string, recordId: string, context: any) => Promise<boolean>;
418423
}
@@ -2250,8 +2255,19 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput
22502255
// gates (which answer first, as they do here), for a principal with an
22512256
// identity, on a record that exists. (A system principal reads every row,
22522257
// so the question cannot change its verdict.)
2258+
//
2259+
// [#22571] The door asks that question of the by-id `update` and `delete`
2260+
// the caller addresses (`addressedByIdWriteId` in `security-plugin.ts`),
2261+
// and a lifecycle verb reaches it as the operation its door is: a transfer
2262+
// is the PATCH that writes `owner_id`, an update. So the guard reads the
2263+
// explained verb through the one mapping step 2.7 reads, `rlsOp`
2264+
// ({@link rlsOperationForVerb}), never the verb as asked: keyed on the raw
2265+
// verb, a transfer of a row the caller cannot read was reported visible
2266+
// beside the transfer door's 404. `restore` and `purge` map the same way
2267+
// and are refused at the object gate first, so the rewrite never reaches
2268+
// them while their grants are retired.
22532269
if (
2254-
(operation === 'update' || operation === 'delete') &&
2270+
(rlsOp === 'update' || rlsOp === 'delete') &&
22552271
deps.recordAbsentToCaller &&
22562272
context?.userId &&
22572273
recordVerdict.decidedBy !== 'required_permissions' &&

‎packages/plugins/plugin-security/src/lifecycle-verb-rls-operation.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@
2727
* floor hand-over (`masterGateCoversOperation`) key on the mapped verb;
2828
* - `security/explain` (`explain-engine.ts`), the object-level `rls` layer and
2929
* the record-level row story alike, so a `transfer` is explained against the
30-
* update-class policies the door judges it by.
30+
* update-class policies the door judges it by;
31+
* - [#22571] `security/explain`'s read-absent rewrite (#21771 ruling A), whose
32+
* guard asks this mapping which operation the explained verb's door is, so a
33+
* `transfer` of a row the caller cannot read gets the missing-record shape
34+
* the transfer door's 404 is.
3135
*
3236
* The middleware's AST step (step 3) passes its verb RAW, by design: the
3337
* engine's middleware vocabulary carries no lifecycle verb, an invariant

0 commit comments

Comments
 (0)