fix(auth): bind admin-created users to the sole organization#2882
Merged
Conversation
`/admin/create-user` built a login-capable account but never wrote a `sys_member` row, unlike the invite / add-member flows. In a single-org deployment the new user therefore did not "belong to" the Default Organization and was missing from the Members list, even though both are just "add a teammate". Bind the created user to the organization when it is unambiguous (exactly one `sys_organization` — single-org, incl. plugin-auth's default-org bootstrap). Multi-org (>=2 orgs) is left untouched: there the active-org-aware invite / add-member endpoints own membership. The bind is idempotent, best-effort (never fails account creation), and its outcome is surfaced in the response + audit metadata (organizationId, membershipCreated). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XbLd2KKeVs39EztDYMQ1YU
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…bership lifecycle Proposes closing the two structural gaps behind the create-user membership bug (PR #2882): (1) the "every new user gets a membership" invariant gets a single owner — a membership reconciler composed into better-auth's user.create.after hook, with an explicit membershipPolicy (auto/invite-only); (2) tenancy mode becomes a first-class `tenancy` kernel service consumed by SecurityPlugin / SQL driver / auth config, retiring ambient re-derivation (env flag, service probe, org row-counting). Degraded tenancy (multi-org requested, enterprise package missing) fails fast instead of silently stripping tenant RLS, with an explicit OS_ALLOW_DEGRADED_TENANCY escape hatch. Includes backfill plan, edge-case ledger, non-goals, and a 4-phase rollout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XbLd2KKeVs39EztDYMQ1YU
This was referenced Jul 13, 2026
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.
Problem
Creating a user via Setup → Users → Create User (
POST /api/v1/auth/admin/create-user) built a login-capable account but never wrote asys_memberrow. The invite (organization/invite-member) and add-member (organization/add-member) flows do create membership. So the two "add a teammate" paths diverged: in a single-org deployment the admin-created user did not "belong to" the Default Organization and was missing from the Members list — while invited users showed up. That inconsistency is what this PR resolves.Note: in single-org mode membership does not gate data access (no
OrgScopingPlugin, thetenant_isolationRLS wildcard is stripped — RBAC permission sets gate access), so this was a UX/consistency wart, not a security hole. But the missing membership was still confusing and out of step with the invite flow.Change
1. Tactical fix —
runAdminCreateUserbinds membershiprunAdminCreateUsernow binds the newly-created user to the organization when it is unambiguous — i.e. exactly onesys_organizationexists (single-org, including plugin-auth's default-org bootstrap, ADR-0081 D1):sys_member { organization_id, user_id, role: 'member' }.The bind is:
(organization_id, user_id)membership before inserting, so retries/races don't trip the unique index;dataand the audit metadata (organizationId,membershipCreated).This mirrors the existing pattern in
ensure-default-organization.ts, which already insertssys_memberdirectly under a system context.2. Strategic proposal — ADR-0093 (Proposed)
The bug is one instance of two structural gaps, so this PR also carries the ADR that proposes closing them for good:
docs/adr/0093-tenancy-mode-and-membership-lifecycle.md— Tenancy mode as a first-class capability, and a single owner for the user→membership lifecycle. Key decisions (Proposed, for review):user.create.afterhook (covers create-user, import-users, signup, SSO JIT — present and future paths), with an explicitmembershipPolicy: 'auto' | 'invite-only'. Host hooks chain first and win; the reconciler yields to existing memberships.tenancykernel service (mode,isolationActive,requested,degraded,defaultOrgId()) becomes the single source of truth, retiring the four ambient signals (env flag,org-scopingprobe, org row-counting — including this PR's own heuristic — and frontend feature flags re-derivation).OS_MULTI_ORG_ENABLED=truebut@objectstack/organizationsmissing → tenant RLS stripped with only a console warning) fails fast at boot, with an explicitOS_ALLOW_DEGRADED_TENANCY=1escape hatch that brands the deployment degraded end-to-end.Phase 2 of the ADR retires this PR's endpoint-level bind in favor of the reconciler; the two are safely double-covered in the interim (both idempotent, both yield-to-existing).
Files
packages/plugins/plugin-auth/src/admin-user-endpoints.ts— addbindUserToSoleOrganization(+findRows/genMemberIdhelpers), an optionalfindon theAdminUserDataEnginesurface, and wire the bind intorunAdminCreateUser.packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts— cover single-org bind, multi-org no-op, idempotency, bind-failure isolation, and the no-findno-op.docs/adr/0093-tenancy-mode-and-membership-lifecycle.md— the strategic proposal above (Status: Proposed).Testing
vitest run src/admin-user-endpoints.test.ts→ 29 passed (24 existing + 5 new).auth-plugin.ts) already passes the full ObjectQL engine throughgetDataEngine(), which exposesfind, so no route change was needed.Out of scope / follow-up
admin-import-users.ts) creates users the same way; per ADR-0093 Phase 2 it gets covered by the reconciler rather than another endpoint-level patch.🤖 Generated with Claude Code
https://claude.ai/code/session_01XbLd2KKeVs39EztDYMQ1YU