Skip to content

Commit 21301a3

Browse files
fix(plugin-security): the delegated walk counts the permission set of the position's own name (#24793)
Fixes #24736 Clause-②: no ## What changes The delegated-administration gate's position walk, `setsBoundToPosition`, now takes its set names from `setsCarriedByPosition`. That is the helper the organization-level branch already reads. So a delegate's position assignment is judged on every permission set the assignment grants. ADR-0090 D12, as amended on 2026-10-17, lists them: 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 when the catalog holds one. The walk has three callers on `main`, and all three now judge that set: - `assertAssignmentWrite` handles a `sys_user_position` insert, update or delete by an `adminScope` holder. Both the allowlist check and strict containment now see the own-name set. - `describeDelegableScope`, delegated branch: a position whose own-name set is outside every placing scope's allowlist is no longer offered. `GET /api/v1/security/my-delegable-scope` now agrees with the write. - `assertSelfDelegation` rule 6 (ADR-0091 D3): a delegatable position whose own-name set carries an `adminScope` can no longer be self-delegated. A refusal names the set and says it is the set of the position's own name, because the position's definition need not list it. The envelope is unchanged: `403 PERMISSION_DENIED`. Not changed: the organization-level branch, tenant-level administrators, the resolver's fold in `security-plugin.ts`, `packages/spec`, and the shipped permission sets. The change only narrows what a delegate may do. Nothing a delegate was refused before is admitted now. ## Reachability, measured on `origin/main` `795ff708d8` 1. **Shipped and example catalogs hold no permission set named like a position.** This is a source census of the declarations in `packages/**` and `examples/**`, test fixtures excluded. Positions: the six built-ins (`builtin-positions.ts`), three in `app-crm` and ten in `app-showcase`, 19 in all. Permission sets: the platform's eight (`default-permission-sets.ts`), two in `app-crm` and nine in `app-showcase`, all prefixed `showcase_`. The two name lists share 0 names. 2. **A named real producer holds the shape.** The vendored HotCRM 17.1 artifact, `packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json`, carries the permission sets `sales_manager` and `sales_rep`. HotCRM declares positions of the same names, and #24757 measured that set in force through the fold on a booted HotCRM. 3. **On a real stack, the gap was reachable.** The new posture suite was run against the unmodified gate, with only the test file added. A delegate's allowlist covered the position's listed set but not its own-name set, and its assignment was answered `200` in all three postures (`single`, `group`, `isolated`). The suite's ground-truth case shows the assignee's resolved sets then include the own-name set; that case is green before and after. The same held for an own-name set that carries an `adminScope`, and the delegable-scope report offered both positions. The triage note on this card says a reachable shape makes it **p1**. ## Tests, at head `d110c15116` The branch merged `origin/main` `7d45c8e29a`, which touches `plugin-security`'s shipped sets. The closure was rebuilt and the package run again on the merged head: - `pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2`: `Test Files 192 passed (192)`, `Tests 3995 passed | 45 skipped (4040)`. Before the merge, at `a3fc36f503`: `191 passed (191)`, `3961 passed | 45 skipped`. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0 at both heads. Both new test files are in the `tsconfig.test.json` program; `tsc -p tsconfig.test.json --listFilesOnly` lists both. - `pnpm --filter @objectstack/plugin-security build`: exit 0. - New unit pins in `src/delegated-admin-gate.test.ts`, in the describe block on the permission set of the position's own name: - a refusal asserting `code` `PERMISSION_DENIED` and `status` 403 that names the set; - the control, admitted; - a position with no own-name set, unchanged; - a removal, judged on the same sets; - strict containment on an own-name set that carries an `adminScope`; - the listing compared with the write, position by position; - the self-delegation refusal, with its control. - New real-stack suite, `src/delegated-admin-own-name-set.posture.test.ts`: a real `ObjectQL`, `SqlDriver` and `SecurityPlugin` in three postures. Each posture runs the ground truth, the 403 refusal naming the set, the control, the unchanged no-own-name case, strict containment, and the listing compared with the write. ## Ablation Taken at `a3fc36f503`, before the merge, which did not touch the gate or its tests. The change was committed first. Then the old walk, which read `position.definition.permissionSets` only, was put back through `scripts/ablation-replace.mjs`: anchor 1 to 0, blob `2a202d1153e1` to `2e45318f6351`. Both files were run: - `Tests 14 failed | 125 passed (139)`. - Red: the five new unit pins, and per posture the refusal, strict containment and the listing (nine cases). - Green: the controls, the no-own-name cases and the ground truth. Restored with `git checkout HEAD -- PATH`: the blob is back to `2a202d1153e1`, the same as HEAD, `git diff HEAD` is empty, and the marker count is 0. The tests import the subject through relative source paths (`./delegated-admin-gate`, `./security-plugin.js`), not a `dist/`, so the ablation needed no rebuild. ## Gates Local subset, at head `d110c15116`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths, derived 67 commands from the merge base `7d45c8e29a`. All 67 were run one by one, each exit captured before any pipe, and all 67 exited 0. `--ran` printed: `Run reconciliation — 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN.` On the first head (`a3fc36f503`), `check:dual-build-cjs-loads` and `check:i18n` refused with `PREREQUISITE NOT MET` (exit 3): nothing was measured. After the build that `check:i18n` names, `check:i18n` passed on that head, and both passed in the battery on the merged head. Every family CI runs is read from this PR's check-runs. None of them was run again here. ## Acceptance notes - The dispatch expected a binding-write blast-radius consumer. There is none on `main`: the binding authoring rows retired with ADR-0131 D3. The walk's three callers are the ones listed above. - Both branches now share one answer to "which sets does a position carry" (`setsCarriedByPosition`). `setsBoundToPosition` only adds each set's `adminScope`. - Own-name counting follows the catalog. When no set of the position's name exists, nothing extra is judged. A set authored later under a position's name begins granting through existing assignments without a further write. Authoring a set is tenant-level work, so no delegate can bring that about. - The fold's retirement is not touched here; #13419 records it as the maintainer's call. #24757 reads the same fold from Setup and is not addressed here. - Out of scope, reported to the seat in the dev report: the delegated branch of `describeDelegableScope` offers the four built-in identity positions (`platform_admin`, `org_owner`, `org_admin`, `org_member`). The write refuses each of them for every writer with `400 VALIDATION_FAILED`. This was measured on the real stack in all three postures. The organization-level branch already skips them (`isReservedIdentityName`). - Context: #24136 and #24732, where the organization-level branch's helper landed. --- _Generated by [Claude Code](https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 307ce4c commit 21301a3

4 files changed

Lines changed: 429 additions & 19 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
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
6+
7+
Clause-②: no
8+
9+
- **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.
10+
- **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.
11+
- **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.
12+
- **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`.

‎packages/plugins/plugin-security/src/delegated-admin-gate.test.ts‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1349,3 +1349,152 @@ describe('DelegatedAdminGate — organization administrators (ADR-0090 D12 as am
13491349
});
13501350
});
13511351
});
1352+
1353+
// ---------------------------------------------------------------------------
1354+
// [ADR-0090 D12, amended 2026-10-17] The delegated walk counts the own-name set
1355+
// ---------------------------------------------------------------------------
1356+
1357+
/**
1358+
* The sets a position carries are every set an assignment of it grants: the
1359+
* names its definition lists and, while the resolver folds held position names
1360+
* into the requested set names, the permission set of the position's own name.
1361+
* The delegated branch judges all of them — in the allowlist check, in strict
1362+
* containment, in the self-delegation rule against handing out administration
1363+
* and in the delegable-scope listing.
1364+
*
1365+
* Catalog: `rep_pos` → [field_user] beside a plain set named `rep_pos`;
1366+
* `lead_pos` → [field_user] beside a set named `lead_pos` that carries the wide
1367+
* delegate's own scope; `clerk_pos` → [field_user] with no set of its name;
1368+
* `duty_pos` (delegatable) → [field_user] beside a set named `duty_pos` that
1369+
* carries an adminScope; `duty_plain` (delegatable) → [field_user].
1370+
*/
1371+
function makeOwnNameHarness() {
1372+
const tables: Record<string, any[]> = {
1373+
sys_business_unit: [
1374+
{ id: 'bu_hq', name: 'hq', parent_business_unit_id: null },
1375+
{ id: 'bu_east', name: 'east', parent_business_unit_id: 'bu_hq' },
1376+
{ id: 'bu_es', name: 'east_sales', parent_business_unit_id: 'bu_east' },
1377+
],
1378+
sys_user_position: [
1379+
{ id: 'held_rep', user_id: 'u_east_1', position: 'rep_pos', business_unit_id: 'bu_es' },
1380+
{ id: 'boss_duty', user_id: 'u_boss', position: 'duty_pos' },
1381+
{ id: 'boss_plain', user_id: 'u_boss', position: 'duty_plain' },
1382+
],
1383+
sys_business_unit_member: [{ id: 'm1', business_unit_id: 'bu_es', user_id: 'u_east_1' }],
1384+
sys_user: [{ id: 'u_east_1' }, { id: 'u_boss' }, { id: 'u_deleg' }],
1385+
};
1386+
const matches = (row: any, where: any): boolean =>
1387+
Object.entries(where ?? {}).every(([k, v]) => {
1388+
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
1389+
if (v && typeof v === 'object' && Array.isArray((v as any).$in)) return (v as any).$in.includes(row[k]);
1390+
return row[k] === v;
1391+
});
1392+
const ql: any = {
1393+
tables,
1394+
async find(object: string, opts: any) {
1395+
const rows = (tables[object] ?? []).filter((r) => matches(r, opts?.where));
1396+
return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows;
1397+
},
1398+
async findOne(object: string, opts: any) {
1399+
return (tables[object] ?? []).find((r) => matches(r, opts?.where)) ?? null;
1400+
},
1401+
};
1402+
const scope = (allow: string[]) => ({ businessUnit: 'east', includeSubtree: true, manageAssignments: true, assignablePermissionSets: allow });
1403+
const WIDE_ALLOW = ['field_user', 'rep_pos', 'lead_pos'];
1404+
bindTestSecurityCatalog(ql, {
1405+
permissions: [
1406+
{ name: 'field_user', objects: { crm_account: { allowRead: true } } },
1407+
{ name: 'rep_pos', objects: { crm_account: { allowRead: true, allowEdit: true } } },
1408+
{ name: 'lead_pos', objects: {}, adminScope: scope(WIDE_ALLOW) },
1409+
{ name: 'duty_pos', objects: {}, adminScope: scope(['field_user']) },
1410+
],
1411+
positions: [
1412+
{ name: 'rep_pos', permissionSets: ['field_user'] },
1413+
{ name: 'lead_pos', permissionSets: ['field_user'] },
1414+
{ name: 'clerk_pos', permissionSets: ['field_user'] },
1415+
{ name: 'duty_pos', permissionSets: ['field_user'], delegatable: true },
1416+
{ name: 'duty_plain', permissionSets: ['field_user'], delegatable: true },
1417+
],
1418+
});
1419+
const SETS: Record<string, any[]> = {
1420+
// Allowlists the sets the positions LIST, not their own-name sets.
1421+
narrow: [{ name: 'east_admin', objects: {}, adminScope: scope(['field_user']) }],
1422+
// Also allowlists the own-name sets.
1423+
wide: [{ name: 'east_admin_wide', objects: {}, adminScope: scope(WIDE_ALLOW) }],
1424+
member: [{ name: 'member_default', objects: {} }],
1425+
};
1426+
const gate = new DelegatedAdminGate({
1427+
ql,
1428+
resolveSets: async (c: any) => SETS[c?.principal ?? ''] ?? [],
1429+
now: () => T0,
1430+
});
1431+
const ctxOf = (principal: string) => ({ principal, userId: `u_${principal}`, positions: [] });
1432+
const assign = (principal: string, position: string) => gate.assert({
1433+
object: 'sys_user_position', operation: 'insert', context: ctxOf(principal),
1434+
data: { user_id: 'u_east_1', position, business_unit_id: 'bu_es' },
1435+
});
1436+
const offered = async (principal: string) =>
1437+
(await gate.describeDelegableScope(SETS[principal] as any, ctxOf(principal))).assignablePositions.sort();
1438+
return { gate, tables, ctxOf, assign, offered };
1439+
}
1440+
1441+
describe("DelegatedAdminGate — a delegate is judged on the permission set of the position's own name (ADR-0090 D12 as amended 2026-10-17)", () => {
1442+
let n: ReturnType<typeof makeOwnNameHarness>;
1443+
beforeEach(() => { n = makeOwnNameHarness(); });
1444+
1445+
it("refuses an assignment whose own-name set is outside the allowlist — 403 PERMISSION_DENIED, naming the set", async () => {
1446+
const err = await deniedOf(() => n.assign('narrow', 'rep_pos'));
1447+
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/);
1448+
expect(err.details).toMatchObject({ position: 'rep_pos' });
1449+
});
1450+
1451+
it('CONTROL: admits the same assignment for a delegate whose allowlist covers the own-name set too', async () => {
1452+
const row: any = { user_id: 'u_east_1', position: 'rep_pos', business_unit_id: 'bu_es' };
1453+
await expect(n.gate.assert({ object: 'sys_user_position', operation: 'insert', data: row, context: n.ctxOf('wide') }))
1454+
.resolves.toBeUndefined();
1455+
expect(row.granted_by).toBe('u_wide');
1456+
});
1457+
1458+
it('a position with no own-name set in the catalog is judged on its listed sets alone, as before', async () => {
1459+
await expect(n.assign('narrow', 'clerk_pos')).resolves.toBeUndefined();
1460+
});
1461+
1462+
it('removing an assignment is judged on the same sets: the narrow delegate may not evict one granting a set outside its allowlist', async () => {
1463+
const err = await deniedOf(() => n.gate.assert({
1464+
object: 'sys_user_position', operation: 'delete', options: { where: { id: 'held_rep' } }, context: n.ctxOf('narrow'),
1465+
}));
1466+
expect(err.message).toMatch(/permission set 'rep_pos'.*not in the scope's allowlist/);
1467+
await expect(n.gate.assert({
1468+
object: 'sys_user_position', operation: 'delete', options: { where: { id: 'held_rep' } }, context: n.ctxOf('wide'),
1469+
})).resolves.toBeUndefined();
1470+
});
1471+
1472+
it('an own-name set carrying an adminScope is held to strict containment, allowlisted or not', async () => {
1473+
const err = await deniedOf(() => n.assign('wide', 'lead_pos'));
1474+
expect(err.message).toMatch(/granting set 'lead_pos' would hand out an adminScope not strictly contained by yours/);
1475+
});
1476+
1477+
it('the delegable-scope listing offers exactly the positions the write admits', async () => {
1478+
expect(await n.offered('narrow')).toEqual(['clerk_pos', 'duty_plain']);
1479+
expect(await n.offered('wide')).toEqual(['clerk_pos', 'duty_plain', 'rep_pos']);
1480+
for (const principal of ['narrow', 'wide']) {
1481+
const offered = await n.offered(principal);
1482+
for (const position of ['rep_pos', 'lead_pos', 'clerk_pos', 'duty_pos', 'duty_plain']) {
1483+
const admitted = await n.assign(principal, position).then(() => true, () => false);
1484+
expect(admitted, `${principal} · ${position}`).toBe(offered.includes(position));
1485+
}
1486+
}
1487+
});
1488+
1489+
it('a delegatable position whose own-name set carries an adminScope cannot be self-delegated', async () => {
1490+
const delegation = (position: string) => n.gate.assert({
1491+
object: 'sys_user_position', operation: 'insert', context: { userId: 'u_boss', positions: [], principal: 'member' },
1492+
data: { user_id: 'u_deleg', position, delegated_from: 'u_boss', valid_until: iso(T0 + 5 * DAY), reason: 'cover' },
1493+
});
1494+
const err = await deniedOf(() => delegation('duty_pos'));
1495+
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/);
1496+
expect(err.details).toMatchObject({ position: 'duty_pos', permissionSet: 'duty_pos' });
1497+
// CONTROL: the same holder delegates a delegatable position with no such set.
1498+
await expect(delegation('duty_plain')).resolves.toBeUndefined();
1499+
});
1500+
});

‎packages/plugins/plugin-security/src/delegated-admin-gate.ts‎

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
* what one of their scopes fully approves: the right kind of action
2626
* (manageAssignments), inside their BU subtree, handing out only
2727
* allowlisted sets, with STRICT containment whenever the granted set itself
28-
* carries an adminScope. The rows it governs are the two assignment tables
28+
* carries an adminScope. A position assignment hands out every set it
29+
* grants — the definition's list and the set of the position's own name. The rows it governs are the two assignment tables
2930
* (`sys_user_position`, `sys_user_permission_set`); the binding and set
3031
* authoring rows retired with the catalog row objects (ADR-0131 D3), so a
3132
* position's sets and a set's definition are written through the metadata
@@ -261,6 +262,17 @@ interface BoundSet {
261262
adminScope?: unknown;
262263
}
263264

265+
/**
266+
* What a refusal adds when the set it names is the one of the position's own
267+
* name: the position's definition need not list it, so the reader is told why
268+
* it was judged at all.
269+
*/
270+
function ownNameNote(positionName: string, setName: string): string {
271+
return setName === positionName
272+
? " (the permission set of the position's own name, which an assignment of the position also grants)"
273+
: '';
274+
}
275+
264276
function rowsOf(opCtx: any): any[] {
265277
const d = opCtx?.data;
266278
if (Array.isArray(d)) return d;
@@ -598,8 +610,8 @@ export class DelegatedAdminGate {
598610
const placeableBusinessUnitIds = [...new Set(placing.flatMap((h) => [...h.subtree]))];
599611

600612
// A position is assignable when at least ONE placing scope allowlists
601-
// every set it distributes — exactly `assertAssignmentWrite`'s test,
602-
// containment check included.
613+
// every set it distributes — the set of its own name included — exactly
614+
// `assertAssignmentWrite`'s test, containment check included.
603615
const assignablePositions: string[] = [];
604616
if (placing.length > 0) {
605617
for (const positionName of await allPositions()) {
@@ -792,6 +804,9 @@ export class DelegatedAdminGate {
792804
* the set of the position's own name, when the catalog holds one. `null`
793805
* when the catalog does not hold the position. An unreadable catalog throws
794806
* (`AuthzStoreUnavailableError`) and the write is refused with it.
807+
*
808+
* The one answer to "which sets does this position carry" for both branches:
809+
* the delegated walk ({@link setsBoundToPosition}) reads it too.
795810
*/
796811
private async setsCarriedByPosition(positionName: string): Promise<string[] | null> {
797812
const catalog = this.catalog();
@@ -1006,11 +1021,12 @@ export class DelegatedAdminGate {
10061021
deny(`position '${positionName}' is not delegatable — set delegatable: true on the position to allow it`, { position: positionName });
10071022
}
10081023

1009-
// 6. A delegatable position must not distribute administration.
1024+
// 6. A delegatable position must not distribute administration — through
1025+
// any set an assignment of it grants, the own-name set included.
10101026
const boundSets = await this.setsBoundToPosition(positionName);
10111027
for (const b of boundSets) {
10121028
if (parseMaybeJson((b as any).admin_scope ?? (b as any).adminScope)) {
1013-
deny(`position '${positionName}' distributes the admin set '${b.name}' — administration cannot be self-delegated (D12 containment)`, { position: positionName, permissionSet: b.name });
1029+
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 });
10141030
}
10151031
}
10161032
}
@@ -1100,10 +1116,11 @@ export class DelegatedAdminGate {
11001116
if (!prevBu) return 'the existing assignment is unanchored (no business_unit_id) — only a tenant admin may modify it';
11011117
if (!s.subtree.has(String(prevBu))) return `the existing assignment's business unit '${prevBu}' is outside the delegated subtree`;
11021118
}
1103-
// Every set the position distributes must be allowlisted.
1119+
// Every set the position distributes must be allowlisted — the set of
1120+
// the position's own name included ({@link setsBoundToPosition}).
11041121
for (const bound of boundSets) {
11051122
if (!s.scope.assignablePermissionSets.includes(bound.name)) {
1106-
return `position '${positionName}' distributes permission set '${bound.name}', which is not in the scope's allowlist`;
1123+
return `position '${positionName}' distributes permission set '${bound.name}'${ownNameNote(positionName, bound.name)}, which is not in the scope's allowlist`;
11071124
}
11081125
const contained = this.assertScopeGrantContainment(bound, held, /*dryRun*/ true);
11091126
if (contained) return contained;
@@ -1387,14 +1404,21 @@ export class DelegatedAdminGate {
13871404
}
13881405

13891406
/**
1390-
* [ADR-0131 D3/D4] The permission sets a position distributes: the names its
1391-
* DEFINITION carries (`PositionSchema.permissionSets`), each with the
1392-
* `adminScope` of the catalog's definition of that name — the binding the
1393-
* authorization resolver grants through, read from the same security
1407+
* [ADR-0131 D3/D4, ADR-0090 D12 as amended 2026-10-17] The permission sets a
1408+
* position distributes, as the delegated walk judges them: every set an
1409+
* assignment of it grants — {@link setsCarriedByPosition}, the one answer the
1410+
* organization-level branch reads too: the names its DEFINITION carries
1411+
* (`PositionSchema.permissionSets`) and, while the resolver folds held
1412+
* position names into the requested set names, the permission set of the
1413+
* position's own name when the catalog holds one — each with the
1414+
* `adminScope` of the catalog's definition of that name. That is the binding
1415+
* the authorization resolver grants through, read from the same security
13941416
* catalog, never from a `sys_position_permission_set` junction row (which
1395-
* grants nothing since the resolver stopped reading it). So the D12
1396-
* containment and the assignable-positions listing judge exactly what an
1397-
* assignment will grant.
1417+
* grants nothing since the resolver stopped reading it). So the allowlist
1418+
* check, the D12 strict containment, the self-delegation rule against
1419+
* distributing administration and the assignable-positions listing all judge
1420+
* exactly what an assignment will grant — a set granted by the position's
1421+
* name alone included.
13981422
*
13991423
* A name the definition carries that the catalog does not hold is still
14001424
* returned, with no `adminScope`: it grants nothing today, but the binding is
@@ -1409,12 +1433,9 @@ export class DelegatedAdminGate {
14091433
private async setsBoundToPosition(positionName: string): Promise<BoundSet[]> {
14101434
const catalog = this.catalog();
14111435
if (!catalog || !positionName) return [];
1412-
const position = await catalog.resolve('position', positionName);
1413-
const names = Array.isArray(position?.definition.permissionSets)
1414-
? (position.definition.permissionSets as unknown[]).filter((n): n is string => typeof n === 'string' && n !== '')
1415-
: [];
1436+
const names = (await this.setsCarriedByPosition(positionName)) ?? [];
14161437
const out: BoundSet[] = [];
1417-
for (const name of new Set(names)) {
1438+
for (const name of names) {
14181439
const set = await catalog.resolve('permission', name);
14191440
out.push({ name, ...(set?.definition.adminScope ? { adminScope: set.definition.adminScope } : {}) });
14201441
}

0 commit comments

Comments
 (0)