From a22a290ccaae3c43043879b5263cfa7d6e7e80ab Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 01:11:27 +0000 Subject: [PATCH 1/5] fix(plugin-security)!: a write from a caller the read door refuses is refused, whatever rows it addresses The predicate update and delete a caller addresses are answered the read door's own refusal when that caller's read of the object is refused, before the write runs, as the by-id update and delete already are. Nothing changes. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- ...icate-write-unreadable-not-matched.test.ts | 134 +++++++++++++++++- .../src/security-plugin-addressed-writes.ts | 8 +- .../src/security-plugin-authz-middleware.ts | 7 +- .../plugin-security/src/security-plugin.ts | 15 +- 4 files changed, 150 insertions(+), 14 deletions(-) diff --git a/packages/plugins/plugin-security/src/predicate-write-unreadable-not-matched.test.ts b/packages/plugins/plugin-security/src/predicate-write-unreadable-not-matched.test.ts index f952f18e1cd..41437d42b81 100644 --- a/packages/plugins/plugin-security/src/predicate-write-unreadable-not-matched.test.ts +++ b/packages/plugins/plugin-security/src/predicate-write-unreadable-not-matched.test.ts @@ -16,13 +16,24 @@ * 3. A caller who can read a matched row but may not write it keeps the 403 a * per-row gate answers, as before. * 4. A visible, writable match is still written (control). - * 5. The read question's three outcomes: a read the read door REFUSES keeps - * the write's previous answer; a store fault propagates; a readable set + * 5. The read question's three outcomes: a read the read door REFUSES is the + * write's answer (8, below); a store fault propagates; a readable set * larger than one write may name is refused, nothing written. * 6. A predicate write the platform issues under the caller's context (a * hook's own write) is not addressed by the caller and keeps the answer it * had before. * 7. The by-id doors keep their answers (regression). + * 8. A write from a caller the read door refuses is refused, whatever rows it + * addresses: the predicate update and delete that caller addresses are + * answered the read door's own refusal before the write runs, with + * row-level policies and without, and nothing changes — the answer the + * by-id leg gives the same caller. A caller who reads keeps its scoped + * write, and a write the platform issues under the caller's context, or a + * system write, keeps the answer it had. (The read here is refused by a + * capability the object requires for a read and not for a write, ADR-0066 + * ⑤: since an object permission that edits or deletes a record also reads + * it, the one shape in which the write door admits a caller the read door + * refuses.) * * ## The rig * @@ -86,7 +97,23 @@ function outcome(p: Promise): Promise { ); } -async function boot(opts: { bulkRows?: number } = {}): Promise { +/** + * The rig's knobs. Defaults are the rig the first suites run on; the + * read-refused caller's suite (8) gates the child's read on a capability, and + * runs with and without the guard set's row-level policies. + */ +interface BootOptions { + /** Rows on the bulk object, for the readable-set cap. */ + bulkRows?: number; + /** `requiredPermissions` on the child object (ADR-0066 D3/⑤). */ + childRequiredPermissions?: Record; + /** Keep the guard set's row-level policies (the default) or run with none. */ + rls?: boolean; + /** Capabilities the guard set grants. */ + capabilities?: string[]; +} + +async function boot(opts: BootOptions = {}): Promise { const engine = new ObjectQL(); engine.registerDriver( new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never, @@ -114,7 +141,13 @@ async function boot(opts: { bulkRows?: number } = {}): Promise { sharingModel: 'public_read_write', fields: { id: { name: 'id', type: 'text', primaryKey: true }, name: { name: 'name', type: 'text' } }, }, - { name: CHILD, label: 'Child', sharingModel: 'public_read_write', fields: childFields }, + { + name: CHILD, + label: 'Child', + sharingModel: 'public_read_write', + ...(opts.childRequiredPermissions ? { requiredPermissions: opts.childRequiredPermissions } : {}), + fields: childFields, + }, { name: BULK, label: 'Bulk', sharingModel: 'public_read_write', fields: childFields }, ], } as never); @@ -151,8 +184,9 @@ async function boot(opts: { bulkRows?: number } = {}): Promise { ]; const guard = PermissionSetSchema.parse({ name: 'qa_pw_guard', + ...(opts.capabilities ? { systemPermissions: opts.capabilities } : {}), objects: { [PARENT]: crud, [CHILD]: crud, [BULK]: crud }, - rowLevelSecurity: [...rls(CHILD), ...rls(BULK)], + rowLevelSecurity: opts.rls === false ? [] : [...rls(CHILD), ...rls(BULK)], }); // Edit and delete on the child, and no read: the read door refuses this // caller outright, which is not a hidden row. @@ -366,3 +400,93 @@ describe('the by-id doors keep their answers', () => { expect(got).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403, message: GATE_SENTENCE }); }); }); + +describe('a write from a caller the read door refuses is refused, whatever rows it addresses', () => { + // The child's read is gated on a capability the caller lacks, and its writes + // are not (ADR-0066 ⑤), so the write door admits a caller the read door + // refuses. The rig is a `single` install (its Default Organization), so every + // answer here is the `single` posture's. + const READ_CAP = 'qa_pw_read_cap'; + const READ_GATED = { childRequiredPermissions: { read: [READ_CAP] } }; + const EVERY_ROW = ids('c_own', 'c_hidden_locked', 'c_hidden_open', 'c_shared_locked', 'c_shared'); + + for (const verb of ['update', 'delete'] as const) { + const write = (r: Rig, where: Record, context?: Record) => + verb === 'update' ? r.update(where, context) : r.remove(where, context); + + for (const rls of [true, false]) { + const policies = rls ? 'under row-level policies' : 'with no row-level policy'; + it(`${verb}, ${policies}: the caller gets the read door's refusal for every predicate, and nothing changes`, async () => { + const r = await boot({ ...READ_GATED, rls }); + const read = await outcome(r.engine.find(CHILD, { where: EVERY_ROW, context: { ...CALLER } } as never)); + expect(read).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); + const before = await names(r); + + const answers = [ + await write(r, EVERY_ROW), + await write(r, ids('c_own')), + await write(r, ids('c_hidden_open')), + await write(r, { team: 'ops' }), + await write(r, ids('c_nowhere')), + ]; + + for (const got of answers) expect(got).toEqual(read); + // Refused means untouched, every row. + expect(await names(r)).toEqual(before); + }); + } + + it(`${verb}: the by-id leg gives that caller the same answer`, async () => { + const r = await boot({ ...READ_GATED, rls: false }); + const read = await outcome(r.engine.find(CHILD, { where: ids('c_own'), context: { ...CALLER } } as never)); + const predicate = await write(r, ids('c_own')); + const byId = await outcome( + verb === 'update' + ? r.engine.update(CHILD, { name: 'swept' }, { where: { id: 'c_own' }, context: { ...CALLER } } as never) + : r.engine.delete(CHILD, { where: { id: 'c_own' }, context: { ...CALLER } } as never), + ); + expect(read).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 }); + expect(predicate).toEqual(read); + expect(byId).toEqual(read); + expect((await r.stored(CHILD, 'c_own'))?.name).toBe('mine'); + }); + + it(`${verb}: a caller holding the capability the read asks for keeps its write, scoped to the rows it reads`, async () => { + const r = await boot({ ...READ_GATED, capabilities: [READ_CAP] }); + expect(await outcome(r.engine.find(CHILD, { where: ids('c_own'), context: { ...CALLER } } as never))) + .toMatchObject({ kind: 'landed' }); + + const got = await write(r, ids('c_own', 'c_hidden_locked', 'c_hidden_open')); + + expect(got).toEqual({ kind: 'landed', result: 1 }); + const after = await names(r); + if (verb === 'update') expect(after.c_own).toBe('swept'); + else expect(after).not.toHaveProperty('c_own'); + expect(after.c_hidden_locked).toBe('theirs, hidden, locked'); + expect(after.c_hidden_open).toBe('theirs, hidden, open'); + }); + } + + it('a predicate write the platform issues under that caller\'s context, and a system write, keep the answers they had', async () => { + const r = await boot(READ_GATED); + r.engine.registerHook( + 'beforeUpdate', + async (hookCtx: any) => { + await hookCtx.api.object(CHILD).update({ name: 'written by the hook' }, { where: ids('c_hidden_open'), multi: true }); + }, + { object: PARENT, packageId: PACKAGE }, + ); + + const viaHook = await outcome( + r.engine.update(PARENT, { name: 'renamed' }, { where: { id: 'p_hook' }, context: { ...CALLER } } as never), + ); + const system = await outcome( + r.engine.update(CHILD, { name: 'written by the system' }, { where: ids('c_shared'), multi: true, context: { isSystem: true } } as never), + ); + + expect(viaHook).toMatchObject({ kind: 'landed' }); + expect((await r.stored(CHILD, 'c_hidden_open'))?.name).toBe('written by the hook'); + expect(system).toEqual({ kind: 'landed', result: 1 }); + expect((await r.stored(CHILD, 'c_shared'))?.name).toBe('written by the system'); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts b/packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts index d5df0e5ad7b..d14ca820626 100644 --- a/packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts +++ b/packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts @@ -136,7 +136,7 @@ export function addressedPredicateWrite( export async function readableRowsScopeForPredicateWrite( this: Pick, opCtx: any, -): Promise | null> { +): Promise> { if (typeof this.ql?.find !== 'function') { // The read question cannot be asked, and a write that cannot be narrowed // must not run un-narrowed. Unreachable in a real deployment — `start()` @@ -159,7 +159,11 @@ export async function readableRowsScopeForPredicateWrite( context: withoutOperationPrivateKeys((opCtx.context ?? {}) as Record), }), ); - if (answer.refused) return null; + // [#21829, ruling B] A caller the read door refuses writes none of the + // object's records: the read door's refusal is the predicate write's answer, + // before the write runs — the answer the by-id leg gives the same caller + // (`callerCannotReadAddressedRow`). + if (answer.refused) throw answer.error; const rows: unknown[] = Array.isArray(answer.value) ? answer.value : []; if (rows.length > PREDICATE_WRITE_READABLE_ID_CAP) { throw predicateWriteReadableCapError(opCtx.object, opCtx.operation); diff --git a/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts b/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts index bfb67b5e47f..148afdacc3f 100644 --- a/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts +++ b/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts @@ -2365,9 +2365,12 @@ export function registerAuthzMiddleware( // writable. Composed here, before `next()`, so it binds the engine's // matched-row read, its per-row hook dispatch and the driver write // alike — a hidden row is not written, not counted and not refused. + // [#21829, ruling B] A caller the read door REFUSES is answered that + // refusal here, thrown by the read question itself before `next()`: it + // writes none of the object's records, whatever its predicate addresses, + // as on the by-id leg (step 2.7). if (this.addressedPredicateWrite(opCtx, nestedInOperation, referentialFieldClearWrite)) { - const readable = await this.readableRowsScopeForPredicateWrite(opCtx); - if (readable) extra.push(readable); + extra.push(await this.readableRowsScopeForPredicateWrite(opCtx)); } // [#15813 / ADR-0131 D8] RECORD the Layer 0 verdict on the operation — // the seam ruled on #15706 (option (i)): the wall states what it diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 9d824074b85..7c48431d51d 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -819,7 +819,8 @@ export async function unreadableUnderCallerRead(read: () => Promise): P * - an answer: whatever the read door returned (no row, a row, a row set); * - `refused`: a declared 4xx — the read itself was refused (the object's read * grant withheld, a predicate the driver will not compile). Not a hidden - * row, so the write keeps the answer it has today; + * row: the by-id and the predicate write doors answer it as the read door's + * refusal to this caller, carried in `error`; * - anything else is a store fault and propagates as raised (#7505): an outage * is neither an answer nor a refusal, and reporting it as either would * relabel it. @@ -4027,15 +4028,19 @@ export class SecurityPlugin implements Plugin { * - an answer: the scope to compose. An empty answer composes the empty id * list, so a predicate that matches only rows the caller cannot read * matches nothing at all; - * - `refused` (a declared 4xx — the read grant withheld, a predicate the - * driver will not compile): `null`, and the write keeps the answer it has - * today; + * - `refused` (a declared 4xx — a capability the object requires for a read + * and not for a write, a predicate the driver will not compile): that + * refusal is THROWN, so the write is answered the read door's own refusal + * before it runs and changes nothing ([#21829] ruling B). A caller the + * read door refuses writes none of the object's records, whatever its + * predicate addresses — the answer the by-id leg gives the same caller + * ({@link callerCannotReadAddressedRow}); * - a store fault propagates as raised. * * The id list is bounded by {@link PREDICATE_WRITE_READABLE_ID_CAP}; see * there for what happens above it. */ - private readableRowsScopeForPredicateWrite(opCtx: any): Promise | null> { + private readableRowsScopeForPredicateWrite(opCtx: any): Promise> { // [#25223 cut 8] The body moved to `security-plugin-addressed-writes.ts` (see `AddressedWritesHost`). const readQl = () => this.ql; const host: Pick = { From 5fe65da3ed02d881d411272b98b6415e5899d904 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 01:15:39 +0000 Subject: [PATCH 2/5] test(dogfood): a write from a caller the read door refuses is refused at the flow trigger door; changeset and docs state the rule Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- ...ec-hardening-p3-write-refused-read-door.md | 16 ++ content/docs/permissions/authorization.mdx | 4 + .../docs/permissions/permissions-matrix.mdx | 2 +- ...ite-unreadable-not-matched.dogfood.test.ts | 247 ++++++++++++++++++ 4 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 .changeset/sec-hardening-p3-write-refused-read-door.md diff --git a/.changeset/sec-hardening-p3-write-refused-read-door.md b/.changeset/sec-hardening-p3-write-refused-read-door.md new file mode 100644 index 00000000000..fcb0269805b --- /dev/null +++ b/.changeset/sec-hardening-p3-write-refused-read-door.md @@ -0,0 +1,16 @@ +--- +'@objectstack/plugin-security': patch +--- + +fix(plugin-security)!: a write from a caller the read door refuses is refused, whatever rows it addresses + +Clause-②: no (narrowing) — a predicate update or delete (`multi: true`, addressed by a `where`) from a caller whose read of the object is refused is refused with that read refusal before the write runs, where it was admitted before; nothing is widened. +Metadata-protocol: none + + + +**BREAKING** for a caller whose read of an object is refused while its write is admitted, and that updates or deletes that object's records by predicate. With the permission model's own grants that caller exists in one shape only: an object whose `requiredPermissions` map gates `read` and not `update` or `delete` (ADR-0066 ⑤), and a caller holding the write grant without the capability the read requires. + +- **The rule.** A caller the read door refuses writes none of the object's records, by id or by predicate. The predicate update and delete such a caller addresses — a flow's `update_record` or `delete_record` node with `multi: true` run as that user, or an engine `update` / `delete` with `multi: true` under that caller's context — now answer the read door's own refusal (the `PERMISSION_DENIED` envelope that caller's read gets), and nothing is written. The by-id update and delete already answered it. +- **Unchanged.** A caller who reads the object keeps its predicate write, scoped to the records it reads. A caller the object's CRUD grants refuse is refused as before. A write the platform issues under the caller's context (a hook's or a cascade's own write, the referential clear of a deleted record's lookups) and a system write keep their answers. A store fault on the read propagates as before. +- **Who moves.** A deployment that relies on such a caller writing by predicate grants it the capability the object's read requires, or gates the write verbs on a capability that caller holds. diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 85faf26ce99..216589dac46 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -632,6 +632,10 @@ The complete, prioritized gap map lives in issue **#2561** (the production object's `requiredPermissions` may be a `string[]` (gates all CRUD) or a `{ read, create, update, delete }` map (read-open / write-gated), enforced per operation by plugin-security (`security-plugin.ts` capability AND-gate). + A map that gates `read` and not `update` or `delete` does not let a caller + without the read capability change records it cannot read: an update or + delete from a caller the read door refuses is refused with that read + refusal, by id or by predicate. - **Secure-by-default rollout** (ADR-0066 ④) — **system-object slice landed**: the raw secret/credential stores (`sys_secret`, `sys_jwks`, `sys_verification`, `sys_oauth_access_token`, `sys_oauth_refresh_token`, diff --git a/content/docs/permissions/permissions-matrix.mdx b/content/docs/permissions/permissions-matrix.mdx index 1d6bb30dd0e..bffa124b6ca 100644 --- a/content/docs/permissions/permissions-matrix.mdx +++ b/content/docs/permissions/permissions-matrix.mdx @@ -370,7 +370,7 @@ flowchart TD | 7 | **Field-Level Security** | Which fields is the user allowed to see/edit? | -**Performance:** For reads, steps 2–6 are compiled into a query filter (owner-match ∪ materialized shares, AND-ed with RLS) at query time, not evaluated record-by-record. By-id writes are verified with a pre-image check: the target row is re-read through the write-scope filter before the mutation, and a row the caller cannot read answers exactly what a missing id answers (`404 RECORD_NOT_FOUND`). A predicate (`multi: true`) update or delete matches only the rows the caller can read: a row the read door would not return is not written, not counted and not refused, and above 10 000 such rows the write is refused (`400 INVALID_FILTER`) rather than narrowed by a cut-off list. This keeps security checks efficient even on tables with millions of rows. +**Performance:** For reads, steps 2–6 are compiled into a query filter (owner-match ∪ materialized shares, AND-ed with RLS) at query time, not evaluated record-by-record. By-id writes are verified with a pre-image check: the target row is re-read through the write-scope filter before the mutation, and a row the caller cannot read answers exactly what a missing id answers (`404 RECORD_NOT_FOUND`). A predicate (`multi: true`) update or delete matches only the rows the caller can read: a row the read door would not return is not written, not counted and not refused, and above 10 000 such rows the write is refused (`400 INVALID_FILTER`) rather than narrowed by a cut-off list. A caller whose read of the object is refused writes none of its records: its by-id and predicate updates and deletes are answered that read refusal, whatever rows they address. This keeps security checks efficient even on tables with millions of rows. ## See also diff --git a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts index dd615c94370..bf226b17d22 100644 --- a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts +++ b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts @@ -28,6 +28,10 @@ // 4. the by-id doors keep their answers: the hidden row's by-id write still // answers the read door's not-found. // +// A second top-level describe, at the foot of the file, pins the rule for a +// caller the read door REFUSES, at an HTTP door: a write from that caller is +// refused, whatever rows it addresses (its own header). +// // ── the arming is the pin ───────────────────────────────────────────────── // // Each boot proves its principal's domain before anything is measured: outside @@ -43,6 +47,7 @@ import { tmpdir } from 'node:os'; import { defineStack } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; +import type { Flow } from '@objectstack/spec/automation'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { resolveAuthzContext } from '@objectstack/core'; import { bootStack, type VerifyStack } from '@objectstack/verify'; @@ -453,3 +458,245 @@ describe('predicate write door: a row the caller cannot read is not matched', () } } }); + +// ── A write from a caller the read door refuses is refused ───────────────── +// +// [#21829, ruling B] A caller the read door REFUSES writes none of the +// object's records, whatever its write addresses: the predicate update and +// delete it addresses are answered the read door's own refusal before the +// write runs, as its by-id update and delete are. +// +// The HTTP door: a `runAs: 'user'` flow started at +// `POST /automation/:name/trigger`, whose `update_record` / `delete_record` +// node declares bulk intent (`multi: true`) over a filter the start input +// supplies — a predicate write under the caller's own identity, addressed by +// that caller. The object's read is gated on a capability the writer lacks and +// its writes are not (ADR-0066 ⑤): since an object permission that edits or +// deletes a record also reads it, the one shape in which the write door admits +// a caller the read door refuses. The boot is the production `single` shape. +// +// 1. ⭐ the writer's predicate update and delete answer the read door's +// refusal (the run fails with the node's inner refusal), for a filter +// naming rows and a filter naming none alike, and no row changes; +// 2. control: a principal holding the capability keeps its predicate write; +// 3. control: the by-id doors give the writer the same refusal (unchanged). + +const GATED_READ_CAP = 'pw_gated_read'; + +/** Read-gated on a capability; writes are not gated. No row-level policy binds it. */ +const buildPwGated = () => ObjectSchema.create({ + name: 'pw_gated', + label: 'Predicate Write Gated', + pluralLabel: 'Predicate Write Gated', + sharingModel: 'public_read_write', + requiredPermissions: { read: [GATED_READ_CAP] }, + fields: { + name: Field.text({ label: 'Name', required: true }), + tag: Field.text({ label: 'Tag' }), + }, +}); + +/** `start → _record (multi, filtered by the `tag` input) → end`, as the triggering user. */ +const sweepFlow = (verb: 'update' | 'delete'): Flow => ({ + name: `pw_gated_sweep_${verb}`, + label: `Sweep gated rows by tag (${verb})`, + type: 'autolaunched', + runAs: 'user', + variables: [{ name: 'tag', type: 'text', isInput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + verb === 'update' + ? { + id: 'sweep', + type: 'update_record', + label: 'Sweep', + config: { objectName: 'pw_gated', filter: { tag: { dialect: 'cel', source: 'tag' } }, fields: { name: 'swept' }, multi: true }, + } + : { + id: 'sweep', + type: 'delete_record', + label: 'Sweep', + config: { objectName: 'pw_gated', filter: { tag: { dialect: 'cel', source: 'tag' } }, multi: true }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'sweep' }, + { id: 'e2', source: 'sweep', target: 'end' }, + ], +} as Flow); + +const buildGatedStackDefinition = () => + defineStack({ + manifest: { + id: 'com.dogfood.predicate-write-read-door-refusal', + version: '0.0.0', + type: 'app', + name: 'Predicate write: a caller the read door refuses', + description: + 'A read-gated object and two user-mode flows that update or delete its rows by a filter: the answer a caller the read door refuses gets from a predicate write.', + }, + objects: [buildPwGated()], + flows: [sweepFlow('update'), sweepFlow('delete')], + }); + +/** Every bit on the object, and not the capability its read requires. */ +const gatedWriterSet: PermissionSet = PermissionSetSchema.parse({ + name: 'pw_gated_writer', + label: 'Predicate write fixture — gated writer', + objects: { pw_gated: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, +}); + +/** The capability the object's read requires, and nothing else. */ +const gatedReaderSet: PermissionSet = PermissionSetSchema.parse({ + name: 'pw_gated_reader', + label: 'Predicate write fixture — gated reader', + systemPermissions: [GATED_READ_CAP], + objects: {}, +}); + +describe('a write from a caller the read door refuses is refused, whatever rows it addresses (HTTP door)', () => { + let stack: VerifyStack; + let ql: any; + let writerTok: string; + let readerTok: string; + const ids: Record = {}; + + interface Answer { status: number; body: any } + const call = async (token: string, method: string, path: string, body?: unknown): Promise => { + const res = await stack.apiAs(token, method, path, body); + let parsed: unknown; + try { parsed = await res.json(); } catch { parsed = undefined; } + return { status: res.status, body: parsed }; + }; + const trigger = (token: string, verb: 'update' | 'delete', tag: string) => + call(token, 'POST', `/automation/pw_gated_sweep_${verb}/trigger`, { params: { tag } }); + /** The refusal an HTTP answer carries: code and message, whichever envelope spells them. */ + const refusalOf = (a: Answer) => ({ + code: a.body?.error?.code ?? a.body?.code, + message: typeof a.body?.error === 'string' ? a.body.error : a.body?.error?.message ?? a.body?.message, + }); + /** Every row, as the system reads it. */ + const snapshot = async () => + JSON.stringify( + ((await ql.find('pw_gated', { limit: 100, context: SYS })) as any[]) + .map((r) => [r.id, r.name, r.tag]) + .sort((a, b) => String(a[0]).localeCompare(String(b[0]))), + ); + + beforeAll(async () => { + stack = await bootStack(buildGatedStackDefinition() as never, { + orgContext: true, + automation: true, + security: new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, gatedWriterSet, gatedReaderSet], + fallbackPermissionSet: gatedWriterSet.name, + }), + }); + await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + const defaultOrg = await ql.findOne('sys_organization', { where: { slug: 'default' }, context: SYS }); + expect(defaultOrg?.id, 'the `single` boot names a Default Organization').toBeTruthy(); + const grant = async (email: string, sets: string[]): Promise => { + const token = await stack.signUp(email); + const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; + expect(userId, `${email} provisioned`).toBeTruthy(); + for (const name of sets) { + expect(await catalogPermissionSet(ql, name), `fixture permission set ${name} served by the security catalog`).toBeTruthy(); + await ql.insert('sys_user_permission_set', { + id: `ups_${userId}_${name}`, user_id: userId, permission_set: name, organization_id: String(defaultOrg.id), + }, { context: SYS }); + } + return token; + }; + writerTok = await grant('pw-gated-writer@verify.test', [gatedWriterSet.name]); + readerTok = await grant('pw-gated-reader@verify.test', [gatedWriterSet.name, gatedReaderSet.name]); + + for (const [tag, n] of [['alpha', 2], ['beta', 1], ['gamma', 2], ['delta', 1], ['kappa', 1]] as const) { + ids[tag] = []; + for (let i = 0; i < n; i++) { + ids[tag].push(String((await ql.insert('pw_gated', { name: `${tag} ${i}`, tag }, { context: { ...SYS } })).id)); + } + } + + await assertArmed([ + armedWhen({ + control: 'the writer holds the write grant and not the capability the read requires — the read door refuses it', + disarmedBy: 'a writer holding the capability (it reads, and measures the control) or missing the grant (an object refusal)', + observe: async () => ({ + ctx: await resolveAuthzFor(stack, writerTok), + read: (await call(writerTok, 'GET', '/data/pw_gated')).status, + }), + armed: ({ ctx, read }) => + ctx.permissions.includes(gatedWriterSet.name) && !ctx.permissions.includes(gatedReaderSet.name) && read === 403, + describe: ({ ctx, read }) => `permissions=${JSON.stringify(ctx.permissions)} GET=${read}`, + }), + armedWhen({ + control: 'the reader holds the write grant and the capability — the read door answers it', + disarmedBy: 'a reader missing the capability: the control then measures the refused caller twice', + observe: async () => ({ + ctx: await resolveAuthzFor(stack, readerTok), + read: (await call(readerTok, 'GET', '/data/pw_gated')).status, + }), + armed: ({ ctx, read }) => + ctx.permissions.includes(gatedWriterSet.name) && ctx.permissions.includes(gatedReaderSet.name) && read === 200, + describe: ({ ctx, read }) => `permissions=${JSON.stringify(ctx.permissions)} GET=${read}`, + }), + ]); + }, 240_000); + + afterAll(async () => { + await stack?.stop(); + }); + + for (const [verb, tag] of [['update', 'alpha'], ['delete', 'beta']] as const) { + it(`${verb}: the writer's predicate ${verb} answers the read door's refusal, for rows and for none, and no row changes`, async () => { + const read = await call(writerTok, 'GET', '/data/pw_gated'); + expect(read.status, JSON.stringify(read.body)).toBe(403); + const readRefusal = refusalOf(read); + expect(readRefusal.code).toBe('PERMISSION_DENIED'); + const before = await snapshot(); + + const rows = await trigger(writerTok, verb, tag); + const none = await trigger(writerTok, verb, 'nowhere'); + + for (const got of [rows, none]) { + expect(got.status, JSON.stringify(got.body)).toBe(400); + expect(got.body?.error?.code, JSON.stringify(got.body)).toBe('FLOW_FAILED'); + expect(got.body?.data, JSON.stringify(got.body)).toBeUndefined(); + expect(got.body?.error?.details?.innerRefusal, JSON.stringify(got.body)).toEqual(readRefusal); + } + expect(none.body?.error?.message).toBe(rows.body?.error?.message); + // Refused means untouched — every row of the object. + expect(await snapshot()).toBe(before); + }); + } + + it('control: a principal holding the capability keeps its predicate update and delete', async () => { + const updated = await trigger(readerTok, 'update', 'gamma'); + expect(updated.status, JSON.stringify(updated.body)).toBe(200); + expect(updated.body?.data?.success, JSON.stringify(updated.body)).toBe(true); + for (const id of ids.gamma) { + expect((await ql.findOne('pw_gated', { where: { id }, context: SYS }))?.name).toBe('swept'); + } + + const deleted = await trigger(readerTok, 'delete', 'delta'); + expect(deleted.status, JSON.stringify(deleted.body)).toBe(200); + expect(deleted.body?.data?.success, JSON.stringify(deleted.body)).toBe(true); + for (const id of ids.delta) expect(await ql.findOne('pw_gated', { where: { id }, context: SYS })).toBeFalsy(); + // Its filter wrote nothing else. + expect((await ql.findOne('pw_gated', { where: { id: ids.kappa[0] }, context: SYS }))?.name).toBe('kappa 0'); + }); + + it('control: the by-id doors give the writer the same refusal, and the row is untouched', async () => { + const readRefusal = refusalOf(await call(writerTok, 'GET', '/data/pw_gated')); + const id = ids.kappa[0]; + const patched = await call(writerTok, 'PATCH', `/data/pw_gated/${id}`, { name: 'should not land' }); + const removed = await call(writerTok, 'DELETE', `/data/pw_gated/${id}`); + for (const got of [patched, removed]) { + expect(got.status, JSON.stringify(got.body)).toBe(403); + expect(refusalOf(got)).toEqual(readRefusal); + } + expect((await ql.findOne('pw_gated', { where: { id }, context: SYS }))?.name).toBe('kappa 0'); + }); +}); From f6401266b72aa5e6b0a6f16abef1dbcceee88293 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 01:20:51 +0000 Subject: [PATCH 3/5] test(dogfood): seed each principal's rows under its own context; the by-id control compares a row with an id naming none Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- ...ite-unreadable-not-matched.dogfood.test.ts | 54 ++++++++++++++----- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts index bf226b17d22..fdd4ec3ea0e 100644 --- a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts +++ b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts @@ -479,7 +479,9 @@ describe('predicate write door: a row the caller cannot read is not matched', () // refusal (the run fails with the node's inner refusal), for a filter // naming rows and a filter naming none alike, and no row changes; // 2. control: a principal holding the capability keeps its predicate write; -// 3. control: the by-id doors give the writer the same refusal (unchanged). +// 3. control: the by-id doors give the writer one refusal for a row and for +// an id naming none (unchanged; inside the ownership floor's domain it is +// the record-level refusal, the answer step 2.7 gives every id there). const GATED_READ_CAP = 'pw_gated_read'; @@ -612,10 +614,35 @@ describe('a write from a caller the read door refuses is refused, whatever rows writerTok = await grant('pw-gated-writer@verify.test', [gatedWriterSet.name]); readerTok = await grant('pw-gated-reader@verify.test', [gatedWriterSet.name, gatedReaderSet.name]); - for (const [tag, n] of [['alpha', 2], ['beta', 1], ['gamma', 2], ['delta', 1], ['kappa', 1]] as const) { + // Each principal's rows are created under its own context, as the REST door + // resolves it for its bearer token, so the platform's ownership floor admits + // that principal's writes to them: what refuses the writer below is its + // read, never the floor. + const contextFor = async (token: string) => { + const authService: any = await stack.kernel.getServiceAsync('auth'); + let api: any = authService?.api; + if (!api && typeof authService?.getApi === 'function') api = await authService.getApi(); + return resolveAuthzContext({ + userAuthGates: undefined, + apiKeyLifecycle: undefined, + ql, + headers: new Headers({ authorization: `Bearer ${token}` }), + getSession: async (h: any) => api?.getSession?.({ headers: h }), + }); + }; + const writerCtx = await contextFor(writerTok); + const readerCtx = await contextFor(readerTok); + const seeds = [ + ['alpha', 2, writerCtx], + ['beta', 2, writerCtx], + ['gamma', 2, readerCtx], + ['delta', 1, readerCtx], + ['kappa', 1, SYS], + ] as const; + for (const [tag, n, context] of seeds) { ids[tag] = []; for (let i = 0; i < n; i++) { - ids[tag].push(String((await ql.insert('pw_gated', { name: `${tag} ${i}`, tag }, { context: { ...SYS } })).id)); + ids[tag].push(String((await ql.insert('pw_gated', { name: `${tag} ${i}`, tag }, { context: { ...context } })).id)); } } @@ -688,15 +715,18 @@ describe('a write from a caller the read door refuses is refused, whatever rows expect((await ql.findOne('pw_gated', { where: { id: ids.kappa[0] }, context: SYS }))?.name).toBe('kappa 0'); }); - it('control: the by-id doors give the writer the same refusal, and the row is untouched', async () => { - const readRefusal = refusalOf(await call(writerTok, 'GET', '/data/pw_gated')); - const id = ids.kappa[0]; - const patched = await call(writerTok, 'PATCH', `/data/pw_gated/${id}`, { name: 'should not land' }); - const removed = await call(writerTok, 'DELETE', `/data/pw_gated/${id}`); - for (const got of [patched, removed]) { - expect(got.status, JSON.stringify(got.body)).toBe(403); - expect(refusalOf(got)).toEqual(readRefusal); + it('control: the by-id doors give the writer one refusal for its own row and for an id naming none, and the row is untouched', async () => { + const id = ids.alpha[0]; + const missing = 'pwgatedmissing0001'; + /** The answer with the caller-supplied id written out, so two ids compare. */ + const withoutId = (a: Answer, written: string) => JSON.stringify(a).split(written).join('ID'); + for (const [method, body] of [['PATCH', { name: 'should not land' }], ['DELETE', undefined]] as const) { + const row = await call(writerTok, method, `/data/pw_gated/${id}`, body); + const none = await call(writerTok, method, `/data/pw_gated/${missing}`, body); + expect(row.status, JSON.stringify(row.body)).toBe(403); + expect(refusalOf(row).code, JSON.stringify(row.body)).toBe('PERMISSION_DENIED'); + expect(withoutId(none, missing)).toBe(withoutId(row, id)); } - expect((await ql.findOne('pw_gated', { where: { id }, context: SYS }))?.name).toBe('kappa 0'); + expect((await ql.findOne('pw_gated', { where: { id }, context: SYS }))?.name).toBe('alpha 0'); }); }); From 84c82fd1c96a968a920c4f7ea592bf893c4529b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 01:23:00 +0000 Subject: [PATCH 4/5] test(dogfood): the by-id control reads a row of the writer's own that no other case addresses Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .../predicate-write-unreadable-not-matched.dogfood.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts index fdd4ec3ea0e..9f35e04dcc3 100644 --- a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts +++ b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts @@ -637,6 +637,7 @@ describe('a write from a caller the read door refuses is refused, whatever rows ['beta', 2, writerCtx], ['gamma', 2, readerCtx], ['delta', 1, readerCtx], + ['epsilon', 1, writerCtx], ['kappa', 1, SYS], ] as const; for (const [tag, n, context] of seeds) { @@ -716,7 +717,8 @@ describe('a write from a caller the read door refuses is refused, whatever rows }); it('control: the by-id doors give the writer one refusal for its own row and for an id naming none, and the row is untouched', async () => { - const id = ids.alpha[0]; + // A row of the writer's own that no other case here addresses. + const id = ids.epsilon[0]; const missing = 'pwgatedmissing0001'; /** The answer with the caller-supplied id written out, so two ids compare. */ const withoutId = (a: Answer, written: string) => JSON.stringify(a).split(written).join('ID'); @@ -727,6 +729,6 @@ describe('a write from a caller the read door refuses is refused, whatever rows expect(refusalOf(row).code, JSON.stringify(row.body)).toBe('PERMISSION_DENIED'); expect(withoutId(none, missing)).toBe(withoutId(row, id)); } - expect((await ql.findOne('pw_gated', { where: { id }, context: SYS }))?.name).toBe('alpha 0'); + expect((await ql.findOne('pw_gated', { where: { id }, context: SYS }))?.name).toBe('epsilon 0'); }); }); From 5dea55fda88256ac633800affcc09f3ab2b9c674 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 02:31:14 +0000 Subject: [PATCH 5/5] docs(plugin-security): state that a write from a caller the read door refuses is refused, by id or by predicate Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .changeset/sec-hardening-p3-write-refused-read-door.md | 2 +- content/docs/permissions/authorization.mdx | 4 ++-- content/docs/permissions/permissions-matrix.mdx | 2 +- .../src/predicate-write-unreadable-not-matched.test.ts | 5 +++-- .../src/security-plugin-addressed-writes.ts | 4 ++-- .../src/security-plugin-authz-middleware.ts | 4 ++-- packages/plugins/plugin-security/src/security-plugin.ts | 8 ++++---- ...predicate-write-unreadable-not-matched.dogfood.test.ts | 2 +- 8 files changed, 16 insertions(+), 15 deletions(-) diff --git a/.changeset/sec-hardening-p3-write-refused-read-door.md b/.changeset/sec-hardening-p3-write-refused-read-door.md index fcb0269805b..9c8362328cd 100644 --- a/.changeset/sec-hardening-p3-write-refused-read-door.md +++ b/.changeset/sec-hardening-p3-write-refused-read-door.md @@ -11,6 +11,6 @@ Metadata-protocol: none **BREAKING** for a caller whose read of an object is refused while its write is admitted, and that updates or deletes that object's records by predicate. With the permission model's own grants that caller exists in one shape only: an object whose `requiredPermissions` map gates `read` and not `update` or `delete` (ADR-0066 ⑤), and a caller holding the write grant without the capability the read requires. -- **The rule.** A caller the read door refuses writes none of the object's records, by id or by predicate. The predicate update and delete such a caller addresses — a flow's `update_record` or `delete_record` node with `multi: true` run as that user, or an engine `update` / `delete` with `multi: true` under that caller's context — now answer the read door's own refusal (the `PERMISSION_DENIED` envelope that caller's read gets), and nothing is written. The by-id update and delete already answered it. +- **The rule.** A caller the read door refuses writes none of the object's records, by id or by predicate. The predicate update and delete such a caller addresses — a flow's `update_record` or `delete_record` node with `multi: true` run as that user, or an engine `update` / `delete` with `multi: true` under that caller's context — now answer the read door's own refusal (the `PERMISSION_DENIED` envelope that caller's read gets), and nothing is written. The by-id update and delete are refused too. - **Unchanged.** A caller who reads the object keeps its predicate write, scoped to the records it reads. A caller the object's CRUD grants refuse is refused as before. A write the platform issues under the caller's context (a hook's or a cascade's own write, the referential clear of a deleted record's lookups) and a system write keep their answers. A store fault on the read propagates as before. - **Who moves.** A deployment that relies on such a caller writing by predicate grants it the capability the object's read requires, or gates the write verbs on a capability that caller holds. diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 216589dac46..e9ad38ef192 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -634,8 +634,8 @@ The complete, prioritized gap map lives in issue **#2561** (the production per operation by plugin-security (`security-plugin.ts` capability AND-gate). A map that gates `read` and not `update` or `delete` does not let a caller without the read capability change records it cannot read: an update or - delete from a caller the read door refuses is refused with that read - refusal, by id or by predicate. + delete from a caller the read door refuses is refused, by id or by + predicate. - **Secure-by-default rollout** (ADR-0066 ④) — **system-object slice landed**: the raw secret/credential stores (`sys_secret`, `sys_jwks`, `sys_verification`, `sys_oauth_access_token`, `sys_oauth_refresh_token`, diff --git a/content/docs/permissions/permissions-matrix.mdx b/content/docs/permissions/permissions-matrix.mdx index bffa124b6ca..c4feed1f962 100644 --- a/content/docs/permissions/permissions-matrix.mdx +++ b/content/docs/permissions/permissions-matrix.mdx @@ -370,7 +370,7 @@ flowchart TD | 7 | **Field-Level Security** | Which fields is the user allowed to see/edit? | -**Performance:** For reads, steps 2–6 are compiled into a query filter (owner-match ∪ materialized shares, AND-ed with RLS) at query time, not evaluated record-by-record. By-id writes are verified with a pre-image check: the target row is re-read through the write-scope filter before the mutation, and a row the caller cannot read answers exactly what a missing id answers (`404 RECORD_NOT_FOUND`). A predicate (`multi: true`) update or delete matches only the rows the caller can read: a row the read door would not return is not written, not counted and not refused, and above 10 000 such rows the write is refused (`400 INVALID_FILTER`) rather than narrowed by a cut-off list. A caller whose read of the object is refused writes none of its records: its by-id and predicate updates and deletes are answered that read refusal, whatever rows they address. This keeps security checks efficient even on tables with millions of rows. +**Performance:** For reads, steps 2–6 are compiled into a query filter (owner-match ∪ materialized shares, AND-ed with RLS) at query time, not evaluated record-by-record. By-id writes are verified with a pre-image check: the target row is re-read through the write-scope filter before the mutation, and a row the caller cannot read answers exactly what a missing id answers (`404 RECORD_NOT_FOUND`). A predicate (`multi: true`) update or delete matches only the rows the caller can read: a row the read door would not return is not written, not counted and not refused, and above 10 000 such rows the write is refused (`400 INVALID_FILTER`) rather than narrowed by a cut-off list. A caller whose read of the object is refused writes none of its records: its by-id and predicate updates and deletes are refused, whatever rows they address. This keeps security checks efficient even on tables with millions of rows. ## See also diff --git a/packages/plugins/plugin-security/src/predicate-write-unreadable-not-matched.test.ts b/packages/plugins/plugin-security/src/predicate-write-unreadable-not-matched.test.ts index 41437d42b81..98dc6ee48c4 100644 --- a/packages/plugins/plugin-security/src/predicate-write-unreadable-not-matched.test.ts +++ b/packages/plugins/plugin-security/src/predicate-write-unreadable-not-matched.test.ts @@ -26,8 +26,9 @@ * 8. A write from a caller the read door refuses is refused, whatever rows it * addresses: the predicate update and delete that caller addresses are * answered the read door's own refusal before the write runs, with - * row-level policies and without, and nothing changes — the answer the - * by-id leg gives the same caller. A caller who reads keeps its scoped + * row-level policies and without, and nothing changes. The by-id leg + * refuses the same caller too; with no row-level policy it gives the same + * answer. A caller who reads keeps its scoped * write, and a write the platform issues under the caller's context, or a * system write, keeps the answer it had. (The read here is refused by a * capability the object requires for a read and not for a write, ADR-0066 diff --git a/packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts b/packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts index d14ca820626..801d490fdfd 100644 --- a/packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts +++ b/packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts @@ -161,8 +161,8 @@ export async function readableRowsScopeForPredicateWrite( ); // [#21829, ruling B] A caller the read door refuses writes none of the // object's records: the read door's refusal is the predicate write's answer, - // before the write runs — the answer the by-id leg gives the same caller - // (`callerCannotReadAddressedRow`). + // before the write runs. The by-id leg refuses the same caller too (step + // 2.7). if (answer.refused) throw answer.error; const rows: unknown[] = Array.isArray(answer.value) ? answer.value : []; if (rows.length > PREDICATE_WRITE_READABLE_ID_CAP) { diff --git a/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts b/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts index c8070991158..e74140ae2a3 100644 --- a/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts +++ b/packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts @@ -2382,8 +2382,8 @@ export function registerAuthzMiddleware( // alike — a hidden row is not written, not counted and not refused. // [#21829, ruling B] A caller the read door REFUSES is answered that // refusal here, thrown by the read question itself before `next()`: it - // writes none of the object's records, whatever its predicate addresses, - // as on the by-id leg (step 2.7). + // writes none of the object's records, whatever its predicate addresses. + // The by-id leg (step 2.7) refuses the same caller too. if (this.addressedPredicateWrite(opCtx, nestedInOperation, referentialFieldClearWrite)) { extra.push(await this.readableRowsScopeForPredicateWrite(opCtx)); } diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 7c48431d51d..7d92a2069a6 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -819,8 +819,8 @@ export async function unreadableUnderCallerRead(read: () => Promise): P * - an answer: whatever the read door returned (no row, a row, a row set); * - `refused`: a declared 4xx — the read itself was refused (the object's read * grant withheld, a predicate the driver will not compile). Not a hidden - * row: the by-id and the predicate write doors answer it as the read door's - * refusal to this caller, carried in `error`; + * row: a write door that asks this question throws it, carried in `error`, + * and the write is refused; * - anything else is a store fault and propagates as raised (#7505): an outage * is neither an answer nor a refusal, and reporting it as either would * relabel it. @@ -4033,8 +4033,8 @@ export class SecurityPlugin implements Plugin { * refusal is THROWN, so the write is answered the read door's own refusal * before it runs and changes nothing ([#21829] ruling B). A caller the * read door refuses writes none of the object's records, whatever its - * predicate addresses — the answer the by-id leg gives the same caller - * ({@link callerCannotReadAddressedRow}); + * predicate addresses; the by-id leg refuses the same caller too (step + * 2.7); * - a store fault propagates as raised. * * The id list is bounded by {@link PREDICATE_WRITE_READABLE_ID_CAP}; see diff --git a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts index 9f35e04dcc3..3e84c024b69 100644 --- a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts +++ b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts @@ -464,7 +464,7 @@ describe('predicate write door: a row the caller cannot read is not matched', () // [#21829, ruling B] A caller the read door REFUSES writes none of the // object's records, whatever its write addresses: the predicate update and // delete it addresses are answered the read door's own refusal before the -// write runs, as its by-id update and delete are. +// write runs, and its by-id update and delete are refused too. // // The HTTP door: a `runAs: 'user'` flow started at // `POST /automation/:name/trigger`, whose `update_record` / `delete_record`