A user table is easy until the same person needs a second workspace.
The first invite works. Then someone joins another client account, starts helping a second product, or needs customer access in one tenant and agent access in another. A tenant-scoped user model responds by copying the user. That solves the invitation in front of you and creates a small identity-management hobby for every future you.
We changed Muninx in the other direction. A user is now a global account. Access to a tenant is a separate membership.
That sentence is short enough to fit in a pull-request title. Getting it right took changes across authentication, tenant management, invitations, permissions, token handling, and the interface where people switch workspaces. The useful lesson is not “put users in a different table.” It is deciding which facts belong to identity and which facts only make sense inside one workspace.
If you want the operator case before the machinery, start with why a support account should not be trapped in one workspace.
The boundary we wanted
The root account owns facts that remain true when a person changes workspace:
- normalized email address
- credentials and sign-in methods
- profile details and avatar
- password reset and account recovery
- account deletion
The membership owns facts that are meaningful only inside one tenant:
- tenant identifier
- role and membership status
- team or customer-organization assignment
- tenant-specific work behavior and preferences
- seat and billing effects for staff roles
Tickets, messages, customer records, internal notes, settings, and analytics remain tenant-scoped. The global account is not permission to see all data. It is a reliable answer to “who authenticated?” The selected membership and active tenant answer “what may they do right now?”
That separation also lets one person have different relationships in different places. An admin in one tenant can be an agent in another and a customer in a third. Trying to encode that in a single user record either produces a surprising number of nullable columns or a career in explaining why role = admin does not actually mean admin. Neither is ideal.
Login is no longer a tenant lookup
The old order tends to be: find the tenant, then find the user in that tenant, then authenticate. The workspace URL or slug becomes part of the login ritual.
The new order is: authenticate the global account, fetch the memberships the account may see, then choose an active tenant. Login itself does not need a tenant identifier.
The active tenant still matters. API requests and interface navigation need a workspace context, and the session/token issued after switching is scoped to that context. That preserves the useful property of tenant isolation: an authenticated account is not a blank cheque across every workspace it belongs to.
For the interface, this becomes a straightforward user menu: show the account once, list memberships the person can enter, indicate the active workspace and role, and let them switch deliberately. A suspended membership stays visible enough to explain why it cannot be selected. Hidden states are how access bugs get their tiny capes.
Invitations create memberships, not duplicate people
Invitations are where an identity refactor either becomes coherent or starts growing a second identity system in the basement.
When an admin adds someone to a tenant, the service first resolves whether the email already belongs to a global account.
- Existing account: create the tenant membership immediately.
- New account: create a pending membership, send an invitation, and activate that membership when the person completes account setup.
The difference matters. In both cases the tenant receives a membership record. The only question is whether the account already exists. The invitation is not a temporary user-shaped object that later needs to become a different user-shaped object.
Email normalization is part of this boundary, not a tidy-up task for later. Store and compare a normalized address consistently before looking up accounts or memberships. Otherwise [email protected] and [email protected] can acquire separate histories, which is a remarkably expensive way to learn that email is case-insensitive in practice.
Membership acceptance and removal should also be idempotent. Invitation links get opened twice. Webhooks retry. Browser tabs survive longer than good intentions. A second acceptance should safely report the already-active membership; a completed removal should not revive access because a retry arrived late.
Authorization belongs at the membership boundary
Moving identity out of the tenant does not make authorization less important. It gives it a proper home.
In Muninx, membership management is authorized in the target tenant. An admin can manage memberships in their current tenant; a person may inspect their own cross-tenant membership list, but not everyone else’s. That lets the product build a workspace switcher without quietly adding an organization-discovery API for every signed-in user.
The membership also carries the state that needs local rules. An administrator or agent is assigned to the staff team; a customer can be associated with a customer organization. A membership can be pending, active, or suspended. Suspending or removing a membership changes access in that tenant without changing the person’s account elsewhere.
Some actions need sharper edges:
- A person should not suspend their own active membership by accident.
- An owner must transfer ownership before leaving a tenant.
- Removing a membership revokes that tenant’s API tokens.
- Staff memberships can affect the tenant’s seat count and billing; customer memberships should not accidentally consume a paid agent seat.
- A role change may not be a harmless update when the old and new roles have different data boundaries.
None of these are glamorous. They are exactly the rules that prevent a global identity model from becoming an elegant diagram with a surprise production incident attached.
The migration is a lifecycle project
The implementation work was not one database migration. It was a sequence of boundary changes across services.
First, preserve every existing person as a root account and create the memberships that represent their current tenant access. Do not change authentication before you can explain each existing person’s memberships. The data migration should be able to answer: which account owns this email, which tenant access does it retain, and what role/status does it have there?
Then change the flows in order:
- Authenticate a global account without requiring a tenant at login.
- Create an owner membership with each new tenant.
- Convert tenant invitations into membership creation and acceptance.
- List memberships after authentication and let the user select an active tenant.
- Scope tokens, tenant settings, and API authorization to that active membership.
- Update offboarding so it removes memberships and revokes tenant-specific credentials before account deletion is ever considered.
The order matters because every half-migrated path is an invitation to create an account with no access, access with no account, or a token whose tenant claims make no sense. Distributed systems love a transitional state. Customers do not need to meet all of them.
Test the unpleasant states on purpose
The happy path is short: sign in, choose workspace, open ticket. The confidence comes from testing what happens when the account lifecycle is less polite.
Use a matrix that includes at least these cases:
- an existing account invited to a second tenant
- a new account with a pending invitation
- acceptance retried after the membership is already active
- a suspended membership in the workspace list
- a user leaving one of several tenants
- a tenant owner attempting to leave before transferring ownership
- membership removal revoking only that tenant’s API tokens
- root-account deletion removing memberships across all tenants
- a user attempting to list another person’s memberships
These are not merely test cases. They are the promises the model makes. A clean data diagram with an ambiguous offboarding flow is still an ambiguous product.
What this does not solve
Global identity and memberships give SSO, SCIM, and fine-grained permissions a clearer place to attach. They do not implement those things automatically. Cross-tenant reporting remains a deliberate product and privacy decision. Workspace-level authorization still needs tests. And a single login does not make it acceptable to blend customer data across tenants.
The gain is more modest and more durable: access becomes a relationship you can add, change, suspend, or remove. The person remains a person.
That is a much better foundation than copying user records every time the real world inconveniently provides the same email address twice.