Skip to content

Commit 7c7e246

Browse files
os-zhuangclaude
andauthored
feat(authz): expose the caller's delegable scope — the read half of the delegated-admin gate (ADR-0090 D12 / ADR-0105 D8) (#3674)
* feat(authz): expose the caller's delegable scope — the read half of the delegated-admin 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, 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL * docs(permissions): document the delegable-scope read surface on the delegated-administration page 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL * chore(spec): regenerate the api-surface snapshot for the delegable-scope types DelegableScope + DelegableAdminScope are additive (0 breaking, 2 added) — the drift guard just needs the refreshed snapshot committed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015FebXPaaGrLhGKw1LHPbpL --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 97e9c30 commit 7c7e246

10 files changed

Lines changed: 429 additions & 0 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-security": minor
4+
"@objectstack/rest": minor
5+
"@objectstack/client": minor
6+
---
7+
8+
feat(authz): expose the caller's delegable scope — the read half of the
9+
delegated-administration gate (ADR-0090 D12 / ADR-0105 D8)
10+
11+
`adminScope` decided writes but could not be READ: `assignablePermissionSets`
12+
lived only inside `delegated-admin-gate.ts`, so a UI offering "place this
13+
person in a unit, with these positions" (the D8 scoped-invitation form) had no
14+
way to narrow its pickers. It would list the whole tree and let the user
15+
discover the boundary by being refused — which turns an authorization gate into
16+
a validator and makes the boundary invisible until it bites.
17+
18+
`ISecurityService.describeDelegableScope(callerContext)` answers it, exposed as
19+
`GET /api/v1/security/my-delegable-scope` and `client.security.describeDelegableScope()`:
20+
21+
- `placeableBusinessUnitIds` — union of the subtrees where the caller may place
22+
people (scopes granting `manageAssignments`);
23+
- `assignablePositions` — positions whose every distributed permission set the
24+
caller may hand out (containment check included);
25+
- `scopes` — the held `adminScope`s with subtrees resolved, for attribution;
26+
- `isTenantAdmin` — unconstrained, with everything enumerated so a consumer
27+
renders ONE uniform picker instead of special-casing.
28+
29+
Computed by the same helpers the write gate enforces with, so an option this
30+
reports is one `assert()` accepts — a test asserts that agreement directly. It
31+
NARROWS; the gate still decides.
32+
33+
Strictly self-scoped: no target-user parameter, so it discloses nothing beyond
34+
the authority the caller already holds (unlike `explain`, which has one and
35+
gates it). Fail-closed — unresolvable scopes contribute nothing, a caller with
36+
no delegated authority gets empty lists, and a deployment without
37+
`@objectstack/plugin-security` gets 501.

content/docs/permissions/delegated-administration.mdx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,50 @@ The [explain engine](/docs/permissions/explain) closes the loop twice:
9292
- contributor attribution plus the `granted_by` stamp answer both *who
9393
granted this* and *who could have*.
9494

95+
## Reading your own scope
96+
97+
The gate also answers the *read* question — **what may I delegate?** — so an
98+
admin surface can narrow its pickers instead of offering the whole tree and
99+
letting the user discover the boundary by being refused:
100+
101+
```bash
102+
GET /api/v1/security/my-delegable-scope
103+
```
104+
105+
```ts
106+
const scope = await client.security.describeDelegableScope();
107+
```
108+
109+
```jsonc
110+
{
111+
"isTenantAdmin": false,
112+
"placeableBusinessUnitIds": ["bu_east", "bu_east_sales"], // where you may place people
113+
"assignablePositions": ["sales_rep"], // positions you may hand out
114+
"scopes": [ // the scopes those derive from
115+
{ "setName": "sub_admin", "businessUnit": "east", "manageAssignments": true,
116+
"assignablePermissionSets": ["sales_user"], "businessUnitIds": ["bu_east", "bu_east_sales"] }
117+
]
118+
}
119+
```
120+
121+
Three properties worth knowing:
122+
123+
- **It narrows; it does not decide.** The lists are computed by the same
124+
helpers the write gate enforces with, so an option here is one the gate
125+
accepts — but the gate still runs on the write. A UI that skipped it would
126+
be refused, not obeyed.
127+
- **A position appears only if you may hand out *every* set it distributes.**
128+
`mixed_pos` bundling an allowlisted set with a non-allowlisted one is
129+
withheld, exactly as the write gate would refuse it.
130+
- **Strictly self-scoped** — there is no target-user parameter, so it
131+
discloses nothing beyond the authority you already hold. A tenant-level
132+
admin comes back `isTenantAdmin: true` with everything enumerated, so
133+
consumers render one uniform picker rather than special-casing.
134+
135+
Scoped invitations (ADR-0105 D8) are the first consumer: the invitation form
136+
narrows its unit/position pickers with this, and the invitation's placement
137+
intent is then authorized against the *issuer's* scope at issuance time.
138+
95139
## See also
96140

97141
- [Permission Sets](/docs/permissions/permission-sets) — the container `adminScope` rides on

packages/client/src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2936,6 +2936,24 @@ export class ObjectStackClient {
29362936
return this.unwrapResponse<any>(res);
29372937
},
29382938

2939+
/**
2940+
* ADR-0090 D12 / ADR-0105 D8 — what the CALLER may delegate: the
2941+
* business units they may place people into (`placeableBusinessUnitIds`)
2942+
* and the positions they may assign (`assignablePositions`), plus the
2943+
* `adminScope`s those derive from.
2944+
*
2945+
* Shaped for a picker: a scoped-invitation form narrows its options with
2946+
* this rather than offering the whole tree and letting the user find the
2947+
* boundary by being refused. It NARROWS — the server-side gate still
2948+
* decides. Strictly self-scoped (no target-user parameter), so it
2949+
* discloses nothing beyond the caller's own authority; a tenant admin
2950+
* comes back `isTenantAdmin: true` with everything enumerated.
2951+
*/
2952+
describeDelegableScope: async (): Promise<any> => {
2953+
const res = await this.fetch(`${this.baseUrl}/api/v1/security/my-delegable-scope`);
2954+
return this.unwrapResponse<any>(res);
2955+
},
2956+
29392957
suggestedBindings: {
29402958
/** List suggestions, optionally by `status` / `packageId` (reconciles first). */
29412959
list: async (opts?: { status?: string; packageId?: string }): Promise<any> => {

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,3 +607,74 @@ describe('DelegatedAdminGate — self-delegation anchor containment (cloud#830 f
607607
.rejects.toThrow(/only via delegation/);
608608
});
609609
});
610+
611+
// ── [ADR-0090 D12 / ADR-0105 D8] describeDelegableScope — the READ half ──────
612+
//
613+
// A picker narrows with this; the gate still decides. So the property that
614+
// matters is AGREEMENT: what the report offers is what `assert()` accepts.
615+
describe('DelegatedAdminGate — describeDelegableScope', () => {
616+
it('a delegate gets their own subtree and only the positions they may hand out', async () => {
617+
const report = await h.gate.describeDelegableScope(
618+
await (h.gate as any).deps.resolveSets({ principal: 'delegate' }),
619+
);
620+
621+
expect(report.isTenantAdmin).toBe(false);
622+
// east + its descendant east_sales — never hq or the west sibling.
623+
expect([...report.placeableBusinessUnitIds].sort()).toEqual(['bu_east', 'bu_es']);
624+
// `sales_rep` distributes sales_user (allowlisted). `mixed_pos` also
625+
// distributes finance_admin, which is NOT — so it is withheld, exactly as
626+
// assertAssignmentWrite would refuse it.
627+
expect(report.assignablePositions).toEqual(['sales_rep']);
628+
// The scope itself is reported for attribution in a UI.
629+
expect(report.scopes).toHaveLength(1);
630+
expect(report.scopes[0]).toMatchObject({
631+
setName: 'sub_admin',
632+
businessUnit: 'east',
633+
manageAssignments: true,
634+
assignablePermissionSets: ['sales_user', 'sub_admin'],
635+
});
636+
});
637+
638+
it('agrees with the gate: every offered (unit, position) pair is one assert() accepts', async () => {
639+
const report = await h.gate.describeDelegableScope(
640+
await (h.gate as any).deps.resolveSets({ principal: 'delegate' }),
641+
);
642+
for (const business_unit_id of report.placeableBusinessUnitIds) {
643+
for (const position of report.assignablePositions) {
644+
await expect(
645+
insertAssignment(h.ctxOf('delegate'), { user_id: 'u_new', position, business_unit_id }),
646+
).resolves.toBeUndefined();
647+
}
648+
}
649+
// …and a withheld position really is refused, so the narrowing is not cosmetic.
650+
await expect(
651+
insertAssignment(h.ctxOf('delegate'), {
652+
user_id: 'u_new', position: 'mixed_pos', business_unit_id: 'bu_east',
653+
}),
654+
).rejects.toThrow(/not in the scope's allowlist/);
655+
});
656+
657+
it('a tenant admin is unconstrained — flagged, and everything enumerated for one uniform picker', async () => {
658+
const report = await h.gate.describeDelegableScope(
659+
await (h.gate as any).deps.resolveSets({ principal: 'tenant_admin' }),
660+
);
661+
expect(report.isTenantAdmin).toBe(true);
662+
expect([...report.placeableBusinessUnitIds].sort()).toEqual(['bu_east', 'bu_es', 'bu_hq', 'bu_west']);
663+
// Audience anchors are implicit and can never be assigned (ADR-0090 D9),
664+
// so they stay out of a picker even for a tenant admin.
665+
expect(report.assignablePositions).not.toContain('everyone');
666+
expect([...report.assignablePositions].sort()).toEqual(['mixed_pos', 'sales_rep']);
667+
});
668+
669+
it('plain CRUD on the RBAC tables delegates nothing (fail closed)', async () => {
670+
const report = await h.gate.describeDelegableScope(
671+
await (h.gate as any).deps.resolveSets({ principal: 'crud_only' }),
672+
);
673+
expect(report).toMatchObject({
674+
isTenantAdmin: false,
675+
scopes: [],
676+
placeableBusinessUnitIds: [],
677+
assignablePositions: [],
678+
});
679+
});
680+
});

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

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,31 @@ export interface DelegatedAdminGateDeps {
7373
delegationCeilingMs?: number;
7474
}
7575

76+
/**
77+
* [ADR-0090 D12 / ADR-0105 D8] What the caller may delegate — the read half
78+
* of the gate, shaped for a picker (see
79+
* {@link DelegatedAdminGate.describeDelegableScope}).
80+
*/
81+
export interface DelegableScopeReport {
82+
/** ADR-0066 superuser wildcard: unconstrained. The lists below then enumerate everything. */
83+
isTenantAdmin: boolean;
84+
/** Every `adminScope` the caller holds, with its subtree resolved to ids. */
85+
scopes: Array<{
86+
setName: string;
87+
businessUnit: string;
88+
includeSubtree: boolean;
89+
manageAssignments: boolean;
90+
manageBindings: boolean;
91+
authorEnvironmentSets: boolean;
92+
assignablePermissionSets: string[];
93+
businessUnitIds: string[];
94+
}>;
95+
/** Union of the subtrees where the caller may PLACE people (`manageAssignments`). */
96+
placeableBusinessUnitIds: string[];
97+
/** Positions the caller may assign — every set they distribute is allowlisted. */
98+
assignablePositions: string[];
99+
}
100+
76101
interface HeldScope {
77102
/** The set that carries the scope (for error messages). */
78103
setName: string;
@@ -232,6 +257,105 @@ export class DelegatedAdminGate {
232257
return false;
233258
}
234259

260+
/**
261+
* [ADR-0090 D12 / ADR-0105 D8] Describe what the caller may DELEGATE —
262+
* the read half of this gate.
263+
*
264+
* A UI that offers "place this person in a unit, with these positions"
265+
* (the D8 scoped-invitation form) must narrow its options to what the
266+
* issuer could actually assign; otherwise every picker lists the whole
267+
* tree and the user discovers the boundary only by being refused. The
268+
* lists here are computed by the SAME `resolveHeldScopes` /
269+
* `setsBoundToPosition` / containment helpers the write path enforces
270+
* with, so an option this report offers is one `assert()` accepts — the
271+
* picker narrows, it does not decide.
272+
*
273+
* Self-scoped by construction: the caller's own resolved sets are the
274+
* only input, so reading it discloses nothing beyond the authority they
275+
* already hold. Fail-closed shape: unresolvable scopes contribute
276+
* nothing, and a caller with no delegated authority gets empty lists.
277+
*
278+
* A tenant-level admin (ADR-0066 superuser wildcard) is unconstrained;
279+
* the report says so AND enumerates everything, so a consumer can render
280+
* one uniform picker instead of special-casing.
281+
*/
282+
async describeDelegableScope(sets: PermissionSet[]): Promise<DelegableScopeReport> {
283+
const ql = this.deps.ql;
284+
const allPositions = async (): Promise<string[]> => {
285+
if (!ql?.find) return [];
286+
try {
287+
const rows = await ql.find('sys_position', { limit: 1000, context: SYSTEM_CTX });
288+
return (Array.isArray(rows) ? rows : [])
289+
.map((r: any) => String(r?.name ?? ''))
290+
.filter((n) => n && !ANCHOR_POSITIONS.has(n));
291+
} catch {
292+
return [];
293+
}
294+
};
295+
296+
if (isTenantAdmin(sets)) {
297+
let businessUnitIds: string[] = [];
298+
if (ql?.find) {
299+
try {
300+
const rows = await ql.find('sys_business_unit', { limit: 5000, context: SYSTEM_CTX });
301+
businessUnitIds = (Array.isArray(rows) ? rows : [])
302+
.map((r: any) => String(r?.id ?? ''))
303+
.filter(Boolean);
304+
} catch {
305+
businessUnitIds = [];
306+
}
307+
}
308+
return {
309+
isTenantAdmin: true,
310+
scopes: [],
311+
placeableBusinessUnitIds: businessUnitIds,
312+
assignablePositions: await allPositions(),
313+
};
314+
}
315+
316+
const held = await this.resolveHeldScopes(sets);
317+
const scopes = held.map((h) => ({
318+
setName: h.setName,
319+
businessUnit: h.scope.businessUnit,
320+
includeSubtree: h.scope.includeSubtree,
321+
manageAssignments: h.scope.manageAssignments,
322+
manageBindings: h.scope.manageBindings,
323+
authorEnvironmentSets: h.scope.authorEnvironmentSets,
324+
assignablePermissionSets: [...h.scope.assignablePermissionSets],
325+
businessUnitIds: [...h.subtree],
326+
}));
327+
328+
// Placement needs `manageAssignments` — a bindings-only or authoring-only
329+
// scope administers capability without placing people.
330+
const placing = held.filter((h) => h.scope.manageAssignments);
331+
const placeableBusinessUnitIds = [...new Set(placing.flatMap((h) => [...h.subtree]))];
332+
333+
// A position is assignable when at least ONE placing scope allowlists
334+
// every set it distributes — exactly `assertAssignmentWrite`'s test,
335+
// containment check included.
336+
const assignablePositions: string[] = [];
337+
if (placing.length > 0) {
338+
for (const positionName of await allPositions()) {
339+
const boundSets = await this.setsBoundToPosition(positionName);
340+
const ok = placing.some((s) =>
341+
boundSets.every(
342+
(bound) =>
343+
s.scope.assignablePermissionSets.includes(bound.name) &&
344+
this.assertScopeGrantContainment(bound, held, /*dryRun*/ true) === null,
345+
),
346+
);
347+
if (ok) assignablePositions.push(positionName);
348+
}
349+
}
350+
351+
return {
352+
isTenantAdmin: false,
353+
scopes,
354+
placeableBusinessUnitIds,
355+
assignablePositions,
356+
};
357+
}
358+
235359
// ── [ADR-0091 D3] Self-service delegation of duty ────────────────────
236360

237361
/** A delegation write = a `sys_user_position` INSERT whose rows stamp

packages/plugins/plugin-security/src/security-plugin.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,26 @@ export class SecurityPlugin implements Plugin {
661661
{ ...request, operation: String(request.operation) },
662662
callerContext,
663663
),
664+
// [ADR-0090 D12 / ADR-0105 D8] What the CALLER may delegate — the read
665+
// half of the delegated-admin gate. A scoped-invitation form narrows
666+
// its unit/position pickers with this instead of listing the whole
667+
// tree and letting the user discover the boundary by refusal. Purely
668+
// self-scoped (the caller's own resolved sets are the only input), so
669+
// it discloses nothing beyond the authority they already hold.
670+
describeDelegableScope: async (callerContext?: any) => {
671+
const sets = await this.resolvePermissionSetsForContext(callerContext);
672+
// No gate wired (degraded start) → no delegable authority, and the
673+
// consumer renders an empty picker rather than a permissive one.
674+
if (!this.delegatedAdminGate) {
675+
return {
676+
isTenantAdmin: false,
677+
scopes: [],
678+
placeableBusinessUnitIds: [],
679+
assignablePositions: [],
680+
};
681+
}
682+
return this.delegatedAdminGate.describeDelegableScope(sets);
683+
},
664684
// [ADR-0090 D5/D9] Install-time suggestion surface: packages suggest
665685
// audience-anchor bindings; a tenant admin confirms (the binding is
666686
// written under the anchor + delegated-admin gates) or dismisses.

packages/rest/src/rest-route-ledger.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,10 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [
150150
note: 'query transport of the same ExplainRequestSchema contract; the SDK speaks the POST form' },
151151
{ route: 'POST /api/v1/security/explain', family: 'security-explain', source: 'route-manager', disposition: 'sdk', client: 'security.explain' },
152152

153+
// ── delegable scope (ADR-0090 D12 / ADR-0105 D8) ──────────────────────────
154+
{ route: 'GET /api/v1/security/my-delegable-scope', family: 'security-explain', source: 'route-manager', disposition: 'sdk', client: 'security.describeDelegableScope',
155+
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" },
156+
153157
// ── per-record shares ─────────────────────────────────────────────────────
154158
{ route: 'GET /api/v1/data/:object/:id/shares', family: 'record-shares', source: 'route-manager', disposition: 'sdk', client: 'shares.list' },
155159
{ route: 'POST /api/v1/data/:object/:id/shares', family: 'record-shares', source: 'route-manager', disposition: 'sdk', client: 'shares.grant' },

0 commit comments

Comments
 (0)