|
| 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 | +}); |
0 commit comments