Skip to content

fix(auth): admin member management works without the admin's own membership - #881

Merged
elliotBraem merged 1 commit into
mainfrom
fix/auth-admin-organizations-followups
Nov 11, 2026
Merged

elliotBraem merged 1 commit into
mainfrom
fix/auth-admin-organizations-followups

Conversation

@elliotBraem

Copy link
Copy Markdown
Contributor

Summary

Follow-ups to #876/#880 (admin organization routes). The admin mutation set is supposed to operate on any organization without requiring membership — but one mutation still leaned on Better Auth's organization-permission model:

 adminAddMember
-  auth.api.addMember({ headers })   # checks the CALLER's org permissions → FORBIDDEN
+  direct member insert              # like every other mutation in the service
+    org exists → user exists → not already a member
+    membershipPolicy.hasCapacity(memberCount)

A platform admin administering an organization they don't belong to — the whole point of an admin route — got FORBIDDEN. The tests masked it: every fixture organization was owned by the admin, so Better Auth's permission check passed.

Rides along:

  • adminUpdateMemberRole refuses to demote an organization's only owner, matching adminRemoveMember's existing last-owner guard
  • getOrganizationForAdmin uses the standard requireAdmin middleware instead of a hand-rolled role check (same pattern as the feat(auth): admin organization management routes #880 routes)
  • crossSubDomainCookies (Auth plugin: expose cross-subdomain cookie configuration (cookie domain) in the auth config schema #668) gains config-time warnings for the two ways a shared cookie domain fails silently at the browser: a domain that doesn't cover the app's own baseUrl host (cookie dropped outright → sign-in breaks) and localhost/loopback base URLs (browsers refuse Domain attributes)
  • README documents the full multi-app session recipe: shared cookie domain + shared BETTER_AUTH_SECRET + shared postgres:// AUTH_DATABASE_URL (ADR 0027's decomposition)

Evidence

Before — the unmasked case (admin not in the org) failed under Better Auth's permission check; no test covered it. After — plugins/auth/tests/integration/admin-organizations.test.ts:

  • adds a member without the admin belonging to the organization — admin adds to an org owned by someone else; member row verified in DB; duplicate add and unknown user still rejected
  • refuses to demote the only owner of an organization — demote rejected while sole owner (role unchanged in DB); succeeds after a second owner is added
  • enforces the organization membership limit when adding members — limit now enforced by the plugin's own policy rather than Better Auth internals
  • organizations.test.ts admin-rejection message updated to the requireAdmin middleware's

Full auth plugin suite: 43 files, 486 tests passed.

Merge Danger

Door: two-way — straight revert, no data migrations.

Blast radius: auth plugin API. adminAddMember gains two refusals Better Auth previously provided implicitly (duplicate member, full org) — same outcomes, now surfaced as BAD_REQUEST from the plugin itself. getOrganizationForAdmin's rejection message changes (Admin access required → Requires role: admin); consumers asserting the old string must match the middleware's. Cookie-domain warnings are advisory; behavior is unchanged without crossSubDomainCookies config.

…ership

adminAddMember forwarded the caller's headers into Better Auth's addMember,
which checks the caller's ORGANIZATION permissions — a platform admin who was
not a member of the target organization (the whole point of an admin route)
got FORBIDDEN. The mutation is now a direct write like the rest of
AdminOrganizationsService: membership-limit policy enforced, duplicate
memberships refused. Tests now cover adding a member to an organization the
admin does not belong to — the case the old fixtures masked by creating every
organization owned by the admin.

- adminUpdateMemberRole refuses to demote an organization's only owner,
  matching adminRemoveMember's last-owner guard
- getOrganizationForAdmin uses the standard requireAdmin middleware
- crossSubDomainCookies gains config-time warnings for the two silent
  browser failures: a cookie domain that does not cover the baseUrl host,
  and localhost/loopback base URLs
- README documents the full multi-app session recipe: shared cookie domain +
  shared BETTER_AUTH_SECRET + shared postgres:// AUTH_DATABASE_URL (ADR 0027)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant