One user, one group: why we removed multiple group membership
Letting a user belong to several groups sounds flexible. In practice, it made permissions ambiguous: which group wins, should rights be merged, and what happens when one group allows and another forbids? Here is why we moved to exactly one group per user, how a single column made the rule impossible to break, and the trade-off we accepted.
The problem
Users in several groups had ambiguous, hard-to-explain permissions, and every token had to merge or choose between groups.
The result
Each user has exactly one group, enforced by the schema itself; resolving a user's permissions for an app is a single, unambiguous lookup.
Stack
The problem
In the first version of our central auth service, a user could belong to several groups, like most systems allow. It seemed like the flexible choice: someone in Sales who also reviews reports could be in "Sales" and "Report Viewers".
Then we noticed the kind of questions this model produces:
- "Why can this user delete folders in the inventory app? Their group can't." Because a second group they were added to months ago can.
- "I removed someone from the Managers group, so why do they still have manager rights?" Because another group gives the same rights.
- "What does this person actually have access to?" Nobody can answer without opening every one of their groups, app by app.
Every question has an answer, but the answers keep surprising people. And in access control, surprise is a security problem. If admins can't predict what a user can do, they can't safely give or remove access.
The details
What multiple groups really require
With several groups per user, every time a token is minted, the auth service has to combine them. That forces decisions that don't have one obvious answer:
- Union or intersection? Usually union: if any group allows it, the user can. But then adding a user to a "read-only" group can never restrict them; it can only add.
- What about "deny"? If one group should prevent something, you need explicit deny rules and a priority order. That's a whole policy engine.
- Which group is "activated" for an app? If group A gives access to the billing app and group B doesn't, does the user get in? With union, yes, and now group B's admin can't keep them out.
- Who is responsible? When a permission comes from three overlapping groups, removing it means finding all three.
The data model also made it easy to create inconsistent states: a join table user_groups(user_id, group_id) needs a unique index to prevent duplicates, extra checks to prevent conflicts, and care around races when two admins edit the same user at once.
A pattern in the confusion
When we looked at why people were in several groups, the answer was almost always the same: an admin wanted a specific combination of rights for a person, and the only tool available was stacking groups. The flexibility wasn't being used to model the organization. It was being used to patch exceptions, and each patch made the next question harder to answer.
The challenges
- The rule has to be enforced, not just recommended. A UI that "usually" assigns one group still allows a second one through a script, an API call or a race.
- Token minting must be unambiguous. For a given user and app, there must be at most one source of permissions.
- Changing groups must be safe. Moving someone from one group to another must update their rights in every affected app, immediately.
- Some people really do need combined rights. Those cases can't disappear; they need a clear home.
The solution
1. The rule: exactly one group per user
A user holds at most one group. That one group defines their rights in every app.
No group means no access to any app (the Root Admin, who manages the auth service itself, bypasses groups entirely).
2. Enforce it with the schema: a column, not a table
Instead of a join table with constraints, membership became one nullable column on the user:
model User {
id Int @id @default(autoincrement())
name String
email String @unique
isRootAdmin Boolean @default(false)
/// A user holds at most one group, ever: a single column, so
/// "one group per user" cannot be violated.
groupId Int?
group Group? @relation(fields: [groupId], references: [id], onDelete: SetNull)
}
A column holds one value. "One group per user" is now structurally impossible to break:
- no unique index to maintain,
- no conflict check in application code,
- no race condition between two admins assigning groups at the same moment,
- no way for a script or a future endpoint to add a second group by accident.
onDelete: SetNull handles group deletion cleanly: when a group is deleted, its members simply have no group anymore (and lose access, which is correct).
This is my favorite kind of rule: one the database makes impossible to break, instead of one the code has to remember.
3. Resolving permissions becomes one lookup
To mint a token for a user in an app, the auth service follows a single path:
users.groupId → the (group, app) access row → its ticked permissions
Because a user has one group, and a group has at most one access row per app (unique on (groupId, appId)), at most one row can ever match. There's never anything to merge, nothing to prioritize, no "which group wins". The answer to "what can this person do in billing?" is always "whatever their group has ticked for billing".
That simplicity shows up everywhere: in the admin UI ("user → Managers → billing: view, create"), in support questions, and in the code that mints tokens.
4. Changing groups: replace, then refresh rights everywhere it matters
Assigning a new group replaces the old one. The response says so explicitly:
"User moved to group 'Finance'. A user holds one group only, so the previous one was replaced."
And because the user's rights change in every app the old group reached and every app the new group reaches, both sets are refreshed:
const previousGroupId = user.groupId;
if (previousGroupId === newGroupId) return { message: 'Nothing changed.' };
await users.setGroup(userId, newGroupId);
const newApps = await groups.findAppIds(newGroupId);
const oldApps = previousGroupId ? await groups.findAppIds(previousGroupId) : [];
const affectedApps = [...new Set([...oldApps, ...newApps])];
await versions.bumpPermissionsForUsersInApps([userId], affectedApps);
Apps in neither set are untouched: the user's tokens there stay valid. Apps in either set get a fresh permissions version, so the user's next request in those apps picks up the new rights immediately (how that works is in "Making an admin change apply everywhere, immediately").
Assigning the same group again is a no-op, so double clicks and retries are harmless.
5. The trade-off we accepted
Permissions can't be stacked. Someone who needs "Worker" rights plus "Report Viewer" rights gets a combined group, for example "Workers + Reports", instead of two groups.
That sounds like more work, and occasionally it is. But it forces a healthy question every time: is this a real role in our organization, or a one-off exception? Real roles become well-named groups that many people share. True exceptions become visible, named and reviewable, instead of hiding in a stack of overlapping memberships.
And many "exceptions" turn out to be shared by several people, which makes them real roles. That's where the next idea came from: organizing groups by department (see "Groups by department: access for a whole team in one click").
6. What about the admin group?
There's no special "admin" table. An "Admin" group is just a group like any other, and its members only get the administrative permissions ticked for it in the apps it's activated for. The single Root Admin, who manages apps, catalogs and groups in the auth service itself, is a flag on the user, outside the group system.
Lessons learned
- Flexibility in access control has a cost: predictability. If admins can't predict what a user can do, the system isn't really under control.
- Look at how a feature is actually used. Multiple groups were mostly used to patch exceptions, not to model the organization.
- Let the schema enforce your invariants. A single column makes "one group per user" impossible to break, with no checks and no races.
- One source of truth per decision. One group → one access row per app → one permission list. Nothing to merge.
- Name your exceptions. A combined group is visible and reviewable; a stack of memberships isn't.
- Changing a group affects two sets of apps. Refresh rights in the old group's apps and the new group's apps.
- Make no-ops explicit. Re-assigning the same group should change nothing and say so.
FAQ
Should users be allowed to belong to multiple roles or groups?
It depends on how much complexity you can manage. Multiple groups require rules for merging permissions and resolving conflicts. One group per user is less flexible but far easier to understand, audit and enforce.
How do I enforce "one group per user" in the database?
Store the group as a single foreign key column on the user table instead of using a many-to-many join table. A column can only hold one value, so a second membership is impossible.
What is the downside of one group per user?
You can't stack permission sets. Users who need a combination of rights need a dedicated group for that combination. In practice, this often reveals shared roles that deserve a clear name.
What happens to users when their group is deleted?
With onDelete: SetNull, their group becomes empty (null), which means no access to any app until an admin assigns a new group. Their sessions in affected apps should be refreshed so the change applies immediately.
Facing a similar problem?
I help teams fix performance, backend and reliability issues in production.
Let's talk