Skip to content

Commit 7dc3c67

Browse files
committed
test(plugin-security): pin explain's controlled_by_parent update and delete verdict under a Modify All Data master beside the door
The console record header asks `security/explain` with a `recordId` for `update` and `delete` and shows Edit and Delete on `record.visible`. A caller who holds `viewAllRecords` + `modifyAllRecords` on the master only, and plain CRUD on the controlled_by_parent detail, reaches the detail's rows through the master check alone. No suite pinned explain's verdict for that route beside the door: the existing pairs cover an owned master, an edit-shared master, an RLS-admitted master and a two-hop chain. This pins it over the real SecurityPlugin, ObjectQL with better-sqlite3, the real SharingService and its middleware, and the platform's member_default ownership floor armed for both callers: the bypass holder is explained writable for update and delete and the door admits; the master's reader without the bypass is refused by both, on the master check's leg. Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
1 parent d7b26df commit 7dc3c67

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)