Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/24736-delegated-walk-own-name-set.md
Original file line number Diff line number Diff line change
@@ -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`.
149 changes: 149 additions & 0 deletions packages/plugins/plugin-security/src/delegated-admin-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any[]> = {
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<string, any[]> = {
// 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<typeof makeOwnNameHarness>;
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();
});
});
59 changes: 40 additions & 19 deletions packages/plugins/plugin-security/src/delegated-admin-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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<string[] | null> {
const catalog = this.catalog();
Expand Down Expand Up @@ -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 });
}
}
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -1409,12 +1433,9 @@ export class DelegatedAdminGate {
private async setsBoundToPosition(positionName: string): Promise<BoundSet[]> {
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 } : {}) });
}
Expand Down
Loading
Loading