Found while writing the D8 end-to-end assertions in cloud's ee-group-showcase (cloud#880). Not a bug in any one commit — a gap where two independently-correct designs meet.
Revision (2026-07-27, same day): the original "Options" section recommended registering a delegated_admin role carrying invitation: ["create"] as a one-line fix. As written, that opens a privilege-escalation hole — see "The escalation chain" below. The recommendation stands only in its complete form: role registration plus an invitation role-cap guard. The body has been corrected; do not implement the bare-role version.
The claim
D8 authorizes invitation placement against the issuer's adminScope (ADR-0090 D12), so a delegated plant admin may invite only into their own subtree. That gate is implemented, unit-proven, and reachable — but no principal can currently reach it in a state where it does anything.
Why
Two facts, each fine alone:
-
better-auth grants invitation: ["create"] to owner and admin only. From better-auth/plugins/organization/access/statement.mjs, memberAc holds invitation: []. Roles registered through our additionalOrgRoles are built with defaultAc.newRole(memberAc.statements) (auth-manager.ts), so they inherit the empty invitation statement too. A plain member calling /organization/invite-member is refused by better-auth before any ObjectStack hook runs.
-
Under group posture the framework auto-elevates owners and admins to tenant admins. auto-org-admin-grant.ts → orgAdminSetNameForPosture() returns ORGANIZATION_ADMIN whenever postureEnforcesWall(posture), and that set carries wildcard viewAllRecords/modifyAllRecords — which is exactly isTenantAdmin(). DelegatedAdminGate.assert short-circuits on it:
if (isTenantAdmin(sets)) return; // status quo — downstream CRUD/RLS decide
Intersect them: the set of principals who may issue an invitation and the set for whom the adminScope gate narrows anything are disjoint. Issuance placement is bounded by the Layer 0 org wall (which is real, and correct) — never by adminScope.
What this does and does not break
- Not a security hole today. The wall still bounds every issuer to their own organization; the failure mode is a plant admin placing into a unit outside their delegated subtree but inside their own org, which is authority they already hold directly.
- It does hollow out D8's motivating story. "A plant admin invites into their own subtree without a platform admin finishing the job" only bites when the plant admin is not an org admin — and that principal cannot issue at all.
The escalation chain the bare-role fix would open
Registering delegated_admin with invitation: ["create"] and stopping there is exploitable in four steps. The only role-level guard on what role you may invite someone as is crud-invites.mjs:123:
if (!member.role.split(",").includes(creatorRole) && roles.split(",").includes(creatorRole))
throw YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE
creatorRole defaults to "owner" (:101). It blocks inviting an owner; it does not block inviting an admin. So:
delegated_admin gains invitation: ["create"];
- they invite someone with
role: "admin" — the guard above passes;
- acceptance writes
sys_member(role='admin');
auto-org-admin-grant.ts:118 (isAdminRole = contains owner or admin) fires → organization_admin → wildcard modifyAllRecords → isTenantAdmin().
A delegate scoped to one subtree has manufactured a tenant admin — authority strictly greater than their own. Every existing defense is off this path: DelegatedAdminGate's GOVERNED_OBJECTS (delegated-admin-gate.ts:55-60) covers the four RBAC tables but not sys_member, and the acceptance-time membership write runs under better-auth's own context, not the issuer's — the gate is never in the loop. ADR-0090 D12's whole point is "administration is a scoped capability"; this chain would make it unscoped again.
The complete fix (revised option 1)
Two pieces, shipped together:
a) Register the role in auth-manager.ts — delegated_admin built from {...memberAc.statements, invitation: ["create"]}. Deliberately not invitation: ["cancel"]: the cancel route (crud-invites.mjs:425-440) carries no inviterId attribution check — any holder of the permission may cancel anyone's pending invitation in the org. "Cancel my own invitations" is a legitimate follow-up, but it needs its own attribution guard first.
The role carries no ObjectStack authority by construction: mapMembershipRole passes unknown roles through as a position name, and with no sys_position_permission_set binding that name resolves to nothing. Placement authority still comes solely from a separately-granted adminScope set. Role = can reach the endpoint; adminScope = what the endpoint permits. The gate, /security/my-delegable-scope (#3674), and the console picker (objectui#2868) all hang off the latter and need no changes.
b) Invitation role-cap guard in the framework's own beforeCreateInvitation hook (auth-manager.ts — the D8 placement gate already lives there): an issuer who is not an org owner/admin may not issue an invitation whose role is admin or owner (more precisely: may not issue a role of higher grade than their own). Placed here rather than via better-auth's creatorRole because creatorRole is single-valued and cannot express "not above your own grade"; because the hook applies to all invitations, not just placement-carrying ones (the hole is independent of placement); and because it sits next to the D8 gate for single-point review.
With both pieces: a delegate can add people to the org, but only as member, and their placement still passes the adminScope gate (subtree + allowlist + strict containment). A delegate cannot manufacture authority exceeding their own — the D12 invariant holds end to end.
Non-goals of this fix, filed for separate follow-up:
sys_member absent from GOVERNED_OBJECTS — currently unexploitable (all writers are better-auth paths), but any future direct-write surface reopens the same chain. Belongs to an ADR-0090 hardening pass, not this change.
- Attributed cancel for delegates.
What this changes for deployments
Today, under group, only org owners/admins can invite (and they are tenant admins). After the fix, one new class of principal exists: members holding the delegated_admin org role, who can invite into the org — as member only. That is a real capability expansion (a new kind of principal can add people, consume seats, expose org data to invitees), bounded as above. It is opt-in twice over: someone must explicitly set the membership role, and separately grant an adminScope set — a default deployment changes not at all.
Awaiting a founder decision before implementation — this changes who can send invitations. Open sub-questions: (1) proceed at all; (2) role name (delegated_admin follows ADR-0090 D12's own vocabulary; a neutral alternative like org_delegate, or making it configurable, are both cheap at this stage).
Verification available today
cloud's showcase (cloud#886) pins both halves over real HTTP:
my-delegable-scope narrows a plain member with an adminScope to their own subtree (child unit included — subtree expansion proven to happen; sibling excluded — proven to stop);
- the same endpoint reports
isTenantAdmin: true for an org owner, pinned deliberately so this issue's premise fails loudly if the framework stops auto-elevating or grants a non-admin role invitation:create — at which point the scope-bounded-refusal e2e becomes writable there.
The role-cap guard (b) gets its own regression: a delegated_admin issuing role: "admin" must be refused with no invitation row left behind.
Related: #3541 (D8 tracking), #3663 (engine), #3674 (read surface), #3695 (issuer-grant resolution fix), objectui#2868 (console UX), cloud#886 (e2e).
Found while writing the D8 end-to-end assertions in cloud's
ee-group-showcase(cloud#880). Not a bug in any one commit — a gap where two independently-correct designs meet.The claim
D8 authorizes invitation placement against the issuer's
adminScope(ADR-0090 D12), so a delegated plant admin may invite only into their own subtree. That gate is implemented, unit-proven, and reachable — but no principal can currently reach it in a state where it does anything.Why
Two facts, each fine alone:
better-auth grants
invitation: ["create"]toownerandadminonly. Frombetter-auth/plugins/organization/access/statement.mjs,memberAcholdsinvitation: []. Roles registered through ouradditionalOrgRolesare built withdefaultAc.newRole(memberAc.statements)(auth-manager.ts), so they inherit the empty invitation statement too. A plain member calling/organization/invite-memberis refused by better-auth before any ObjectStack hook runs.Under
groupposture the framework auto-elevates owners and admins to tenant admins.auto-org-admin-grant.ts→orgAdminSetNameForPosture()returnsORGANIZATION_ADMINwheneverpostureEnforcesWall(posture), and that set carries wildcardviewAllRecords/modifyAllRecords— which is exactlyisTenantAdmin().DelegatedAdminGate.assertshort-circuits on it:Intersect them: the set of principals who may issue an invitation and the set for whom the adminScope gate narrows anything are disjoint. Issuance placement is bounded by the Layer 0 org wall (which is real, and correct) — never by
adminScope.What this does and does not break
The escalation chain the bare-role fix would open
Registering
delegated_adminwithinvitation: ["create"]and stopping there is exploitable in four steps. The only role-level guard on what role you may invite someone as iscrud-invites.mjs:123:creatorRoledefaults to"owner"(:101). It blocks inviting anowner; it does not block inviting anadmin. So:delegated_admingainsinvitation: ["create"];role: "admin"— the guard above passes;sys_member(role='admin');auto-org-admin-grant.ts:118(isAdminRole= containsowneroradmin) fires →organization_admin→ wildcardmodifyAllRecords→isTenantAdmin().A delegate scoped to one subtree has manufactured a tenant admin — authority strictly greater than their own. Every existing defense is off this path:
DelegatedAdminGate'sGOVERNED_OBJECTS(delegated-admin-gate.ts:55-60) covers the four RBAC tables but notsys_member, and the acceptance-time membership write runs under better-auth's own context, not the issuer's — the gate is never in the loop. ADR-0090 D12's whole point is "administration is a scoped capability"; this chain would make it unscoped again.The complete fix (revised option 1)
Two pieces, shipped together:
a) Register the role in
auth-manager.ts—delegated_adminbuilt from{...memberAc.statements, invitation: ["create"]}. Deliberately notinvitation: ["cancel"]: the cancel route (crud-invites.mjs:425-440) carries no inviterId attribution check — any holder of the permission may cancel anyone's pending invitation in the org. "Cancel my own invitations" is a legitimate follow-up, but it needs its own attribution guard first.The role carries no ObjectStack authority by construction:
mapMembershipRolepasses unknown roles through as a position name, and with nosys_position_permission_setbinding that name resolves to nothing. Placement authority still comes solely from a separately-grantedadminScopeset. Role = can reach the endpoint; adminScope = what the endpoint permits. The gate,/security/my-delegable-scope(#3674), and the console picker (objectui#2868) all hang off the latter and need no changes.b) Invitation role-cap guard in the framework's own
beforeCreateInvitationhook (auth-manager.ts— the D8 placement gate already lives there): an issuer who is not an orgowner/adminmay not issue an invitation whose role isadminorowner(more precisely: may not issue a role of higher grade than their own). Placed here rather than via better-auth'screatorRolebecausecreatorRoleis single-valued and cannot express "not above your own grade"; because the hook applies to all invitations, not just placement-carrying ones (the hole is independent of placement); and because it sits next to the D8 gate for single-point review.With both pieces: a delegate can add people to the org, but only as
member, and their placement still passes the adminScope gate (subtree + allowlist + strict containment). A delegate cannot manufacture authority exceeding their own — the D12 invariant holds end to end.Non-goals of this fix, filed for separate follow-up:
sys_memberabsent fromGOVERNED_OBJECTS— currently unexploitable (all writers are better-auth paths), but any future direct-write surface reopens the same chain. Belongs to an ADR-0090 hardening pass, not this change.What this changes for deployments
Today, under
group, only org owners/admins can invite (and they are tenant admins). After the fix, one new class of principal exists: members holding thedelegated_adminorg role, who can invite into the org — asmemberonly. That is a real capability expansion (a new kind of principal can add people, consume seats, expose org data to invitees), bounded as above. It is opt-in twice over: someone must explicitly set the membership role, and separately grant an adminScope set — a default deployment changes not at all.Awaiting a founder decision before implementation — this changes who can send invitations. Open sub-questions: (1) proceed at all; (2) role name (
delegated_adminfollows ADR-0090 D12's own vocabulary; a neutral alternative likeorg_delegate, or making it configurable, are both cheap at this stage).Verification available today
cloud's showcase (cloud#886) pins both halves over real HTTP:
my-delegable-scopenarrows a plain member with anadminScopeto their own subtree (child unit included — subtree expansion proven to happen; sibling excluded — proven to stop);isTenantAdmin: truefor an org owner, pinned deliberately so this issue's premise fails loudly if the framework stops auto-elevating or grants a non-admin roleinvitation:create— at which point the scope-bounded-refusal e2e becomes writable there.The role-cap guard (b) gets its own regression: a
delegated_adminissuingrole: "admin"must be refused with no invitation row left behind.Related: #3541 (D8 tracking), #3663 (engine), #3674 (read surface), #3695 (issuer-grant resolution fix), objectui#2868 (console UX), cloud#886 (e2e).