Skip to content

Commit d7c0389

Browse files
fix(plugin-security)!: a write from a caller the read door refuses is refused, whatever rows it addresses (#30219)
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 Skills-docs: updated here — `content/docs/permissions/permissions-matrix.mdx` (the write paragraph) and `content/docs/permissions/authorization.mdx` (the per-operation `requiredPermissions` bullet) state the rule; `skills/**` teaches no read-gated, write-open `requiredPermissions` map, so no skill moves. ## 维护者速读 - 改了什么:读权限被拒的调用者,按条件(where)批量更新或删除时,直接收到读被拒的那条拒绝,一条记录都不改;按 id 写早已如此,现在两条写路一致。 - 为什么改:按裁决 #21829 B 与维护者第 4 批第 6 项授权——读门拒绝的调用者,对该对象的任何写都得到同一拒绝,不写任何记录。 - 风险与回滚:收窄。只影响「对象 `requiredPermissions` 只卡读、不卡改删,且调用者有写授权而无读能力」这一种组合;能读的调用者、系统写、钩子与级联写不变。回滚即回退本 PR。 - 先看:`packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts`(读被拒时抛出读拒绝),`packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts`(第 3 步按条件写的接线),`packages/plugins/plugin-security/src/security-plugin.ts`(契约注释)。 - 两分钟验证:`pnpm --filter @objectstack/plugin-security exec vitest run predicate-write -t "read door refuses is refused"`,预期 1 个文件、9 条用例全绿,其余跳过。 - 需要你做的:无,席位落地。 ## What changes **The rule: a write from a caller the read door refuses is refused, whatever rows it addresses.** - **`@objectstack/plugin-security`.** The predicate update and delete a caller addresses ask the read door's own question of the caller's predicate before the write runs (`readableRowsScopeForPredicateWrite`). When that read is refused (a declared 4xx), the refusal is now thrown, so the write answers the read door's refusal and changes nothing. The by-id update and delete refuse the same caller too (step 2.7). With the permission model's own grants, the caller who meets this exists in one shape: an object whose `requiredPermissions` map gates `read` and not `update`/`delete` (ADR-0066 ⑤), and a caller holding the write grant without the capability the read requires. - **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 at the object gate as before. Writes 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 system writes keep the exclusions step 2.7 has. A store fault on the read propagates as before. The by-id doors are not touched. - **Docs.** `permissions-matrix.mdx` and `authorization.mdx` state the rule. - **Changeset.** `patch` for `@objectstack/plugin-security`, `!`, ADR-0087 disposition `not-required (no-migration-prescription)`: no metadata an author writes changes and no stored row is rewritten. ## Tests - `@objectstack/plugin-security`, the predicate-write suite, new top-level describe "a write from a caller the read door refuses is refused, whatever rows it addresses" (real `ObjectQL` over a real SQL driver, the real middleware, a `single` install): - update and delete, with row-level policies and without: five predicates (every row, one row, a row the write scope reaches, a field predicate, an id naming no row) each answer exactly the caller's read refusal (`code`, `status`, message), and no row changes; - the by-id leg gives that caller the same answer as the predicate leg (control, unchanged); - a caller holding the capability keeps its write, scoped to the rows it reads (control); - a hook's own predicate write under that caller's context, and a system write, keep their answers (control: the exclusions hold). - The suite's header item 5 described the earlier answer for a refused read ("keeps the write's previous answer"); it now points at the new rule. No assertion pinned the earlier answer on this head. - `@objectstack/dogfood`, the predicate-write file, new top-level describe at an HTTP door: a `runAs: 'user'` flow started at `POST /automation/:name/trigger` whose `update_record` / `delete_record` node runs with `multi: true` over a filter from the start input. The writer's update and delete answer `400 FLOW_FAILED` carrying the read refusal as the node's inner refusal, for a filter naming rows and for one naming none, and no row changes; a principal holding the capability keeps its update and delete (control); the by-id `PATCH` / `DELETE` give the writer one refusal for its own row and for an id naming none (control, unchanged). ## Acceptance notes - **Shipped metadata.** No shipped object declares a `requiredPermissions` map that gates `read` and not a write verb: every object-level `requiredPermissions` in `packages/**` and `examples/**` is a `string[]`, which gates every verb. The shape is authorable, so the rule is for customer metadata. - **The ADR-0090 D12 administration gate is not touched.** It refuses a write naming no single row before any row is read, in its own words, so a predicate write to the permission-assignment objects keeps the gate's sentence; `readDoorAnswerForGateRefusal` keeps answering the by-id write only. Nothing is written either way. - **The `single` posture.** Both rigs are `single` installs; the change reads no tenancy input, so the controls hold the same way under every posture. - **Two doors, two refusals, one rule.** Inside the ownership floor's domain the by-id doors answer such a caller the record-level `403` for every id (step 2.7's answer where a write-class row filter applies, unchanged); the predicate door answers the read door's own `403`. Both are one answer whatever the write addresses, and both write nothing. - **`test:repo` not run locally** (declared to CI's `Test Core`): the diff edits no file of that project. - **One battery red, from `main`.** See Verification: the full dogfood battery's one failure is a by-id `sys_user_permission_set` control in `update-by-id-refusal-order.dogfood.test.ts` that `main`'s `474c030b13` changed the answer for; it stays red with this diff's guard removed. ## Verification Readings on head `2046ac7fdd` (this branch merged with `origin/main` `474c030b13`) unless named. `5dea55fda8` changes prose only (comments, docblocks, test headers, the changeset bullet and two docs sentences): 92 derived families run, 0 NOT MEASURED; the predicate-write unit file 26 / 26. <details><summary>Red before the fix (unit pins on the unfixed source)</summary> The new describe run against the branch point's source with only the tests added: 6 failed / 26 (both verbs, with and without row-level policies, and the by-id comparison). With the fix: 26 / 26. </details> <details><summary>One-shot ablation (mutate and restore, rebuilt each leg)</summary> The guard line in `security-plugin-addressed-writes.ts` (`if (answer.refused) throw answer.error;`) replaced through `scripts/ablation-replace.mjs` (anchor 1 to 0, blob `d14ca8206269` to `8b335291908e`); `@objectstack/plugin-security` rebuilt; `ablation-dist-preflight` found the marker in 2 built files. Mutated, on head `f6401266b7`: the unit file 6 failed / 26; the dogfood file 3 failed / 16; red, then restored (the by-id control now reads a row no other case addresses, `84c82fd1c9`). Restored by the tool: blob equals HEAD, `git diff HEAD` empty, whole tree clean; rebuilt; preflight `--absent` exit 0 over 6 built files; unit 26 / 26, dogfood file 16 / 16 on `84c82fd1c9`. </details> <details><summary>Suites</summary> - `@objectstack/plugin-security` full, under the verify lock: 242 / 242 files, 5296 passed, 45 skipped (exit 0). - `@objectstack/plugin-security` typecheck: exit 0. `@objectstack/dogfood` typecheck: exit 0. - The dogfood predicate-write file alone: 16 / 16. </details> <details><summary>Dogfood battery (full, once)</summary> `pnpm --filter @objectstack/dogfood test` under the verify lock on `2046ac7fdd`: 417 passed / 1 failed / 3 skipped files; 3487 passed / 1 failed / 34 skipped tests (verdict: command-exit 1). The one failure: `update-by-id-refusal-order.dogfood.test.ts`, "control: an organization administrator, who reads the platform grant object, keeps the administration gate's sentence for a row and for no row" — the id naming no row now answers `404 RECORD_NOT_FOUND` where the pin expects the gate's `403`. That answer is `main`'s `474c030b13` (`readDoorAnswerForGateRefusal` answers the read door's not-found for an assignment row the administrator's read does not return). Control leg: the same file with this diff's guard removed (the ablation above, rebuilt, preflight marker present) fails the same case, 1 failed / 19. Not this diff's consequence; reported to the seat. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 92 families, each run with its exit code captured before any pipe. Three first refused with exit 3 (`check:skill-examples` and `check:dual-build-cjs-loads` on unbuilt packages outside the diff; `check:type-check-debt` when its closure build could not take the verify lock); after building those packages and re-running under the lock, all three exit 0. `--ran` reconciliation: 92 derived, 92 run, 0 NOT-MEASURED, 0 UNRUN (exit 0). `check-adr-0087-registration`: one declared-breaking changeset, `not-required (no-migration-prescription)`. </details> <!-- signed: security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj --> <!-- signed: security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6f095b9 commit d7c0389

8 files changed

Lines changed: 451 additions & 15 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security)!: a write from a caller the read door refuses is refused, whatever rows it addresses
6+
7+
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.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) no metadata an author writes changes and no stored row is rewritten: a runtime write from a caller the object's read refuses is refused -->
11+
12+
**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.
13+
14+
- **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.
15+
- **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.
16+
- **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.

‎content/docs/permissions/authorization.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,10 @@ The complete, prioritized gap map lives in issue **#2561** (the production
632632
object's `requiredPermissions` may be a `string[]` (gates all CRUD) or a
633633
`{ read, create, update, delete }` map (read-open / write-gated), enforced
634634
per operation by plugin-security (`security-plugin.ts` capability AND-gate).
635+
A map that gates `read` and not `update` or `delete` does not let a caller
636+
without the read capability change records it cannot read: an update or
637+
delete from a caller the read door refuses is refused, by id or by
638+
predicate.
635639
- **Secure-by-default rollout** (ADR-0066 ④) — **system-object slice landed**:
636640
the raw secret/credential stores (`sys_secret`, `sys_jwks`,
637641
`sys_verification`, `sys_oauth_access_token`, `sys_oauth_refresh_token`,

‎content/docs/permissions/permissions-matrix.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ flowchart TD
370370
| 7 | **Field-Level Security** | Which fields is the user allowed to see/edit? |
371371

372372
<Callout type="tip">
373-
**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.
373+
**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.
374374
</Callout>
375375

376376
## See also

‎packages/plugins/plugin-security/src/predicate-write-unreadable-not-matched.test.ts‎

Lines changed: 130 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,25 @@
1616
* 3. A caller who can read a matched row but may not write it keeps the 403 a
1717
* per-row gate answers, as before.
1818
* 4. A visible, writable match is still written (control).
19-
* 5. The read question's three outcomes: a read the read door REFUSES keeps
20-
* the write's previous answer; a store fault propagates; a readable set
19+
* 5. The read question's three outcomes: a read the read door REFUSES is the
20+
* write's answer (8, below); a store fault propagates; a readable set
2121
* larger than one write may name is refused, nothing written.
2222
* 6. A predicate write the platform issues under the caller's context (a
2323
* hook's own write) is not addressed by the caller and keeps the answer it
2424
* had before.
2525
* 7. The by-id doors keep their answers (regression).
26+
* 8. A write from a caller the read door refuses is refused, whatever rows it
27+
* addresses: the predicate update and delete that caller addresses are
28+
* answered the read door's own refusal before the write runs, with
29+
* row-level policies and without, and nothing changes. The by-id leg
30+
* refuses the same caller too; with no row-level policy it gives the same
31+
* answer. A caller who reads keeps its scoped
32+
* write, and a write the platform issues under the caller's context, or a
33+
* system write, keeps the answer it had. (The read here is refused by a
34+
* capability the object requires for a read and not for a write, ADR-0066
35+
* ⑤: since an object permission that edits or deletes a record also reads
36+
* it, the one shape in which the write door admits a caller the read door
37+
* refuses.)
2638
*
2739
* ## The rig
2840
*
@@ -86,7 +98,23 @@ function outcome(p: Promise<unknown>): Promise<Outcome> {
8698
);
8799
}
88100

89-
async function boot(opts: { bulkRows?: number } = {}): Promise<Rig> {
101+
/**
102+
* The rig's knobs. Defaults are the rig the first suites run on; the
103+
* read-refused caller's suite (8) gates the child's read on a capability, and
104+
* runs with and without the guard set's row-level policies.
105+
*/
106+
interface BootOptions {
107+
/** Rows on the bulk object, for the readable-set cap. */
108+
bulkRows?: number;
109+
/** `requiredPermissions` on the child object (ADR-0066 D3/⑤). */
110+
childRequiredPermissions?: Record<string, string[]>;
111+
/** Keep the guard set's row-level policies (the default) or run with none. */
112+
rls?: boolean;
113+
/** Capabilities the guard set grants. */
114+
capabilities?: string[];
115+
}
116+
117+
async function boot(opts: BootOptions = {}): Promise<Rig> {
90118
const engine = new ObjectQL();
91119
engine.registerDriver(
92120
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never,
@@ -114,7 +142,13 @@ async function boot(opts: { bulkRows?: number } = {}): Promise<Rig> {
114142
sharingModel: 'public_read_write',
115143
fields: { id: { name: 'id', type: 'text', primaryKey: true }, name: { name: 'name', type: 'text' } },
116144
},
117-
{ name: CHILD, label: 'Child', sharingModel: 'public_read_write', fields: childFields },
145+
{
146+
name: CHILD,
147+
label: 'Child',
148+
sharingModel: 'public_read_write',
149+
...(opts.childRequiredPermissions ? { requiredPermissions: opts.childRequiredPermissions } : {}),
150+
fields: childFields,
151+
},
118152
{ name: BULK, label: 'Bulk', sharingModel: 'public_read_write', fields: childFields },
119153
],
120154
} as never);
@@ -151,8 +185,9 @@ async function boot(opts: { bulkRows?: number } = {}): Promise<Rig> {
151185
];
152186
const guard = PermissionSetSchema.parse({
153187
name: 'qa_pw_guard',
188+
...(opts.capabilities ? { systemPermissions: opts.capabilities } : {}),
154189
objects: { [PARENT]: crud, [CHILD]: crud, [BULK]: crud },
155-
rowLevelSecurity: [...rls(CHILD), ...rls(BULK)],
190+
rowLevelSecurity: opts.rls === false ? [] : [...rls(CHILD), ...rls(BULK)],
156191
});
157192
// Edit and delete on the child, and no read: the read door refuses this
158193
// caller outright, which is not a hidden row.
@@ -366,3 +401,93 @@ describe('the by-id doors keep their answers', () => {
366401
expect(got).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403, message: GATE_SENTENCE });
367402
});
368403
});
404+
405+
describe('a write from a caller the read door refuses is refused, whatever rows it addresses', () => {
406+
// The child's read is gated on a capability the caller lacks, and its writes
407+
// are not (ADR-0066 ⑤), so the write door admits a caller the read door
408+
// refuses. The rig is a `single` install (its Default Organization), so every
409+
// answer here is the `single` posture's.
410+
const READ_CAP = 'qa_pw_read_cap';
411+
const READ_GATED = { childRequiredPermissions: { read: [READ_CAP] } };
412+
const EVERY_ROW = ids('c_own', 'c_hidden_locked', 'c_hidden_open', 'c_shared_locked', 'c_shared');
413+
414+
for (const verb of ['update', 'delete'] as const) {
415+
const write = (r: Rig, where: Record<string, unknown>, context?: Record<string, unknown>) =>
416+
verb === 'update' ? r.update(where, context) : r.remove(where, context);
417+
418+
for (const rls of [true, false]) {
419+
const policies = rls ? 'under row-level policies' : 'with no row-level policy';
420+
it(`${verb}, ${policies}: the caller gets the read door's refusal for every predicate, and nothing changes`, async () => {
421+
const r = await boot({ ...READ_GATED, rls });
422+
const read = await outcome(r.engine.find(CHILD, { where: EVERY_ROW, context: { ...CALLER } } as never));
423+
expect(read).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 });
424+
const before = await names(r);
425+
426+
const answers = [
427+
await write(r, EVERY_ROW),
428+
await write(r, ids('c_own')),
429+
await write(r, ids('c_hidden_open')),
430+
await write(r, { team: 'ops' }),
431+
await write(r, ids('c_nowhere')),
432+
];
433+
434+
for (const got of answers) expect(got).toEqual(read);
435+
// Refused means untouched, every row.
436+
expect(await names(r)).toEqual(before);
437+
});
438+
}
439+
440+
it(`${verb}: the by-id leg gives that caller the same answer`, async () => {
441+
const r = await boot({ ...READ_GATED, rls: false });
442+
const read = await outcome(r.engine.find(CHILD, { where: ids('c_own'), context: { ...CALLER } } as never));
443+
const predicate = await write(r, ids('c_own'));
444+
const byId = await outcome(
445+
verb === 'update'
446+
? r.engine.update(CHILD, { name: 'swept' }, { where: { id: 'c_own' }, context: { ...CALLER } } as never)
447+
: r.engine.delete(CHILD, { where: { id: 'c_own' }, context: { ...CALLER } } as never),
448+
);
449+
expect(read).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 });
450+
expect(predicate).toEqual(read);
451+
expect(byId).toEqual(read);
452+
expect((await r.stored(CHILD, 'c_own'))?.name).toBe('mine');
453+
});
454+
455+
it(`${verb}: a caller holding the capability the read asks for keeps its write, scoped to the rows it reads`, async () => {
456+
const r = await boot({ ...READ_GATED, capabilities: [READ_CAP] });
457+
expect(await outcome(r.engine.find(CHILD, { where: ids('c_own'), context: { ...CALLER } } as never)))
458+
.toMatchObject({ kind: 'landed' });
459+
460+
const got = await write(r, ids('c_own', 'c_hidden_locked', 'c_hidden_open'));
461+
462+
expect(got).toEqual({ kind: 'landed', result: 1 });
463+
const after = await names(r);
464+
if (verb === 'update') expect(after.c_own).toBe('swept');
465+
else expect(after).not.toHaveProperty('c_own');
466+
expect(after.c_hidden_locked).toBe('theirs, hidden, locked');
467+
expect(after.c_hidden_open).toBe('theirs, hidden, open');
468+
});
469+
}
470+
471+
it('a predicate write the platform issues under that caller\'s context, and a system write, keep the answers they had', async () => {
472+
const r = await boot(READ_GATED);
473+
r.engine.registerHook(
474+
'beforeUpdate',
475+
async (hookCtx: any) => {
476+
await hookCtx.api.object(CHILD).update({ name: 'written by the hook' }, { where: ids('c_hidden_open'), multi: true });
477+
},
478+
{ object: PARENT, packageId: PACKAGE },
479+
);
480+
481+
const viaHook = await outcome(
482+
r.engine.update(PARENT, { name: 'renamed' }, { where: { id: 'p_hook' }, context: { ...CALLER } } as never),
483+
);
484+
const system = await outcome(
485+
r.engine.update(CHILD, { name: 'written by the system' }, { where: ids('c_shared'), multi: true, context: { isSystem: true } } as never),
486+
);
487+
488+
expect(viaHook).toMatchObject({ kind: 'landed' });
489+
expect((await r.stored(CHILD, 'c_hidden_open'))?.name).toBe('written by the hook');
490+
expect(system).toEqual({ kind: 'landed', result: 1 });
491+
expect((await r.stored(CHILD, 'c_shared'))?.name).toBe('written by the system');
492+
});
493+
});

‎packages/plugins/plugin-security/src/security-plugin-addressed-writes.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export function addressedPredicateWrite(
136136
export async function readableRowsScopeForPredicateWrite(
137137
this: Pick<AddressedWritesHost, 'ql'>,
138138
opCtx: any,
139-
): Promise<Record<string, unknown> | null> {
139+
): Promise<Record<string, unknown>> {
140140
if (typeof this.ql?.find !== 'function') {
141141
// The read question cannot be asked, and a write that cannot be narrowed
142142
// must not run un-narrowed. Unreachable in a real deployment — `start()`
@@ -159,7 +159,11 @@ export async function readableRowsScopeForPredicateWrite(
159159
context: withoutOperationPrivateKeys((opCtx.context ?? {}) as Record<string, unknown>),
160160
}),
161161
);
162-
if (answer.refused) return null;
162+
// [#21829, ruling B] A caller the read door refuses writes none of the
163+
// object's records: the read door's refusal is the predicate write's answer,
164+
// before the write runs. The by-id leg refuses the same caller too (step
165+
// 2.7).
166+
if (answer.refused) throw answer.error;
163167
const rows: unknown[] = Array.isArray(answer.value) ? answer.value : [];
164168
if (rows.length > PREDICATE_WRITE_READABLE_ID_CAP) {
165169
throw predicateWriteReadableCapError(opCtx.object, opCtx.operation);

‎packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2380,9 +2380,12 @@ export function registerAuthzMiddleware(
23802380
// writable. Composed here, before `next()`, so it binds the engine's
23812381
// matched-row read, its per-row hook dispatch and the driver write
23822382
// alike — a hidden row is not written, not counted and not refused.
2383+
// [#21829, ruling B] A caller the read door REFUSES is answered that
2384+
// refusal here, thrown by the read question itself before `next()`: it
2385+
// writes none of the object's records, whatever its predicate addresses.
2386+
// The by-id leg (step 2.7) refuses the same caller too.
23832387
if (this.addressedPredicateWrite(opCtx, nestedInOperation, referentialFieldClearWrite)) {
2384-
const readable = await this.readableRowsScopeForPredicateWrite(opCtx);
2385-
if (readable) extra.push(readable);
2388+
extra.push(await this.readableRowsScopeForPredicateWrite(opCtx));
23862389
}
23872390
// [#15813 / ADR-0131 D8] RECORD the Layer 0 verdict on the operation —
23882391
// the seam ruled on #15706 (option (i)): the wall states what it

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

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -819,7 +819,8 @@ export async function unreadableUnderCallerRead(read: () => Promise<unknown>): P
819819
* - an answer: whatever the read door returned (no row, a row, a row set);
820820
* - `refused`: a declared 4xx — the read itself was refused (the object's read
821821
* grant withheld, a predicate the driver will not compile). Not a hidden
822-
* row, so the write keeps the answer it has today;
822+
* row: a write door that asks this question throws it, carried in `error`,
823+
* and the write is refused;
823824
* - anything else is a store fault and propagates as raised (#7505): an outage
824825
* is neither an answer nor a refusal, and reporting it as either would
825826
* relabel it.
@@ -4027,15 +4028,19 @@ export class SecurityPlugin implements Plugin {
40274028
* - an answer: the scope to compose. An empty answer composes the empty id
40284029
* list, so a predicate that matches only rows the caller cannot read
40294030
* matches nothing at all;
4030-
* - `refused` (a declared 4xx — the read grant withheld, a predicate the
4031-
* driver will not compile): `null`, and the write keeps the answer it has
4032-
* today;
4031+
* - `refused` (a declared 4xx — a capability the object requires for a read
4032+
* and not for a write, a predicate the driver will not compile): that
4033+
* refusal is THROWN, so the write is answered the read door's own refusal
4034+
* before it runs and changes nothing ([#21829] ruling B). A caller the
4035+
* read door refuses writes none of the object's records, whatever its
4036+
* predicate addresses; the by-id leg refuses the same caller too (step
4037+
* 2.7);
40334038
* - a store fault propagates as raised.
40344039
*
40354040
* The id list is bounded by {@link PREDICATE_WRITE_READABLE_ID_CAP}; see
40364041
* there for what happens above it.
40374042
*/
4038-
private readableRowsScopeForPredicateWrite(opCtx: any): Promise<Record<string, unknown> | null> {
4043+
private readableRowsScopeForPredicateWrite(opCtx: any): Promise<Record<string, unknown>> {
40394044
// [#25223 cut 8] The body moved to `security-plugin-addressed-writes.ts` (see `AddressedWritesHost`).
40404045
const readQl = () => this.ql;
40414046
const host: Pick<AddressedWritesHost, 'ql'> = {

0 commit comments

Comments
 (0)