feat(auth): give ADR-0105 D8's scope-bounded issuance a caller — delegated_admin, capped (#3697) - #3722
Merged
Merged
Conversation
…egated_admin`, capped (#3697) 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 could reach it in a state where it narrowed anything: - better-auth grants `invitation:["create"]` to `owner`/`admin` only (`memberAc` holds `invitation: []`, and roles registered through `additionalOrgRoles` inherited that empty statement); - under a wall-enforcing posture, owners and admins are auto-elevated to `organization_admin`, whose wildcard `modifyAllRecords` makes `isTenantAdmin()` true — and the gate short-circuits on tenant admins. The two sets were disjoint. Issuance was bounded by the Layer 0 org wall (real, and correct) but never by `adminScope`. Two pieces, and they only ship together. 1. The role. `delegated_admin` is registered with the organization plugin as `memberAc.statements` plus `invitation: ["create"]` — the one membership grade that reaches `/organization/invite-member` without being an org admin. Deliberately not `cancel`: that route has no inviterId attribution, so the permission would mean "cancel anyone's pending invitation in the org". The role carries no ObjectStack authority by construction — it passes through as a position name and with no `sys_position_permission_set` binding resolves to nothing. Role = can reach the endpoint; adminScope = what the endpoint permits. 2. The role cap, in the framework's own `beforeCreateInvitation` hook, beside the D8 placement gate. The role alone would have been a four-step escalation: better-auth's `creatorRole` guard (default `owner`) blocks inviting an owner but not an admin, and an accepted `admin` membership is auto-elevated to `organization_admin` → `isTenantAdmin()`. Every existing defense is off that path (`sys_member` is not a GOVERNED_OBJECT; the acceptance write runs under better-auth's context, not the issuer's). The cap refuses a role outranking the issuer's own and holds a below-admin issuer to plain `member` — not merely "not admin/owner", because an app-registered role projects into `current_user.positions` and may be bound to permission sets. It applies to every invitation, placement-carrying or not, and fails closed on an unresolvable issuer membership. `sys_member.role` and `sys_invitation.role` each gain the new option. Those selects are enforced on write — better-auth's own invitation and membership inserts are validated like any other row — so registering the role without listing it in both would have shipped a role nobody could hold and nobody could hand out. The end-to-end regression caught that twice; neither unit test could. Coverage: pure cap decisions (`invitation-role-cap.test.ts`), hook and roles-map wiring (`auth-manager.test.ts`), and the whole path over real HTTP (`delegated-admin-invite.dogfood.test.ts`) — a delegate issues a `member` invitation, the same delegate issuing `admin` is refused with no row left behind, an org owner provisions a delegate by invitation, and a plain member still cannot invite at all. Net effect on deployments: one new, doubly-opt-in class of principal (membership role AND an adminScope grant). A default deployment changes not at all; org owners and admins are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015s4qPrCKBvVwmFeSsFHgNp
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 5 package(s): 108 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…admin doc; refresh the spec API surface Two CI ratchets that a new export and new prose trip by construction: - `check:role-word` (ADR-0090 D3 vocabulary ratchet) flagged 11 new uses of "role" in `delegated-administration.mdx`. All 11 are the better-auth organization membership role (`sys_member.role`, `sys_invitation.role`, the `delegated_admin` value) — the "genuine boundary (better-auth)" case the ratchet documents as baseline-worthy. Renaming them would obscure the very field the section is about; D3's reserved vocabulary governs ObjectStack capability nouns, not better-auth's own column. - `check:api-surface` — regenerated for the additive `MEMBERSHIP_ROLE_DELEGATED_ADMIN` export (0 breaking, 2 added). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015s4qPrCKBvVwmFeSsFHgNp
os-zhuang
marked this pull request as ready for review
July 28, 2026 00:32
This was referenced Jul 28, 2026
os-zhuang
added a commit
that referenced
this pull request
Jul 28, 2026
…able capability (#3697 follow-up) (#3767) Closes the one open non-goal from #3697 / #3722. `DelegatedAdminGate`'s `GOVERNED_OBJECTS` covered the four RBAC link tables but not `sys_member` — so the table that decides who is an org admin was the one authority surface the gate never saw. A membership row is an authority dial: `role` containing `owner`/`admin` is auto-elevated to `organization_admin` by `auto-org-admin-grant.ts`, and that set's wildcard `modifyAllRecords` is exactly what `isTenantAdmin()` tests. Writing one mints a tenant admin — the same escalation the invitation role cap closes on the issuance path, one layer down at the table. Not exploitable today and no working behaviour changes: every writer is a better-auth path running under `isSystem`, short-circuited before this gate, and the ADR-0092 D2 identity write guard refuses user-context writes to better-auth tables upstream. The gate is added so the chain cannot silently reopen when a direct-write surface appears — a `case` label is not enforcement; the call site decides (Prime Directive #10). Tenant-admin-only rather than scope-delegable, deliberately: no axis of `AdminScope` expresses organization membership, so there is nothing for a delegated scope to approve part of, and a delegate who could write one would mint authority strictly greater than their own. Adding people already has a delegable path — the invitation, whose placement this gate authorizes and whose role is capped at the issuer's own grade. The refusal message points there.
os-zhuang
added a commit
to objectstack-ai/objectui
that referenced
this pull request
Jul 28, 2026
…pickers (framework#3697) (#2891) Follow-on to objectstack-ai/objectstack#3722, which registered a fourth organization role. Companion to #2868, which shipped the placement half. #3722 gave ADR-0105 D8's scope-bounded issuance gate its missing caller: `delegated_admin`, the grade that may reach `/organization/invite-member` without being an org admin. But the console could not select the role at all — `MembersPage` and `InviteMemberDialog` each inlined `type Role = 'owner' | 'admin' | 'member'` — so the capability the framework grew was unreachable from either screen. One vocabulary, not two: role names, labels and narrowing rules move into `@object-ui/auth`'s new `org-roles` module and both screens consume it. The list still MIRRORS the server rather than deriving from it (`/auth/config` publishes feature flags but no role vocabulary); the module says so and points at objectstack-ai/objectstack#3723. Both pickers now narrow to what the server will accept, mirroring DIFFERENT gates: - invite role ← `beforeCreateInvitation`'s role cap: never above the issuer's own grade; a below-admin issuer may invite as `member` only. A `delegated_admin` picking "Admin" would have 403'd. - change role ← better-auth's `update-member-role`: needs `member:["update"]` (owner/admin only), and only an owner may set `owner` or re-role an existing owner. An actor who may re-role nobody gets no items rather than three that would 403. Narrowing is convenience, not the boundary — the server re-checks — and it fails toward less. An ordinary invitation's request body is byte-identical to before. Verified: 654 files / 7684 tests passing; lint 0 errors on both packages; tsc 29/29.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3697.
The gap
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 could reach it in a state where it narrowed anything:invitation: ["create"]toowner/adminonly (memberAcholdsinvitation: [], and roles registered throughadditionalOrgRolesinherited that empty statement);organization_admin(auto-org-admin-grant.ts), whose wildcardmodifyAllRecordsmakesisTenantAdmin()true — andDelegatedAdminGate.assertshort-circuits on tenant admins.The two sets were disjoint. Issuance was bounded by the Layer 0 org wall (real, and correct) but never by
adminScope.The fix — two pieces, shipped together
1. The role.
delegated_adminis registered with the organization plugin asmemberAc.statementsplusinvitation: ["create"]— the one membership grade that reaches/organization/invite-memberwithout being an org admin. The roles map is now built unconditionally (previously only when an app suppliedadditionalOrgRoles), and the guard was tightened so a missingdefaultRolesexport builds nothing rather than a map withoutowner.Deliberately not
invitation: ["cancel"]— that route checks only the permission, with no inviterId attribution, so it would mean "cancel anyone's pending invitation in the org".The role carries no ObjectStack authority by construction:
mapMembershipRolepasses it through as a position name, and with nosys_position_permission_setbinding that name resolves to nothing. Role = can reach the endpoint;adminScope= what the endpoint permits.2. The role cap, in the framework's own
beforeCreateInvitationhook, beside the D8 placement gate. Registering the role alone would have been a four-step escalation — better-auth'screatorRoleguard (defaultowner) blocks inviting an owner but not an admin, and an acceptedadminmembership is auto-elevated toorganization_admin→isTenantAdmin(). Every existing defense is off that path (sys_memberis not aGOVERNED_OBJECT; the acceptance write runs under better-auth's context, not the issuer's).The cap:
owner>admin> everyone else);memberonly — not merely "not admin/owner", because an app-registered role projects intocurrent_user.positionsviamapMembershipRoleand may be bound to permission sets, making it a capability channel too. A delegate's channel for capability is the invitation's placement intent, which the D12 gate allowlists position-by-position;sys_memberrow cannot be read confers nothing above a plain member. The common path (role: "member") short-circuits before any lookup, so it costs nothing.What the end-to-end test found that no unit test could
sys_member.roleandsys_invitation.roleareField.selectwith a closed option list, and those selects are enforced on write — better-auth's own invitation and membership inserts are validated like any other row. Registering the role with the org plugin without listing it in both would have shipped a role nobody could hold and nobody could hand out:The dogfood regression caught this twice (once per object). Both now carry
delegated_admin; translation bundles were regenerated withpnpm i18n:extract, and the three non-English locales carry the English label for the new option until localized.Changes
packages/spec/src/identity/eval-user.zod.ts(+index.ts)MEMBERSHIP_ROLE_DELEGATED_ADMIN— one place the role is namedpackages/plugins/plugin-auth/src/invitation-role-cap.tspackages/plugins/plugin-auth/src/auth-manager.tsbeforeCreateInvitation;resolveMembershipRolereads the issuer'ssys_member.rolethrough the system contextpackages/platform-objects/src/identity/sys-member.object.ts,sys-invitation.object.tsdelegated_adminadded to both enforced role selectspackages/platform-objects/src/apps/translations/*.generated.tspnpm i18n:extract)packages/lint/src/validate-approval-approvers.tsMEMBERSHIP_TIERSgains the new tier, so an approver naming it is no longer flagged as "not a membership tier"content/docs/permissions/delegated-administration.mdxCoverage
invitation-role-cap.test.ts— the pure cap: the escalation chain refused, comma-list smuggling, app roles, delegates minting delegates, owners/admins unaffected, fail-closed floor.auth-manager.test.ts— roles map (invitation: ["create"], nocancel, defaults intact, an app cannot downgrade the role by declaring the same name) and hook wiring (refusals, the no-lookup fast path, cap-runs-before-placement).packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts— the whole path over real HTTP: adelegated_adminissues amemberinvitation; the same delegate issuingadminis refused with no invitation row left behind; an org owner provisions a delegate by invitation; a plainmemberstill cannot invite at all (so it is demonstrably the role that opened the endpoint).pnpm test(turbo, all 132 tasks) green on the rebased tree;pnpm check:i18nin sync.What changes for deployments
One new class of principal: members holding the
delegated_adminorg role, who can invite into the org — asmemberonly, into the subtree theiradminScopeallows. Opt-in twice over (someone must set the membership role and grant an adminScope set), so a default deployment changes not at all. Org owners and admins are unaffected.Non-goals (issue's own list, still open)
sys_memberabsent fromDelegatedAdminGate'sGOVERNED_OBJECTS— unexploitable today (all writers are better-auth paths), belongs to an ADR-0090 hardening pass.Found while implementing, filed separately
#3723 —
additionalOrgRoleshas the same wall this PR just discovered: an app registering e.g.sales_repgets a role better-auth accepts on/organization/invite-member, butsys_invitation.role/sys_member.rolereject it on write. Pre-existing and orthogonal, so it is filed rather than widened into this PR.