From 04bd900c661ccbde3e3921a770df703903379996 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 13:56:24 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(authz):=20expose=20the=20caller's=20de?= =?UTF-8?q?legable=20scope=20=E2=80=94=20the=20read=20half=20of=20the=20de?= =?UTF-8?q?legated-admin=20gate=20(ADR-0090=20D12=20/=20ADR-0105=20D8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adminScope decided writes but could not be READ: assignablePermissionSets lived only inside delegated-admin-gate.ts, so a UI offering 'place this person in a unit, with these positions' (the D8 scoped-invitation form) had no way to narrow its pickers — it would list the whole tree and let the user discover the boundary by being refused, turning an authorization gate into a validator. ISecurityService.describeDelegableScope(callerContext), exposed as GET /api/v1/security/my-delegable-scope and client.security.describeDelegableScope(): - placeableBusinessUnitIds — subtrees where the caller may place people - assignablePositions — positions whose every distributed set is allowlisted (containment check included) - scopes — held adminScopes with subtrees resolved, for attribution - isTenantAdmin — unconstrained, everything enumerated so consumers render ONE uniform picker Computed by the same helpers the write path enforces with, so an option this reports is one assert() accepts; a test asserts that agreement directly by replaying every offered (unit, position) pair through the gate. It NARROWS — the gate still decides. Strictly self-scoped (no target-user parameter), so it discloses nothing beyond the authority the caller already holds. Fail closed: unresolvable scopes contribute nothing, no delegated authority yields empty lists, and a deployment without plugin-security gets 501. Verified: plugin-security 606, rest 401, client 175, spec 6677 — all green; route-ledger conformance entry added (#3587); five grep guards + spec check:docs clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL --- .changeset/delegable-scope-read-surface.md | 37 ++++++ packages/client/src/index.ts | 18 +++ .../src/delegated-admin-gate.test.ts | 71 ++++++++++ .../src/delegated-admin-gate.ts | 124 ++++++++++++++++++ .../plugin-security/src/security-plugin.ts | 20 +++ packages/rest/src/rest-route-ledger.ts | 4 + packages/rest/src/rest-server.ts | 55 ++++++++ .../spec/src/contracts/security-service.ts | 54 ++++++++ 8 files changed, 383 insertions(+) create mode 100644 .changeset/delegable-scope-read-surface.md diff --git a/.changeset/delegable-scope-read-surface.md b/.changeset/delegable-scope-read-surface.md new file mode 100644 index 0000000000..e16a21f11a --- /dev/null +++ b/.changeset/delegable-scope-read-surface.md @@ -0,0 +1,37 @@ +--- +"@objectstack/spec": minor +"@objectstack/plugin-security": minor +"@objectstack/rest": minor +"@objectstack/client": minor +--- + +feat(authz): expose the caller's delegable scope — the read half of the +delegated-administration gate (ADR-0090 D12 / ADR-0105 D8) + +`adminScope` decided writes but could not be READ: `assignablePermissionSets` +lived only inside `delegated-admin-gate.ts`, so a UI offering "place this +person in a unit, with these positions" (the D8 scoped-invitation form) had no +way to narrow its pickers. It would list the whole tree and let the user +discover the boundary by being refused — which turns an authorization gate into +a validator and makes the boundary invisible until it bites. + +`ISecurityService.describeDelegableScope(callerContext)` answers it, exposed as +`GET /api/v1/security/my-delegable-scope` and `client.security.describeDelegableScope()`: + +- `placeableBusinessUnitIds` — union of the subtrees where the caller may place + people (scopes granting `manageAssignments`); +- `assignablePositions` — positions whose every distributed permission set the + caller may hand out (containment check included); +- `scopes` — the held `adminScope`s with subtrees resolved, for attribution; +- `isTenantAdmin` — unconstrained, with everything enumerated so a consumer + renders ONE uniform picker instead of special-casing. + +Computed by the same helpers the write gate enforces with, so an option this +reports is one `assert()` accepts — a test asserts that agreement directly. It +NARROWS; the gate still decides. + +Strictly self-scoped: no target-user parameter, so it discloses nothing beyond +the authority the caller already holds (unlike `explain`, which has one and +gates it). Fail-closed — unresolvable scopes contribute nothing, a caller with +no delegated authority gets empty lists, and a deployment without +`@objectstack/plugin-security` gets 501. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index a193d27d9b..f25c0e17fd 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -2936,6 +2936,24 @@ export class ObjectStackClient { return this.unwrapResponse(res); }, + /** + * ADR-0090 D12 / ADR-0105 D8 — what the CALLER may delegate: the + * business units they may place people into (`placeableBusinessUnitIds`) + * and the positions they may assign (`assignablePositions`), plus the + * `adminScope`s those derive from. + * + * Shaped for a picker: a scoped-invitation form narrows its options with + * this rather than offering the whole tree and letting the user find the + * boundary by being refused. It NARROWS — the server-side gate still + * decides. Strictly self-scoped (no target-user parameter), so it + * discloses nothing beyond the caller's own authority; a tenant admin + * comes back `isTenantAdmin: true` with everything enumerated. + */ + describeDelegableScope: async (): Promise => { + const res = await this.fetch(`${this.baseUrl}/api/v1/security/my-delegable-scope`); + return this.unwrapResponse(res); + }, + suggestedBindings: { /** List suggestions, optionally by `status` / `packageId` (reconciles first). */ list: async (opts?: { status?: string; packageId?: string }): Promise => { 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 b93124b0d6..0bcab670c3 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts @@ -607,3 +607,74 @@ describe('DelegatedAdminGate — self-delegation anchor containment (cloud#830 f .rejects.toThrow(/only via delegation/); }); }); + +// ── [ADR-0090 D12 / ADR-0105 D8] describeDelegableScope — the READ half ────── +// +// A picker narrows with this; the gate still decides. So the property that +// matters is AGREEMENT: what the report offers is what `assert()` accepts. +describe('DelegatedAdminGate — describeDelegableScope', () => { + it('a delegate gets their own subtree and only the positions they may hand out', async () => { + const report = await h.gate.describeDelegableScope( + await (h.gate as any).deps.resolveSets({ principal: 'delegate' }), + ); + + expect(report.isTenantAdmin).toBe(false); + // east + its descendant east_sales — never hq or the west sibling. + expect([...report.placeableBusinessUnitIds].sort()).toEqual(['bu_east', 'bu_es']); + // `sales_rep` distributes sales_user (allowlisted). `mixed_pos` also + // distributes finance_admin, which is NOT — so it is withheld, exactly as + // assertAssignmentWrite would refuse it. + expect(report.assignablePositions).toEqual(['sales_rep']); + // The scope itself is reported for attribution in a UI. + expect(report.scopes).toHaveLength(1); + expect(report.scopes[0]).toMatchObject({ + setName: 'sub_admin', + businessUnit: 'east', + manageAssignments: true, + assignablePermissionSets: ['sales_user', 'sub_admin'], + }); + }); + + it('agrees with the gate: every offered (unit, position) pair is one assert() accepts', async () => { + const report = await h.gate.describeDelegableScope( + await (h.gate as any).deps.resolveSets({ principal: 'delegate' }), + ); + for (const business_unit_id of report.placeableBusinessUnitIds) { + for (const position of report.assignablePositions) { + await expect( + insertAssignment(h.ctxOf('delegate'), { user_id: 'u_new', position, business_unit_id }), + ).resolves.toBeUndefined(); + } + } + // …and a withheld position really is refused, so the narrowing is not cosmetic. + await expect( + insertAssignment(h.ctxOf('delegate'), { + user_id: 'u_new', position: 'mixed_pos', business_unit_id: 'bu_east', + }), + ).rejects.toThrow(/not in the scope's allowlist/); + }); + + it('a tenant admin is unconstrained — flagged, and everything enumerated for one uniform picker', async () => { + const report = await h.gate.describeDelegableScope( + await (h.gate as any).deps.resolveSets({ principal: 'tenant_admin' }), + ); + expect(report.isTenantAdmin).toBe(true); + expect([...report.placeableBusinessUnitIds].sort()).toEqual(['bu_east', 'bu_es', 'bu_hq', 'bu_west']); + // Audience anchors are implicit and can never be assigned (ADR-0090 D9), + // so they stay out of a picker even for a tenant admin. + expect(report.assignablePositions).not.toContain('everyone'); + expect([...report.assignablePositions].sort()).toEqual(['mixed_pos', 'sales_rep']); + }); + + it('plain CRUD on the RBAC tables delegates nothing (fail closed)', async () => { + const report = await h.gate.describeDelegableScope( + await (h.gate as any).deps.resolveSets({ principal: 'crud_only' }), + ); + expect(report).toMatchObject({ + isTenantAdmin: false, + scopes: [], + placeableBusinessUnitIds: [], + assignablePositions: [], + }); + }); +}); diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index 2e005a8cc3..49d093844b 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -73,6 +73,31 @@ export interface DelegatedAdminGateDeps { delegationCeilingMs?: number; } +/** + * [ADR-0090 D12 / ADR-0105 D8] What the caller may delegate — the read half + * of the gate, shaped for a picker (see + * {@link DelegatedAdminGate.describeDelegableScope}). + */ +export interface DelegableScopeReport { + /** ADR-0066 superuser wildcard: unconstrained. The lists below then enumerate everything. */ + isTenantAdmin: boolean; + /** Every `adminScope` the caller holds, with its subtree resolved to ids. */ + scopes: Array<{ + setName: string; + businessUnit: string; + includeSubtree: boolean; + manageAssignments: boolean; + manageBindings: boolean; + authorEnvironmentSets: boolean; + assignablePermissionSets: string[]; + businessUnitIds: string[]; + }>; + /** Union of the subtrees where the caller may PLACE people (`manageAssignments`). */ + placeableBusinessUnitIds: string[]; + /** Positions the caller may assign — every set they distribute is allowlisted. */ + assignablePositions: string[]; +} + interface HeldScope { /** The set that carries the scope (for error messages). */ setName: string; @@ -232,6 +257,105 @@ export class DelegatedAdminGate { return false; } + /** + * [ADR-0090 D12 / ADR-0105 D8] Describe what the caller may DELEGATE — + * the read half of this gate. + * + * A UI that offers "place this person in a unit, with these positions" + * (the D8 scoped-invitation form) must narrow its options to what the + * issuer could actually assign; otherwise every picker lists the whole + * tree and the user discovers the boundary only by being refused. The + * lists here are computed by the SAME `resolveHeldScopes` / + * `setsBoundToPosition` / containment helpers the write path enforces + * with, so an option this report offers is one `assert()` accepts — the + * picker narrows, it does not decide. + * + * Self-scoped by construction: the caller's own resolved sets are the + * only input, so reading it discloses nothing beyond the authority they + * already hold. Fail-closed shape: unresolvable scopes contribute + * nothing, and a caller with no delegated authority gets empty lists. + * + * A tenant-level admin (ADR-0066 superuser wildcard) is unconstrained; + * the report says so AND enumerates everything, so a consumer can render + * one uniform picker instead of special-casing. + */ + async describeDelegableScope(sets: PermissionSet[]): Promise { + const ql = this.deps.ql; + const allPositions = async (): Promise => { + if (!ql?.find) return []; + try { + const rows = await ql.find('sys_position', { limit: 1000, context: SYSTEM_CTX }); + return (Array.isArray(rows) ? rows : []) + .map((r: any) => String(r?.name ?? '')) + .filter((n) => n && !ANCHOR_POSITIONS.has(n)); + } catch { + return []; + } + }; + + if (isTenantAdmin(sets)) { + let businessUnitIds: string[] = []; + if (ql?.find) { + try { + const rows = await ql.find('sys_business_unit', { limit: 5000, context: SYSTEM_CTX }); + businessUnitIds = (Array.isArray(rows) ? rows : []) + .map((r: any) => String(r?.id ?? '')) + .filter(Boolean); + } catch { + businessUnitIds = []; + } + } + return { + isTenantAdmin: true, + scopes: [], + placeableBusinessUnitIds: businessUnitIds, + assignablePositions: await allPositions(), + }; + } + + const held = await this.resolveHeldScopes(sets); + const scopes = held.map((h) => ({ + setName: h.setName, + businessUnit: h.scope.businessUnit, + includeSubtree: h.scope.includeSubtree, + manageAssignments: h.scope.manageAssignments, + manageBindings: h.scope.manageBindings, + authorEnvironmentSets: h.scope.authorEnvironmentSets, + assignablePermissionSets: [...h.scope.assignablePermissionSets], + businessUnitIds: [...h.subtree], + })); + + // Placement needs `manageAssignments` — a bindings-only or authoring-only + // scope administers capability without placing people. + const placing = held.filter((h) => h.scope.manageAssignments); + 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. + const assignablePositions: string[] = []; + if (placing.length > 0) { + for (const positionName of await allPositions()) { + const boundSets = await this.setsBoundToPosition(positionName); + const ok = placing.some((s) => + boundSets.every( + (bound) => + s.scope.assignablePermissionSets.includes(bound.name) && + this.assertScopeGrantContainment(bound, held, /*dryRun*/ true) === null, + ), + ); + if (ok) assignablePositions.push(positionName); + } + } + + return { + isTenantAdmin: false, + scopes, + placeableBusinessUnitIds, + assignablePositions, + }; + } + // ── [ADR-0091 D3] Self-service delegation of duty ──────────────────── /** A delegation write = a `sys_user_position` INSERT whose rows stamp diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 133a40d1e1..02e6b00320 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -661,6 +661,26 @@ export class SecurityPlugin implements Plugin { { ...request, operation: String(request.operation) }, callerContext, ), + // [ADR-0090 D12 / ADR-0105 D8] What the CALLER may delegate — the read + // half of the delegated-admin gate. A scoped-invitation form narrows + // its unit/position pickers with this instead of listing the whole + // tree and letting the user discover the boundary by refusal. Purely + // self-scoped (the caller's own resolved sets are the only input), so + // it discloses nothing beyond the authority they already hold. + describeDelegableScope: async (callerContext?: any) => { + const sets = await this.resolvePermissionSetsForContext(callerContext); + // No gate wired (degraded start) → no delegable authority, and the + // consumer renders an empty picker rather than a permissive one. + if (!this.delegatedAdminGate) { + return { + isTenantAdmin: false, + scopes: [], + placeableBusinessUnitIds: [], + assignablePositions: [], + }; + } + return this.delegatedAdminGate.describeDelegableScope(sets); + }, // [ADR-0090 D5/D9] Install-time suggestion surface: packages suggest // audience-anchor bindings; a tenant admin confirms (the binding is // written under the anchor + delegated-admin gates) or dismisses. diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 3c18c39ac2..3d20e5ecc3 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -150,6 +150,10 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ note: 'query transport of the same ExplainRequestSchema contract; the SDK speaks the POST form' }, { route: 'POST /api/v1/security/explain', family: 'security-explain', source: 'route-manager', disposition: 'sdk', client: 'security.explain' }, + // ── delegable scope (ADR-0090 D12 / ADR-0105 D8) ────────────────────────── + { route: 'GET /api/v1/security/my-delegable-scope', family: 'security-explain', source: 'route-manager', disposition: 'sdk', client: 'security.describeDelegableScope', + note: "read half of the delegated-admin gate; strictly self-scoped (no target-user parameter), so a scoped-invitation picker can narrow to what the caller may actually delegate" }, + // ── per-record shares ───────────────────────────────────────────────────── { route: 'GET /api/v1/data/:object/:id/shares', family: 'record-shares', source: 'route-manager', disposition: 'sdk', client: 'shares.list' }, { route: 'POST /api/v1/data/:object/:id/shares', family: 'record-shares', source: 'route-manager', disposition: 'sdk', client: 'shares.grant' }, diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 73b4e75a20..a059b919ad 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -5418,6 +5418,61 @@ export class RestServer { handler, metadata: { summary: 'Explain why a principal can (or cannot) perform an operation on an object (ADR-0090 D6)', tags: ['security'] }, }); + + /** + * [ADR-0090 D12 / ADR-0105 D8] What the CALLER may delegate. + * + * GET {basePath}/security/my-delegable-scope + * + * The read half of the delegated-admin gate, shaped for a picker: the + * business units the caller may place people into and the positions + * they may assign. A scoped-invitation form narrows its options with + * this instead of listing the whole tree and letting the user find the + * boundary by being refused. + * + * Strictly SELF-scoped — no `userId` parameter, by design. The caller's + * own resolved sets are the only input, so it discloses nothing beyond + * the authority they already hold, and there is no "describe someone + * else's authority" surface to authorize (unlike `explain`, which has + * one and gates it). An authenticated caller is still required. + */ + const delegableHandler = async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + if (!context?.userId) { + return res.status(401).json({ + code: 'UNAUTHORIZED', + message: 'The delegable-scope endpoint requires an authenticated caller.', + }); + } + + const svc = await resolveService(environmentId, req); + if (!svc || typeof svc.describeDelegableScope !== 'function') { + return res.status(501).json({ + code: 'NOT_IMPLEMENTED', + message: 'Delegated administration is not available on this deployment (no security service with describeDelegableScope).', + }); + } + + res.json(await svc.describeDelegableScope(context)); + } catch (error: any) { + const msg = String(error?.message ?? error ?? ''); + logError('[REST] Delegable scope error:', error); + res.status(500).json({ code: 'DELEGABLE_SCOPE_FAILED', error: msg.slice(0, 500) }); + } + }; + + this.routeManager.register({ + method: 'GET', + path: `${basePath}/security/my-delegable-scope`, + handler: delegableHandler, + metadata: { + summary: "The caller's delegable scope: business units they may place into and positions they may assign (ADR-0090 D12 / ADR-0105 D8)", + tags: ['security'], + }, + }); } private registerSharingEndpoints(basePath: string): void { diff --git a/packages/spec/src/contracts/security-service.ts b/packages/spec/src/contracts/security-service.ts index f465f5404d..21a2c90095 100644 --- a/packages/spec/src/contracts/security-service.ts +++ b/packages/spec/src/contracts/security-service.ts @@ -63,6 +63,41 @@ import type { ExplainDecision, ExplainOperation } from '../security/explain.zod. */ export type SecurityContext = Partial; +/** + * [ADR-0090 D12 / ADR-0105 D8] One `adminScope` the caller holds, with its + * business-unit subtree resolved to ids. + */ +export interface DelegableAdminScope { + /** Permission set carrying the scope (for attribution in a UI). */ + setName: string; + /** `sys_business_unit.name` of the delegation root. */ + businessUnit: string; + includeSubtree: boolean; + manageAssignments: boolean; + manageBindings: boolean; + authorEnvironmentSets: boolean; + /** Permission-set names the delegate may hand out. */ + assignablePermissionSets: string[]; + /** Resolved subtree — root plus descendants when `includeSubtree`. */ + businessUnitIds: string[]; +} + +/** Return shape of {@link ISecurityService.describeDelegableScope}. */ +export interface DelegableScope { + /** + * ADR-0066 superuser wildcard: the caller is unconstrained. The lists below + * then enumerate everything, so a consumer renders ONE uniform picker + * instead of special-casing tenant admins. + */ + isTenantAdmin: boolean; + /** Every held `adminScope`, resolved. Empty for a tenant admin (nothing narrows them). */ + scopes: DelegableAdminScope[]; + /** Union of subtrees where the caller may PLACE people (`manageAssignments`). */ + placeableBusinessUnitIds: string[]; + /** Positions the caller may assign — every set they distribute is allowlisted. */ + assignablePositions: string[]; +} + /** Selector for {@link ISecurityService.explain}. */ export interface ExplainAccessRequest { /** Object API name to explain access for. */ @@ -167,6 +202,25 @@ export interface ISecurityService { */ explain(request: ExplainAccessRequest, callerContext?: SecurityContext): Promise; + /** + * [ADR-0090 D12 / ADR-0105 D8] Describe what the CALLER may delegate: the + * business units they may place people into and the positions they may + * assign, derived from the `adminScope`s their permission sets carry. + * + * The read half of the delegated-administration gate, and shaped for a + * picker: a scoped-invitation form narrows its options with this instead of + * offering the whole tree and letting the user discover the boundary by + * being refused. Computed by the same helpers the write gate enforces with, + * so an option this reports is one the gate accepts — it NARROWS, it does + * not decide. + * + * Strictly self-scoped: there is no target-user parameter, so it discloses + * nothing beyond the authority the caller already holds. Fail-closed — + * unresolvable scopes contribute nothing, and no delegated authority yields + * empty lists. + */ + describeDelegableScope(callerContext?: SecurityContext): Promise; + /** * List install-time audience-binding suggestions (packages propose an * audience anchor; a tenant admin confirms or dismisses). Re-syncs before From 0b6f02de52b0237ec96f578a24d79ff91fff563b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 13:58:33 +0000 Subject: [PATCH 2/3] docs(permissions): document the delegable-scope read surface on the delegated-administration page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page described what the write gate enforces but had no answer to 'what may I delegate?' — the question an admin surface must ask to narrow its pickers. Adds the endpoint, the SDK call, the response shape, and the three properties that matter: it narrows but does not decide, a position appears only if every set it distributes is allowlisted, and it is strictly self-scoped. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL --- .../permissions/delegated-administration.mdx | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/content/docs/permissions/delegated-administration.mdx b/content/docs/permissions/delegated-administration.mdx index 7c9b8608f2..2b684ec9a4 100644 --- a/content/docs/permissions/delegated-administration.mdx +++ b/content/docs/permissions/delegated-administration.mdx @@ -92,6 +92,50 @@ The [explain engine](/docs/permissions/explain) closes the loop twice: - contributor attribution plus the `granted_by` stamp answer both *who granted this* and *who could have*. +## Reading your own scope + +The gate also answers the *read* question — **what may I delegate?** — so an +admin surface can narrow its pickers instead of offering the whole tree and +letting the user discover the boundary by being refused: + +```bash +GET /api/v1/security/my-delegable-scope +``` + +```ts +const scope = await client.security.describeDelegableScope(); +``` + +```jsonc +{ + "isTenantAdmin": false, + "placeableBusinessUnitIds": ["bu_east", "bu_east_sales"], // where you may place people + "assignablePositions": ["sales_rep"], // positions you may hand out + "scopes": [ // the scopes those derive from + { "setName": "sub_admin", "businessUnit": "east", "manageAssignments": true, + "assignablePermissionSets": ["sales_user"], "businessUnitIds": ["bu_east", "bu_east_sales"] } + ] +} +``` + +Three properties worth knowing: + +- **It narrows; it does not decide.** The lists are computed by the same + helpers the write gate enforces with, so an option here is one the gate + accepts — but the gate still runs on the write. A UI that skipped it would + be refused, not obeyed. +- **A position appears only if you may hand out *every* set it distributes.** + `mixed_pos` bundling an allowlisted set with a non-allowlisted one is + withheld, exactly as the write gate would refuse it. +- **Strictly self-scoped** — there is no target-user parameter, so it + discloses nothing beyond the authority you already hold. A tenant-level + admin comes back `isTenantAdmin: true` with everything enumerated, so + consumers render one uniform picker rather than special-casing. + +Scoped invitations (ADR-0105 D8) are the first consumer: the invitation form +narrows its unit/position pickers with this, and the invitation's placement +intent is then authorized against the *issuer's* scope at issuance time. + ## See also - [Permission Sets](/docs/permissions/permission-sets) — the container `adminScope` rides on From 486fa4e9cd78a6baf8cecba3f9dcb70d767a58f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 14:05:59 +0000 Subject: [PATCH 3/3] chore(spec): regenerate the api-surface snapshot for the delegable-scope types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DelegableScope + DelegableAdminScope are additive (0 breaking, 2 added) — the drift guard just needs the refreshed snapshot committed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL --- packages/spec/api-surface.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 0ae6c1581f..32d00c6a1c 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3460,6 +3460,8 @@ "DatasetCompareTo (interface)", "DatasetSelection (interface)", "DefineSharingRuleInput (interface)", + "DelegableAdminScope (interface)", + "DelegableScope (interface)", "DeployExecutionResult (interface)", "DriverCapabilities (interface)", "EMBEDDER_SERVICE (const)",