Skip to content

Commit 0b740c6

Browse files
fix(plugin-security): a write refused for a caller who cannot read the object answers the same for a hidden row and a missing one (#29845)
Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:权限分配表(`sys_user_permission_set`、`sys_user_position`)上的 ADR-0090 D12 管理门,拒绝"按 id 更新/删除"时,若调用者连这个对象都读不了,改为回答该调用者读门的拒绝(与他按 id 读这行得到的答复完全相同);读得到这个对象的调用者(含组织管理员)仍看到 D12 原句,平台管理员照常写入。 - 为什么改:安全加固(细节按披露裁决不公开)。规则:被拒的写,对看不见该行的调用者,隐藏行与不存在的 id 答复相同。 - 风险与回滚:风险低。只在管理门已经拒绝时多做一次调用者自己的按 id 读;答复仍是拒绝,状态与 code 不变(403 PERMISSION_DENIED),只换句子;读本身故障则原样抛出。回滚 = revert 本 PR。 - 先看:`packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts`(`readDoorAnswerForGateRefusal` 与 D12 调用点的 try/catch);`packages/plugins/plugin-security/src/by-id-write-unreadable-not-found.test.ts`(文件末尾新 describe);`packages/qa/dogfood/test/update-by-id-refusal-order.dogfood.test.ts`(带令牌整答复比较 + 组织管理员对照) - 两分钟验证:`pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/by-id-write-unreadable-not-found.test.ts`,预期 23 个用例全绿。 - 需要你做的:无,席位落地 ## What changes The rule: **a write refused for a caller who cannot read the object answers the same for a hidden row and a missing one** — the same status, code and sentence, with a version token (`If-Match`, `expectedVersion`) and without one. - **`security-plugin-authz-middleware.ts`** — the ADR-0090 D12 delegated-administration gate refuses a write to the permission-assignment objects on the caller and the object alone, before any grant is read, in words written for the write. Its call is now wrapped: when it refuses (a declared 4xx) the by-id update or delete the caller addressed (`addressedByIdWriteId` — not a nested write, not the referential clear, routed by id by the engine; never the record field-access answer's synthetic update, whose verdict is admitted or refused alone), the read door's own question is asked of that id (`getCallerPreImage`, the caller's by-id read through the engine — no second evaluator): - the read is **refused** → that refusal is the answer (it is the read door's answer to this caller for every id); - the read **answers** (a row, or none) → the caller reads the object, and the gate's own refusal stands, its sentence unchanged; - the read **faults** → the fault propagates as raised (#7505). - **Docs** — `content/docs/protocol/kernel/http-protocol.mdx`, the update refusal section: one sentence saying the permission-assignment writes answer a caller refused reading the object with that read refusal, and a caller who reads it with the administration refusal. - **Changeset** — `@objectstack/plugin-security` patch. **Caller classes** (by-id update/delete on an assignment object): | Caller | Before | After | |:--|:--|:--| | reads and writes the object, not an administrator | gate's sentence | unchanged | | reads only | gate's sentence | unchanged | | writes without reading | gate's sentence without a token | the read refusal, every id, with or without a token | | neither reads nor writes | gate's sentence without a token | the read refusal, every id, with or without a token | | create-only | insert: gate's sentence | insert unchanged (names no row); by-id update/delete: the read refusal | | tenant-level administrator | passes the gate | unchanged | | organization administrator (reads the object) | gate's sentence ("never edited in place") | unchanged | | system context | bypasses the middleware | unchanged | | another organization's caller | as its read of the object decides | the same two arms: refused read → read refusal; readable object → gate's sentence | **Clause-② is `no`, patch** — a refusal sentence swapped for another for one caller class (as #28615). Every changed answer was and stays `403 PERMISSION_DENIED`; nothing refused is admitted and nothing admitted is refused, so no accept set narrows. **Metadata-protocol: none.** ## Tests - **Plugin, real engine** (`by-id-write-unreadable-not-found.test.ts`, a new top-level describe in the file that pins "a row the caller cannot read answers what a nonexistent id answers", with its own rig: both assignment objects, a real `ObjectQL` over SQL, the real `SecurityPlugin`). Per object × verb, for a caller with no grant and one with write-without-read: the caller's by-id read is refused (precondition), and the write of the row and of an id naming none both equal that refusal — code, status, message and name; the row is untouched. Controls: a caller who reads the object keeps the gate's sentence for the row and for no row; a tenant-level administrator updates a row and gets `404 RECORD_NOT_FOUND` for no row; an insert keeps the gate's sentence. - **Showcase, HTTP door** (`update-by-id-refusal-order.dogfood.test.ts`): the stale-token families now compare **status, code and sentence** for every family, the two objects the member may neither read nor write included (they compared status and code only). New control: an organization administrator, who reads `sys_user_permission_set`, keeps the gate's "never edited in place" sentence on `PATCH` for a row and for no row. - **Ablation**, once (below): the helper made to always keep the gate's refusal → the 8 no-read plugin cases and the dogfood stale-token case go red; restored → green. ## Acceptance notes - The administration gate's row-independent sentence is kept for every caller who reads the object, so the guidance an administrator or a holder of plain grants sees does not change. - Only the by-id update and delete the caller addressed are affected: an insert, a predicate write and a write the platform issues under the caller's context name no row of the caller's and keep the gate's refusal. ## Verification Head `7e516c3dcd` on merge base `b3d141505` (`origin/main`); the builds, suites, pins, ablation and dogfood battery ran at `04e42a7b06`, and `7e516c3dcd` changes only the changeset's title line (`git diff --stat 04e42a7 7e516c3`: `.changeset/write-refusal-read-door-parity.md | 2 +-`). The gate union ran at `7e516c3dcd`. Exit codes captured before any pipe. Locked runs through `scripts/pm/os-verify-lock.sh` on a shared box. <details> <summary>Before the fix (main at b3d1415): the plugin pin is red</summary> `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 src/by-id-write-unreadable-not-found.test.ts` with the new describe and the unfixed middleware: `Tests 8 failed | 15 passed (23)` — the 8 failures are exactly the no-read caller cases; the reader, administrator and insert controls pass. </details> <details> <summary>Build, plugin suite, typecheck, pins</summary> - `pnpm --filter @objectstack/plugin-security build` → exit 0 - `pnpm --filter @objectstack/plugin-security test` → exit 0 · `Test Files 241 passed (241)` · `Tests 5220 passed | 45 skipped (5265)` - `pnpm --filter @objectstack/plugin-security typecheck` → exit 0 - `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/update-by-id-refusal-order.dogfood.test.ts` → exit 0 · `Tests 19 passed (19)` </details> <details> <summary>Ablation (one shot, dist-mediated)</summary> `node scripts/ablation-replace.mjs` on `security-plugin-authz-middleware.ts`, anchor `if (addressedId === undefined) return refusal;` (hits 1 → 0, blob `5b65837cde22` → `20c73d8e1e31`) replaced by a comparison against the planted literal `ABLATION_SECF_MARK`, so the helper always keeps the gate's refusal: - rebuild → exit 0; `ablation-dist-preflight.mjs @objectstack/plugin-security 'ABLATION_SECF_MARK'` → exit 0 (marker in dist) - plugin pin → exit 1 · `Tests 8 failed | 15 passed (23)` (the 8 no-read cases) - dogfood pin → exit 1 · `Tests 1 failed | 18 passed (19)` (the stale-token test) - restored by the tool's trap: blob `5b65837cde22` == HEAD, `git diff HEAD` empty (0 bytes); rebuild → exit 0; `--absent` preflight → exit 0 </details> <details> <summary>Showcase dogfood battery (final head)</summary> `pnpm --filter @objectstack/dogfood test` (no shard, no path), under the lock: `os-verify-lock: VERDICT command-exit 0` · `Test Files 404 passed | 3 skipped (407)` · `Tests 3327 passed | 28 skipped (3355)` · no failures. </details> <details> <summary>Derived gates (`dispatch-gates.mjs --commands`, 90 families)</summary> All 90 run at `7e516c3dcd`, each exit captured before any pipe: **90 × exit 0**. Two (`pnpm --filter @objectstack/spec run check:skill-examples`, `pnpm check:dual-build-cjs-loads`) first refused with `PREREQUISITE NOT MET` (exit 3: seven packages outside the dogfood closure had no `dist/`); after building those seven under the lock (turbo, `44 cached, 44 total`) both re-ran → exit 0 (`283 prose examples type-check across 3 surface(s)`; `115 published require entry point(s) across 70 package(s) load`). `pnpm check:type-check-debt` → exit 0 (`0 ledger entr(ies) re-measured … none above its recorded number`). Reconciliation: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RECORD` → `90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN`. Not run locally, owned by CI: `test:repo` (the vitest `repo` project; no file of it is touched) and the repo-wide lint scan; `eslint --no-inline-config` over the three changed TypeScript files → exit 0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4985dde commit 0b740c6

5 files changed

Lines changed: 316 additions & 25 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): a write refused for a caller who cannot read the object answers the same for a hidden row and a missing one
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The administration gate on the permission-assignment objects (`sys_user_permission_set`, `sys_user_position`, ADR-0090 D12) refuses a write before the object's grants are read, in words written for the write. For a by-id update or delete, a caller whose read of the object is refused now gets that read refusal from it instead — the answer its by-id read gives for every id — so a row and an id naming none answer the same status, code and sentence, with a version token (`If-Match`, `expectedVersion`) and without one.
11+
12+
- **Unchanged:** a caller who reads the object keeps the gate's own sentence (an organization administrator's "never edited in place", the administration hint for a holder of plain grants); a tenant-level administrator writes as before; an insert, a predicate write and a write the platform issues under the caller's context keep the gate's own refusal. Every answer is still a refusal: nothing that was refused is admitted, and nothing admitted is refused.

‎content/docs/protocol/kernel/http-protocol.mdx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -757,7 +757,11 @@ A version token (`If-Match`, or `expectedVersion`) is compared only against a re
757757
caller can read: a stale token on such a record is answered `409 CONCURRENT_UPDATE`,
758758
whether or not the caller may write it. For a record the caller's read does not return,
759759
and for an id that names no record, a stale token changes none of the answers above. A
760-
caller refused reading the object gets that read refusal.
760+
caller refused reading the object gets that read refusal — and so does the same caller's
761+
update or delete of a permission assignment (`sys_user_permission_set`,
762+
`sys_user_position`), which is refused before the object's grants are read: for any
763+
`{record_id}`, with or without a token. A caller who reads the assignment object gets
764+
the administration refusal's own sentence instead.
761765

762766
### Delete Record
763767

‎packages/plugins/plugin-security/src/by-id-write-unreadable-not-found.test.ts‎

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@
1919
* write — are not addressed by the caller, so they keep the answer they
2020
* had before: the record-level 403, which names nothing. A not-found there
2121
* would carry an id the caller never supplied.
22+
* 5. The ADR-0090 D12 administration gate, which refuses a write to the
23+
* permission-assignment objects before any grant is read, answers the
24+
* addressed by-id update and delete of a caller whose read of the object
25+
* is refused with that read refusal, for a row and for an id naming none
26+
* alike. A caller who reads the object, and an insert, keep the gate's own
27+
* sentence; an administrator still writes. (Its own rig, at the foot of
28+
* the file: the two assignment objects and three more permission sets.)
2229
*
2330
* The rig is one permission set over two objects: a public parent, and a
2431
* child whose rows a caller reads when it owns them or they are flagged
@@ -35,6 +42,10 @@ import { recordNotFoundError } from '@objectstack/core';
3542
import { SecurityPlugin } from './security-plugin.js';
3643
import { defaultPermissionSets } from './objects/default-permission-sets.js';
3744
import { installDefaultOrganization } from './default-organization.testkit.js';
45+
import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js';
46+
import { SysUserPosition } from './objects/sys-user-position.object.js';
47+
import { SysUser } from '@objectstack/platform-objects/identity';
48+
import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity';
3849

3950
const SYS = { context: { isSystem: true } } as never;
4051
const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!;
@@ -253,3 +264,176 @@ describe('a by-id write the platform issues under the caller\'s context keeps th
253264
expect(await r.remove(CHILD, 'c_kid')).toMatchObject({ kind: 'refused', code: 'RECORD_NOT_FOUND', status: 404 });
254265
});
255266
});
267+
268+
269+
// ── The delegated-administration gate (ADR-0090 D12) ─────────────────────────
270+
//
271+
// The gate refuses a write to the permission-assignment objects on the caller
272+
// and the object alone, before any grant is read, in its own words. For a
273+
// by-id update or delete the caller addressed, a caller whose read of the
274+
// object is refused gets the read door's refusal from it instead: the answer
275+
// that caller's by-id read gives for every id, so a row and an id naming none
276+
// answer alike. A caller who reads the object keeps the gate's own sentence,
277+
// an insert (which names no row) keeps it too, and an administrator still
278+
// writes.
279+
280+
const ASSIGNMENT_OBJECTS = ['sys_user_permission_set', 'sys_user_position'] as const;
281+
type AssignmentObject = (typeof ASSIGNMENT_OBJECTS)[number];
282+
const SEEDED_ROW: Record<AssignmentObject, string> = { sys_user_permission_set: 'ups_seeded', sys_user_position: 'upos_seeded' };
283+
const NOWHERE = 'zz_nowhere_0001';
284+
const GATE_SENTENCE = 'requires tenant-level administration or a delegated adminScope';
285+
const ADMIN_SET = defaultPermissionSets.find((p) => p.name === ADMIN_FULL_ACCESS)!;
286+
287+
/** Holds no grant on either object (its one set names the two business objects above). */
288+
const NO_GRANT = { userId: 'usr_nf_nogrant', positions: [], permissions: ['qa_nf_guard'], posture: 'MEMBER' };
289+
/** May create, update and delete both objects, and read neither. */
290+
const WRITE_NO_READ = { userId: 'usr_nf_writer', positions: [], permissions: ['qa_nf_blind_writer'], posture: 'MEMBER' };
291+
/** Reads both objects, and holds nothing else on them. */
292+
const READER = { userId: 'usr_nf_reader', positions: [], permissions: ['qa_nf_reader'], posture: 'MEMBER' };
293+
const ADMIN = { userId: 'usr_nf_admin', positions: [], permissions: [ADMIN_FULL_ACCESS], posture: 'PLATFORM_ADMIN' };
294+
295+
const gateEngines: ObjectQL[] = [];
296+
afterEach(async () => {
297+
for (const engine of gateEngines.splice(0)) {
298+
try { await engine.destroy(); } catch { /* noop */ }
299+
}
300+
});
301+
302+
type Read = { kind: 'read'; row: unknown } | ({ kind: 'refused' } & Refusal);
303+
304+
interface GateRig {
305+
read: (caller: object, object: string, id: string) => Promise<Read>;
306+
write: (caller: object, verb: 'update' | 'delete', object: string, id: string) => Promise<Outcome>;
307+
insert: (caller: object, object: string, row: Record<string, unknown>) => Promise<Outcome>;
308+
stored: (object: string, id: string) => Promise<Record<string, unknown> | null>;
309+
}
310+
311+
async function bootGateRig(): Promise<GateRig> {
312+
const engine = new ObjectQL();
313+
engine.registerDriver(
314+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never,
315+
true,
316+
);
317+
await engine.init();
318+
engine.registerApp({
319+
id: 'com.objectstack.qa.by-id-write-administration-gate',
320+
name: 'By-id write: the administration gate answers a caller who cannot read',
321+
version: '1.0.0',
322+
type: 'plugin',
323+
scope: 'system',
324+
objects: [SysUserPermissionSet, SysUserPosition],
325+
} as never);
326+
await engine.syncSchemas();
327+
// `sys_user` registered and unprovisioned, as in the sibling suites: the
328+
// assignment rows' title formula reads through it.
329+
if (!engine.registry.getObject(SysUser.name)) engine.registry.registerObject(SysUser as never, 'qa.by-id-write-administration-gate');
330+
await installDefaultOrganization(engine);
331+
await engine.insert('sys_user_permission_set', { id: SEEDED_ROW.sys_user_permission_set, user_id: OTHER, permission_set: 'qa_nf_guard' }, SYS);
332+
await engine.insert('sys_user_position', { id: SEEDED_ROW.sys_user_position, user_id: OTHER, position: 'qa_pos' }, SYS);
333+
gateEngines.push(engine);
334+
335+
const grant = (allowRead: boolean, write: boolean) =>
336+
Object.fromEntries(ASSIGNMENT_OBJECTS.map((o) => [o, { allowRead, allowCreate: write, allowEdit: write, allowDelete: write }]));
337+
const sets = [
338+
MEMBER_DEFAULT,
339+
ADMIN_SET,
340+
PermissionSetSchema.parse({
341+
name: 'qa_nf_guard',
342+
objects: {
343+
[PARENT]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
344+
[CHILD]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
345+
},
346+
}),
347+
PermissionSetSchema.parse({ name: 'qa_nf_blind_writer', objects: grant(false, true) }),
348+
PermissionSetSchema.parse({ name: 'qa_nf_reader', objects: grant(true, false) }),
349+
];
350+
const services: Record<string, unknown> = {
351+
manifest: { register: vi.fn() },
352+
objectql: engine,
353+
metadata: {
354+
get: async (type: string, name: string) =>
355+
type === 'permission' ? sets.find((s) => s.name === name) ?? null : engine.getSchema(name) ?? null,
356+
list: async () => sets,
357+
},
358+
};
359+
const ctx = {
360+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
361+
registerService: vi.fn(),
362+
hook: () => undefined,
363+
getService: (name: string) => {
364+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
365+
return services[name];
366+
},
367+
};
368+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
369+
await plugin.init(ctx as never);
370+
await plugin.start(ctx as never);
371+
372+
return {
373+
read: (caller, object, id) =>
374+
engine.findOne(object, { where: { id }, context: { ...caller } } as never).then(
375+
(row): Read => ({ kind: 'read', row }),
376+
(e: any): Read => ({ kind: 'refused', code: e?.code, status: e?.statusCode ?? e?.status, message: String(e?.message), name: e?.name }),
377+
),
378+
write: (caller, verb, object, id) =>
379+
outcome(
380+
verb === 'update'
381+
? engine.update(object, { reason: 'renamed' }, { where: { id }, context: { ...caller } } as never)
382+
: engine.delete(object, { where: { id }, context: { ...caller } } as never),
383+
),
384+
insert: (caller, object, row) => outcome(engine.insert(object, row, { context: { ...caller } } as never)),
385+
stored: async (object, id) =>
386+
((await engine.findOne(object, { where: { id }, context: { isSystem: true } } as never)) ?? null) as Record<string, unknown> | null,
387+
};
388+
}
389+
390+
describe('the administration gate answers a by-id write from a caller who cannot read the object with that read\'s refusal', () => {
391+
for (const object of ASSIGNMENT_OBJECTS) {
392+
for (const verb of ['update', 'delete'] as const) {
393+
for (const [label, caller] of [['no grant on the object', NO_GRANT], ['a write grant without a read grant', WRITE_NO_READ]] as const) {
394+
it(`${object} ${verb}, ${label}: the row and an id naming none get the read door's refusal`, async () => {
395+
const r = await bootGateRig();
396+
const id = SEEDED_ROW[object];
397+
const before = await r.stored(object, id);
398+
// The precondition, read off the read door: it refuses this caller.
399+
const read = await r.read(caller, object, id);
400+
expect(read).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 });
401+
402+
const row = await r.write(caller, verb, object, id);
403+
const none = await r.write(caller, verb, object, NOWHERE);
404+
405+
expect(row, 'the row').toEqual(read);
406+
expect(none, 'an id naming none').toEqual(read);
407+
expect(await r.stored(object, id), 'refused means untouched').toEqual(before);
408+
});
409+
}
410+
411+
it(`${object} ${verb}, a caller who reads the object: the row and an id naming none keep the gate's own refusal`, async () => {
412+
const r = await bootGateRig();
413+
const id = SEEDED_ROW[object];
414+
expect(await r.read(READER, object, id)).toMatchObject({ kind: 'read', row: { id } });
415+
416+
const row = await r.write(READER, verb, object, id);
417+
const none = await r.write(READER, verb, object, NOWHERE);
418+
419+
expect(row).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 });
420+
expect(row.kind === 'refused' && row.message).toContain(GATE_SENTENCE);
421+
expect(withoutId(none, NOWHERE)).toEqual(withoutId(row, id));
422+
});
423+
}
424+
}
425+
426+
it('an administrator still updates an assignment row, and gets not-found for an id naming none', async () => {
427+
const r = await bootGateRig();
428+
expect(await r.write(ADMIN, 'update', 'sys_user_permission_set', SEEDED_ROW.sys_user_permission_set)).toEqual({ kind: 'landed' });
429+
expect((await r.stored('sys_user_permission_set', SEEDED_ROW.sys_user_permission_set))?.reason).toBe('renamed');
430+
expect(await r.write(ADMIN, 'update', 'sys_user_permission_set', NOWHERE)).toMatchObject({ kind: 'refused', code: 'RECORD_NOT_FOUND', status: 404 });
431+
});
432+
433+
it('an insert names no row, so a caller who cannot read the object keeps the gate\'s own refusal', async () => {
434+
const r = await bootGateRig();
435+
const got = await r.insert(NO_GRANT, 'sys_user_permission_set', { user_id: OTHER, permission_set: 'qa_nf_guard' });
436+
expect(got).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 });
437+
expect(got.kind === 'refused' && got.message).toContain(GATE_SENTENCE);
438+
});
439+
});

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

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
* through. The handler retires the permission-set memo on a write, refuses a
88
* flow run's identity-assignment writes, lets every other system operation
99
* through, and serves the ADR-0056 public-form grant. It then runs the
10-
* engine-owned write guard and the ADR-0090 D12 delegated-administration gate,
10+
* engine-owned write guard and the ADR-0090 D12 delegated-administration gate
11+
* (whose refusal of a by-id write answers a caller the read door refuses with
12+
* that read refusal, `readDoorAnswerForGateRefusal`),
1113
* resolves the operation's principals and the object's posture, and runs the
1214
* numbered steps in the order they stand: the `requiredPermissions` AND-gate
1315
* (1.5), the CRUD check (2), the access depth and an external principal's
@@ -97,6 +99,7 @@ import { renderOperationMessage } from '@objectstack/spec/system';
9799
import { resolveEngineUpdateDispatch, type EngineUpdateDispatchData } from '@objectstack/metadata-core';
98100
import type { II18nService, IMetadataService, IObjectQLEngine, SharingWriteVerdict } from '@objectstack/spec/contracts';
99101
import { crossFieldClassRefusalCarriedBy, findCrossFieldClassRefusal } from '@objectstack/formula';
102+
import { declaredHttpStatus } from '@objectstack/types';
100103
import type { SecurityPlugin } from './security-plugin.js';
101104
import { isGuestEnvelope, requiredCapsForOperation } from './security-plugin.js';
102105
import { assertFlowSystemIdentityWriteAllowed } from './flow-system-identity-write-refusal.js';
@@ -228,6 +231,58 @@ function userFacingDenialMessage(
228231
return renderOperationMessage({ messageKey }, { locale, translate });
229232
}
230233

234+
/**
235+
* [ADR-0090 D12; ruling of record 5985885287] What a by-id write the
236+
* delegated-administration gate refused answers a caller the read door
237+
* refuses: that door's refusal.
238+
*
239+
* The gate judges the caller and the object before any grant is read and
240+
* before any row is, and words its refusal for the write. The by-id write
241+
* doors answer a caller who cannot read a row what they answer for an id
242+
* naming no row (ruling of record 5985885287), and where a write is answered
243+
* from the caller's own read of its row — the by-id version comparison answers
244+
* a mismatched token with that read's refusal (`assertVersionOf`,
245+
* `@objectstack/metadata-protocol`) — a caller the read door refuses is
246+
* answered that refusal. So the gate answers that caller the same way: the
247+
* read door's own question is asked of the id the caller addressed
248+
* ({@link SecurityPlugin.getCallerPreImage}: the caller's by-id read through
249+
* the engine, never a second evaluator), and
250+
*
251+
* - the read is REFUSED (a declared 4xx): that refusal is the answer. It is
252+
* the read door's answer to this caller for any id, so the row and an id
253+
* naming none answer alike, with a version token and without;
254+
* - the read ANSWERS (a row, or none): the caller reads the object, and the
255+
* gate's own refusal stands, its guidance intact;
256+
* - the read FAULTS: the fault propagates as raised (#7505) — an outage is
257+
* neither an answer nor a refusal.
258+
*
259+
* Asked only of a refusal (a declared 4xx; anything else the gate throws stays
260+
* what it is), only for the by-id update or delete the caller addressed (an
261+
* insert, a predicate write and a nested write name no row of the caller's —
262+
* {@link SecurityPlugin.addressedByIdWriteId}), and never for a row-write
263+
* probe, whose verdict is admitted or refused and nothing more.
264+
*/
265+
async function readDoorAnswerForGateRefusal(
266+
this: Pick<AuthzMiddlewareHost, 'addressedByIdWriteId' | 'getCallerPreImage'>,
267+
opCtx: any,
268+
refusal: unknown,
269+
nestedInOperation: boolean,
270+
): Promise<unknown> {
271+
const status = declaredHttpStatus(refusal);
272+
if (status === undefined || status >= 500 || isRowWriteProbe(opCtx)) return refusal;
273+
const referentialFieldClear = opCtx.operation === 'update' && opCtx.context?.__referentialFieldClear === true;
274+
const addressedId = this.addressedByIdWriteId(opCtx, nestedInOperation, referentialFieldClear);
275+
if (addressedId === undefined) return refusal;
276+
try {
277+
await this.getCallerPreImage(opCtx, addressedId);
278+
} catch (readRefusal) {
279+
const readStatus = declaredHttpStatus(readRefusal);
280+
if (readStatus !== undefined && readStatus < 500) return readRefusal;
281+
throw readRefusal;
282+
}
283+
return refusal;
284+
}
285+
231286
/**
232287
* The `SecurityPlugin` members the function below reads — every one of them,
233288
* and no other; the function's `this` is the `Pick` of the members it reads.
@@ -485,8 +540,16 @@ export function registerAuthzMiddleware(
485540
// else — including holders of plain CRUD grants on these tables — is
486541
// denied. Runs BEFORE the principal-less refusal below so RBAC
487542
// tables fail CLOSED for principal-less non-system contexts.
543+
//
544+
// It judges before any grant is read, in its own words; on the by-id
545+
// update or delete the caller addressed, a caller the read door refuses
546+
// is answered that read refusal instead — {@link readDoorAnswerForGateRefusal}.
488547
if (this.delegatedAdminGate) {
489-
await this.delegatedAdminGate.assert(opCtx);
548+
try {
549+
await this.delegatedAdminGate.assert(opCtx);
550+
} catch (refusal) {
551+
throw await readDoorAnswerForGateRefusal.call(this, opCtx, refusal, nestedInOperation);
552+
}
490553
}
491554

492555
const positions = opCtx.context?.positions ?? [];

0 commit comments

Comments
 (0)