Skip to content

Commit 04bd900

Browse files
committed
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
1 parent adabaa8 commit 04bd900

8 files changed

Lines changed: 383 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.

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' },

packages/rest/src/rest-server.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5418,6 +5418,61 @@ export class RestServer {
54185418
handler,
54195419
metadata: { summary: 'Explain why a principal can (or cannot) perform an operation on an object (ADR-0090 D6)', tags: ['security'] },
54205420
});
5421+
5422+
/**
5423+
* [ADR-0090 D12 / ADR-0105 D8] What the CALLER may delegate.
5424+
*
5425+
* GET {basePath}/security/my-delegable-scope
5426+
*
5427+
* The read half of the delegated-admin gate, shaped for a picker: the
5428+
* business units the caller may place people into and the positions
5429+
* they may assign. A scoped-invitation form narrows its options with
5430+
* this instead of listing the whole tree and letting the user find the
5431+
* boundary by being refused.
5432+
*
5433+
* Strictly SELF-scoped — no `userId` parameter, by design. The caller's
5434+
* own resolved sets are the only input, so it discloses nothing beyond
5435+
* the authority they already hold, and there is no "describe someone
5436+
* else's authority" surface to authorize (unlike `explain`, which has
5437+
* one and gates it). An authenticated caller is still required.
5438+
*/
5439+
const delegableHandler = async (req: any, res: any) => {
5440+
try {
5441+
const environmentId = isScoped ? req.params?.environmentId : undefined;
5442+
const context = await this.resolveExecCtx(environmentId, req);
5443+
if (this.enforceAuth(req, res, context)) return;
5444+
if (!context?.userId) {
5445+
return res.status(401).json({
5446+
code: 'UNAUTHORIZED',
5447+
message: 'The delegable-scope endpoint requires an authenticated caller.',
5448+
});
5449+
}
5450+
5451+
const svc = await resolveService(environmentId, req);
5452+
if (!svc || typeof svc.describeDelegableScope !== 'function') {
5453+
return res.status(501).json({
5454+
code: 'NOT_IMPLEMENTED',
5455+
message: 'Delegated administration is not available on this deployment (no security service with describeDelegableScope).',
5456+
});
5457+
}
5458+
5459+
res.json(await svc.describeDelegableScope(context));
5460+
} catch (error: any) {
5461+
const msg = String(error?.message ?? error ?? '');
5462+
logError('[REST] Delegable scope error:', error);
5463+
res.status(500).json({ code: 'DELEGABLE_SCOPE_FAILED', error: msg.slice(0, 500) });
5464+
}
5465+
};
5466+
5467+
this.routeManager.register({
5468+
method: 'GET',
5469+
path: `${basePath}/security/my-delegable-scope`,
5470+
handler: delegableHandler,
5471+
metadata: {
5472+
summary: "The caller's delegable scope: business units they may place into and positions they may assign (ADR-0090 D12 / ADR-0105 D8)",
5473+
tags: ['security'],
5474+
},
5475+
});
54215476
}
54225477

54235478
private registerSharingEndpoints(basePath: string): void {

0 commit comments

Comments
 (0)