Skip to content

Commit 18d9990

Browse files
fix(plugin-security): explain answers a transfer of a row the caller cannot read as missing, as its door does (#22610)
Fixes #22571 Clause-②: no `POST /api/v1/security/explain` now explains a transfer of a row the caller cannot read with the missing-record shape: `record: { recordId, visible: false }`, with no `decidedBy`. That is what the transfer door's `404 RECORD_NOT_FOUND` says. The #21771 ruling-A rewrite in `explain-engine.ts` checked the explained verb as asked (`update` or `delete`). It now reads the verb through `rlsOperationForVerb`, 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. `ExplainEngineDeps` is unchanged and nothing is added to `index.ts`, so this is `Clause-②: no` with a `patch` changeset. Measured on `origin/main` `76bc1e03a`. Line numbers are at this PR's head `abf492af0` unless marked. ## Step 1: the structural option, measured first (triage `6093130738`) **The question:** can `explain` take 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): | door gate | door site | explain reads it through | |:--|:--|:--| | requiredPermissions AND-gate | `requiredCapsForOperation` | `deps.requiredCaps` | | object CRUD | `PermissionEvaluator.checkObjectPermission` | `deps.evaluator` | | step 2.7 composition and floor decision | `computeLayeredRlsFilter`, `resolvePreImageFloorDrop` (:3180) | `deps.computeLayeredRlsFilter` with `recordWriteFloorOptions` (:5472) | | step 2.7 read question (#21771) | `callerCannotReadAddressedRow` (:7774), a caller-context `readRowById` | `deps.recordAbsentToCaller` (:5560), the same `absentUnderCallerRead` over the same read | | step 2.8 master-detail check | `assertControlledByParentWrite` | `deps.checkControlledByParentWrite` (:5569) | | plugin-sharing write gate | `canEdit` / `canDelete` | `deps.canEditRecord` / `deps.canDeleteRecord` | **What explain re-models.** Two things: 1. **Step 2.7's orchestration**, meaning which verbs reach which question. This is inline middleware code, not a function. It is the pre-image guard list (:3163) and the two branches of `addressedByIdWriteId`: `update` (:7749) and `delete` (:7755). 2. **Step 2.7's pre-image re-read.** The door runs `ql.findOne` for 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 `ExplainEngineDeps` key. Step 2.7 reads middleware-local state that has no caller-free form today: - the D10 delegator sets and context; - `preImageFloorOpts`, written at :3208 and consumed later by step 3.6's post-image check; - `nestedInOperation` and `referentialFieldClearWrite`, which `addressedByIdWriteId` takes; - the dispatch inputs `extractSingleId`, `opCtx.data` and `opCtx.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 `ExplainEngineDeps` unchanged. 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 1. **Which verbs the door asks the read question for: engine `update` and `delete` only.** This is `addressedByIdWriteId` (: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 the `PATCH` that writes `owner_id`, an engine `update`; the engine dispatches no `transfer`. `rlsOperationForVerb` states 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 engine `transfer`, which nothing dispatches today, would not be asked (see Acceptance notes). 2. **Axis (a), read-absent on update: already closed on `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. 3. **Axis (b), no-policy fail-closed: falsified as stated.** On a caller whose sets declare no write-class policy, the door composes the update scope from the caller's SELECT narrowing (`computeLayeredRlsFilter`, :8257-:8266). explain composes the same scope through the same function. On `main`, the only divergence on this axis was the transfer verb's shape: the door answers 404, and explain answered `visible: false, decidedBy: 'rls'`, which is a 403's shape. The same guard fixes it. - The #22588 item 3(b) reading does not reproduce at record level; see the last table. - hotclm's `clm_admin` holder also holds `clm_requester`, whose `contract_requester_edit_window` update policy names no positions. It therefore applies to that holder, and `modifyAllRecords` does not bypass business RLS on an ordinary posture (ADR-0066 ①). - hotclm's call used the batch form, whose top-level `allowed` answers the object question by contract. The record verdict is in `records[i]`. 4. **Structural option:** see Step 1. ## Verb × axis, door beside explain (cpg fixture, `public_read_write` `cpg_board`, `org_member` callers) The door's 404 is `security-plugin.ts` :3257 / :3300 (`recordNotFoundError`). Its 403 is :3286 (`record_access_denied`). explain's missing shape is `explain-engine.ts` :2269-:2279. Its `rls` decider is `applyRecordAttribution`'s `rlsExcluded` branch. | axis | verb | row | door | explain before (`76bc1e03a`) | explain after | |:--|:--|:--|:--|:--|:--| | read-absent | update | unreadable | 404 RECORD_NOT_FOUND | visible false, no decider | unchanged | | read-absent | update | readable, not theirs / their own | 200 / 200 | visible true (rls) | unchanged | | read-absent | delete | unreadable | 404 RECORD_NOT_FOUND | visible false, no decider | unchanged | | read-absent | delete | readable, not theirs / their own | 200 / 200 | visible true (rls) | unchanged | | read-absent | **transfer** | **unreadable** | **404 RECORD_NOT_FOUND** | **visible true, decidedBy rls** | **visible false, no decider** | | read-absent | transfer | readable, not theirs / their own | 200 / 200 | visible true (rls) | unchanged | | read-absent | restore, purge | every row | object gate, no route | visible false, object_crud | unchanged | | no policy | update | unreadable | 404 RECORD_NOT_FOUND | visible false, no decider | unchanged | | no policy | update | readable, not theirs / their own | 200 / 200 | visible true (rls) | unchanged | | no policy | delete | unreadable | 404 RECORD_NOT_FOUND | visible false, no decider | unchanged | | no policy | delete | readable, not theirs | 403 PERMISSION_DENIED (ownership floor `owner_only_deletes`) | visible false, decidedBy rls | unchanged | | no policy | delete | readable, their own | 200 | visible true (rls) | unchanged | | no policy | **transfer** | **unreadable** | **404 RECORD_NOT_FOUND** | **visible false, decidedBy rls** (403 shape) | **visible false, no decider** | | no policy | transfer | readable, not theirs / their own | 200 / 200 | visible true (rls) | unchanged | | no policy | restore, purge | every row | object gate, no route | visible false, object_crud | unchanged | 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 with `viewAllRecords` + `modifyAllRecords` and no RLS, plus a second set with an update policy `name == 'draft'`. The row was named `approved` and readable. | request | door | explain record | |:--|:--|:--| | update | 403 PERMISSION_DENIED | visible false, decidedBy rls | | update, batch form (`recordIds`) | 403 PERMISSION_DENIED | `records[0]`: visible false, decidedBy rls (top-level `allowed: true` is the object question) | | transfer | 403 PERMISSION_DENIED | visible false, decidedBy rls | ## Pins and ablations Ablation: the old guard was put back, keyed on the raw verb. Every leg went through `scripts/ablation-replace.mjs` WRAP mode, with the mutation, the restore and the blob proof on disk. Both legs were run from committed HEAD `9fefec732`. | pin | domain | cells | ablation result | restore | |:--|:--|:--|:--|:--| | `packages/plugins/plugin-security/src/explain-read-absent-every-write-verb.test.ts` (unit, real `SecurityPlugin` over real `ObjectQL` + SQL driver, engine-level door) | explain verbs the mapping sends to update or delete (asserted equal to the door rows), × 2 axes, + vocabulary totality | 18 passed | **2 failed / 16 passed**: read-absent transfer gave `visible: true, decidedBy: 'rls'`, and no-policy transfer gave `visible: false, decidedBy: 'rls'`, each against the expected `{ recordId, visible: false }` | blob `1d8f2ff143a5` == HEAD, `git diff HEAD` empty | | `packages/qa/dogfood/test/explain-write-door-parity.dogfood.test.ts` (REST, cpg fixture, `org_member`, `assertArmed`) | all 8 explain verbs classified; 5 write verbs × 2 axes × 3 rows | 23 passed | `ablation-dist-preflight` found the marker in 2 built `dist/` files before the run. **2 failed / 21 passed**, the same two cells | blob == HEAD; rebuilt; marker absent from all 6 built files; tree clean; 23 passed | The dogfood pin resolves `@objectstack/plugin-security` through `dist/`, 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 in `dist/`. The old spelling `operation === "update" || operation === "delete"` already appears 3 times in `dist/`, 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 the `tsconfig.test.json` program (`--listFiles` count 1), which the script's `check:test-typecheck` compiles. - `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 (`--listFiles` count 1). - The dogfood files that touch explain (`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. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 70 commands. Each ran with its exit code recorded, and `--ran` reconciles: "70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN". `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET because 8 unrelated packages had no `dist/`. After building them it answered exit 0, and that is the recorded code. - Lint is narrowed to the 4 changed TS files: `eslint --no-inline-config --format json` read 4 files, 0 errors, 0 warnings. - Population: `eslint --print-config` exits 0 for each file, so none is ignored. - Invariance: `eslint.config.mjs` enables no type-aware linting for any file (its own statement at :327-:328), so this diff cannot move an untouched file's verdict. - The full `pnpm lint` is CI's. ## Acceptance notes - **The by-id write verb set is spelled inline in several places.** - In the door, `security-plugin.ts`: step 2.6 at :3084, step 2.7 at :3163, and with `insert` at :3321, :7333 and :7455. - In explain: `MASTER_CHECKED_BY_ID_WRITES` at `explain-engine.ts` :964. - In `delegated-admin-gate.ts` at :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 `update` and `delete` (`addressedByIdWriteId`). If the lifecycle batch ever dispatches a by-id engine `transfer`, `restore` or `purge`, the door would answer an unreadable row 403 while explain answers missing. Nothing dispatches those today; `restore` and `purge` are 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 `allowed` beside per-record `records[i]`, which is the documented contract. The #22588 item 3 reading took `allowed: true` as the write verdict. Not a defect; noted for the seat that triages #22588. --- _Generated by [Claude Code](https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 514bf3c commit 18d9990

5 files changed

Lines changed: 579 additions & 2 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
`security/explain` answers a transfer of a row the caller cannot read the way the transfer door does: as a record that does not exist.
6+
7+
- **What was wrong.** On the write doors, a row the caller cannot read answers what a nonexistent id answers: the by-id update or delete the caller addresses gets `404 RECORD_NOT_FOUND`. A transfer's door is the `PATCH` that writes `owner_id`, an update, so a transfer of an unreadable row gets that 404 too. explain's matching rewrite (the missing-record shape, `record: { visible: false }` with no `decidedBy`) keyed on the verb as asked, `update` or `delete`, so it never ran for `transfer`. Where the caller's update policy admitted the hidden row, explain answered `visible: true`, decided by `rls`. Where the caller declared no write policy, it answered `visible: false` decided by `rls`, which is the shape of a 403 refusal, not of the door's 404.
8+
- **What it does now.** The rewrite asks which operation the verb's door is through the same mapping the by-id write path reads (`transfer` and `restore` as update, `purge` as delete). So every write verb whose door is a by-id update or delete is asked the read question, and a transfer of a row the caller cannot read is `{ visible: false }` with no decider, beside the door's 404.
9+
- **What is unchanged.** `update` and `delete` are answered exactly as before. `restore` and `purge` are still refused at the object gate for every principal, which answers first. A readable row keeps its verdict for every verb. The object-level `allowed` answers the object question and is not touched. No door changes, and no export is added to the package.

‎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' &&
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// `security/explain` answers a by-id write of a row the caller cannot READ
4+
// with the missing-record shape the door's 404 is, for EVERY write verb, on
5+
// both axes that reach that answer.
6+
//
7+
// ## The door's question, and the verbs it is asked for
8+
//
9+
// Ruling A on the write doors (#21771): a row the caller cannot read is a row
10+
// that does not exist. The by-id write pre-image gate (step 2.7) asks the read
11+
// door's own question of the by-id `update` and `delete` the caller addresses
12+
// (`addressedByIdWriteId`), and answers `404 RECORD_NOT_FOUND` for a row that
13+
// read does not return. A lifecycle verb meets that question as the operation
14+
// its door is: a transfer is the update that writes `owner_id` (the engine
15+
// dispatches no `transfer` operation). That relation is the one mapping
16+
// `rlsOperationForVerb` declares, so the verbs this file iterates are read off
17+
// it: every explain verb the mapping sends to `update` or `delete`. A verb added
18+
// to the mapping is iterated here with no new card, and a verb added to
19+
// explain's vocabulary fails the totality check below until it has a door row.
20+
//
21+
// explain's rewrite used to key on the RAW verb (`update` or `delete`), so a
22+
// transfer of an unreadable row was reported `visible: true` (or refused on
23+
// `rls`) beside the door's 404.
24+
//
25+
// ## The two axes
26+
//
27+
// - read-absent: the caller's write-class policies ADMIT the hidden row and
28+
// only the read hides it, so readability alone separates the door's 404
29+
// from its admission;
30+
// - no policy: the caller's sets carry no write-class policy at all, so the
31+
// door composes the write scope from the caller's SELECT narrowing (#7665,
32+
// "you cannot mutate what you cannot see"): fail closed, not unbounded.
33+
//
34+
// Each cell drives both faces through the real `SecurityPlugin` over a real
35+
// `ObjectQL` and SQL driver, and asserts the door's own answer too, so a cell
36+
// cannot go green by both faces drifting together. The callers hold no
37+
// `org_member` position, so the platform's ownership floor is out of play and
38+
// each axis is exactly what it says. The REST-level twin, on the `cpg` fixture
39+
// with the floor in play, is `packages/qa/dogfood/test/explain-write-door-parity.dogfood.test.ts`.
40+
41+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
42+
import { ObjectQL } from '@objectstack/objectql';
43+
import { SqlDriver } from '@objectstack/driver-sql';
44+
import { ExplainOperationSchema, PermissionSetSchema } from '@objectstack/spec/security';
45+
import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security';
46+
import { SecurityPlugin } from './security-plugin.js';
47+
import { rlsOperationForVerb } from './lifecycle-verb-rls-operation.js';
48+
import { defaultPermissionSets } from './objects/default-permission-sets.js';
49+
50+
const SYS = { context: { isSystem: true } } as never;
51+
const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!;
52+
const OBJ = 'qa_read_absent_board';
53+
54+
/** Every write grant, so the object gate admits each verb it can and the row gates answer. */
55+
const ALL_WRITES = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowTransfer: true };
56+
57+
/** The read hides the row named `hidden`; the write-class policies admit every row. */
58+
const READ_ABSENT_SET = PermissionSetSchema.parse({
59+
name: 'qa_read_absent_writer',
60+
objects: { [OBJ]: ALL_WRITES },
61+
rowLevelSecurity: [
62+
{ name: 'qa_ra_reads_not_hidden', object: OBJ, operation: 'select', using: "name != 'hidden'" },
63+
{ name: 'qa_ra_updates_any', object: OBJ, operation: 'update', using: "name != 'nothing'" },
64+
{ name: 'qa_ra_deletes_any', object: OBJ, operation: 'delete', using: "name != 'nothing'" },
65+
],
66+
});
67+
68+
/** The same read narrowing, and no write-class policy: the door derives the write scope from it. */
69+
const NO_POLICY_SET = PermissionSetSchema.parse({
70+
name: 'qa_policyless_writer',
71+
objects: { [OBJ]: ALL_WRITES },
72+
rowLevelSecurity: [
73+
{ name: 'qa_np_reads_not_hidden', object: OBJ, operation: 'select', using: "name != 'hidden'" },
74+
],
75+
});
76+
77+
const AXES = [
78+
{ axis: 'read-absent', set: READ_ABSENT_SET.name },
79+
{ axis: 'no policy', set: NO_POLICY_SET.name },
80+
] as const;
81+
82+
type Answer = { kind: 'admitted' } | { kind: 'refused'; code?: string; status?: number };
83+
84+
const landed = (p: Promise<unknown>): Promise<Answer> =>
85+
p.then(
86+
() => ({ kind: 'admitted' as const }),
87+
(e: any) => ({ kind: 'refused' as const, code: e?.code == null ? undefined : String(e.code), status: e?.statusCode ?? e?.status }),
88+
);
89+
90+
/**
91+
* Every operation explain answers, classified against the door. Total by
92+
* construction and checked against the spec's vocabulary below.
93+
*
94+
* - `by_id_door`: the engine write the verb's door is, addressed by id;
95+
* - `object_gate_refused`: the object gate refuses the verb to every
96+
* principal (its grant is retired until the lifecycle batch returns it) and
97+
* no route dispatches it, so the object-level CRUD layer answers first;
98+
* - `no_by_id_write`: a read, an insert or an export — no row is written by id.
99+
*/
100+
type DoorRow =
101+
| { kind: 'by_id_door'; door: (rig: Rig, id: string, caller: Caller) => Promise<Answer> }
102+
| { kind: 'object_gate_refused' }
103+
| { kind: 'no_by_id_write' };
104+
105+
type Caller = { userId: string; positions: string[]; permissions: string[]; posture: string };
106+
type Rig = { engine: ObjectQL; plugin: SecurityPlugin };
107+
108+
const DOORS: Record<ExplainOperation, DoorRow> = {
109+
update: {
110+
kind: 'by_id_door',
111+
door: (r, id, c) => landed(r.engine.update(OBJ, { title: 'edited' }, { where: { id }, context: c } as never)),
112+
},
113+
delete: {
114+
kind: 'by_id_door',
115+
door: (r, id, c) => landed(r.engine.delete(OBJ, { where: { id }, context: c } as never)),
116+
},
117+
// The transfer door: the update that writes `owner_id`.
118+
transfer: {
119+
kind: 'by_id_door',
120+
door: (r, id, c) => landed(r.engine.update(OBJ, { owner_id: 'usr_successor' }, { where: { id }, context: c } as never)),
121+
},
122+
restore: { kind: 'object_gate_refused' },
123+
purge: { kind: 'object_gate_refused' },
124+
create: { kind: 'no_by_id_write' },
125+
read: { kind: 'no_by_id_write' },
126+
export: { kind: 'no_by_id_write' },
127+
};
128+
129+
/** The write verbs: every explain verb whose door the mapping names a by-id update or delete. */
130+
const WRITE_VERBS = ExplainOperationSchema.options.filter((v) => ['update', 'delete'].includes(rlsOperationForVerb(v)));
131+
132+
const callerFor = (set: string): Caller => ({ userId: `usr_${set}`, positions: ['qa_pos'], permissions: [set], posture: 'MEMBER' });
133+
134+
let rig: Rig;
135+
136+
beforeAll(async () => {
137+
const engine = new ObjectQL();
138+
engine.registerDriver(
139+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never,
140+
true,
141+
);
142+
await engine.init();
143+
engine.registerApp({
144+
id: 'com.objectstack.qa.explain-read-absent-every-write-verb',
145+
name: 'Explain read-absent: every write verb',
146+
version: '1.0.0',
147+
type: 'plugin',
148+
scope: 'system',
149+
objects: [
150+
{
151+
name: OBJ,
152+
label: 'Board',
153+
sharingModel: 'public_read_write',
154+
fields: {
155+
id: { name: 'id', type: 'text', primaryKey: true },
156+
name: { name: 'name', type: 'text' },
157+
title: { name: 'title', type: 'text' },
158+
owner_id: { name: 'owner_id', type: 'text' },
159+
},
160+
},
161+
],
162+
} as never);
163+
await engine.syncSchemas();
164+
// One unreadable and one readable row per (axis, verb), so a delete or a
165+
// transfer that lands never changes another cell's row.
166+
for (const { set } of AXES) {
167+
for (const verb of ExplainOperationSchema.options) {
168+
await engine.insert(OBJ, [
169+
{ id: `${set}_${verb}_hidden`, name: 'hidden', title: 'x', owner_id: 'usr_founder' },
170+
{ id: `${set}_${verb}_shown`, name: 'shown', title: 'x', owner_id: 'usr_founder' },
171+
], SYS);
172+
}
173+
}
174+
const services: Record<string, unknown> = {
175+
manifest: { register: vi.fn() },
176+
objectql: engine,
177+
metadata: {
178+
get: async (_type: string, name: string) => engine.getSchema(name) ?? null,
179+
list: async () => [MEMBER_DEFAULT, READ_ABSENT_SET, NO_POLICY_SET],
180+
},
181+
};
182+
const ctx = {
183+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
184+
registerService: vi.fn(),
185+
// The lifecycle hooks are collected and never fired, so no bootstrap read
186+
// is left running into the engine teardown.
187+
hook: () => undefined,
188+
getService: (name: string) => {
189+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
190+
return services[name];
191+
},
192+
};
193+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
194+
await plugin.init(ctx as never);
195+
await plugin.start(ctx as never);
196+
rig = { engine, plugin };
197+
});
198+
199+
afterAll(async () => {
200+
try { await rig?.engine.destroy(); } catch { /* noop */ }
201+
});
202+
203+
const explain = (operation: ExplainOperation, recordId: string, caller: Caller): Promise<ExplainDecision> =>
204+
rig.plugin.explainAccessForCaller({ object: OBJ, operation, recordId }, caller);
205+
206+
describe('explain answers a write of a row the caller cannot read as the door does, for every write verb', () => {
207+
it('the classification is total: every operation explain answers has a door row', () => {
208+
expect([...ExplainOperationSchema.options].sort()).toEqual(Object.keys(DOORS).sort());
209+
});
210+
211+
it("the write verbs are the mapping's: each has a by-id door or is refused at the object gate, and nothing else does", () => {
212+
const writeRows = (Object.keys(DOORS) as ExplainOperation[]).filter((v) => DOORS[v].kind !== 'no_by_id_write').sort();
213+
expect(writeRows).toEqual([...WRITE_VERBS].sort());
214+
});
215+
216+
for (const { axis, set } of AXES) {
217+
describe(axis, () => {
218+
for (const verb of WRITE_VERBS) {
219+
const row = DOORS[verb];
220+
if (row.kind === 'by_id_door') {
221+
it(`${verb}: a row the caller cannot read is explained missing, beside the door's 404`, async () => {
222+
const caller = callerFor(set);
223+
const id = `${set}_${verb}_hidden`;
224+
const explained = await explain(verb, id, caller);
225+
const door = await row.door(rig, id, caller);
226+
expect(door, `${verb} door on the unreadable row`).toEqual({ kind: 'refused', code: 'RECORD_NOT_FOUND', status: 404 });
227+
// The missing-record shape: no decider, exactly what an id no row
228+
// carries is explained as.
229+
expect(explained.record, `explain ${verb}`).toStrictEqual({ recordId: id, visible: false });
230+
});
231+
232+
it(`${verb}: control, a readable row keeps its verdict, beside the door's admission`, async () => {
233+
const caller = callerFor(set);
234+
const id = `${set}_${verb}_shown`;
235+
const explained = await explain(verb, id, caller);
236+
const door = await row.door(rig, id, caller);
237+
expect(door, `${verb} door on the readable row`).toEqual({ kind: 'admitted' });
238+
expect(explained.record, `explain ${verb}`).toMatchObject({ recordId: id, visible: true });
239+
expect(explained.record?.decidedBy, `explain ${verb}: a decider names the admission`).toBeDefined();
240+
});
241+
} else {
242+
it(`${verb}: refused at the object gate, for the unreadable and the readable row alike`, async () => {
243+
const caller = callerFor(set);
244+
for (const id of [`${set}_${verb}_hidden`, `${set}_${verb}_shown`]) {
245+
const explained = await explain(verb, id, caller);
246+
expect(explained.record, `explain ${verb} ${id}`).toStrictEqual({ recordId: id, visible: false, decidedBy: 'object_crud' });
247+
}
248+
});
249+
}
250+
}
251+
});
252+
}
253+
});

‎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)