Skip to content

ADR-0105 D8: the scope-bounded issuance path has no caller — every principal who may invite is already a tenant admin #3697

Description

@os-zhuang

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:

  1. 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.

  2. Under group posture the framework auto-elevates owners and admins to tenant admins. auto-org-admin-grant.tsorgAdminSetNameForPosture() 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:

  1. delegated_admin gains invitation: ["create"];
  2. they invite someone with role: "admin" — the guard above passes;
  3. acceptance writes sys_member(role='admin');
  4. auto-org-admin-grant.ts:118 (isAdminRole = contains owner or admin) fires → organization_admin → wildcard modifyAllRecordsisTenantAdmin().

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.tsdelegated_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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions