Skip to content

Commit aaee084

Browse files
fix(plugin-security): the controlled_by_parent master-edit leg asks the master's own write-door question (#25008)
Fixes #24986 Clause-②: no ## What this changes ADR-0055 §3: "editing/deleting/creating a detail requires **edit** access to its master". The object-level leg of the `controlled_by_parent` detail write gate (leg 1 of `assertMasterRowEditable`) asked the permission evaluator for an object-level `update` grant on the master, and nothing else. For an `update` on the master, the master's own write door asks three things: - the master's posture must resolve (#3545); - the capabilities the master requires for `update` must be held (ADR-0066 D3/⑤); - the grant is asked under the master's posture (ADR-0066 D2). The leg now asks those three. Each input is read from `getObjectSecurityMeta`, the source every door reads it from. No rule is re-derived: each check is the evaluator's answer or the posture's own, asked the way the door asks it. - **One region.** In `security-plugin.ts` the diff is leg 1 of `assertMasterRowEditable` and nothing else. Legs 2 and 3 (the master's write RLS and record sharing), the chain walk and every other region are untouched. - **Every door that asks the leg follows.** That is an insert, a by-id update and a by-id delete through the engine. It is also `checkControlledByParentWrite`, which the comment and attachment parent gates ask. The record-grained `security/explain` of a by-id write asks the same master gate. - **No widening.** The change adds refusals ahead of the existing grant check. The posture option only removes coverage, and never adds a grant. ## Reach Stated abstractly, per the ruling recorded on #9387. Measured with throwaway probes (not committed) on a real `ObjectQL` + `SqlDriver` + `SecurityPlugin`, at `e4d604efd3`, which includes #24985. - **Reach is shown.** Some principals are refused by the master's own write door at its object gate when they update the master. They can still read that master, and the detail's own door admits them. On `main` this leg was the deciding check for them, and it admitted them. An insert, a by-id update and a by-id delete of a detail under such a master all landed, and `checkControlledByParentWrite` answered `allow`. Two inputs produce this class on their own: the master's posture, and a capability the master requires for `update`. The grant half has a named real producer. With a non-empty master write filter, the leg still admitted rows inside the filter. - **Other gates stop the rest.** If a principal cannot read the master, the read door stops it before this leg. On an insert that is the reference check. On a by-id update or delete it is the read scope (since #24985). The on-behalf-of half is stopped the same way when the agent's ceiling does not cover the master. The delegator's half is the direct case. - **Other paths.** Readings for paths this change does not touch are held privately by the seat; see Acceptance notes. The concrete probes, grant shapes and served values are held privately by the seat. ## Scope, beyond the card's lead The card's lead names one input: the posture. The capability prerequisite and the unresolvable posture are the same defect: this leg asks a narrower question than the master's door. Both are handled here under the bounded in-place rule, and all four of its conditions hold: 1. it is the same defect class; 2. the fix is mechanical, in the shape the door already uses; 3. no other claim holds this region (#24988 touched `reportNameFoldCollisions`, which is disjoint, and has landed); 4. it is the same gate family, with the same new test file. How the four axes read: - **Real need.** The capability case has measured reach at every write door above. The unresolvable case had no reach through an engine write, which already refused. It did have reach through `checkControlledByParentWrite`, which answered `allow`. - **Long-term fit.** The leg and the door now ask one question. Each door pairs the posture with its unresolvable case, because an unresolved posture reads as not private. - **AI authoring.** A master's declared `requiredPermissions.update` is now enforced for its details too. Declared is enforced. - **Startup scope.** No new capability and no new gate. The seat may veto either part. Each part is one `if` block with its own pin. ### Envelopes that move (declared) - Some principals are refused here and also cannot read the master, including on an on-behalf-of write. An insert by such a principal now answers `PERMISSION_DENIED` (403). Before, it reached the reference check's `VALIDATION_FAILED`. An existing master id and an id that exists nowhere answer the same 403 (pinned), so there is no existence oracle. - An insert under a master object that does not resolve now answers `PERMISSION_DENIED` (403). Before, it answered `OBJECT_NOT_FOUND` (404). ## Tests New `packages/plugins/plugin-security/src/controlled-by-parent-master-object-write.test.ts`: 13 tests on the real stack. - **Refusals.** Three refused groups, one per input. Each pins the precondition where it applies (the master's own door refuses the update) and the insert, by-id update and by-id delete envelopes (`PERMISSION_DENIED` / 403). Each also pins that nothing was written, and that `checkControlledByParentWrite` answers `{ outcome: 'deny', leg: 'object_permission' }`. - **Controls.** These keep landing: an object-level edit grant, a super-user grant, the required capability held, a master with none of the three inputs, and a system context. **Ablation.** One leg per pinned behaviour. Each leg was run after the fix was committed (`38081b6a05`), with `scripts/ablation-replace.mjs` in wrap mode under a trap-armed driver. - **Posture** (the option deleted): 3 failed, 10 passed. - **Capability** (the check disabled): 1 failed, 12 passed. - **Unresolvable posture** (the check disabled): 2 failed, 11 passed. Ablated, the insert answered `OBJECT_NOT_FOUND`/404 and the check answered `allow`. Every restore was proven: the blob equals HEAD (`7dbb423417f0`) and `git diff HEAD` is empty. The suite imports `./security-plugin.js` from source, so no `dist/` sits in the ablation path. ## Verification Every run below went under `scripts/pm/os-verify-lock.sh`. The final head is `85f0113f1e`: the fix, a merge of `main` at `7532ea3c08` (which brings in #24988, a disjoint region), and a changeset-only commit. The gate union was run at `85f0113f1e`. The build, test and typecheck were run at `b24e619107`, whose code is byte-identical to the final head (the last commit edits only `.changeset/`). - **Closure build** `pnpm --filter '@objectstack/plugin-security^...' build`: exit 0. - **`pnpm --filter @objectstack/plugin-security test`**: 199 files passed; 4114 tests passed, 45 skipped. - **`pnpm --filter @objectstack/plugin-security typecheck`** (`tsc` ×2 + `check:test-typecheck`): exit 0. - **`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`** at `85f0113f1e`: 69 derived commands, each run with its exit captured before any pipe, all 69 exit 0. The `--ran` reconciliation answers "69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN". The union at `b24e619107` read the same. - **Before the merge** (`ac70f18533`): test 4113 passed, typecheck exit 0, gates 67 exit 0. `check:dual-build-cjs-loads` and `check:i18n` exited 3 (PREREQUISITE NOT MET) there; both are green at the final head. ## Acceptance notes - **A finding outside this leg.** One write path that never asks this leg was measured too. It is reported to the seat privately as a separate finding, under the same disclosure ruling. This PR's pins do not touch that path. - **The leg vocabulary's contract text.** `ControlledByParentWriteDenialLeg`'s TSDoc in `@objectstack/spec` reads `object_permission` as "no object-level `update` grant on a master". That leg now also carries the capability and unresolvable-posture refusals, which are the door's object gate. The spec is out of this card's scope, so its text is unchanged. - `test:repo` (the vitest `repo` project) was not run locally. The diff does not touch it, and CI's `Test Core` runs it. --- _Generated by [Claude Code](https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 62bc129 commit aaee084

3 files changed

Lines changed: 422 additions & 1 deletion

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): a `controlled_by_parent` detail write asks its master the object-level question the master's own write door asks (ADR-0055 §3)
6+
7+
Clause-②: no
8+
9+
ADR-0055 §3 says editing, deleting or creating a detail requires edit access to its master. The master-edit gate's object-level leg asked only for an object-level `update` grant on the master. It now asks what the master's own write door asks of an `update` on the master, with that door's inputs: the master's security posture must resolve, the capabilities the master requires for `update` (ADR-0066 D3) must be held, and the grant is asked under the master's posture (ADR-0066 D2). A principal the master's own write door refuses at its object gate can no longer write a detail under that master.
10+
11+
- **Every door that asks the leg.** An insert, a by-id update and a by-id delete of a detail answer `PERMISSION_DENIED` (403) and write nothing. `checkControlledByParentWrite`, which the comment and attachment parent gates ask, answers `deny` on the `object_permission` leg. A record-grained `security/explain` of a by-id write follows the same leg.
12+
- **An insert answers before the reference check.** For such a principal, an insert under a master id that exists answers the same refusal as one under an id that exists nowhere. A principal that also cannot read the master, on an on-behalf-of write too, now gets this `PERMISSION_DENIED` on insert where it got the reference check's `VALIDATION_FAILED`. An insert under a master object that does not resolve answers `PERMISSION_DENIED` where it answered `OBJECT_NOT_FOUND` (404).
13+
- **Unchanged.** A principal the master's own write door admits for an `update` keeps writing its details, and so do a super-user grant and a system context. Under a master that is `public`, resolves and requires no capability for `update`, every principal gets the answer it got before.
Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0055 §3] Writing a `controlled_by_parent` detail requires EDIT access to
5+
* its master, and the master's OBJECT-level edit admission is the one its own
6+
* write door asks.
7+
*
8+
* §3: "editing/deleting/creating a detail requires edit access to its master".
9+
* The master-edit gate's object-level leg is therefore the master write door's
10+
* object gate, asked of an `update` on the master with that door's own inputs:
11+
*
12+
* - the master's posture resolves, and an unresolvable one refuses;
13+
* - the capabilities the master requires for `update` are held;
14+
* - the CRUD grant is asked under the master's posture.
15+
*
16+
* Pinned at every door that asks the leg: an insert, a by-id update and a
17+
* by-id delete through the engine, and the security service's master-detail
18+
* write check (`checkControlledByParentWrite`) that the parent gates of other
19+
* subsystems ask. Each refusal is the leg's own, `object_permission`, and
20+
* writes nothing.
21+
*
22+
* The controls hold the change to exactly that rule: a principal the master's
23+
* own door admits for an `update` keeps writing its details, and so does a
24+
* super-user grant and a master that carries none of the three inputs.
25+
*
26+
* Real stack: `ObjectQL` over better-sqlite3 and the real `SecurityPlugin`.
27+
*/
28+
29+
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
30+
import { AsyncLocalStorage } from 'node:async_hooks';
31+
import { ObjectQL } from '@objectstack/objectql';
32+
import { SqlDriver } from '@objectstack/driver-sql';
33+
import { PermissionSetSchema } from '@objectstack/spec/security';
34+
import type { ControlledByParentWriteOutcome } from '@objectstack/spec/contracts';
35+
import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity';
36+
37+
import { SysPosition, SysPermissionSet, SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js';
38+
import { SysUserPosition } from './objects/sys-user-position.object.js';
39+
import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js';
40+
import { SecurityPlugin } from './security-plugin.js';
41+
import { defaultPermissionSets } from './objects/default-permission-sets.js';
42+
43+
const SYS = { context: { isSystem: true } } as never;
44+
const ORG = 'org_cbp_mow';
45+
const ME = 'u_cbp_mow_me';
46+
47+
/** A master whose posture its own door asks. */
48+
const MASTER_POSTURE = 'qa_cbp_mow_master_posture';
49+
/** A master that requires a capability for an update. */
50+
const MASTER_CAPABILITY = 'qa_cbp_mow_master_capability';
51+
/** A master that carries none of the three inputs. */
52+
const MASTER_PLAIN = 'qa_cbp_mow_master_plain';
53+
/** A master name no registry resolves. */
54+
const MASTER_UNRESOLVED = 'qa_cbp_mow_master_unresolved';
55+
56+
const DETAIL_POSTURE = 'qa_cbp_mow_detail_posture';
57+
const DETAIL_CAPABILITY = 'qa_cbp_mow_detail_capability';
58+
const DETAIL_PLAIN = 'qa_cbp_mow_detail_plain';
59+
const DETAIL_UNRESOLVED = 'qa_cbp_mow_detail_unresolved';
60+
61+
const CAPABILITY = 'qa_cbp_mow_capability';
62+
const CRUD = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } as const;
63+
64+
const SET = {
65+
readMasters: 'qa_cbp_mow_read_masters',
66+
broad: 'qa_cbp_mow_broad',
67+
superUser: 'qa_cbp_mow_super_user',
68+
editPostureMaster: 'qa_cbp_mow_edit_posture_master',
69+
editCapabilityMaster: 'qa_cbp_mow_edit_capability_master',
70+
capabilityHolder: 'qa_cbp_mow_capability_holder',
71+
} as const;
72+
73+
type Outcome = { kind: 'landed' } | { kind: 'refused'; code?: string; status?: number };
74+
75+
const outcomeOf = (p: Promise<unknown>): Promise<Outcome> =>
76+
p.then(
77+
() => ({ kind: 'landed' as const }),
78+
(e: unknown) => {
79+
const x = e as { code?: unknown; status?: unknown; statusCode?: unknown } | null;
80+
const status = x?.statusCode ?? x?.status;
81+
return {
82+
kind: 'refused' as const,
83+
code: x?.code == null ? undefined : String(x.code),
84+
status: status == null ? undefined : Number(status),
85+
};
86+
},
87+
);
88+
89+
const REFUSED = { kind: 'refused', code: 'PERMISSION_DENIED', status: 403 } as const;
90+
const LEG_REFUSAL: ControlledByParentWriteOutcome = { outcome: 'deny', leg: 'object_permission' };
91+
92+
/** Release a torn-down rig's async storages (see the explain-enforce-parity suite). */
93+
const releaseAsyncContexts = (...owners: object[]): void => {
94+
for (const owner of owners) {
95+
for (const value of Object.values(owner)) if (value instanceof AsyncLocalStorage) value.disable();
96+
}
97+
};
98+
99+
const textField = (name: string) => ({ name, type: 'text' });
100+
const masterOf = (name: string, extra: Record<string, unknown> = {}) => ({
101+
name,
102+
label: name,
103+
sharingModel: 'public_read_write',
104+
...extra,
105+
fields: { id: { name: 'id', type: 'text', primaryKey: true }, name: textField('name') },
106+
});
107+
const detailOf = (name: string, master: string) => ({
108+
name,
109+
label: name,
110+
sharingModel: 'controlled_by_parent',
111+
fields: {
112+
id: { name: 'id', type: 'text', primaryKey: true },
113+
note: textField('note'),
114+
master: { name: 'master', type: 'master_detail', required: true, reference: master },
115+
},
116+
});
117+
118+
async function boot() {
119+
const engine = new ObjectQL();
120+
engine.registerDriver(
121+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never,
122+
true,
123+
);
124+
await engine.init();
125+
engine.registerApp({
126+
id: 'com.objectstack.qa.cbp-master-object-write',
127+
name: 'A controlled_by_parent detail is written only by a principal its master object admits for an update',
128+
version: '1.0.0',
129+
type: 'plugin',
130+
scope: 'system',
131+
objects: [
132+
SysPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPosition, SysUserPermissionSet,
133+
SysOrganization, SysUser, SysMember,
134+
masterOf(MASTER_POSTURE, { access: { default: 'private' } }),
135+
masterOf(MASTER_CAPABILITY, { requiredPermissions: { update: [CAPABILITY] } }),
136+
masterOf(MASTER_PLAIN),
137+
detailOf(DETAIL_POSTURE, MASTER_POSTURE),
138+
detailOf(DETAIL_CAPABILITY, MASTER_CAPABILITY),
139+
detailOf(DETAIL_PLAIN, MASTER_PLAIN),
140+
detailOf(DETAIL_UNRESOLVED, MASTER_UNRESOLVED),
141+
],
142+
} as never);
143+
await engine.syncSchemas();
144+
145+
const e = engine as unknown as {
146+
insert: (o: string, row: Record<string, unknown>, opts: never) => Promise<unknown>;
147+
getDriver: (o: string) => { create: (o: string, row: Record<string, unknown>) => Promise<unknown> };
148+
logger: { warn: () => void };
149+
};
150+
await e.insert('sys_organization', { id: ORG, name: ORG, slug: ORG }, SYS);
151+
await e.insert('sys_user', { id: ME, name: ME, email: `${ME}@example.test` }, SYS);
152+
await e.insert('sys_member', { id: `mem_${ME}`, user_id: ME, organization_id: ORG, role: 'member' }, SYS);
153+
154+
const sets = [
155+
...defaultPermissionSets,
156+
PermissionSetSchema.parse({
157+
name: SET.readMasters,
158+
objects: {
159+
[MASTER_POSTURE]: { allowRead: true },
160+
[MASTER_CAPABILITY]: { allowRead: true },
161+
[MASTER_PLAIN]: { allowRead: true },
162+
},
163+
}),
164+
PermissionSetSchema.parse({ name: SET.broad, objects: { '*': CRUD } }),
165+
PermissionSetSchema.parse({
166+
name: SET.superUser,
167+
objects: { '*': { ...CRUD, viewAllRecords: true, modifyAllRecords: true } },
168+
}),
169+
PermissionSetSchema.parse({
170+
name: SET.editPostureMaster,
171+
objects: { [MASTER_POSTURE]: { allowRead: true, allowEdit: true }, [DETAIL_POSTURE]: CRUD },
172+
}),
173+
PermissionSetSchema.parse({
174+
name: SET.editCapabilityMaster,
175+
objects: { [MASTER_CAPABILITY]: { allowRead: true, allowEdit: true }, [DETAIL_CAPABILITY]: CRUD },
176+
}),
177+
PermissionSetSchema.parse({
178+
name: SET.capabilityHolder,
179+
objects: { [MASTER_CAPABILITY]: { allowRead: true, allowEdit: true }, [DETAIL_CAPABILITY]: CRUD },
180+
systemPermissions: [CAPABILITY],
181+
}),
182+
];
183+
184+
for (const master of [MASTER_POSTURE, MASTER_CAPABILITY, MASTER_PLAIN]) {
185+
await e.insert(master, { id: `${master}_row`, name: 'row' }, SYS);
186+
}
187+
188+
let security: { checkControlledByParentWrite: (o: string, id: string, c: unknown) => Promise<ControlledByParentWriteOutcome> } | undefined;
189+
const services: Record<string, unknown> = {
190+
manifest: { register: vi.fn() },
191+
objectql: engine,
192+
metadata: {
193+
get: async (type: string, name: string) =>
194+
type === 'permission' ? sets.find((s: { name?: string }) => s.name === name) ?? null : engine.getSchema(name) ?? null,
195+
list: async () => sets,
196+
},
197+
tenancy: { posture: 'single' },
198+
};
199+
const ctx = {
200+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
201+
registerService: (name: string, svc: unknown) => {
202+
if (name === 'security') security = svc as typeof security;
203+
else services[name] = svc;
204+
},
205+
hook: () => undefined,
206+
getService: (name: string) => {
207+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
208+
return services[name];
209+
},
210+
};
211+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
212+
await plugin.init(ctx as never);
213+
await plugin.start(ctx as never);
214+
// The row gates log the writes they refuse.
215+
vi.spyOn(e.logger, 'warn').mockImplementation(() => undefined);
216+
217+
let n = 0;
218+
return {
219+
engine,
220+
/** A stored detail under `master`, written as SYSTEM (seed replay skips the reference check). */
221+
seedDetail: async (detail: string, master: string) => {
222+
const id = `${detail}_seed_${n++}`;
223+
await e.getDriver(detail).create(detail, { id, note: 'seeded', master });
224+
return id;
225+
},
226+
stored: async (object: string, id: string) =>
227+
((await engine.findOne(object, { where: { id }, context: { isSystem: true } } as never)) ?? null) as Record<string, unknown> | null,
228+
checkWrite: (detail: string, id: string, context: unknown) =>
229+
security!.checkControlledByParentWrite(detail, id, { ...(context as object) }),
230+
teardown: async () => {
231+
try { await engine.destroy(); } catch { /* noop */ }
232+
releaseAsyncContexts(engine, plugin);
233+
},
234+
};
235+
}
236+
237+
type Rig = Awaited<ReturnType<typeof boot>>;
238+
239+
const member = (...permissions: string[]) => ({ userId: ME, positions: [], permissions, posture: 'MEMBER' });
240+
241+
let insertSeq = 0;
242+
243+
/** Insert, by-id update and by-id delete of one detail under `masterId`, and what each left stored. */
244+
async function writeDetail(rig: Rig, detail: string, masterId: string, context: unknown) {
245+
const inserted = `${detail}_insert_${insertSeq++}`;
246+
const insert = await outcomeOf(
247+
rig.engine.insert(detail, { id: inserted, note: '', master: masterId }, { context: { ...(context as object) } } as never),
248+
);
249+
const updated = await rig.seedDetail(detail, masterId);
250+
const update = await outcomeOf(
251+
rig.engine.update(detail, { note: 'written' }, { where: { id: updated }, context: { ...(context as object) } } as never),
252+
);
253+
const deleted = await rig.seedDetail(detail, masterId);
254+
const del = await outcomeOf(rig.engine.delete(detail, { where: { id: deleted }, context: { ...(context as object) } } as never));
255+
return {
256+
insert,
257+
update,
258+
delete: del,
259+
insertedStored: (await rig.stored(detail, inserted)) !== null,
260+
updatedNote: (await rig.stored(detail, updated))?.note,
261+
deletedStored: (await rig.stored(detail, deleted)) !== null,
262+
check: await rig.checkWrite(detail, await rig.seedDetail(detail, masterId), context),
263+
};
264+
}
265+
266+
const masterUpdate = (rig: Rig, master: string, context: unknown) =>
267+
outcomeOf(
268+
rig.engine.update(master, { name: 'written' }, { where: { id: `${master}_row` }, context: { ...(context as object) } } as never),
269+
);
270+
271+
describe("a controlled_by_parent detail is not written by a principal its master's own write door refuses at the object gate", () => {
272+
let rig: Rig;
273+
beforeAll(async () => { rig = await boot(); });
274+
afterAll(async () => { await rig.teardown(); });
275+
276+
describe("on the master's posture", () => {
277+
const context = member(SET.readMasters, SET.broad);
278+
279+
it("the master's own write door refuses the update, and the principal reads the master (the precondition)", async () => {
280+
expect(await masterUpdate(rig, MASTER_POSTURE, context)).toMatchObject(REFUSED);
281+
const read = await rig.engine.findOne(MASTER_POSTURE, { where: { id: `${MASTER_POSTURE}_row` }, context: { ...context } } as never);
282+
expect(read).toBeTruthy();
283+
});
284+
285+
it('insert, by-id update and by-id delete are refused, and nothing is written', async () => {
286+
const got = await writeDetail(rig, DETAIL_POSTURE, `${MASTER_POSTURE}_row`, context);
287+
expect(got.insert).toMatchObject(REFUSED);
288+
expect(got.update).toMatchObject(REFUSED);
289+
expect(got.delete).toMatchObject(REFUSED);
290+
expect(got.insertedStored).toBe(false);
291+
expect(got.updatedNote).toBe('seeded');
292+
expect(got.deletedStored).toBe(true);
293+
});
294+
295+
it('the master-detail write check refuses on the object_permission leg', async () => {
296+
const id = await rig.seedDetail(DETAIL_POSTURE, `${MASTER_POSTURE}_row`);
297+
expect(await rig.checkWrite(DETAIL_POSTURE, id, context)).toEqual(LEG_REFUSAL);
298+
});
299+
300+
it('an insert naming a master id that exists nowhere answers the same refusal', async () => {
301+
expect(
302+
await outcomeOf(
303+
rig.engine.insert(DETAIL_POSTURE, { id: 'qa_cbp_mow_dangling', note: '', master: 'qa_cbp_mow_no_such_master' }, { context: { ...context } } as never),
304+
),
305+
).toMatchObject(REFUSED);
306+
});
307+
});
308+
309+
describe('on a capability the master requires for an update', () => {
310+
const context = member(SET.editCapabilityMaster);
311+
312+
it("the master's own write door refuses the update (the precondition)", async () => {
313+
expect(await masterUpdate(rig, MASTER_CAPABILITY, context)).toMatchObject(REFUSED);
314+
});
315+
316+
it('insert, by-id update and by-id delete are refused, and nothing is written', async () => {
317+
const got = await writeDetail(rig, DETAIL_CAPABILITY, `${MASTER_CAPABILITY}_row`, context);
318+
expect(got.insert).toMatchObject(REFUSED);
319+
expect(got.update).toMatchObject(REFUSED);
320+
expect(got.delete).toMatchObject(REFUSED);
321+
expect(got.insertedStored).toBe(false);
322+
expect(got.updatedNote).toBe('seeded');
323+
expect(got.deletedStored).toBe(true);
324+
expect(got.check).toEqual(LEG_REFUSAL);
325+
});
326+
});
327+
328+
describe("on a master whose posture does not resolve", () => {
329+
const context = member(SET.broad);
330+
331+
it('an insert is refused, and nothing is written', async () => {
332+
const got = await outcomeOf(
333+
rig.engine.insert(DETAIL_UNRESOLVED, { id: 'qa_cbp_mow_unresolved_insert', note: '', master: 'qa_cbp_mow_any' }, { context: { ...context } } as never),
334+
);
335+
expect(got).toMatchObject(REFUSED);
336+
expect(await rig.stored(DETAIL_UNRESOLVED, 'qa_cbp_mow_unresolved_insert')).toBeNull();
337+
});
338+
339+
it('the master-detail write check refuses on the object_permission leg', async () => {
340+
const id = await rig.seedDetail(DETAIL_UNRESOLVED, 'qa_cbp_mow_any');
341+
expect(await rig.checkWrite(DETAIL_UNRESOLVED, id, context)).toEqual(LEG_REFUSAL);
342+
});
343+
});
344+
});
345+
346+
describe("a principal its master's own write door admits for an update keeps writing the master's details", () => {
347+
let rig: Rig;
348+
beforeAll(async () => { rig = await boot(); });
349+
afterAll(async () => { await rig.teardown(); });
350+
351+
const LANDS = { insert: { kind: 'landed' }, update: { kind: 'landed' }, delete: { kind: 'landed' } } as const;
352+
const WRITTEN = { insertedStored: true, updatedNote: 'written', deletedStored: false, check: { outcome: 'allow' } } as const;
353+
354+
const CONTROLS = [
355+
{ label: 'an object-level edit grant on the master', context: member(SET.editPostureMaster), master: MASTER_POSTURE, detail: DETAIL_POSTURE },
356+
{ label: 'a super-user grant', context: member(SET.superUser), master: MASTER_POSTURE, detail: DETAIL_POSTURE },
357+
{ label: 'the required capability with an edit grant', context: member(SET.capabilityHolder), master: MASTER_CAPABILITY, detail: DETAIL_CAPABILITY },
358+
{ label: 'a master that carries none of the three inputs', context: member(SET.readMasters, SET.broad), master: MASTER_PLAIN, detail: DETAIL_PLAIN },
359+
] as const;
360+
361+
for (const { label, context, master, detail } of CONTROLS) {
362+
it(`${label}: the master's own door admits the update, and insert, by-id update and by-id delete of a detail land`, async () => {
363+
expect(await masterUpdate(rig, master, context)).toEqual({ kind: 'landed' });
364+
const got = await writeDetail(rig, detail, `${master}_row`, context);
365+
expect(got).toMatchObject({ ...LANDS, ...WRITTEN });
366+
});
367+
}
368+
369+
it('a system context writes a detail under every master', async () => {
370+
for (const [detail, master] of [
371+
[DETAIL_POSTURE, `${MASTER_POSTURE}_row`],
372+
[DETAIL_CAPABILITY, `${MASTER_CAPABILITY}_row`],
373+
[DETAIL_UNRESOLVED, 'qa_cbp_mow_any'],
374+
] as const) {
375+
const id = `${detail}_system`;
376+
expect(await outcomeOf(rig.engine.insert(detail, { id, note: '', master }, SYS))).toEqual({ kind: 'landed' });
377+
expect(await rig.stored(detail, id)).toBeTruthy();
378+
}
379+
});
380+
});

0 commit comments

Comments
 (0)