Skip to content

Commit a5afe38

Browse files
huangyiireneclaude
andauthored
fix(plugin-security): the delegated-admin gate resolves a scope's business-unit anchor inside the caller's own organization (#19800)
Fixes #19775 Clause-②: no The delegated-administration gate resolved a scope's business-unit anchor **by NAME across organizations**. In a single-database multi-org posture (ADR-0105 D1 `group` / `isolated`; ADR-0132 「single-database organization isolation ships open」) a name collision therefore crossed an organization boundary, in both directions at once. ## The defect, verified at source before the repair Verified on `origin/main` at the branch point `2cf9db7c43`, not taken from the card: - `delegated-admin-gate.ts:44` — `const SYSTEM_CTX = { isSystem: true } as const;` carries no tenant. - `delegated-admin-gate.ts:912` — the anchor lookup: `ql.find('sys_business_unit', { where: { name: businessUnitName }, limit: 1, context: SYSTEM_CTX })`. **That is the by-name call.** The descendant walk at `:933-937` runs under the same context with no organization predicate. - `packages/objectql/src/engine.ts:4295` — `hasTenant` is `execCtx?.tenantId !== undefined && …`, and `:4316` sets `opts.tenantId` only under it. It is independent of `isSystem`, which is what makes the repair possible at all. - `packages/drivers/driver-sql/src/sql-driver.ts:13771` — `applyTenantScope` returns the builder untouched when `tenantId` is undefined, null or empty. - `packages/platform-objects/src/identity/sys-business-unit.object.ts:260-263` — `name` carries no uniqueness; the only unique index is `['code', 'organization_id']`. So nothing scoped that read: `limit: 1` plus the SQL driver's `ORDER BY id ASC` answered "whichever id sorts first", across every organization. ## The repair — fail closed, and narrower at every choice The anchor read, the descendant walk, and the two catalog reads behind `describeDelegableScope` now carry the caller's own organization, and the candidates that come back are reduced to the caller's own rows. - **The organization is `organizationId ?? tenantId`** — the same spelling `SecurityPlugin.callerOrganizationId` (`security-plugin.ts:989`) already resolves a caller's permission sets with. The `adminScope` this gate reads arrives on a set loaded out of THAT organization's catalog, so anchoring its business unit anywhere else pairs an authority minted in one tenant with a tree owned by another. - **Two arms, both load-bearing, each measured** (ablation below): the context carries the organization so `applyTenantScope` composes a predicate, AND `resolveOwnOrganizationRow` (reused from `per-organization-catalog.ts`, no new symbol) picks the caller's own row out of what came back. The driver's compatibility arm deliberately also returns organization-less rows, and a driver with no tenant scoping at all returns every organization's. - **Narrower where there was a choice.** Under the `group` posture the anchor resolves in the caller's ACTIVE organization, not their whole membership set. Under a walled posture an organization-less business unit no longer answers a delegation boundary — that posture already declares such a row invalid state (`per-organization-catalog.ts`). A tenant admin's picker is unconstrained inside its organization and never across organizations. - **Unchanged where there is no boundary to cross**: a caller carrying no organization (the `single` posture) keeps the by-name answer, pinned by its own test. - **No new exported symbol, no new key on any published payload.** `describeDelegableScope` and `scopesCoverUser` take the caller's context as a new OPTIONAL argument; `DelegableScopeReport` is byte-identical. `ISecurityService.describeDelegableScope(callerContext)` in `packages/spec` already declared that parameter, so this lane touches `packages/spec` in zero lines. - **No new error code, no new refusal message.** The crossing lands on the refusals that already existed: "outside the delegated subtree" for a write, and the empty fail-closed subtree a misconfigured scope already produced. ## Evidence — the crossing itself, failing before and passing after Two suites, deliberately complementary. `delegated-admin-gate-cross-organization.test.ts` is a REAL `ObjectQL` over a REAL `SqlDriver` on in-memory SQLite: two organizations each holding a unit named `sales` with one child, ids ordered so the OTHER organization's sort first (`bu_0_*` before `bu_a_*`). `delegated-admin-gate.test.ts` gains the companion block on a fake `ql` that ignores `context` entirely — a driver with no tenant scoping at all. **BEFORE** — the two source files reverted to the branch point `2cf9db7c43` (proved on disk by blob hash, `07ee0a9ff2…` for the gate), the new test kept: ``` $ pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 \ src/delegated-admin-gate-cross-organization.test.ts × the org A delegate KEEPS its own subtree — a write inside org A `sales` is approved AssertionError: promise rejected "PermissionDeniedError: [Security] Access …" instead of resolving × the org A delegate cannot reach org B — a write anchored in the other organization is REFUSED AssertionError: promise resolved "undefined" instead of rejecting × describeDelegableScope names the caller's own units and NOTHING of the other organization AssertionError: expected [ 'bu_0_sales', 'bu_0_sales_east' ] to deeply equal [ 'bu_a_sales', 'bu_a_sales_east' ] × a tenant admin is unconstrained INSIDE its organization, never across organizations AssertionError: expected [ 'bu_0_sales', …(3) ] to deeply equal [ 'bu_a_sales', 'bu_a_sales_east' ] Test Files 1 failed (1) Tests 4 failed | 4 passed (8) ``` Both measured directions of the defect are in those four lines: the org A delegate **lost its own subtree** (rejected where it should resolve), and the gate **approved** a delegated write anchored in org B (resolved where it should reject). The four that passed are the controls — ground truth, the single-organization dark control, the firing control, and the organization-less caller. **AFTER** — same command, same tree, the repair restored (`git checkout HEAD -- …`, restore proved by `git diff HEAD` empty and a matching `git hash-object`): ``` Test Files 1 passed (1) Tests 8 passed (8) ``` **ABLATION — each arm proved independently load-bearing.** Removing only the row-level arm (`resolveOwnOrganizationRow(...).own` replaced by `rows[0] ?? null`; the injected text confirmed present on disk and the deleted text confirmed absent, under an `EXIT INT TERM` trap that restores from `HEAD`): ``` $ pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 \ src/delegated-admin-gate.test.ts src/delegated-admin-gate-cross-organization.test.ts × resolves the caller's own `sales`, not the first row the driver handed over × approves the same write inside the caller's own organization × an anchor that exists ONLY in another organization approves nothing (fail closed) Test Files 1 failed | 1 passed (2) Tests 3 failed | 69 passed (72) ``` The unscoping-driver half goes red while the real-engine half stays green — exactly the split the code comment claims, so neither arm is decoration. The tree was restored byte-exact afterwards (`git hash-object` = `732cc64311b9a36af7a2e338e6fc86c8ff80f698`, the `HEAD` blob). **No existing test asserted the defect.** The package's 118 files / 2261 tests pass unchanged; nothing had to be corrected, and no admission decision was widened to make anything green. ## Verification | what | command | result | |:--|:--|:--| | package tests | `pnpm --filter @objectstack/plugin-security test` | 118 files / **2261 passed** | | package typecheck | `pnpm --filter @objectstack/plugin-security typecheck` | exit 0, test layer 0 errors | | dependency closure | `pnpm --filter '@objectstack/plugin-security^...' build` | exit 0 | | derived gate families | `node scripts/pm/dispatch-gates.mjs --commands` then `--ran` | **62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN** | | repo lint (whole tree, not narrowed) | `pnpm lint` (`eslint . --no-inline-config`) | exit 0 | Three of the 62 first answered `PREREQUISITE NOT MET` (exit 3, not a finding) because they read built output; all three are green after `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'` (72/72 successful): `check:dual-build-cjs-loads`, `check:i18n`, `check:type-check-debt`. `check:where-matcher` found one real defect in my own new fixture — a `$`-prefixed key read as a field name — fixed in `da621549c0` and green. All readings are taken at `da621549c0`, the final commit. Not run here and left to CI: the five path-scheduled CI jobs, the type-check lanes over the whole workspace, and the artifact-roster families the derivation scores `silent` for every card. ## Acceptance notes Observed while reading the fix face, deliberately NOT changed here — handed back to the PM rather than filed, and rather than widened into this PR: - **The sibling by-name reads of `sys_position` in the same file are the same defect class, unrepaired.** `positionIsDelegatable` (`:586` pre-fix) and `setsBoundToPosition` (`:1005` pre-fix) both do `ql.find('sys_position', { where: { name: positionName }, limit: 1, context: SYSTEM_CTX })`, and `sys_position` is a per-organization catalog row upserted by `(name, organization_id)`. They decide whether a position may be self-delegated and which permission sets it distributes, so a cross-organization row answering either one is an authority decision. The card listed them under "Not measured"; they need their own fixture and their own witness, which is a new verification surface this card's scope does not carry. - `businessUnitsOfUser` and `assignmentAnchorsOfPosition` read `sys_business_unit_member` / `sys_user_position` under the same bare system context. Both are keyed on a globally unique id rather than a name, so the collision this card is about does not reach them — noted as boundary, not claimed as a defect. --- _Generated by [Claude Code](https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4a12ac0 commit a5afe38

5 files changed

Lines changed: 572 additions & 29 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
The delegated-administration gate resolves a scope's business-unit anchor **inside the caller's own organization**. In a single-database multi-org posture (ADR-0105 D1 `group` / `isolated`) a unit name shared by two organizations no longer crosses the boundary in either direction (#19775).
6+
7+
`sys_business_unit.name` carries no uniqueness — the object's only unique index is `(code, organization_id)` — yet the gate looked the anchor up by name alone under a bare `{ isSystem: true }` context, which carries no tenant. The engine threads a tenant to the driver only when `execCtx.tenantId` is defined and `SqlDriver.applyTenantScope` returns early without one, so nothing scoped that read: a `limit: 1` lookup answered whichever id the driver ordered first, and which organization won was an id ordering. Measured on a real engine over a real SQL driver, with two organizations each holding a unit called `sales`, both directions were wrong at once — the delegate **lost its own subtree** (denied inside its own unit) while the gate **approved** a delegated write anchored in the other organization, and `describeDelegableScope` handed that organization's unit ids back to the caller.
8+
9+
- **What changed**: the anchor read, the descendant walk and the two catalog reads behind `describeDelegableScope` now carry the caller's organization — `organizationId ?? tenantId`, the same spelling the permission-set load already resolves a caller's authority with — and the candidates that come back are reduced to the caller's own rows. Both arms are load-bearing and each was measured to be: the driver's compatibility arm deliberately also returns organization-less rows, and a driver with no tenant scoping at all returns every organization's.
10+
- **Fail closed**: an anchor that resolves to no unit of the caller's own organization now approves nothing, exactly as a misconfigured scope already did. Under a walled posture this also refuses an **organization-less** business unit, which that posture already treats as invalid state; a delegation anchored on one stops resolving and must be re-anchored on a unit the organization owns.
11+
- **`group` posture**: the anchor resolves in the caller's **active** organization, not their whole membership set — the narrower of the two, and the one the caller's permission sets (and therefore the `adminScope` itself) were already loaded in.
12+
- **Unchanged where there is no boundary to cross**: a caller carrying no organization (the `single` posture) keeps the by-name answer it had.
13+
14+
No exported symbol and no payload key was added: `describeDelegableScope` and `scopesCoverUser` take the caller's context as a new optional argument, and omitting it resolves exactly as before.
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The delegated-admin gate resolves a scope's business-unit anchor INSIDE the
5+
* caller's own organization — pinned over a real engine, at the crossing.
6+
*
7+
* ## The defect this exists to keep closed
8+
*
9+
* `sys_business_unit.name` carries no uniqueness (the object's only unique
10+
* index is `(code, organization_id)`), so in a single-database multi-org
11+
* posture — ADR-0105 D1 `group` / `isolated`, which ADR-0132 ships open — two
12+
* organizations may each hold a unit called `sales`. The gate looked the
13+
* anchor up by NAME alone under a bare `{ isSystem: true }` context, which
14+
* carries no tenant: the engine threads a tenant to the driver only when
15+
* `execCtx.tenantId` is defined, and `SqlDriver.applyTenantScope` returns
16+
* early without one. So nothing scoped that read, `limit: 1` answered
17+
* whichever id the driver ordered first, and which organization won was an id
18+
* ordering.
19+
*
20+
* Both directions were wrong at once, and both are pinned below:
21+
* - the org A delegate LOST its own subtree (denied inside its own unit);
22+
* - the gate APPROVED a delegated write anchored in org B, and
23+
* `describeDelegableScope` handed org B's unit ids to the org A caller.
24+
*
25+
* ## Why a real engine and a real driver
26+
*
27+
* The crossing IS the tenant scoping, so a fake `ql` that ignores `context`
28+
* cannot exercise it — it would only re-assert this file's own beliefs. Here
29+
* the ids are ordered so org B's sort first (`bu_0_*` before `bu_a_*`): under
30+
* the old resolution every assertion below flips, and insertion order makes no
31+
* difference because the SQL driver's `limit` read is `ORDER BY id ASC`.
32+
* `delegated-admin-gate.test.ts` pins the complementary half on a fake `ql`
33+
* that scopes NOTHING — the row-level arm that stands even where a driver has
34+
* no tenant scoping at all.
35+
*
36+
* Harness lineage: `tenant-layer0-verdict-end-to-end.test.ts` (a real
37+
* `ObjectQL` over a real `SqlDriver` on in-memory SQLite).
38+
*/
39+
40+
import { describe, it, expect, afterEach } from 'vitest';
41+
import { ObjectQL } from '@objectstack/objectql';
42+
import { SqlDriver } from '@objectstack/driver-sql';
43+
import type { PermissionSet } from '@objectstack/spec/security';
44+
import { DelegatedAdminGate } from './delegated-admin-gate.js';
45+
46+
const ORG_A = 'org_a_acme';
47+
/** Deliberately sorts BEFORE every `org_a`/`bu_a` id — the losing side of the defect. */
48+
const ORG_B = 'org_0_globex';
49+
50+
const text = (name: string) => ({ name, type: 'text' as const });
51+
const object = (name: string, fields: string[]) => ({
52+
name,
53+
label: name,
54+
fields: {
55+
id: { name: 'id', type: 'text' as const, primaryKey: true },
56+
organization_id: text('organization_id'),
57+
...Object.fromEntries(fields.map((f) => [f, text(f)])),
58+
},
59+
});
60+
61+
const OBJECTS = [
62+
object('sys_business_unit', ['name', 'parent_business_unit_id']),
63+
object('sys_position', ['name', 'delegatable']),
64+
object('sys_permission_set', ['name', 'admin_scope']),
65+
object('sys_position_permission_set', ['position_id', 'permission_set_id']),
66+
object('sys_user_position', ['user_id', 'position', 'business_unit_id', 'granted_by', 'delegated_from']),
67+
object('sys_business_unit_member', ['user_id', 'business_unit_id']),
68+
object('sys_user', ['primary_business_unit_id']),
69+
];
70+
71+
/** The delegate's scope: anchored at the unit NAMED `sales`, subtree included. */
72+
const SALES_SCOPE = {
73+
businessUnit: 'sales',
74+
includeSubtree: true,
75+
manageAssignments: true,
76+
manageBindings: false,
77+
authorEnvironmentSets: false,
78+
assignablePermissionSets: ['sales_user'],
79+
};
80+
81+
const DELEGATE_SETS = [
82+
{ name: 'member_default', objects: {} },
83+
{
84+
name: 'sales_admin',
85+
objects: { sys_user_position: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
86+
adminScope: SALES_SCOPE,
87+
},
88+
] as unknown as PermissionSet[];
89+
90+
const engines: ObjectQL[] = [];
91+
afterEach(async () => {
92+
while (engines.length) {
93+
try { await engines.pop()?.destroy(); } catch { /* noop */ }
94+
}
95+
});
96+
97+
const sysCtx = (organizationId: string) => ({ isSystem: true, tenantId: organizationId });
98+
99+
async function bootEngine() {
100+
const engine = new ObjectQL();
101+
engine.registerDriver(
102+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }),
103+
true,
104+
);
105+
await engine.init();
106+
engine.registerApp({
107+
id: 'com.objectstack.qa.delegated-admin-cross-organization',
108+
name: 'Delegated admin cross-organization anchor',
109+
version: '1.0.0',
110+
type: 'plugin',
111+
scope: 'system',
112+
objects: OBJECTS,
113+
} as any);
114+
await engine.syncSchemas();
115+
engines.push(engine);
116+
return engine;
117+
}
118+
119+
/**
120+
* One organization's slice of the fixture: a unit named `sales` with one
121+
* child, a `sales_rep` position distributing the allowlisted `sales_user`
122+
* set, and one member. `idPrefix` decides how this organization's ids sort.
123+
*/
124+
async function seedOrganization(engine: ObjectQL, organizationId: string, idPrefix: string) {
125+
const ctx = sysCtx(organizationId);
126+
const ids = {
127+
sales: `bu_${idPrefix}_sales`,
128+
salesEast: `bu_${idPrefix}_sales_east`,
129+
position: `pos_${idPrefix}_sales_rep`,
130+
set: `ps_${idPrefix}_sales_user`,
131+
user: `usr_${idPrefix}_member`,
132+
};
133+
await engine.insert('sys_business_unit', [
134+
{ id: ids.sales, name: 'sales', parent_business_unit_id: null },
135+
{ id: ids.salesEast, name: 'sales_east', parent_business_unit_id: ids.sales },
136+
], { context: ctx } as any);
137+
await engine.insert('sys_position', [{ id: ids.position, name: 'sales_rep' }], { context: ctx } as any);
138+
await engine.insert('sys_permission_set', [{ id: ids.set, name: 'sales_user' }], { context: ctx } as any);
139+
await engine.insert('sys_position_permission_set', [
140+
{ id: `bind_${idPrefix}`, position_id: ids.position, permission_set_id: ids.set },
141+
], { context: ctx } as any);
142+
await engine.insert('sys_user', [{ id: ids.user, primary_business_unit_id: ids.salesEast }], { context: ctx } as any);
143+
await engine.insert('sys_business_unit_member', [
144+
{ id: `mem_${idPrefix}`, user_id: ids.user, business_unit_id: ids.salesEast },
145+
], { context: ctx } as any);
146+
return ids;
147+
}
148+
149+
function gateOn(engine: ObjectQL) {
150+
return new DelegatedAdminGate({
151+
ql: engine,
152+
resolveSets: async (context: any) => (context?.principal === 'delegate' ? DELEGATE_SETS : []),
153+
});
154+
}
155+
156+
/** The org A delegate's own execution context, as a transport would carry it. */
157+
const delegateCtx = (organizationId: string) => ({
158+
principal: 'delegate',
159+
userId: 'usr_delegate',
160+
tenantId: organizationId,
161+
positions: ['sales_admin'],
162+
});
163+
164+
const assignInto = (gate: DelegatedAdminGate, organizationId: string, businessUnitId: string, userId: string) =>
165+
gate.assert({
166+
object: 'sys_user_position',
167+
operation: 'insert',
168+
data: { user_id: userId, position: 'sales_rep', business_unit_id: businessUnitId },
169+
context: delegateCtx(organizationId),
170+
});
171+
172+
describe('DelegatedAdminGate — a scope anchor never crosses an organization', () => {
173+
it('ground truth: two organizations each hold a unit named `sales`, and org B sorts first', async () => {
174+
const engine = await bootEngine();
175+
const b = await seedOrganization(engine, ORG_B, '0');
176+
const a = await seedOrganization(engine, ORG_A, 'a');
177+
178+
// Read past every scope: the population the by-name read chose from.
179+
const driver: any = (engine as any).getDriver('sys_business_unit');
180+
const raw = await driver.knex('sys_business_unit').where({ name: 'sales' }).orderBy('id', 'asc');
181+
expect(raw.map((r: any) => r.id)).toEqual([b.sales, a.sales]);
182+
expect(raw.map((r: any) => r.organization_id)).toEqual([ORG_B, ORG_A]);
183+
});
184+
185+
it('the org A delegate KEEPS its own subtree — a write inside org A `sales` is approved', async () => {
186+
const engine = await bootEngine();
187+
await seedOrganization(engine, ORG_B, '0');
188+
const a = await seedOrganization(engine, ORG_A, 'a');
189+
const gate = gateOn(engine);
190+
191+
await expect(assignInto(gate, ORG_A, a.salesEast, a.user)).resolves.toBeUndefined();
192+
});
193+
194+
it('the org A delegate cannot reach org B — a write anchored in the other organization is REFUSED', async () => {
195+
const engine = await bootEngine();
196+
const b = await seedOrganization(engine, ORG_B, '0');
197+
await seedOrganization(engine, ORG_A, 'a');
198+
const gate = gateOn(engine);
199+
200+
await expect(assignInto(gate, ORG_A, b.salesEast, b.user)).rejects.toThrow(
201+
/outside the delegated subtree/,
202+
);
203+
});
204+
205+
it('describeDelegableScope names the caller\'s own units and NOTHING of the other organization', async () => {
206+
const engine = await bootEngine();
207+
const b = await seedOrganization(engine, ORG_B, '0');
208+
const a = await seedOrganization(engine, ORG_A, 'a');
209+
const gate = gateOn(engine);
210+
211+
const report = await gate.describeDelegableScope(DELEGATE_SETS, delegateCtx(ORG_A));
212+
213+
expect([...report.placeableBusinessUnitIds].sort()).toEqual([a.sales, a.salesEast].sort());
214+
expect(report.scopes).toHaveLength(1);
215+
expect([...report.scopes[0].businessUnitIds].sort()).toEqual([a.sales, a.salesEast].sort());
216+
// The refusal, stated as absence: no org B id appears anywhere in the report.
217+
const serialized = JSON.stringify(report);
218+
expect(serialized).not.toContain(b.sales);
219+
expect(serialized).not.toContain(b.salesEast);
220+
});
221+
222+
it('a tenant admin is unconstrained INSIDE its organization, never across organizations', async () => {
223+
const engine = await bootEngine();
224+
const b = await seedOrganization(engine, ORG_B, '0');
225+
const a = await seedOrganization(engine, ORG_A, 'a');
226+
const gate = new DelegatedAdminGate({
227+
ql: engine,
228+
resolveSets: async () => [
229+
{ name: 'admin_full', objects: { '*': { allowRead: true, modifyAllRecords: true } } },
230+
] as unknown as PermissionSet[],
231+
});
232+
233+
const report = await gate.describeDelegableScope([
234+
{ name: 'admin_full', objects: { '*': { allowRead: true, modifyAllRecords: true } } },
235+
] as unknown as PermissionSet[], { userId: 'usr_admin', tenantId: ORG_A });
236+
237+
expect(report.isTenantAdmin).toBe(true);
238+
expect([...report.placeableBusinessUnitIds].sort()).toEqual([a.sales, a.salesEast].sort());
239+
expect(report.placeableBusinessUnitIds).not.toContain(b.sales);
240+
});
241+
242+
// ── Controls: the assertions above must be able to fail, and the
243+
// organization-less surface must be untouched. ───────────────────────
244+
it('control (dark) — one organization holding `sales` resolves it, and the in-org write is approved', async () => {
245+
const engine = await bootEngine();
246+
const a = await seedOrganization(engine, ORG_A, 'a');
247+
const gate = gateOn(engine);
248+
249+
const report = await gate.describeDelegableScope(DELEGATE_SETS, delegateCtx(ORG_A));
250+
expect([...report.placeableBusinessUnitIds].sort()).toEqual([a.sales, a.salesEast].sort());
251+
await expect(assignInto(gate, ORG_A, a.salesEast, a.user)).resolves.toBeUndefined();
252+
});
253+
254+
it('control (firing) — an anchor naming no unit in the caller\'s organization approves nothing', async () => {
255+
const engine = await bootEngine();
256+
const a = await seedOrganization(engine, ORG_A, 'a');
257+
const gate = new DelegatedAdminGate({
258+
ql: engine,
259+
resolveSets: async (context: any) =>
260+
context?.principal === 'delegate'
261+
? ([{ name: 'sales_admin', objects: {}, adminScope: { ...SALES_SCOPE, businessUnit: 'no_such_unit' } }] as unknown as PermissionSet[])
262+
: [],
263+
});
264+
265+
await expect(assignInto(gate, ORG_A, a.salesEast, a.user)).rejects.toThrow(
266+
/outside the delegated subtree/,
267+
);
268+
});
269+
270+
it('a `single`-posture caller (no organization in context) keeps the by-name answer', async () => {
271+
const engine = await bootEngine();
272+
const a = await seedOrganization(engine, ORG_A, 'a');
273+
const gate = gateOn(engine);
274+
275+
const report = await gate.describeDelegableScope(DELEGATE_SETS, {
276+
principal: 'delegate',
277+
userId: 'usr_delegate',
278+
});
279+
expect([...report.placeableBusinessUnitIds].sort()).toEqual([a.sales, a.salesEast].sort());
280+
});
281+
});

0 commit comments

Comments
 (0)