Skip to content

Latest commit

 

History

History
144 lines (119 loc) · 7.32 KB

File metadata and controls

144 lines (119 loc) · 7.32 KB
title Delegated Administration
description Administration itself as a scoped grant — a business-unit subtree, an action set, and an assignable-set allowlist, with self-escalation structurally impossible (ADR-0090 D12).

Delegated Administration

A group with fifty subsidiaries cannot manage every grant from headquarters — but handing each subsidiary a full admin is worse. ADR-0090 D12's answer: administration itself becomes a scoped capability. A delegate can onboard their own staff and reshuffle their own positions, but can never grant anything outside an explicit allowlist — including to themselves — and can never touch tenant-level assets.

Because the scope lives on an ordinary permission set, it is distributed via positions, audited in the same tables, and explained by the same engine as every other grant. No parallel admin subsystem.

Authoring an admin scope

export const FieldOpsDelegate = definePermissionSet({
  name: 'field_ops_delegate',
  label: 'Field Ops Delegate Admin',
  // The scope authorizes WHAT may be administered…
  adminScope: {
    businessUnit: 'Field Operations',   // WHERE: this sys_business_unit subtree
    includeSubtree: true,               // default true
    manageAssignments: true,            // user ↔ position rows (sys_user_position)
    manageBindings: false,              // position ↔ set rows (sys_position_permission_set)
    authorEnvironmentSets: false,       // may they author environment-owned sets?
    assignablePermissionSets: ['showcase_contributor', 'showcase_manager'],
  },
  // …and plain CRUD on the RBAC link tables lets the requests through at all.
  // Both are required: table CRUD with NO scope is refused outright.
  objects: {
    sys_user_position: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
    sys_position: { allowRead: true },
    sys_permission_set: { allowRead: true },
    sys_business_unit: { allowRead: true },
    sys_business_unit_member: { allowRead: true },
    sys_user: { allowRead: true },
  },
});

businessUnit names the subtree root by sys_business_unit.name; a misconfigured name resolves to an empty subtree and approves nothing (fail-closed).

What the runtime gate enforces

Writes to the governed tables (sys_user_position, sys_position_permission_set, sys_user_permission_set, sys_permission_set) pass through the DelegatedAdminGate:

Rule Effect
Tenant admins pass through The superuser wildcard reaches the ordinary CRUD/RLS checks — delegation constrains delegates, not HQ
Subtree anchoring Assignments a delegate creates must be anchored (sys_user_position.business_unit_id) inside their subtree, and the target user must sit inside it
Allowlist, no self-escalation Every set reached by the write — bound to the assigned position, or granted directly — must be on assignablePermissionSets. Granting an un-allowlisted set is refused for anyone, including the delegate themselves
Action flags manageAssignments / manageBindings / authorEnvironmentSets gate their table classes independently; re-composing a position (manageBindings) additionally requires every current holder to sit inside the subtree
Strict containment Granting or authoring a set that itself carries an adminScope requires a held scope that strictly contains it — handing your own exact scope to a peer is refused (no lateral propagation)
Single-row writes Delegates write single rows by id only — a broad filter-write cannot be boundary-checked
Audit stamp Every assignment a delegate creates is granted_by-stamped automatically
Tenant-level assets stay tenant-level The everyone/guest audience anchors and security-domain publishes are untouchable from any delegated scope
No scope, no admin Holders of plain CRUD on the RBAC tables with no scope are refused: administration is a scoped capability now, not a side effect of table access

Every rule above is exercised end-to-end by the showcase permission zoo (packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts): the in-subtree allowlisted assignment passes with a granted_by stamp; the out-of-subtree anchor, the off-allowlist grant (to self), and the manageBindings: false binding write are each refused.

The assignment anchor

Positions never bind to a business unit at the definition level — that would recreate the position-per-department explosion. The assignment row may: sys_user_position.business_unit_id names the unit the person holds the position in ("张三 is sales_manager of 华东"). It does exactly three things: anchors that assignment's depth grants to the subtree, provides the delegation boundary check above, and makes "manager of what" an auditable fact. Capability bits are never BU-scoped.

Explaining delegated decisions

The explain engine closes the loop twice:

  • explaining another user is authorized by manage_users or a delegated adminScope whose subtree covers that user — a delegate can diagnose their own people, and only their own people;
  • 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:

GET /api/v1/security/my-delegable-scope
const scope = await client.security.describeDelegableScope();
{
  "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