From a3fc36f5033bf7cb4fd08ab9fa608728aa826c3d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 05:48:16 +0000 Subject: [PATCH] fix(plugin-security): the delegated walk counts the permission set of the position's own name ADR-0090 D12 as amended 2026-10-17: the sets a position carries are every set an assignment of it grants, the own-name set included while the resolver folds held position names into the requested set names. The delegated branch's setsBoundToPosition now reads setsCarriedByPosition, the helper the organization-level branch already used, so the allowlist check, strict containment, the self-delegation rule against handing out administration and the delegable-scope listing all judge that set. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../24736-delegated-walk-own-name-set.md | 12 + .../src/delegated-admin-gate.test.ts | 149 ++++++++++++ .../src/delegated-admin-gate.ts | 59 +++-- ...legated-admin-own-name-set.posture.test.ts | 228 ++++++++++++++++++ 4 files changed, 429 insertions(+), 19 deletions(-) create mode 100644 .changeset/24736-delegated-walk-own-name-set.md create mode 100644 packages/plugins/plugin-security/src/delegated-admin-own-name-set.posture.test.ts diff --git a/.changeset/24736-delegated-walk-own-name-set.md b/.changeset/24736-delegated-walk-own-name-set.md new file mode 100644 index 00000000000..48b71808a08 --- /dev/null +++ b/.changeset/24736-delegated-walk-own-name-set.md @@ -0,0 +1,12 @@ +--- +'@objectstack/plugin-security': patch +--- + +fix(plugin-security): a delegated administrator's position assignment is judged on every permission set the assignment grants, the set of the position's own name included + +Clause-②: no + +- **The rule (ADR-0090 D12, as amended 2026-10-17).** The sets a position carries are every set an assignment of it grants: the names its definition's `permissionSets` lists and, while the authorization resolver folds held position names into the requested set names, the permission set of the position's own name when the catalog holds one. The organization-level branch already counted that set. The delegated branch now does too, through the same helper. +- **What a delegate is now refused.** A `sys_user_position` insert, update or delete by an `adminScope` holder whose allowlist does not cover the position's own-name set is refused `403 PERMISSION_DENIED`. The message names the set and says it is the set of the position's own name. An own-name set that carries an `adminScope` is held to strict containment like any other distributed set. A delegatable position whose own-name set carries an `adminScope` can no longer be self-delegated. +- **What the delegable-scope report offers.** `GET /api/v1/security/my-delegable-scope` no longer lists a position for a delegate whose allowlist misses that position's own-name set, so the picker offers only what the write admits. +- **Unchanged.** A position with no permission set of its own name is judged on its listed sets alone, as before. Tenant-level administrators and the organization-level branch are untouched, and the resolver's fold itself is not changed. To keep a delegate able to assign such a position, add the own-name set to the scope's `assignablePermissionSets`. diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts index 15377fb65f8..6d884d5c0e7 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts @@ -1349,3 +1349,152 @@ describe('DelegatedAdminGate — organization administrators (ADR-0090 D12 as am }); }); }); + +// --------------------------------------------------------------------------- +// [ADR-0090 D12, amended 2026-10-17] The delegated walk counts the own-name set +// --------------------------------------------------------------------------- + +/** + * The sets a position carries are every set an assignment of it grants: the + * names its definition lists and, while the resolver folds held position names + * into the requested set names, the permission set of the position's own name. + * The delegated branch judges all of them — in the allowlist check, in strict + * containment, in the self-delegation rule against handing out administration + * and in the delegable-scope listing. + * + * Catalog: `rep_pos` → [field_user] beside a plain set named `rep_pos`; + * `lead_pos` → [field_user] beside a set named `lead_pos` that carries the wide + * delegate's own scope; `clerk_pos` → [field_user] with no set of its name; + * `duty_pos` (delegatable) → [field_user] beside a set named `duty_pos` that + * carries an adminScope; `duty_plain` (delegatable) → [field_user]. + */ +function makeOwnNameHarness() { + const tables: Record = { + sys_business_unit: [ + { id: 'bu_hq', name: 'hq', parent_business_unit_id: null }, + { id: 'bu_east', name: 'east', parent_business_unit_id: 'bu_hq' }, + { id: 'bu_es', name: 'east_sales', parent_business_unit_id: 'bu_east' }, + ], + sys_user_position: [ + { id: 'held_rep', user_id: 'u_east_1', position: 'rep_pos', business_unit_id: 'bu_es' }, + { id: 'boss_duty', user_id: 'u_boss', position: 'duty_pos' }, + { id: 'boss_plain', user_id: 'u_boss', position: 'duty_plain' }, + ], + sys_business_unit_member: [{ id: 'm1', business_unit_id: 'bu_es', user_id: 'u_east_1' }], + sys_user: [{ id: 'u_east_1' }, { id: 'u_boss' }, { id: 'u_deleg' }], + }; + const matches = (row: any, where: any): boolean => + Object.entries(where ?? {}).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + if (v && typeof v === 'object' && Array.isArray((v as any).$in)) return (v as any).$in.includes(row[k]); + return row[k] === v; + }); + const ql: any = { + tables, + async find(object: string, opts: any) { + const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where)); + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; + }, + async findOne(object: string, opts: any) { + return (tables[object] ?? []).find((r) => matches(r, opts?.where)) ?? null; + }, + }; + const scope = (allow: string[]) => ({ businessUnit: 'east', includeSubtree: true, manageAssignments: true, assignablePermissionSets: allow }); + const WIDE_ALLOW = ['field_user', 'rep_pos', 'lead_pos']; + bindTestSecurityCatalog(ql, { + permissions: [ + { name: 'field_user', objects: { crm_account: { allowRead: true } } }, + { name: 'rep_pos', objects: { crm_account: { allowRead: true, allowEdit: true } } }, + { name: 'lead_pos', objects: {}, adminScope: scope(WIDE_ALLOW) }, + { name: 'duty_pos', objects: {}, adminScope: scope(['field_user']) }, + ], + positions: [ + { name: 'rep_pos', permissionSets: ['field_user'] }, + { name: 'lead_pos', permissionSets: ['field_user'] }, + { name: 'clerk_pos', permissionSets: ['field_user'] }, + { name: 'duty_pos', permissionSets: ['field_user'], delegatable: true }, + { name: 'duty_plain', permissionSets: ['field_user'], delegatable: true }, + ], + }); + const SETS: Record = { + // Allowlists the sets the positions LIST, not their own-name sets. + narrow: [{ name: 'east_admin', objects: {}, adminScope: scope(['field_user']) }], + // Also allowlists the own-name sets. + wide: [{ name: 'east_admin_wide', objects: {}, adminScope: scope(WIDE_ALLOW) }], + member: [{ name: 'member_default', objects: {} }], + }; + const gate = new DelegatedAdminGate({ + ql, + resolveSets: async (c: any) => SETS[c?.principal ?? ''] ?? [], + now: () => T0, + }); + const ctxOf = (principal: string) => ({ principal, userId: `u_${principal}`, positions: [] }); + const assign = (principal: string, position: string) => gate.assert({ + object: 'sys_user_position', operation: 'insert', context: ctxOf(principal), + data: { user_id: 'u_east_1', position, business_unit_id: 'bu_es' }, + }); + const offered = async (principal: string) => + (await gate.describeDelegableScope(SETS[principal] as any, ctxOf(principal))).assignablePositions.sort(); + return { gate, tables, ctxOf, assign, offered }; +} + +describe("DelegatedAdminGate — a delegate is judged on the permission set of the position's own name (ADR-0090 D12 as amended 2026-10-17)", () => { + let n: ReturnType; + beforeEach(() => { n = makeOwnNameHarness(); }); + + it("refuses an assignment whose own-name set is outside the allowlist — 403 PERMISSION_DENIED, naming the set", async () => { + const err = await deniedOf(() => n.assign('narrow', 'rep_pos')); + expect(err.message).toMatch(/position 'rep_pos' distributes permission set 'rep_pos' \(the permission set of the position's own name.*not in the scope's allowlist/); + expect(err.details).toMatchObject({ position: 'rep_pos' }); + }); + + it('CONTROL: admits the same assignment for a delegate whose allowlist covers the own-name set too', async () => { + const row: any = { user_id: 'u_east_1', position: 'rep_pos', business_unit_id: 'bu_es' }; + await expect(n.gate.assert({ object: 'sys_user_position', operation: 'insert', data: row, context: n.ctxOf('wide') })) + .resolves.toBeUndefined(); + expect(row.granted_by).toBe('u_wide'); + }); + + it('a position with no own-name set in the catalog is judged on its listed sets alone, as before', async () => { + await expect(n.assign('narrow', 'clerk_pos')).resolves.toBeUndefined(); + }); + + it('removing an assignment is judged on the same sets: the narrow delegate may not evict one granting a set outside its allowlist', async () => { + const err = await deniedOf(() => n.gate.assert({ + object: 'sys_user_position', operation: 'delete', options: { where: { id: 'held_rep' } }, context: n.ctxOf('narrow'), + })); + expect(err.message).toMatch(/permission set 'rep_pos'.*not in the scope's allowlist/); + await expect(n.gate.assert({ + object: 'sys_user_position', operation: 'delete', options: { where: { id: 'held_rep' } }, context: n.ctxOf('wide'), + })).resolves.toBeUndefined(); + }); + + it('an own-name set carrying an adminScope is held to strict containment, allowlisted or not', async () => { + const err = await deniedOf(() => n.assign('wide', 'lead_pos')); + expect(err.message).toMatch(/granting set 'lead_pos' would hand out an adminScope not strictly contained by yours/); + }); + + it('the delegable-scope listing offers exactly the positions the write admits', async () => { + expect(await n.offered('narrow')).toEqual(['clerk_pos', 'duty_plain']); + expect(await n.offered('wide')).toEqual(['clerk_pos', 'duty_plain', 'rep_pos']); + for (const principal of ['narrow', 'wide']) { + const offered = await n.offered(principal); + for (const position of ['rep_pos', 'lead_pos', 'clerk_pos', 'duty_pos', 'duty_plain']) { + const admitted = await n.assign(principal, position).then(() => true, () => false); + expect(admitted, `${principal} · ${position}`).toBe(offered.includes(position)); + } + } + }); + + it('a delegatable position whose own-name set carries an adminScope cannot be self-delegated', async () => { + const delegation = (position: string) => n.gate.assert({ + object: 'sys_user_position', operation: 'insert', context: { userId: 'u_boss', positions: [], principal: 'member' }, + data: { user_id: 'u_deleg', position, delegated_from: 'u_boss', valid_until: iso(T0 + 5 * DAY), reason: 'cover' }, + }); + const err = await deniedOf(() => delegation('duty_pos')); + expect(err.message).toMatch(/position 'duty_pos' distributes the admin set 'duty_pos' \(the permission set of the position's own name.*administration cannot be self-delegated/); + expect(err.details).toMatchObject({ position: 'duty_pos', permissionSet: 'duty_pos' }); + // CONTROL: the same holder delegates a delegatable position with no such set. + await expect(delegation('duty_plain')).resolves.toBeUndefined(); + }); +}); diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index cd73f6e5b21..2a202d1153e 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -25,7 +25,8 @@ * what one of their scopes fully approves: the right kind of action * (manageAssignments), inside their BU subtree, handing out only * allowlisted sets, with STRICT containment whenever the granted set itself - * carries an adminScope. The rows it governs are the two assignment tables + * carries an adminScope. A position assignment hands out every set it + * grants — the definition's list and the set of the position's own name. The rows it governs are the two assignment tables * (`sys_user_position`, `sys_user_permission_set`); the binding and set * authoring rows retired with the catalog row objects (ADR-0131 D3), so a * position's sets and a set's definition are written through the metadata @@ -261,6 +262,17 @@ interface BoundSet { adminScope?: unknown; } +/** + * What a refusal adds when the set it names is the one of the position's own + * name: the position's definition need not list it, so the reader is told why + * it was judged at all. + */ +function ownNameNote(positionName: string, setName: string): string { + return setName === positionName + ? " (the permission set of the position's own name, which an assignment of the position also grants)" + : ''; +} + function rowsOf(opCtx: any): any[] { const d = opCtx?.data; if (Array.isArray(d)) return d; @@ -598,8 +610,8 @@ export class DelegatedAdminGate { const placeableBusinessUnitIds = [...new Set(placing.flatMap((h) => [...h.subtree]))]; // A position is assignable when at least ONE placing scope allowlists - // every set it distributes — exactly `assertAssignmentWrite`'s test, - // containment check included. + // every set it distributes — the set of its own name included — exactly + // `assertAssignmentWrite`'s test, containment check included. const assignablePositions: string[] = []; if (placing.length > 0) { for (const positionName of await allPositions()) { @@ -792,6 +804,9 @@ export class DelegatedAdminGate { * the set of the position's own name, when the catalog holds one. `null` * when the catalog does not hold the position. An unreadable catalog throws * (`AuthzStoreUnavailableError`) and the write is refused with it. + * + * The one answer to "which sets does this position carry" for both branches: + * the delegated walk ({@link setsBoundToPosition}) reads it too. */ private async setsCarriedByPosition(positionName: string): Promise { const catalog = this.catalog(); @@ -1006,11 +1021,12 @@ export class DelegatedAdminGate { deny(`position '${positionName}' is not delegatable — set delegatable: true on the position to allow it`, { position: positionName }); } - // 6. A delegatable position must not distribute administration. + // 6. A delegatable position must not distribute administration — through + // any set an assignment of it grants, the own-name set included. const boundSets = await this.setsBoundToPosition(positionName); for (const b of boundSets) { if (parseMaybeJson((b as any).admin_scope ?? (b as any).adminScope)) { - deny(`position '${positionName}' distributes the admin set '${b.name}' — administration cannot be self-delegated (D12 containment)`, { position: positionName, permissionSet: b.name }); + deny(`position '${positionName}' distributes the admin set '${b.name}'${ownNameNote(positionName, b.name)} — administration cannot be self-delegated (D12 containment)`, { position: positionName, permissionSet: b.name }); } } } @@ -1100,10 +1116,11 @@ export class DelegatedAdminGate { if (!prevBu) return 'the existing assignment is unanchored (no business_unit_id) — only a tenant admin may modify it'; if (!s.subtree.has(String(prevBu))) return `the existing assignment's business unit '${prevBu}' is outside the delegated subtree`; } - // Every set the position distributes must be allowlisted. + // Every set the position distributes must be allowlisted — the set of + // the position's own name included ({@link setsBoundToPosition}). for (const bound of boundSets) { if (!s.scope.assignablePermissionSets.includes(bound.name)) { - return `position '${positionName}' distributes permission set '${bound.name}', which is not in the scope's allowlist`; + return `position '${positionName}' distributes permission set '${bound.name}'${ownNameNote(positionName, bound.name)}, which is not in the scope's allowlist`; } const contained = this.assertScopeGrantContainment(bound, held, /*dryRun*/ true); if (contained) return contained; @@ -1387,14 +1404,21 @@ export class DelegatedAdminGate { } /** - * [ADR-0131 D3/D4] The permission sets a position distributes: the names its - * DEFINITION carries (`PositionSchema.permissionSets`), each with the - * `adminScope` of the catalog's definition of that name — the binding the - * authorization resolver grants through, read from the same security + * [ADR-0131 D3/D4, ADR-0090 D12 as amended 2026-10-17] The permission sets a + * position distributes, as the delegated walk judges them: every set an + * assignment of it grants — {@link setsCarriedByPosition}, the one answer the + * organization-level branch reads too: the names its DEFINITION carries + * (`PositionSchema.permissionSets`) and, while the resolver folds held + * position names into the requested set names, the permission set of the + * position's own name when the catalog holds one — each with the + * `adminScope` of the catalog's definition of that name. That is the binding + * the authorization resolver grants through, read from the same security * catalog, never from a `sys_position_permission_set` junction row (which - * grants nothing since the resolver stopped reading it). So the D12 - * containment and the assignable-positions listing judge exactly what an - * assignment will grant. + * grants nothing since the resolver stopped reading it). So the allowlist + * check, the D12 strict containment, the self-delegation rule against + * distributing administration and the assignable-positions listing all judge + * exactly what an assignment will grant — a set granted by the position's + * name alone included. * * A name the definition carries that the catalog does not hold is still * returned, with no `adminScope`: it grants nothing today, but the binding is @@ -1409,12 +1433,9 @@ export class DelegatedAdminGate { private async setsBoundToPosition(positionName: string): Promise { const catalog = this.catalog(); if (!catalog || !positionName) return []; - const position = await catalog.resolve('position', positionName); - const names = Array.isArray(position?.definition.permissionSets) - ? (position.definition.permissionSets as unknown[]).filter((n): n is string => typeof n === 'string' && n !== '') - : []; + const names = (await this.setsCarriedByPosition(positionName)) ?? []; const out: BoundSet[] = []; - for (const name of new Set(names)) { + for (const name of names) { const set = await catalog.resolve('permission', name); out.push({ name, ...(set?.definition.adminScope ? { adminScope: set.definition.adminScope } : {}) }); } diff --git a/packages/plugins/plugin-security/src/delegated-admin-own-name-set.posture.test.ts b/packages/plugins/plugin-security/src/delegated-admin-own-name-set.posture.test.ts new file mode 100644 index 00000000000..0ac3b993baf --- /dev/null +++ b/packages/plugins/plugin-security/src/delegated-admin-own-name-set.posture.test.ts @@ -0,0 +1,228 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0090 D12, amended 2026-10-17] A delegate's position assignment is judged + * on EVERY permission set the assignment grants — the names the position's + * definition lists and, while the resolver folds held position names into the + * requested set names, the permission set of the position's own name. + * + * Measured on a REAL `ObjectQL` engine over a real SQL driver with the REAL + * `SecurityPlugin` registered on it (the delegated-administration gate, the CRUD + * check and the grant-holder membership hooks in their production order), in + * the three postures `org-admin-assignment-boundary.posture.test.ts` covers. + * Every refusal is identified by its ADR-0112 envelope — `status` and `code` + * through `resolveThrownHttpError`, the resolver both HTTP doors answer with — + * and names the set it judged; every admission reads the stored row back. + * + * The unit pins beside the gate (`delegated-admin-gate.test.ts`) hold the same + * rule on a fake engine; this file holds it where the catalog, the resolver's + * fold and the gate meet. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resolveThrownHttpError } from '@objectstack/types'; +import { SysUser, SysMember, SysBusinessUnit, SysBusinessUnitMember } from '@objectstack/platform-objects/identity'; + +import { SecurityPlugin } from './security-plugin.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { defaultPermissionSets } from './objects/index.js'; + +const SYS = { isSystem: true } as const; +const ORG_A = 'org_a'; + +/** A delegate's scope over the `east` unit and its subtree, allowlisting `allow`. */ +const eastScope = (allow: string[]) => ({ + businessUnit: 'east', + includeSubtree: true, + manageAssignments: true, + assignablePermissionSets: allow, +}); +const CRUD = { sys_user_position: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }; + +/** + * The catalog. `field_rep` and `field_lead` each list only `field_user`, and + * the catalog also holds a permission set of each one's own name; `field_clerk` + * lists `field_user` and has no set of its own name. + */ +const PERMISSIONS = [ + ...defaultPermissionSets, + { name: 'field_user', label: 'Field user', objects: { sys_user: { allowRead: true } } }, + { name: 'field_rep', label: 'Field rep (own name)', objects: { sys_user: { allowRead: true, allowEdit: true } } }, + // Carries an adminScope equal to the wide delegate's own: not strictly contained by it. + { name: 'field_lead', label: 'Field lead (own name)', objects: CRUD, adminScope: eastScope(['field_user', 'field_rep', 'field_lead']) }, + // Narrow: allowlists the sets the positions LIST, not their own-name sets. + { name: 'east_admin', label: 'East delegate', objects: CRUD, adminScope: eastScope(['field_user']) }, + // Wide: also allowlists the own-name sets. + { name: 'east_admin_wide', label: 'East delegate (wide)', objects: CRUD, adminScope: eastScope(['field_user', 'field_rep', 'field_lead']) }, +]; +const POSITIONS = [ + { name: 'field_rep', label: 'Field rep', permissionSets: ['field_user'] }, + { name: 'field_lead', label: 'Field lead', permissionSets: ['field_user'] }, + { name: 'field_clerk', label: 'Field clerk', permissionSets: ['field_user'] }, +]; + +type Posture = 'single' | 'group' | 'isolated'; +const POSTURES: Posture[] = ['single', 'group', 'isolated']; + +const delegateCtx = (userId: string, set: string) => + ({ userId, positions: [], permissions: [set], tenantId: ORG_A, accessible_org_ids: [ORG_A] }); +const NARROW = delegateCtx('u_delegate', 'east_admin'); +const WIDE = delegateCtx('u_delegate_wide', 'east_admin_wide'); + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function boot(posture: Posture) { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.delegated-admin-own-name-set', + name: 'Delegated administration counts the own-name set', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [SysUserPosition, SysUserPermissionSet, SysMember, SysBusinessUnit, SysBusinessUnitMember], + } as any); + await engine.syncSchemas(); + // `sys_user` registered and unprovisioned, as in the sibling posture suite. + if (!engine.registry.getObject(SysUser.name)) engine.registry.registerObject(SysUser as never, 'qa.delegated-own-name'); + engines.push(engine); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (type: string, name: string) => + (type === 'position' ? POSITIONS.find((p) => p.name === name) + : type === 'permission' ? PERMISSIONS.find((p) => p.name === name) + : type === 'capability' ? undefined + : engine.getSchema(name)) ?? null, + list: async (type: string) => (type === 'position' ? POSITIONS : type === 'permission' ? PERMISSIONS : []), + }, + ...(posture === 'single' ? {} : { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture } }), + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + hook: vi.fn(), + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx); + await plugin.start(ctx); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + + const sys = { context: { isSystem: true, tenantId: ORG_A } } as any; + await engine.insert('sys_business_unit', [ + { id: 'bu_hq', name: 'hq', parent_business_unit_id: null, organization_id: ORG_A }, + { id: 'bu_east', name: 'east', parent_business_unit_id: 'bu_hq', organization_id: ORG_A }, + { id: 'bu_east_field', name: 'east_field', parent_business_unit_id: 'bu_east', organization_id: ORG_A }, + ], sys); + for (const userId of ['u_delegate', 'u_delegate_wide', 'u_target']) { + await engine.insert('sys_member', { id: `m_${userId}`, user_id: userId, organization_id: ORG_A, role: 'member' }, sys); + } + await engine.insert('sys_business_unit_member', + { id: 'bum_target', user_id: 'u_target', business_unit_id: 'bu_east_field', organization_id: ORG_A }, sys); + return { engine, plugin }; +} + +async function outcome(run: () => Promise): Promise<{ status: number; code?: string; message?: string }> { + try { + await run(); + return { status: 200 }; + } catch (e) { + const r = resolveThrownHttpError(e); + return { status: r.status, code: r.code, message: (e as Error).message }; + } +} + +const assign = (h: Awaited>, context: any, position: string) => + outcome(() => h.engine.insert('sys_user_position', + { user_id: 'u_target', position, business_unit_id: 'bu_east_field' }, { context } as any)); + +async function storedPositions(h: Awaited>): Promise { + const rows = await h.engine.find('sys_user_position', { where: { user_id: 'u_target' }, context: SYS }); + return (Array.isArray(rows) ? rows : []).map((r: any) => r.position).sort(); +} + +describe('[ADR-0090 D12, amended 2026-10-17] a delegate is judged on every set a position assignment grants — three postures', () => { + for (const posture of POSTURES) { + describe(`posture '${posture}'`, () => { + it("ground truth: an assignment of the position grants the permission set of the position's own name", async () => { + const h = await boot(posture); + const sets = await (h.plugin as any).resolvePermissionSetsForContext( + { userId: 'u_target', positions: ['field_rep'], permissions: ['field_user'], tenantId: ORG_A, accessible_org_ids: [ORG_A] }); + expect(sets.map((s: any) => s.name)).toEqual(expect.arrayContaining(['field_user', 'field_rep'])); + }); + + it("a delegate whose allowlist lacks the position's own-name set is refused 403, naming that set", async () => { + const h = await boot(posture); + const r = await assign(h, NARROW, 'field_rep'); + expect([r.status, r.code], r.message).toEqual([403, 'PERMISSION_DENIED']); + expect(r.message).toMatch(/position 'field_rep' distributes permission set 'field_rep'.*not in the scope's allowlist/); + expect(await storedPositions(h)).toEqual([]); + }); + + it('CONTROL: a delegate whose allowlist also covers the own-name set is admitted', async () => { + const h = await boot(posture); + expect(await assign(h, WIDE, 'field_rep')).toEqual({ status: 200 }); + expect(await storedPositions(h)).toEqual(['field_rep']); + }); + + it('a position with no own-name set in the catalog is judged on its listed sets alone, as before', async () => { + const h = await boot(posture); + expect(await assign(h, NARROW, 'field_clerk')).toEqual({ status: 200 }); + expect(await storedPositions(h)).toEqual(['field_clerk']); + }); + + it('an own-name set that carries an adminScope is held to strict containment', async () => { + const h = await boot(posture); + const r = await assign(h, WIDE, 'field_lead'); + expect([r.status, r.code], r.message).toEqual([403, 'PERMISSION_DENIED']); + expect(r.message).toMatch(/granting set 'field_lead' would hand out an adminScope not strictly contained by yours/); + expect(await storedPositions(h)).toEqual([]); + }); + + it("the delegable-scope report offers one of this catalog's positions exactly when the write admits it", async () => { + const h = await boot(posture); + const security = h.plugin as any; + const fixture = POSITIONS.map((p) => p.name); + const offered = async (context: any) => { + const sets = await security.resolvePermissionSetsForContext(context); + const report = await security.delegatedAdminGate.describeDelegableScope(sets, context); + return (report.assignablePositions as string[]).filter((n) => fixture.includes(n)).sort(); + }; + expect(await offered(NARROW)).toEqual(['field_clerk']); + expect(await offered(WIDE)).toEqual(['field_clerk', 'field_rep']); + for (const [label, context] of [['narrow', NARROW], ['wide', WIDE]] as const) { + const listed = await offered(context); + for (const position of fixture) { + const r = await assign(h, context, position); + expect(r.status === 200, `${label} · ${position}: ${r.message ?? 'admitted'}`).toBe(listed.includes(position)); + if (r.status === 200) { + const rows = await h.engine.find('sys_user_position', { where: { user_id: 'u_target', position }, context: SYS }); + for (const row of Array.isArray(rows) ? rows : []) { + await h.engine.delete('sys_user_position', { where: { id: (row as any).id }, context: SYS } as any); + } + } + } + } + }); + }); + } +});