Delegated administration — scoped admin rights
Hand out scoped admin rights: a business-unit subtree, an action set and an assignable-set allowlist, with self-escalation structurally impossible.
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)
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_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_user_permission_set,
sys_member) pass through the DelegatedAdminGate. Positions and permission
sets themselves are catalog definitions, not rows (ADR-0131 D3), so the set
above grants no object permission for them: an assignment names a position or
set, and the gate resolves the name in the security catalog. The gate's rules:
| 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 flag | manageAssignments gates the assignment rows — sys_user_position, and direct sys_user_permission_set grants — and is the scope's only action right. A position's sets are part of its definition and a permission set is a definition: both are written through the metadata door, which admits a manage_metadata holder (the platform operator under group or isolated) and reads no adminScope (ADR-0131 D3). The scope's former manageBindings and authorEnvironmentSets named the retired sys_position_permission_set and sys_permission_set rows, admitted no write once those objects were gone, and are retired: the schema refuses both with the fix |
| Strict containment | Granting a set that itself carries an adminScope — assigning a position that distributes it, or granting it directly — requires a held scope that strictly contains it: the same root, no wider subtree, manageAssignments only if yours has it, an allowlist inside yours, and smaller on at least one of those. 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 |
| Membership is not delegable | sys_member writes are tenant-admin-only: no adminScope axis expresses org membership, and an admin-grade membership row is auto-elevated to organization_admin — writing one would mint a tenant admin. Adding people is delegable, through an invitation |
| 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 |
The showcase permission zoo exercises the gate end-to-end
(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 and the off-allowlist grant (to self) 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_usersor a delegatedadminScopewhose subtree covers that user — a delegate can diagnose their own people, and only their own people; - contributor attribution plus the
granted_bystamp 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-scopeconst 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_posbundling 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: truewith 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.
Letting a delegate invite (delegated_admin)
A scope is what you may administer; it does not by itself let you reach the
invitation endpoint. The underlying organization plugin grants
invitation: ["create"] to owner and admin only — and both of those are
auto-elevated to organization_admin (a tenant admin), for whom the scope gate
narrows nothing. So the scope-bounded issuance path needs a membership grade
that is neither: delegated_admin.
// sys_member — the better-auth membership row
{ "user_id": "usr_plant_admin", "organization_id": "org_acme", "role": "delegated_admin" }Two independent switches, and you need both:
| Grants | Without it | |
|---|---|---|
sys_member.role = delegated_admin | May reach /organization/invite-member | Refused by the org plugin before any framework hook runs |
An adminScope-carrying permission set | What the invitation may place (subtree + allowlist) | The gate refuses every placement — the role alone administers nothing |
The role carries no ObjectStack authority of its own: it passes through as a
position name, and with no position of that name declaring permissionSets it
resolves to nothing. Role = can reach the endpoint. adminScope = what the endpoint
permits. A default deployment is unaffected — nobody holds the role until you
set it.
The invitation role cap
An invitation may add a person; it may never add authority above the issuer's own. The framework caps every invitation — placement-carrying or not — at issuance:
- an invited role may not outrank the issuer's (
owner>admin> everyone else); - an issuer below admin grade may invite as
memberonly. Not just "not admin/owner": every value stored insys_member.roleprojects intocurrent_user.positions, so any tier above plain member is a capability channel too. A delegate's channel for capability is the invitation's placement intent, which this gate allowlists position-by-position — and since ADR-0108 that is the only way capability travels on an invitation, because the tier vocabulary is closed to four framework-owned names; - an issuer whose membership cannot be read confers nothing above a plain member (fail-closed).
Without the cap the role would be a ladder: invite an admin, and acceptance
auto-elevates that membership to organization_admin → tenant admin — the
grade the delegation exists to withhold. sys_member is one of the gate's
governed objects (the "Membership is not delegable" rule above), but the
acceptance write never reaches the gate: better-auth writes the membership row
as the system (isSystem), and the security middleware passes a system write
through before the gate runs. So the cap — judged at issuance, while the
inviter is still the actor — is the defense on that path.
Both halves are pinned end-to-end over the real HTTP route by
packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts: a
delegated_admin issues a member invitation successfully, the same principal
issuing admin is refused with no invitation row left behind, and a plain
member still cannot invite at all — so it is demonstrably the role, not some
unrelated loosening, that opened the endpoint.
See also
- Permission Sets — the container
adminScoperides on - Positions — how delegates receive the scope
- Explain Engine
- Authorization Architecture
Positions
Positions are flat groups that hand permission sets to users — the built-in identity positions, the everyone and guest anchors, and where hierarchy lives.
Sharing Rules
Record-level access in ObjectStack: organization-wide defaults per object, the external sharing dial, criteria sharing rules and RLS-safe analytics reads.