Skip to content

Commit a18c514

Browse files
test(plugin-security): pin explain controlled_by_parent update/delete verdict under a Modify All Data master beside the door (#22760)
Fixes #22721 Clause-②: no ## What this is Stage 1 of #22721: the measurement the triage direction (comment 6101562558) asks for, plus one test file that pins the one route the measurement found unpinned. No source file changes. **The card's defect does not reproduce on `main`.** On a booted stack at `origin/main` `d7b26df5`, which contains #22529 (`d303b3e7af`) and #22549 (`86bf9ed7d5`), `git merge-base --is-ancestor` exit 0 for both, the record-grained `security/explain` answers `record.visible: true` for both `update` and `delete` of a `controlled_by_parent` child whose master another user owns and the caller may edit. The data door answers 200 for the same caller and row, through each of the three routes measured. A caller who may not edit the master is refused by both. The console header (objectui `origin/main` `5330afd`, `packages/plugin-detail/src/useRecordEditable.ts`) posts exactly `{object, operation, recordId}` for `update` and `delete` and reads `decision.record.visible`, so on `main` it offers Edit and Delete to the HotCRM sales manager. The card was measured on 17.7.0, which predates both corrections; hosted tenants receive them with cloud's v18 pin. ## The measurement, through the real HTTP doors Booted with `@objectstack/verify` `bootStack`: better-auth sign-ups, the REST explain route, the generic `/data` door, plugin-sharing and plugin-security. The boot is `orgContext: true`, so the ownership floor (`owner_only_writes` / `owner_only_deletes`, positions `org_member`) binds every caller. This was a scratch probe, untracked and deleted after the reading. - **Master** `hmx_account`: `sharingModel: 'private'`, owned and created by another member (`rep`). - **Details** `hmx_contract`, `hmx_quote`, `hmx_note`: all `controlled_by_parent` under it. Every child was inserted as the system, so no caller created any of them. - `hmx_contract` has HotCRM's `crm_contract` grant shape: edit, no delete, no write bypass. - `hmx_quote` has HotCRM's `crm_quote` grant shape: CRUD, with `modifyAllRecords` on the detail too. - `hmx_note` has plain CRUD and no bypass, so a delete reaches the master check alone. - **Grants:** each caller's set is distributed through a position's `permissionSets` (the `main` spelling since `bfc15d27`). Each caller's resolved positions and sets were asserted before anything was measured. | route | caller | how the caller may edit the master | HotCRM analogue (hotcrm `1d7148bf`) | |---|---|---|---| | A | `mgr` | `viewAllRecords` + `modifyAllRecords` on the master | the `sales_manager` profile on `crm_account` (`src/sales/profiles/sales-manager.profile.ts`) | | B | `team` | a criteria sharing rule on the master, `accessLevel: 'edit'`, recipient a position the caller holds | `account_team_sharing` (`src/sales/sharing/account.sharing.ts`) | | C | `unitmgr` | a business-unit subtree sharing rule (`unit_and_subordinates`, edit); the caller is in the parent unit of the owner's unit | none. This is the open edition's "manager over the owner"; hierarchy write depth needs `hierarchy-security` (enterprise), and `defineStack` refuses it here | | X | `control` | object-level edit on master and details, and no route to the master row | none | The master's own `PATCH` answered 200 for A, B and C, and 404 for X. Each cell below shows explain's `record.visible` (with `decidedBy` where it is a refusal), then the door's status. Explain was `POST /api/v1/security/explain {object, operation, recordId}`, 200 on every cell. The door was `PATCH` or `DELETE /api/v1/data/OBJECT/ID`. | caller, master | contract update | contract delete | quote update | quote delete | note update | note delete | |---|---|---|---|---|---|---| | A, a master rule B matches | true · 200 | false, object_crud · 403 | true · 200 | true · 200 | true · 200 | true · 200 | | A, a master rule B does not match | true · 200 | false, object_crud · 403 | true · 200 | true · 200 | true · 200 | true · 200 | | B, a master the rule matches | true · 200 | false, object_crud · 403 | true · 200 | true · 200 | true · 200 | true · 200 | | B, a master the rule does not match (control) | false, no decider · 404 | false, object_crud · 403 | false, no decider · 404 | false, no decider · 404 | false, no decider · 404 | false, no decider · 404 | | C | true · 200 | false, object_crud · 403 | true · 200 | true · 200 | true · 200 | true · 200 | | X (control) | false, no decider · 404 | false, object_crud · 403 | false, no decider · 404 | false, no decider · 404 | false, no decider · 404 | false, no decider · 404 | Explain's verdict equals the door's on all 36 cells. - **Contract delete** is refused by both faces at the object gate, as HotCRM intends (`allowDelete: false` on `crm_contract`). - **The two controls** cannot read the master, so the child is unreadable. The door answers 404 (Ruling A on the write doors), and explain answers the matching missing-record shape: `visible: false` with no decider. ## What was already pinned, and why one new file **Already pinned, explain beside the door, on `controlled_by_parent`:** - `controlled-by-parent-write-member.test.ts` covers update, delete and transfer for four masters: an owned master, a master shared at `edit` to the caller (`c_shared`), an RLS-admitted master and a two-hop chain. - Its REST twin, `packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts`, covers the owned master. - Route B reduces to the `c_shared` shape at evaluation. The probe read the rule's grants as three `sys_record_share` rows with `recipient_type: 'user'` and `access_level: 'edit'`. **Not pinned anywhere for explain:** route A, `modifyAllRecords` on the master alone, which is HotCRM's actual route. `controlled-by-parent-detail-write-authority.test.ts` pins the door with the bypass on both objects and never asks explain. The master check reaches the bypass through two seams of its own: - the master's ownership floor comes off on the sharing write verdict `allow`; - the record-sharing leg is the bypass branch of `canEdit`. **New:** `packages/plugins/plugin-security/src/explain-controlled-by-parent-master-modify-all.test.ts`. The rig is the real `SecurityPlugin`, `ObjectQL` over better-sqlite3, the real `SharingService` and its middleware, and the platform's `member_default` floor armed (`org_member`) for both callers. - **Armed:** every row was created by another user. For each caller, explain's `rls` record on a row neither caller created names the floor's predicate, `{ created_by: CALLER }`. - **Update and delete by the bypass holder:** explain answers `visible: true`, and the door admits. - **Control** (`viewAllRecords` on the master, without `modifyAllRecords`): the door answers `PERMISSION_DENIED` / 403 with "requires edit access to its master record". Explain answers `{ visible: false, decidedBy: 'sharing' }`, and its sharing detail names the master check's leg. - **Reference:** the master's own door admits the holder and refuses the viewer. ## Ablation of the negative pin The mutation went through `scripts/ablation-replace.mjs` in WRAP mode, in `packages/plugins/plugin-security/src/explain-engine.ts`. It changes `const masterOrFault = MASTER_CHECKED_BY_ID_WRITES.has(engineOp) && recordExists && deps.checkControlledByParentWrite` to `const masterOrFault = false && MASTER_CHECKED_BY_ID_WRITES.has(engineOp) && ...`, so explain stops asking the master check. That is the pre-#22529 shape. - **Landed:** anchor count 1 to 0, blob `1d8f2ff1` to `ee54fcff`. - **Run:** 2 failed, 4 passed. The failures are exactly the two control cells. Their door assertion (403) held, and explain answered: ```text - "decidedBy": "sharing", + "decidedBy": "object_crud", "recordId": "c_viewer_update", - "visible": false, + "visible": true, ``` (`delete` reads the same.) - **Restored:** the blob after restore is `1d8f2ff1`, equal to HEAD; `git diff HEAD` is empty and `git status --porcelain` is clean. The mutation was never staged or committed. - **Run twice:** at `7dc3c671` and again at `da157165` (after the merge below), with the same reading both times. `explain-engine.ts` and the test are byte-identical across the merge. ## Tests and gates, at HEAD `da157165` The branch merges `origin/main` `149294c02`. Two commits there touch `plugin-security` and `plugin-sharing` (#22671, #22745); the delta against `main` is still exactly the one new file (+275). After `pnpm install --frozen-lockfile`, the `@objectstack/plugin-security...` closure was rebuilt (18 tasks). - **The new file:** 6 passed. - **The full `@objectstack/plugin-security` suite (`vitest run --maxWorkers=2`):** 200 files passed, 4145 tests passed and 45 skipped. The lock reported VERDICT command-exit 0. Before the merge, at `7dc3c671`, it read 199 files, 4132 passed and 45 skipped. - **`pnpm --filter @objectstack/plugin-security typecheck`:** exit 0. The test-layer program (`tsconfig.test.json`, `--listFilesOnly`) lists the new file, with 0 errors and 0 debt. - **Derived gates** (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`): 57 derived, all exit 0. Each exit code was recorded before any pipe. The `--ran` reconciliation reads: 57 derived, 57 run, 0 NOT-MEASURED (derived from the recorded codes), 0 UNRUN. - In the first battery, at `7dc3c671`, three gates exited 3, PREREQUISITE NOT MET. - `check-plugin-teardown-shape.mjs --self-test` lacked its pinned fixture commit on this shallow clone. It exits 0 after `git fetch --depth=1` of `621a4876`. - `check:dual-build-cjs-loads` and `check:i18n` lacked other packages' `dist/`, which that battery's `check:type-check-debt` then built. Both were rerun at `da157165` on post-merge builds. `check:dual-build-cjs-loads` reports 107 require entry points across 66 packages load. `check:i18n` reports 9 packages, all bundles in sync. - **Lint, narrowed to the one touched file:** `eslint --no-inline-config --format json` reads 1 file, 0 errors and 0 warnings. eslint's own resolved config for the file (`--print-config`) is a linted config, and it carries no `parserOptions.project` and no `projectService`. With no type-aware linting, a new test file cannot move any other file's verdict, and the merge did not change `eslint.config.mjs`. The repo-wide `pnpm lint` is CI's. - **Changeset:** none, and `skip-changeset` applies. `files[]` is `dist`, `README.md` and `CHANGELOG.md`. After a rebuild of `plugin-security`, `dist/` carries 0 hits for the test's symbols, against 4 files each for two positive controls. ## Acceptance notes - **Out-of-scope finding, reported to the seat:** for the two unreadable-child controls, explain's `sharing` layer detail says "The update answers 403 PERMISSION_DENIED" (and likewise for delete), while the door answers 404 `RECORD_NOT_FOUND`. The top-level record verdict is the correct missing-record shape; only the layer prose names a status the door does not give. The read-absent rewrite in `explainAccess` replaces `record` and leaves layer prose as computed. Nothing here touches it. - **The fixture's position binding:** the fixture binds sets through a position's `permissionSets`, which is `main`'s ADR-0131 spelling. HotCRM on 17.7.0 binds its `sales_manager` set by naming it after the position. The route under test, the master check's answer for a bypass holder on the master, does not depend on which spelling delivered the set. --- _Generated by [Claude Code](https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2ff0825 commit a18c514

1 file changed

Lines changed: 275 additions & 0 deletions

File tree

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [ADR-0055] `security/explain`'s record-grained verdict for an update and a
4+
// delete of a `controlled_by_parent` record whose MASTER the caller may edit
5+
// through `modifyAllRecords` on the master alone, beside the door's answer for
6+
// the same caller and row.
7+
//
8+
// ## Why this route has its own file
9+
//
10+
// The console's record header asks exactly this question: it posts
11+
// `security/explain` with a `recordId`, once for `update` and once for
12+
// `delete`, and shows Edit and Delete on `decision.record.visible`. A sales
13+
// manager who holds `viewAllRecords` + `modifyAllRecords` on the master (the
14+
// account) and plain edit on the detail (a contract, a quote) reaches the
15+
// detail's rows only through the master: the detail grants no bypass of its
16+
// own, and the master is owned and created by somebody else.
17+
//
18+
// The routes to the master that the explain/door pair already pins beside each
19+
// other (`controlled-by-parent-write-member.test.ts`, and the REST twin
20+
// `packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts`) are an
21+
// owned master, a master shared at `edit`, a master an RLS policy admits and a
22+
// two-hop chain. None of them is the Modify All Data bypass, which the master
23+
// check reaches by two seams of its own: the master's ownership floor comes off
24+
// on the sharing write verdict `allow` that the bypass answers, and the master's
25+
// record-sharing leg is the bypass branch of `canEdit`. This file pins that
26+
// route, with the floor armed on both objects.
27+
//
28+
// ## The control
29+
//
30+
// The viewer holds `viewAllRecords` on the master WITHOUT `modifyAllRecords`:
31+
// the near neighbour of the manager's grant. They read every master and every
32+
// detail, and may edit neither the master nor, therefore, its details. Both
33+
// faces must refuse them, and explain must name the master check's leg. A read
34+
// bypass that leaked into a write verdict, or an explanation that stopped
35+
// asking the master check, fails this half.
36+
//
37+
// ## The rig
38+
//
39+
// The real `SecurityPlugin` over a real `ObjectQL` and SQL driver
40+
// (better-sqlite3, in memory), the real `SharingService` as the kernel's
41+
// `sharing` service with its middleware on the engine after this plugin's, and
42+
// the platform's own `member_default` set, whose wildcard `owner_only_writes` /
43+
// `owner_only_deletes` floor (`created_by == current_user.id`, positions
44+
// `['org_member']`) is armed for both callers. The armed case below shows it
45+
// binding each of them on this stack, through explain's own `rls` record.
46+
//
47+
// `@objectstack/core` and `@objectstack/plugin-sharing` resolve through their
48+
// built `dist/` here, as this package's other suites read them.
49+
50+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
51+
import { ObjectQL } from '@objectstack/objectql';
52+
import { SqlDriver } from '@objectstack/driver-sql';
53+
import { SharingService, buildSharingMiddleware, SysRecordShare } from '@objectstack/plugin-sharing';
54+
import { PermissionSetSchema } from '@objectstack/spec/security';
55+
import type { ExplainDecision } from '@objectstack/spec/security';
56+
import { SecurityPlugin } from './security-plugin.js';
57+
import { defaultPermissionSets } from './objects/default-permission-sets.js';
58+
59+
const SYS = { context: { isSystem: true } } as never;
60+
const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!;
61+
62+
const ACCOUNT = 'cbm_account';
63+
const CONTRACT = 'cbm_contract';
64+
/**
65+
* The floor witness: an object with no master and no owner column, whose rows
66+
* neither caller created. Explaining a write of one shows the floor's own
67+
* predicate in the `rls` record, for each caller.
68+
*/
69+
const MEMO = 'cbm_memo';
70+
71+
/** The master's owner and creator, and the creator of every detail row: nobody the suite explains. */
72+
const REP = 'usr_rep';
73+
const MANAGER = 'usr_manager';
74+
const VIEWER = 'usr_viewer';
75+
76+
const CRUD = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true };
77+
78+
/** Modify All Data on the master only; the detail grants plain CRUD, reads included, and no write bypass. */
79+
const MANAGER_SET = PermissionSetSchema.parse({
80+
name: 'cbm_manager',
81+
objects: {
82+
[ACCOUNT]: { ...CRUD, viewAllRecords: true, modifyAllRecords: true },
83+
[CONTRACT]: { ...CRUD, viewAllRecords: true, modifyAllRecords: false },
84+
[MEMO]: { allowRead: true, allowEdit: true, viewAllRecords: true },
85+
},
86+
});
87+
88+
/** The control: the same grants with the master's write bypass withheld. */
89+
const VIEWER_SET = PermissionSetSchema.parse({
90+
name: 'cbm_viewer',
91+
objects: {
92+
[ACCOUNT]: { allowRead: true, allowCreate: true, allowEdit: true, viewAllRecords: true, modifyAllRecords: false },
93+
[CONTRACT]: { ...CRUD, viewAllRecords: true, modifyAllRecords: false },
94+
[MEMO]: { allowRead: true, allowEdit: true, viewAllRecords: true },
95+
},
96+
});
97+
98+
/** The execution context `resolveAuthzContext` hands the middleware for an organization member holding the set. */
99+
const callerOf = (userId: string, set: string) => ({
100+
userId,
101+
positions: ['org_member'],
102+
permissions: [set, 'member_default'],
103+
posture: 'MEMBER',
104+
});
105+
const MANAGER_CALLER = callerOf(MANAGER, MANAGER_SET.name);
106+
const VIEWER_CALLER = callerOf(VIEWER, VIEWER_SET.name);
107+
108+
type Answer = { kind: 'admitted' } | { kind: 'refused'; code?: string; status?: number; message: string };
109+
110+
const landed = (p: Promise<unknown>): Promise<Answer> =>
111+
p.then(
112+
() => ({ kind: 'admitted' as const }),
113+
(e: any) => ({
114+
kind: 'refused' as const,
115+
code: e?.code == null ? undefined : String(e.code),
116+
status: e?.statusCode ?? e?.status,
117+
message: `${String(e?.message ?? '')} ${String(e?.developerMessage ?? '')}`,
118+
}),
119+
);
120+
121+
let engine: ObjectQL;
122+
let plugin: SecurityPlugin;
123+
124+
beforeAll(async () => {
125+
engine = new ObjectQL();
126+
engine.registerDriver(
127+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never,
128+
true,
129+
);
130+
await engine.init();
131+
engine.registerApp({
132+
id: 'com.objectstack.qa.explain-controlled-by-parent-master-modify-all',
133+
name: 'Explain: a controlled_by_parent record under a master edited through Modify All Data',
134+
version: '1.0.0',
135+
type: 'plugin',
136+
scope: 'system',
137+
objects: [
138+
SysRecordShare,
139+
{
140+
name: ACCOUNT,
141+
label: 'Account',
142+
sharingModel: 'private',
143+
tenancy: { enabled: false },
144+
fields: {
145+
id: { name: 'id', type: 'text', primaryKey: true },
146+
name: { name: 'name', type: 'text' },
147+
owner_id: { name: 'owner_id', type: 'text' },
148+
created_by: { name: 'created_by', type: 'text' },
149+
},
150+
},
151+
{
152+
name: CONTRACT,
153+
label: 'Contract',
154+
sharingModel: 'controlled_by_parent',
155+
tenancy: { enabled: false },
156+
fields: {
157+
id: { name: 'id', type: 'text', primaryKey: true },
158+
name: { name: 'name', type: 'text' },
159+
account: { name: 'account', type: 'master_detail', reference: ACCOUNT, required: true },
160+
created_by: { name: 'created_by', type: 'text' },
161+
},
162+
},
163+
{
164+
name: MEMO,
165+
label: 'Memo',
166+
sharingModel: 'private',
167+
tenancy: { enabled: false },
168+
fields: {
169+
id: { name: 'id', type: 'text', primaryKey: true },
170+
name: { name: 'name', type: 'text' },
171+
created_by: { name: 'created_by', type: 'text' },
172+
},
173+
},
174+
],
175+
} as never);
176+
await engine.syncSchemas();
177+
await engine.insert(ACCOUNT, [{ id: 'acct_rep', name: "the rep's account", owner_id: REP, created_by: REP }], SYS);
178+
// One detail row per (caller, verb), so a delete that lands never changes another case's row.
179+
await engine.insert(CONTRACT, [
180+
{ id: 'c_manager_update', name: 'contract', account: 'acct_rep', created_by: REP },
181+
{ id: 'c_manager_delete', name: 'contract', account: 'acct_rep', created_by: REP },
182+
{ id: 'c_viewer_update', name: 'contract', account: 'acct_rep', created_by: REP },
183+
{ id: 'c_viewer_delete', name: 'contract', account: 'acct_rep', created_by: REP },
184+
], SYS);
185+
await engine.insert(MEMO, [{ id: 'm_rep', name: "the rep's memo", created_by: REP }], SYS);
186+
187+
const sets = [MEMBER_DEFAULT, MANAGER_SET, VIEWER_SET];
188+
let security: unknown;
189+
let sharing: SharingService | undefined;
190+
const services: Record<string, unknown> = {
191+
manifest: { register: vi.fn() },
192+
objectql: engine,
193+
metadata: {
194+
get: async (type: string, name: string) =>
195+
type === 'permission' ? sets.find((s) => s.name === name) ?? null : engine.getSchema(name) ?? null,
196+
list: async () => sets,
197+
},
198+
get sharing() { return sharing; },
199+
};
200+
const ctx = {
201+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
202+
registerService: (name: string, svc: unknown) => { if (name === 'security') security = svc; },
203+
// The lifecycle hooks are collected and never fired, so no bootstrap read
204+
// is left running into the engine teardown.
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+
plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
212+
await plugin.init(ctx as never);
213+
await plugin.start(ctx as never);
214+
sharing = new SharingService({ engine: engine as never, securityService: () => security as never });
215+
engine.registerMiddleware(buildSharingMiddleware(sharing, ctx.logger) as never, { object: '*' });
216+
});
217+
218+
afterAll(async () => {
219+
try { await engine?.destroy(); } catch { /* noop */ }
220+
});
221+
222+
const explain = (operation: 'update' | 'delete', recordId: string, caller: object): Promise<ExplainDecision> =>
223+
plugin.explainAccessForCaller({ object: CONTRACT, operation, recordId }, { ...caller });
224+
225+
const door = (operation: 'update' | 'delete', recordId: string, caller: object): Promise<Answer> =>
226+
operation === 'update'
227+
? landed(engine.update(CONTRACT, { name: 'edited' }, { where: { id: recordId }, context: { ...caller } } as never))
228+
: landed(engine.delete(CONTRACT, { where: { id: recordId }, context: { ...caller } } as never));
229+
230+
const sharingDetailOf = (d: ExplainDecision): string =>
231+
String(d.layers.find((l) => l.layer === 'sharing')?.record?.detail ?? '');
232+
233+
describe('[ADR-0055] explain answers a controlled_by_parent write under a Modify All Data master as the door does', () => {
234+
it('armed: every row was created by another user, and the ownership floor binds both callers on this stack', async () => {
235+
const stored = (await engine.find(CONTRACT, { where: {}, context: { isSystem: true } } as never)) as Array<Record<string, unknown>>;
236+
expect(stored.map((r) => r.created_by)).toEqual([REP, REP, REP, REP]);
237+
// The floor is the only update policy these sets carry: explained on a row
238+
// the caller did not create, the `rls` record names its predicate,
239+
// `created_by == current_user.id`, and it excludes the row.
240+
for (const caller of [MANAGER_CALLER, VIEWER_CALLER]) {
241+
const d = await plugin.explainAccessForCaller({ object: MEMO, operation: 'update', recordId: 'm_rep' }, { ...caller });
242+
const rls = d.layers.find((l) => l.layer === 'rls')?.record;
243+
expect(rls?.rowFilter, `the floor's predicate for ${caller.userId}`).toEqual({ created_by: caller.userId });
244+
expect(rls?.outcome).toBe('excluded');
245+
}
246+
});
247+
248+
for (const verb of ['update', 'delete'] as const) {
249+
it(`${verb}: the master's Modify All Data holder, who created neither row, is explained writable and the door admits`, async () => {
250+
const id = `c_manager_${verb}`;
251+
const explained = await explain(verb, id, MANAGER_CALLER);
252+
const answered = await door(verb, id, MANAGER_CALLER);
253+
expect(answered, `${verb} door, the manager`).toEqual({ kind: 'admitted' });
254+
expect(explained.record, `explain ${verb}, the manager`).toMatchObject({ recordId: id, visible: true });
255+
expect(sharingDetailOf(explained)).not.toContain('master-detail write check');
256+
});
257+
258+
it(`${verb}: control, the master's reader without Modify All Data is refused by both, on the master leg`, async () => {
259+
const id = `c_viewer_${verb}`;
260+
const explained = await explain(verb, id, VIEWER_CALLER);
261+
const answered = await door(verb, id, VIEWER_CALLER);
262+
expect(answered, `${verb} door, the viewer`).toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 });
263+
expect((answered as { message: string }).message).toMatch(/requires edit access to its master record/);
264+
expect(explained.record, `explain ${verb}, the viewer`).toEqual({ recordId: id, visible: false, decidedBy: 'sharing' });
265+
expect(sharingDetailOf(explained)).toMatch(new RegExp(`refuses this ${verb} on its '[a-z_]+' leg`));
266+
});
267+
}
268+
269+
it("reference: the master's own door gives the two callers the answers the master check gives them", async () => {
270+
const asManager = await landed(engine.update(ACCOUNT, { name: 'renamed' }, { where: { id: 'acct_rep' }, context: { ...MANAGER_CALLER } } as never));
271+
const asViewer = await landed(engine.update(ACCOUNT, { name: 'renamed' }, { where: { id: 'acct_rep' }, context: { ...VIEWER_CALLER } } as never));
272+
expect(asManager, 'the manager PATCHes the master').toEqual({ kind: 'admitted' });
273+
expect(asViewer, 'the viewer PATCHes the master').toMatchObject({ kind: 'refused', code: 'PERMISSION_DENIED', status: 403 });
274+
});
275+
});

0 commit comments

Comments
 (0)