Skip to content

Commit dfa8fac

Browse files
committed
test(plugin-security): pin that an on-behalf-of by-id write never exceeds the delegator's own write
Real-stack pins under `isolated` and `single`: the delegated by-id update, delete, stored row and master-gated detail write are refused wherever the delegator's own write is, with the ADR-0112 envelope and the delegator's own request as the control; rows the delegator's own write admits and the delegated write has always refused stay refused. Explain's record verdict is asserted beside the door, and the parity table gains the matching rig 4 rows. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
1 parent 412f6ca commit dfa8fac

3 files changed

Lines changed: 426 additions & 8 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): an on-behalf-of by-id write never admits a row its delegator's own by-id write refuses (ADR-0090 D10)
6+
7+
Clause-②: no
8+
9+
ADR-0090 D10 says a delegated principal, such as an OAuth agent acting for a user, never exceeds its delegator. The by-id write already asked every row gate as the delegator too. But it composed the delegator's row-level write filter differently from the filter the delegator's own direct write of the same row gets. Where an app-authored row-level write policy bounds the delegator, a write on the delegator's behalf could therefore be admitted for a row the delegator's own write is refused.
10+
11+
Each gate that judges a delegated by-id write now also composes the delegator's filter as the delegator's own direct write gets it, and requires both:
12+
13+
- the pre-image check of a by-id `update` or `delete`;
14+
- the post-image check of the row an `update` stores;
15+
- the master gate of a `controlled_by_parent` detail write, on the delegator's pass.
16+
17+
The change only narrows. A write is admitted only where it was admitted before AND the delegator's own write of that row is admitted. Nothing refused before is admitted now: where the delegator's own write admits a row that the delegated write has always refused, that row stays refused. `security.explain`'s record verdict for an on-behalf-of `update` or `delete` composes the same two filters, so it still gives the answer the door gives. Writes that are not delegated are unchanged.
Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0090 D10] An on-behalf-of by-id write never admits a row its
5+
* delegator's own by-id write refuses.
6+
*
7+
* An OAuth agent acting for a user carries a ceiling of its own and is bounded
8+
* by that user: every row gate a by-id write meets asks the delegator as well.
9+
* The bound pinned here is the one the delegator's OWN direct write sets, at
10+
* each gate that judges a delegated by-id write:
11+
*
12+
* - the by-id `update` and `delete` of a row;
13+
* - the row an `update` would store;
14+
* - a `controlled_by_parent` detail's write, judged through its master.
15+
*
16+
* Every refusal stands beside the delegator's own request as its control, and
17+
* every row-level verdict beside explain's record verdict for the same caller,
18+
* under a walled (`isolated`) and a wall-less (`single`) posture.
19+
*
20+
* The bound only narrows. Two shapes the delegator's own write admits stay
21+
* refused on its behalf, exactly as they were: making the delegated write
22+
* match the delegator's own there would WIDEN it, which is not what this rule
23+
* says, so those rows pin the refusal as unchanged.
24+
*
25+
* Real stack: `ObjectQL` over better-sqlite3, the real `SecurityPlugin`, the
26+
* real sharing service and middleware, and contexts assembled by
27+
* `resolveAuthzContext` and `assembleExecutionContext` — the agent's with the
28+
* `mcp_agent_data_write` OAuth ceiling. `@objectstack/core` and
29+
* `@objectstack/plugin-sharing` resolve through their built `dist/`, as this
30+
* package's other suites read them.
31+
*/
32+
33+
import { describe, it, expect, vi, afterAll } from 'vitest';
34+
import { AsyncLocalStorage } from 'node:async_hooks';
35+
import { ObjectQL } from '@objectstack/objectql';
36+
import { SqlDriver } from '@objectstack/driver-sql';
37+
import { resolveAuthzContext, assembleExecutionContext } from '@objectstack/core';
38+
import { SharingService, buildSharingMiddleware, SysRecordShare } from '@objectstack/plugin-sharing';
39+
import { PermissionSetSchema } from '@objectstack/spec/security';
40+
import type { ExplainDecision, TenancyPosture } from '@objectstack/spec/security';
41+
import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity';
42+
43+
import { SysPosition, SysPermissionSet, SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js';
44+
import { SysUserPosition } from './objects/sys-user-position.object.js';
45+
import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js';
46+
import { SecurityPlugin } from './security-plugin.js';
47+
import { defaultPermissionSets } from './objects/default-permission-sets.js';
48+
49+
const SYS = { context: { isSystem: true } } as never;
50+
const ALPHA = 'org_alpha';
51+
52+
/** A delegator whose own writes app-authored row-level policies bound. */
53+
const BOUNDED = 'u_bounded_writer';
54+
/** A delegator with an `org` write grant and no authored policy. */
55+
const PLAIN = 'u_plain_writer';
56+
/** Owns and creates the rows nobody else here does. */
57+
const OTHER = 'u_someone_else';
58+
59+
/** An outcome with its ADR-0112 envelope: what a request answered. */
60+
type Outcome = { kind: 'admitted' } | { kind: 'refused'; code?: string; status?: number };
61+
const ADMITTED: Outcome = { kind: 'admitted' };
62+
const DENIED: Outcome = { kind: 'refused', code: 'PERMISSION_DENIED', status: 403 };
63+
64+
const outcomeOf = (p: Promise<unknown>): Promise<Outcome> =>
65+
p.then(
66+
() => ADMITTED,
67+
(e: unknown) => {
68+
const x = e as { code?: unknown; status?: unknown; statusCode?: unknown } | null;
69+
const status = x?.statusCode ?? x?.status;
70+
return {
71+
kind: 'refused' as const,
72+
code: x?.code == null ? undefined : String(x.code),
73+
status: status == null ? undefined : Number(status),
74+
};
75+
},
76+
);
77+
78+
/** See the explain-enforce-parity suite: a torn-down rig's async storages are released once its last row is judged. */
79+
const releaseAsyncContexts = (...owners: object[]): void => {
80+
for (const owner of owners) {
81+
for (const value of Object.values(owner)) if (value instanceof AsyncLocalStorage) value.disable();
82+
}
83+
};
84+
85+
async function boot(posture: TenancyPosture) {
86+
const OBJ = 'qa_bounded_record';
87+
const MASTER = 'qa_bounded_master';
88+
const DETAIL = 'qa_bounded_detail';
89+
const engine = new ObjectQL();
90+
engine.registerDriver(
91+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never,
92+
true,
93+
);
94+
await engine.init();
95+
const fields = {
96+
id: { name: 'id', type: 'text', primaryKey: true },
97+
name: { name: 'name', type: 'text' },
98+
note: { name: 'note', type: 'text' },
99+
owner_id: { name: 'owner_id', type: 'text' },
100+
created_by: { name: 'created_by', type: 'text' },
101+
};
102+
engine.registerApp({
103+
id: `com.objectstack.qa.delegated-write-bound-${posture}`,
104+
name: 'An on-behalf-of write is bounded by its delegator',
105+
version: '1.0.0',
106+
type: 'plugin',
107+
scope: 'system',
108+
objects: [
109+
SysPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPosition, SysUserPermissionSet,
110+
SysOrganization, SysUser, SysMember, SysRecordShare,
111+
{ name: OBJ, label: 'Record', sharingModel: 'private', fields },
112+
{ name: MASTER, label: 'Master', sharingModel: 'private', fields },
113+
{
114+
name: DETAIL,
115+
label: 'Detail',
116+
sharingModel: 'controlled_by_parent',
117+
fields: {
118+
id: { name: 'id', type: 'text', primaryKey: true },
119+
note: { name: 'note', type: 'text' },
120+
created_by: { name: 'created_by', type: 'text' },
121+
master: { name: 'master', type: 'master_detail', required: true, reference: MASTER },
122+
},
123+
},
124+
],
125+
} as never);
126+
await engine.syncSchemas();
127+
128+
const e = engine as unknown as {
129+
insert: (o: string, row: Record<string, unknown>, opts: never) => Promise<unknown>;
130+
getDriver: (o: string) => { create: (o: string, row: Record<string, unknown>) => Promise<unknown> };
131+
logger: { warn: () => void };
132+
};
133+
await e.insert('sys_organization', { id: ALPHA, name: ALPHA, slug: ALPHA }, SYS);
134+
for (const id of [BOUNDED, PLAIN, OTHER]) {
135+
await e.insert('sys_user', { id, name: id, email: `${id}@example.test` }, SYS);
136+
await e.insert('sys_member', { id: `mem_${id}`, user_id: id, organization_id: ALPHA, role: 'member' }, SYS);
137+
}
138+
const org = { allowRead: true, allowEdit: true, allowDelete: true, readScope: 'org', writeScope: 'org' } as const;
139+
const sets = [
140+
...defaultPermissionSets,
141+
PermissionSetSchema.parse({
142+
name: 'qa_bounded_writer',
143+
objects: { [OBJ]: org, [MASTER]: org, [DETAIL]: { allowRead: true, allowEdit: true, allowDelete: true } },
144+
rowLevelSecurity: [
145+
{ name: 'qa_bounded_updates', object: OBJ, operation: 'update', using: "name != 'held'" },
146+
{ name: 'qa_bounded_deletes', object: OBJ, operation: 'delete', using: "name != 'held'" },
147+
{ name: 'qa_bounded_master_updates', object: MASTER, operation: 'update', using: "name != 'held'" },
148+
],
149+
}),
150+
PermissionSetSchema.parse({ name: 'qa_plain_writer', objects: { [OBJ]: org } }),
151+
];
152+
for (const name of ['qa_bounded_writer', 'qa_plain_writer']) {
153+
await e.getDriver('sys_permission_set').create('sys_permission_set', {
154+
id: `ps_${name}`, name, label: name, organization_id: null, managed_by: 'admin', active: true,
155+
object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]),
156+
});
157+
}
158+
for (const [user, set] of [[BOUNDED, 'qa_bounded_writer'], [PLAIN, 'qa_plain_writer']] as const) {
159+
await e.insert('sys_user_permission_set', {
160+
id: `ups_${user}`, user_id: user, permission_set: set, organization_id: ALPHA,
161+
}, SYS);
162+
}
163+
const row = (id: string, name: string, owner: string, creator: string) =>
164+
({ id, name, note: '', owner_id: owner, created_by: creator, organization_id: ALPHA });
165+
for (const r of [
166+
row('held_1', 'held', BOUNDED, BOUNDED),
167+
row('held_2', 'held', OTHER, BOUNDED),
168+
row('free_1', 'free', BOUNDED, BOUNDED),
169+
row('held_del_direct', 'held', BOUNDED, BOUNDED),
170+
row('held_del_agent', 'held', BOUNDED, BOUNDED),
171+
row('free_del_direct', 'free', BOUNDED, BOUNDED),
172+
row('free_del_agent', 'free', BOUNDED, BOUNDED),
173+
row('store_direct', 'free', BOUNDED, BOUNDED),
174+
row('store_agent', 'free', BOUNDED, BOUNDED),
175+
row('plain_1', 'x', OTHER, OTHER),
176+
row('plain_2', 'x', PLAIN, OTHER),
177+
]) await e.insert(OBJ, r, SYS);
178+
await e.insert(MASTER, row('m_held', 'held', BOUNDED, BOUNDED), SYS);
179+
await e.insert(MASTER, row('m_free', 'free', BOUNDED, BOUNDED), SYS);
180+
for (const [id, master] of [
181+
['d_held', 'm_held'], ['d_free', 'm_free'], ['d_held_del_direct', 'm_held'], ['d_held_del_agent', 'm_held'],
182+
] as const) {
183+
await e.insert(DETAIL, { id, note: '', created_by: BOUNDED, master, organization_id: ALPHA }, SYS);
184+
}
185+
186+
let security: { explain: (input: unknown, ctx: unknown) => Promise<ExplainDecision> } | undefined;
187+
let sharing: SharingService | undefined;
188+
const services: Record<string, unknown> = {
189+
manifest: { register: vi.fn() },
190+
objectql: engine,
191+
metadata: {
192+
get: async (type: string, name: string) =>
193+
type === 'permission' ? sets.find((s: { name?: string }) => s.name === name) ?? null : engine.getSchema(name) ?? null,
194+
list: async () => sets,
195+
},
196+
tenancy: { posture },
197+
get sharing() { return sharing; },
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+
sharing = new SharingService({ engine: engine as never, securityService: () => security as never });
215+
engine.registerMiddleware(buildSharingMiddleware(sharing, ctx.logger) as never, { object: '*' });
216+
// The row gates log the writes they refuse.
217+
vi.spyOn(e.logger, 'warn').mockImplementation(() => undefined);
218+
219+
/** The caller's context as a request assembles it: the user's session, and for an agent its OAuth ceiling. */
220+
const contextOf = async (userId: string, onBehalf: boolean) => {
221+
const authz = await resolveAuthzContext({
222+
userAuthGates: undefined,
223+
apiKeyLifecycle: undefined,
224+
ql: engine,
225+
headers: {},
226+
getSession: async () => ({
227+
user: { id: userId },
228+
session: { id: `sess_${userId}_${posture}`, userId, activeOrganizationId: ALPHA },
229+
}),
230+
tenancyPosture: posture,
231+
});
232+
return assembleExecutionContext({
233+
authz,
234+
oauth: onBehalf
235+
? { userId, scopes: ['data:write'], clientId: 'qa_agent_client', scopePermissions: ['mcp_agent_data_write'], delegatesActions: false }
236+
: undefined,
237+
localization: undefined,
238+
requestLocale: undefined,
239+
accessToken: undefined,
240+
} as never)!;
241+
};
242+
const objects = { OBJ, DETAIL } as const;
243+
type Obj = keyof typeof objects;
244+
let n = 0;
245+
246+
return {
247+
/** Explain's record verdict for the caller's own by-id write of `object/id`. */
248+
explainVisible: async (userId: string, onBehalf: boolean, operation: 'update' | 'delete', object: Obj, id: string) =>
249+
(await security!.explain({ object: objects[object], operation, recordId: id }, { ...(await contextOf(userId, onBehalf)) }))
250+
.record?.visible,
251+
update: async (userId: string, onBehalf: boolean, object: Obj, id: string) =>
252+
outcomeOf(engine.update(objects[object], { note: `n_${++n}` }, {
253+
where: { id }, context: { ...(await contextOf(userId, onBehalf)) },
254+
} as never)),
255+
/** A by-id update whose stored row carries a value the delegator's own update may not store. */
256+
storeHeld: async (userId: string, onBehalf: boolean, id: string) =>
257+
outcomeOf(engine.update(OBJ, { name: 'held' }, {
258+
where: { id }, context: { ...(await contextOf(userId, onBehalf)) },
259+
} as never)),
260+
remove: async (userId: string, onBehalf: boolean, object: Obj, id: string) =>
261+
outcomeOf(engine.delete(objects[object], { where: { id }, context: { ...(await contextOf(userId, onBehalf)) } } as never)),
262+
teardown: async () => {
263+
try { await engine.destroy(); } catch { /* noop */ }
264+
releaseAsyncContexts(engine, plugin);
265+
},
266+
};
267+
}
268+
269+
type Rig = Awaited<ReturnType<typeof boot>>;
270+
const rigs = new Map<TenancyPosture, Promise<Rig>>();
271+
const rigFor = (posture: TenancyPosture): Promise<Rig> => {
272+
let rig = rigs.get(posture);
273+
if (!rig) {
274+
rig = boot(posture);
275+
rigs.set(posture, rig);
276+
}
277+
return rig;
278+
};
279+
afterAll(async () => {
280+
for (const rig of rigs.values()) await (await rig.catch(() => undefined))?.teardown();
281+
});
282+
283+
const word = (o: Outcome): string => (o.kind === 'admitted' ? 'admitted' : `refused ${o.code} / ${o.status}`);
284+
285+
/**
286+
* One by-id write, asked by the delegator itself and by an agent on its
287+
* behalf. `rows` names the row each asks about (a delete that lands removes
288+
* its row, so each delete asker has its own).
289+
*/
290+
interface Cell {
291+
delegator: string;
292+
verb: 'update' | 'delete';
293+
object: 'OBJ' | 'DETAIL';
294+
rows: { direct: string; agent: string };
295+
shape: string;
296+
direct: Outcome;
297+
agent: Outcome;
298+
}
299+
300+
const CELLS: Cell[] = [
301+
{
302+
delegator: BOUNDED, verb: 'update', object: 'OBJ', rows: { direct: 'held_1', agent: 'held_1' },
303+
shape: 'a row the delegator\'s own update refuses', direct: DENIED, agent: DENIED,
304+
},
305+
{
306+
delegator: BOUNDED, verb: 'update', object: 'OBJ', rows: { direct: 'held_2', agent: 'held_2' },
307+
shape: 'a second row the delegator\'s own update refuses', direct: DENIED, agent: DENIED,
308+
},
309+
{
310+
delegator: BOUNDED, verb: 'update', object: 'OBJ', rows: { direct: 'free_1', agent: 'free_1' },
311+
shape: 'the control: a row the delegator\'s own update admits', direct: ADMITTED, agent: ADMITTED,
312+
},
313+
{
314+
delegator: BOUNDED, verb: 'delete', object: 'OBJ', rows: { direct: 'held_del_direct', agent: 'held_del_agent' },
315+
shape: 'a row the delegator\'s own delete refuses', direct: DENIED, agent: DENIED,
316+
},
317+
{
318+
delegator: BOUNDED, verb: 'delete', object: 'OBJ', rows: { direct: 'free_del_direct', agent: 'free_del_agent' },
319+
shape: 'the control: a row the delegator\'s own delete admits', direct: ADMITTED, agent: ADMITTED,
320+
},
321+
{
322+
delegator: BOUNDED, verb: 'update', object: 'DETAIL', rows: { direct: 'd_held', agent: 'd_held' },
323+
shape: 'a detail whose master refuses the delegator\'s own detail update', direct: DENIED, agent: DENIED,
324+
},
325+
{
326+
delegator: BOUNDED, verb: 'delete', object: 'DETAIL', rows: { direct: 'd_held_del_direct', agent: 'd_held_del_agent' },
327+
shape: 'a detail whose master refuses the delegator\'s own detail delete', direct: DENIED, agent: DENIED,
328+
},
329+
{
330+
delegator: BOUNDED, verb: 'update', object: 'DETAIL', rows: { direct: 'd_free', agent: 'd_free' },
331+
shape: 'the control: a detail whose master admits the delegator\'s own detail update', direct: ADMITTED, agent: ADMITTED,
332+
},
333+
// The bound only narrows: rows the delegator's own write admits and the
334+
// delegated write has always refused stay refused.
335+
{
336+
delegator: PLAIN, verb: 'update', object: 'OBJ', rows: { direct: 'plain_1', agent: 'plain_1' },
337+
shape: 'unchanged: a row the delegator\'s own update admits and the delegated update refuses', direct: ADMITTED, agent: DENIED,
338+
},
339+
{
340+
delegator: PLAIN, verb: 'update', object: 'OBJ', rows: { direct: 'plain_2', agent: 'plain_2' },
341+
shape: 'unchanged: a second row the delegator\'s own update admits and the delegated update refuses', direct: ADMITTED, agent: DENIED,
342+
},
343+
];
344+
345+
describe('an on-behalf-of by-id write never admits a row its delegator\'s own by-id write refuses (ADR-0090 D10)', () => {
346+
for (const posture of ['isolated', 'single'] as const) {
347+
for (const c of CELLS) {
348+
it(`\`${posture}\` · ${c.verb} · ${c.shape} → the delegator's own: ${word(c.direct)}; on its behalf: ${word(c.agent)}`, async () => {
349+
const rig = await rigFor(posture);
350+
const ask = (onBehalf: boolean, id: string) =>
351+
c.verb === 'update' ? rig.update(c.delegator, onBehalf, c.object, id) : rig.remove(c.delegator, onBehalf, c.object, id);
352+
// Explain first: a delete that lands removes the row it explains.
353+
const explainedDirect = await rig.explainVisible(c.delegator, false, c.verb, c.object, c.rows.direct);
354+
const explainedAgent = await rig.explainVisible(c.delegator, true, c.verb, c.object, c.rows.agent);
355+
const direct = await ask(false, c.rows.direct);
356+
const agent = await ask(true, c.rows.agent);
357+
expect(direct, 'the delegator\'s own write').toStrictEqual(c.direct);
358+
expect(agent, 'the write on its behalf').toStrictEqual(c.agent);
359+
// Explain's record verdict is the door's, for both callers.
360+
expect(explainedDirect, 'explain, the delegator\'s own write').toBe(c.direct.kind === 'admitted');
361+
expect(explainedAgent, 'explain, the write on its behalf').toBe(c.agent.kind === 'admitted');
362+
}, 60_000);
363+
}
364+
365+
it(`\`${posture}\` · update · a row the delegator's own update may not store → refused on its behalf as for the delegator`, async () => {
366+
const rig = await rigFor(posture);
367+
expect(await rig.storeHeld(BOUNDED, false, 'store_direct'), 'the delegator\'s own write').toStrictEqual(DENIED);
368+
expect(await rig.storeHeld(BOUNDED, true, 'store_agent'), 'the write on its behalf').toStrictEqual(DENIED);
369+
}, 60_000);
370+
}
371+
});

0 commit comments

Comments
 (0)