Skip to content

Commit 4cb682c

Browse files
committed
fix(plugin-security): the delegated-admin gate counts a holding only in the organization the runtime grants it
Self-delegation rule 4 (activeHoldings) and the binding blast radius (assignmentAnchorsOfPosition) read sys_user_position by position NAME under a bare system context, so another organization's same-named holding answered "you currently hold it", and another organization's same-named assignments refused a binding (outside subtree / over cap). Both now apply the runtime resolver's own rule (resolveAuthzContext step 4): a holding stamped for a different organization never counts; an organization-less holding counts in every organization. activeHoldings is keyed on the caller's organization; the blast radius on the bound sys_position row's organization, with the scope in the read's context so the cap counts only reachable holdings. An organization-less caller is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF
1 parent 0e90a8d commit 4cb682c

3 files changed

Lines changed: 423 additions & 11 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
The delegated-admin gate now counts a `sys_user_position` holding only where the runtime grants it. Self-delegation's "you currently hold this position" check (ADR-0091 D3 rule 4) no longer accepts a holding stamped for a different organization — a user can no longer self-delegate a position in an organization where they hold nothing just because they hold a same-named position elsewhere. Organization-less holdings still count, exactly as the runtime authz resolver grants them in every organization. The binding blast-radius check (ADR-0090 D12) likewise counts only the assignments the bound position row reaches — its own organization's plus organization-less ones — so another organization's same-named assignments no longer refuse a binding as outside the subtree or push it over the assignment cap. An organization-less (`single` posture) caller is unchanged.
Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The delegated-admin gate counts a `sys_user_position` holding only where the
5+
* runtime would grant it — pinned over a real engine, one witness per read.
6+
*
7+
* ## The defect this exists to keep closed
8+
*
9+
* `sys_user_position.position` is a position NAME, and `sys_position.name` is
10+
* unique per organization only, so in a single-database multi-org posture
11+
* (ADR-0105 D1 `group` / `isolated`) two organizations' holdings of the same
12+
* name are the same string. Two gate reads selected holdings by that name under
13+
* a bare `{ isSystem: true }` context with no organization condition:
14+
*
15+
* - `activeHoldings` — does the delegator CURRENTLY and DIRECTLY hold the
16+
* position (ADR-0091 D3 rule 4)? A holding stamped for ANOTHER
17+
* organization answered yes, so a user self-delegated a position in an
18+
* organization where they held nothing.
19+
* - `assignmentAnchorsOfPosition` — whose capability does re-binding the
20+
* position re-compose (the ADR-0090 D12 blast radius)? Another
21+
* organization's same-named assignments entered it, so a delegate was
22+
* refused over holders their binding never reaches.
23+
*
24+
* ## The rule both reads now follow — the runtime's own
25+
*
26+
* `resolveAuthzContext` (step 4, `@objectstack/core`) keeps a holding when its
27+
* `organization_id` is empty OR equals the active organization, and drops it
28+
* when it names a DIFFERENT one. The gate agrees: a holding stamped for another
29+
* organization never counts; an organization-less holding counts in every
30+
* organization, exactly as it grants in every organization. An
31+
* organization-less caller (`single` posture) drops nothing, as before.
32+
*/
33+
34+
import { describe, it, expect, afterEach } from 'vitest';
35+
import { ObjectQL } from '@objectstack/objectql';
36+
import { SqlDriver } from '@objectstack/driver-sql';
37+
import type { PermissionSet } from '@objectstack/spec/security';
38+
import { DelegatedAdminGate } from './delegated-admin-gate.js';
39+
40+
const ORG_A = 'org_a_acme';
41+
/** Sorts BEFORE every `org_a` / `*_a_*` id, like the sibling suites. */
42+
const ORG_B = 'org_0_globex';
43+
44+
const text = (name: string) => ({ name, type: 'text' as const });
45+
const object = (name: string, fields: string[]) => ({
46+
name,
47+
label: name,
48+
fields: {
49+
id: { name: 'id', type: 'text' as const, primaryKey: true },
50+
organization_id: text('organization_id'),
51+
...Object.fromEntries(fields.map((f) => [f, text(f)])),
52+
},
53+
});
54+
55+
const OBJECTS = [
56+
object('sys_business_unit', ['name', 'parent_business_unit_id']),
57+
object('sys_position', ['name', 'delegatable']),
58+
object('sys_permission_set', ['name', 'admin_scope']),
59+
object('sys_position_permission_set', ['position_id', 'permission_set_id']),
60+
object('sys_user_position', [
61+
'user_id', 'position', 'business_unit_id', 'granted_by', 'delegated_from', 'valid_until', 'reason',
62+
]),
63+
object('sys_business_unit_member', ['user_id', 'business_unit_id']),
64+
object('sys_user', ['primary_business_unit_id']),
65+
];
66+
67+
/** A binding delegate's scope: the unit NAMED `sales`, may bind `sales_user` only. */
68+
const BIND_SCOPE = {
69+
businessUnit: 'sales',
70+
includeSubtree: true,
71+
manageAssignments: false,
72+
manageBindings: true,
73+
authorEnvironmentSets: false,
74+
assignablePermissionSets: ['sales_user'],
75+
};
76+
77+
const BINDER_SETS = [
78+
{
79+
name: 'sales_binder',
80+
objects: { sys_position_permission_set: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
81+
adminScope: BIND_SCOPE,
82+
},
83+
] as unknown as PermissionSet[];
84+
85+
const engines: ObjectQL[] = [];
86+
afterEach(async () => {
87+
while (engines.length) {
88+
try { await engines.pop()?.destroy(); } catch { /* noop */ }
89+
}
90+
});
91+
92+
const sysCtx = (organizationId?: string) =>
93+
organizationId ? { isSystem: true, tenantId: organizationId } : { isSystem: true };
94+
95+
async function bootEngine() {
96+
const engine = new ObjectQL();
97+
engine.registerDriver(
98+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }),
99+
true,
100+
);
101+
await engine.init();
102+
engine.registerApp({
103+
id: 'com.objectstack.qa.delegated-admin-holding-organization',
104+
name: 'Delegated admin holding organization scope',
105+
version: '1.0.0',
106+
type: 'plugin',
107+
scope: 'system',
108+
objects: OBJECTS,
109+
} as any);
110+
await engine.syncSchemas();
111+
engines.push(engine);
112+
return engine;
113+
}
114+
115+
/**
116+
* One organization's slice: a unit named `sales` with one child, a delegatable
117+
* `field_lead` position, and a plain `sales_user` permission set. No holdings —
118+
* each witness writes exactly the holdings it is about.
119+
*/
120+
async function seedOrganization(engine: ObjectQL, organizationId: string, idPrefix: string) {
121+
const ctx = sysCtx(organizationId);
122+
const ids = {
123+
sales: `bu_${idPrefix}_sales`,
124+
salesEast: `bu_${idPrefix}_sales_east`,
125+
fieldLead: `pos_${idPrefix}_field_lead`,
126+
salesUser: `ps_${idPrefix}_sales_user`,
127+
};
128+
await engine.insert('sys_business_unit', [
129+
{ id: ids.sales, name: 'sales', parent_business_unit_id: null },
130+
{ id: ids.salesEast, name: 'sales_east', parent_business_unit_id: ids.sales },
131+
], { context: ctx } as any);
132+
await engine.insert('sys_position', [
133+
{ id: ids.fieldLead, name: 'field_lead', delegatable: '1' },
134+
], { context: ctx } as any);
135+
await engine.insert('sys_permission_set', [
136+
{ id: ids.salesUser, name: 'sales_user', admin_scope: null },
137+
], { context: ctx } as any);
138+
return ids;
139+
}
140+
141+
/** Write holdings of `field_lead`, stamped for `organizationId` (undefined = organization-less). */
142+
async function hold(
143+
engine: ObjectQL,
144+
organizationId: string | undefined,
145+
rows: Array<{ id: string; user_id: string; business_unit_id?: string | null; delegated_from?: string }>,
146+
) {
147+
// Chunked: SQLite caps one compound INSERT … SELECT at 500 terms.
148+
for (let i = 0; i < rows.length; i += 100) {
149+
await engine.insert('sys_user_position', rows.slice(i, i + 100).map((r) => ({
150+
position: 'field_lead',
151+
business_unit_id: null,
152+
...r,
153+
})), { context: sysCtx(organizationId) } as any);
154+
}
155+
}
156+
157+
function gateOn(engine: ObjectQL) {
158+
return new DelegatedAdminGate({
159+
ql: engine,
160+
resolveSets: async (context: any) => (context?.principal === 'binder' ? BINDER_SETS : []),
161+
});
162+
}
163+
164+
const DAY = 24 * 60 * 60 * 1000;
165+
const USER = 'usr_u';
166+
const COLLEAGUE = 'usr_colleague';
167+
168+
/** USER self-delegates `field_lead` to a colleague, in `organizationId` (undefined = `single` posture). */
169+
const selfDelegate = (gate: DelegatedAdminGate, organizationId: string | undefined, businessUnitId?: string) =>
170+
gate.assert({
171+
object: 'sys_user_position',
172+
operation: 'insert',
173+
data: {
174+
user_id: COLLEAGUE,
175+
position: 'field_lead',
176+
delegated_from: USER,
177+
valid_until: new Date(Date.now() + 7 * DAY).toISOString(),
178+
reason: 'annual leave',
179+
...(businessUnitId ? { business_unit_id: businessUnitId } : {}),
180+
},
181+
context: organizationId
182+
? { principal: 'holder', userId: USER, tenantId: organizationId }
183+
: { principal: 'holder', userId: USER },
184+
});
185+
186+
/** A binding delegate in `organizationId` binds `sales_user` to the position row `positionId`. */
187+
const bind = (gate: DelegatedAdminGate, organizationId: string, positionId: string, permissionSetId: string) =>
188+
gate.assert({
189+
object: 'sys_position_permission_set',
190+
operation: 'insert',
191+
data: { position_id: positionId, permission_set_id: permissionSetId },
192+
context: { principal: 'binder', userId: 'usr_binder', tenantId: organizationId },
193+
});
194+
195+
/** A gate refusal: the ADR-0112 envelope, then the first sentence that names why. */
196+
async function expectRefused(p: Promise<unknown>, why: RegExp) {
197+
const err: any = await p.then(() => null, (e) => e);
198+
expect(err, 'expected a refusal').not.toBeNull();
199+
expect(err).toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 });
200+
expect(String(err.message)).toMatch(why);
201+
}
202+
203+
// ── activeHoldings — self-delegation rule 4 ───────────────────────────────
204+
205+
describe('DelegatedAdminGate — rule 4 counts only holdings the caller\'s organization grants', () => {
206+
it('ground truth: the organization-less holding is stored with no organization', async () => {
207+
const engine = await bootEngine();
208+
await seedOrganization(engine, ORG_A, 'a');
209+
await hold(engine, ORG_B, [{ id: 'up_b', user_id: USER }]);
210+
await hold(engine, undefined, [{ id: 'up_none', user_id: USER }]);
211+
212+
const driver: any = (engine as any).getDriver('sys_user_position');
213+
const raw = await driver.knex('sys_user_position').where({ position: 'field_lead' }).orderBy('id', 'asc');
214+
expect(raw.map((r: any) => [r.id, r.organization_id ?? null])).toEqual([['up_b', ORG_B], ['up_none', null]]);
215+
});
216+
217+
it('a user whose ONLY holding is in org B cannot self-delegate org A\'s same-named position', async () => {
218+
const engine = await bootEngine();
219+
await seedOrganization(engine, ORG_B, '0');
220+
await seedOrganization(engine, ORG_A, 'a');
221+
await hold(engine, ORG_B, [{ id: 'up_b', user_id: USER }]);
222+
223+
await expectRefused(selfDelegate(gateOn(engine), ORG_A), /you do not currently hold 'field_lead'/);
224+
// …and in org B, where the holding lives, it stands.
225+
await expect(selfDelegate(gateOn(engine), ORG_B)).resolves.toBeUndefined();
226+
});
227+
228+
it('a user holding it in org A can self-delegate it in org A', async () => {
229+
const engine = await bootEngine();
230+
await seedOrganization(engine, ORG_B, '0');
231+
await seedOrganization(engine, ORG_A, 'a');
232+
await hold(engine, ORG_A, [{ id: 'up_a', user_id: USER }]);
233+
234+
await expect(selfDelegate(gateOn(engine), ORG_A)).resolves.toBeUndefined();
235+
});
236+
237+
it('an organization-less holding counts in org A — the runtime grants it in every organization', async () => {
238+
const engine = await bootEngine();
239+
await seedOrganization(engine, ORG_B, '0');
240+
await seedOrganization(engine, ORG_A, 'a');
241+
await hold(engine, undefined, [{ id: 'up_none', user_id: USER }]);
242+
243+
await expect(selfDelegate(gateOn(engine), ORG_A)).resolves.toBeUndefined();
244+
});
245+
246+
it('a DIRECT holding in org B does not make org A\'s delegated holding re-delegatable', async () => {
247+
const engine = await bootEngine();
248+
await seedOrganization(engine, ORG_B, '0');
249+
await seedOrganization(engine, ORG_A, 'a');
250+
await hold(engine, ORG_B, [{ id: 'up_b', user_id: USER }]);
251+
await hold(engine, ORG_A, [{ id: 'up_a', user_id: USER, delegated_from: 'usr_boss' }]);
252+
253+
await expectRefused(
254+
selfDelegate(gateOn(engine), ORG_A),
255+
/you hold 'field_lead' only via delegation — a delegated grant is not re-delegatable/,
256+
);
257+
});
258+
259+
it('rule 4b: an org B holding\'s anchor does not widen what an org A delegation may hand out', async () => {
260+
const engine = await bootEngine();
261+
const b = await seedOrganization(engine, ORG_B, '0');
262+
const a = await seedOrganization(engine, ORG_A, 'a');
263+
await hold(engine, ORG_B, [{ id: 'up_b', user_id: USER, business_unit_id: b.sales }]);
264+
await hold(engine, ORG_A, [{ id: 'up_a', user_id: USER, business_unit_id: a.salesEast }]);
265+
266+
await expectRefused(
267+
selfDelegate(gateOn(engine), ORG_A, b.salesEast),
268+
/business unit anchor 'bu_0_sales_east' is outside your own effective anchor for 'field_lead'/,
269+
);
270+
await expect(selfDelegate(gateOn(engine), ORG_A, a.salesEast)).resolves.toBeUndefined();
271+
});
272+
273+
it('a `single`-posture caller (no organization in context) keeps counting every holding', async () => {
274+
const engine = await bootEngine();
275+
await seedOrganization(engine, ORG_B, '0');
276+
await hold(engine, ORG_B, [{ id: 'up_b', user_id: USER }]);
277+
278+
await expect(selfDelegate(gateOn(engine), undefined)).resolves.toBeUndefined();
279+
});
280+
});
281+
282+
// ── assignmentAnchorsOfPosition — the binding blast radius ────────────────
283+
284+
describe('DelegatedAdminGate — a binding\'s blast radius is the holders the bound position reaches', () => {
285+
it('org B\'s same-named assignments do not refuse org A\'s binding (outside-subtree anchor)', async () => {
286+
const engine = await bootEngine();
287+
const b = await seedOrganization(engine, ORG_B, '0');
288+
const a = await seedOrganization(engine, ORG_A, 'a');
289+
await hold(engine, ORG_A, [{ id: 'up_a', user_id: 'usr_a1', business_unit_id: a.salesEast }]);
290+
await hold(engine, ORG_B, [{ id: 'up_b', user_id: 'usr_b1', business_unit_id: b.salesEast }]);
291+
292+
await expect(bind(gateOn(engine), ORG_A, a.fieldLead, a.salesUser)).resolves.toBeUndefined();
293+
});
294+
295+
it('org B\'s same-named assignments do not push org A\'s binding over the blast-radius cap', async () => {
296+
const engine = await bootEngine();
297+
const b = await seedOrganization(engine, ORG_B, '0');
298+
const a = await seedOrganization(engine, ORG_A, 'a');
299+
await hold(engine, ORG_A, [{ id: 'up_a', user_id: 'usr_a1', business_unit_id: a.salesEast }]);
300+
await hold(engine, ORG_B, Array.from({ length: 501 }, (_, i) => ({
301+
id: `up_b_${String(i).padStart(3, '0')}`,
302+
user_id: `usr_b_${i}`,
303+
business_unit_id: b.salesEast,
304+
})));
305+
306+
await expect(bind(gateOn(engine), ORG_A, a.fieldLead, a.salesUser)).resolves.toBeUndefined();
307+
});
308+
309+
it('an organization-less holding IS in org A\'s blast radius — the runtime grants it org A\'s binding', async () => {
310+
const engine = await bootEngine();
311+
await seedOrganization(engine, ORG_B, '0');
312+
const a = await seedOrganization(engine, ORG_A, 'a');
313+
await hold(engine, ORG_A, [{ id: 'up_a', user_id: 'usr_a1', business_unit_id: a.salesEast }]);
314+
await hold(engine, undefined, [{ id: 'up_none', user_id: 'usr_x' }]);
315+
316+
await expectRefused(
317+
bind(gateOn(engine), ORG_A, a.fieldLead, a.salesUser),
318+
/position 'field_lead' has 1 unanchored assignment\(s\)/,
319+
);
320+
});
321+
322+
it('the radius follows the BOUND row\'s organization — org B\'s holders still guard org B\'s row', async () => {
323+
const engine = await bootEngine();
324+
const b = await seedOrganization(engine, ORG_B, '0');
325+
const a = await seedOrganization(engine, ORG_A, 'a');
326+
await hold(engine, ORG_A, [{ id: 'up_a', user_id: 'usr_a1', business_unit_id: a.salesEast }]);
327+
await hold(engine, ORG_B, [{ id: 'up_b', user_id: 'usr_b1', business_unit_id: b.salesEast }]);
328+
329+
await expectRefused(
330+
bind(gateOn(engine), ORG_A, b.fieldLead, a.salesUser),
331+
/position 'field_lead' is held in business unit 'bu_0_sales_east', outside the delegated subtree/,
332+
);
333+
});
334+
335+
it('control (dark) — org A\'s own holder outside the subtree still refuses', async () => {
336+
const engine = await bootEngine();
337+
const a = await seedOrganization(engine, ORG_A, 'a');
338+
await engine.insert('sys_business_unit', [
339+
{ id: 'bu_a_ops', name: 'ops', parent_business_unit_id: null },
340+
], { context: sysCtx(ORG_A) } as any);
341+
await hold(engine, ORG_A, [{ id: 'up_a', user_id: 'usr_a1', business_unit_id: 'bu_a_ops' }]);
342+
343+
await expectRefused(
344+
bind(gateOn(engine), ORG_A, a.fieldLead, a.salesUser),
345+
/position 'field_lead' is held in business unit 'bu_a_ops', outside the delegated subtree/,
346+
);
347+
});
348+
});

0 commit comments

Comments
 (0)