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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/delegable-scope-read-surface.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 44 additions & 0 deletions content/docs/permissions/delegated-administration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2936,6 +2936,24 @@ export class ObjectStackClient {
return this.unwrapResponse<any>(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<any> => {
const res = await this.fetch(`${this.baseUrl}/api/v1/security/my-delegable-scope`);
return this.unwrapResponse<any>(res);
},

suggestedBindings: {
/** List suggestions, optionally by `status` / `packageId` (reconciles first). */
list: async (opts?: { status?: string; packageId?: string }): Promise<any> => {
Expand Down
71 changes: 71 additions & 0 deletions packages/plugins/plugin-security/src/delegated-admin-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
});
});
});
124 changes: 124 additions & 0 deletions packages/plugins/plugin-security/src/delegated-admin-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<DelegableScopeReport> {
const ql = this.deps.ql;
const allPositions = async (): Promise<string[]> => {
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
Expand Down
20 changes: 20 additions & 0 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions packages/rest/src/rest-route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Loading
Loading