Groups by department: giving a whole team access in one click
Assigning permissions user by user doesn't scale: every new hire, every move, every new app means dozens of clicks and inevitable mistakes. Here is how we modeled access around departments: global groups, a separate activation step per app that acts as the real access boundary, and permission ticks that can never leak into an app the group wasn't opened for.
The problem
Giving access user by user across several apps was slow, error-prone, and impossible to review.
The result
Departments are groups: one activation opens an app to the whole team, ticks define what they can do there, and onboarding a person is a single assignment.
Stack
The problem
Imagine the first version of access management in a multi-app company:
- a new employee joins Finance → an admin opens each app, finds the employee, and ticks their permissions one by one,
- someone moves from Sales to Finance → an admin removes the old permissions (hopefully all of them) and adds the new ones, app by app,
- a new app is connected → somebody has to decide, person by person, who gets in and with what rights.
Every one of those steps is slow, repetitive and easy to get wrong. Worse, it's impossible to review: to answer "does everyone in Finance have the same rights in the billing app?", you'd have to compare dozens of individual permission lists.
People don't really get permissions as individuals. They get them because of what they do, and in most companies that means which department or team they belong to. Our access model should look like the organization.
The details
Groups are departments
In our central auth service, a group is a collection of people: Finance, Sales, Production, Branch A, Branch B. It's global, not a role inside one app. The same Finance group is used for every app the company has.
Combined with the rule that each user belongs to exactly one group (see "One user, one group"), onboarding becomes a single action:
New Finance employee → assign them to the Finance group. Done, in every app.
Moving departments is the same single action. Removing someone's group removes their access everywhere.
A group holds no permissions by itself
This is the key design decision. Creating a group grants nothing. What a group's members can do is defined per app, in two separate steps:
- Activate the group for an app. This opens the app to the group.
- Tick permissions for that (group, app) pair. This defines what members can do inside.
So one group can mean very different things in different apps:
Group: Finance (global: one list of people)
├── activated for billing
│ ✔ billing:invoices:view
│ ✔ billing:invoices:create
│ ✘ billing:invoices:delete
│
└── activated for inventory
✔ inventory:stock:view
✘ inventory:stock:update
Finance members are power users in billing and read-only viewers in inventory. One group, two meanings, both explicit.
The challenges
"Can't enter" vs. "can enter but do nothing"
These two states look similar but mean very different things:
- Not allowed into the app at all: login should be refused with a clear message.
- Allowed in, nothing ticked yet: login should succeed with an empty permission list. That's typical while an admin is still setting up a new app.
A model that derives access from "has at least one permission" can't tell them apart.
One careless click must not open a new app
If ticking a permission were enough to grant access, an admin editing billing permissions could, with one wrong selection, give a whole department access to an app they were never supposed to see.
Admin rights must stay inside their app
Some groups contain admins of a specific app. Their admin rights must not leak into other apps.
Every change must apply immediately, to every member
When an admin activates an app for a department of 40 people, all 40 should see the change on their next request, not after logging out and back in.
The solution
1. Activation is a row, and the row is the grant
/// Activates a group for one application. The existence of this row IS
/// the access grant: with no row, members cannot enter that app at all,
/// no matter what is ticked elsewhere.
model GroupAppAccess {
id Int @id @default(autoincrement())
groupId Int
appId Int
permissions GroupAppPermission[]
@@unique([groupId, appId])
}
/// Which permissions a group has ticked for one specific application.
model GroupAppPermission {
groupAppAccessId Int
permissionId Int
@@id([groupAppAccessId, permissionId])
}
Two important properties come straight from the schema:
- Permissions hang off the activation row, not off the group. A tick can only exist inside an activation, so it's impossible to tick a permission for an app the group was never activated for. One careless edit can't open a new app.
(groupId, appId)is unique, so a group is activated for an app at most once, and resolving a user's rights is a single lookup with nothing to merge.
2. The access check reads activation, not permission count
When a user logs in or exchanges their session for an app's token, the decision is:
Root Admin? → allowed (manages the auth service itself)
user has no group? → denied
user's group has no activation for app? → denied
otherwise → allowed, with the ticked permissions (possibly none)
So the two states stay distinct:
| State | Login | Token permissions |
|---|---|---|
| group not activated for the app | 403 permission denied | none (no token issued) |
| activated, nothing ticked | succeeds | [] (may enter, may do nothing) |
| activated, some ticks | succeeds | the ticked permissions |
A denial is an explicit error, never a token with an empty list that would look like a working login.
3. Admin rights can't leak
There's no special admin table. An "Admin" group is just a group, and its members only have the admin permissions ticked for the apps it's activated for. Since ticks live under activations, admin rights in billing say nothing about inventory. Permission keys are also namespaced by app (billing:invoices:delete), so two apps' catalogs never collide.
4. Every group action knows who to refresh
Because a group is global but its rights are per app, one admin action can affect many (user, app) pairs. Each action refreshes exactly the right set:
| Admin action | Users refreshed | Apps refreshed |
|---|---|---|
| Activate a group for an app | every member | that app |
| Deactivate a group for an app | every member | that app |
| Tick / untick permissions | every member | that app |
| Delete a group | every member | every app the group was activated for |
| Rename a group | nobody | nobody (no rights changed) |
"Refreshed" means the members' permissions version for that app is bumped in Redis, so their next request in that app picks up the new rights immediately (details in "Making an admin change apply everywhere, immediately").
Deleting a group reads its members and its apps before deleting, because the cascade removes the activation rows and the foreign key clears each member's groupId. After the delete, there would be nothing left to answer "who was affected?".
5. What it looks like for an admin
Day-to-day access management becomes a handful of clear actions:
- New employee → assign a group (one click, every app).
- New app → activate it for the departments that need it, tick their permissions once per department.
- New feature in an app → tick the new permission for the departments that should have it.
- Review → open a department and read, per app, exactly what it can do.
Instead of hundreds of individual permission lists, the whole access picture of the company is a short list of departments × apps, small enough for a person to read and reason about.
Lessons learned
- Model access the way the organization works. People get rights because of their department or role, so groups should be departments and roles.
- Separate "may enter" from "may do". Activation is the boundary; ticks are the details.
- Make the grant a row. When the existence of a row is the access, the deny rule is simple and the data can't contradict itself.
- Let the schema prevent mistakes. Ticks that can only live under an activation can't leak into other apps.
- Keep "empty" and "denied" distinct. "Allowed in with no rights yet" and "not allowed in" need different responses.
- Every bulk action needs a precise blast radius. Know exactly which users and apps each admin action affects, and refresh only those.
- Read before you cascade. Collect who's affected before a delete erases the answer.
FAQ
What is the difference between a group and a role?
A group is a collection of people (like a department). A role is a set of permissions. In this model, a group gets a different set of permissions in each app, which acts like a per-app role for every member.
Why separate app access from permissions?
So that granting access to an app is a deliberate, separate step. Editing permissions can then never accidentally open an app to people who were never meant to use it.
How do I give a whole department access to a new application?
Activate the department's group for the application, then tick the permissions that department needs there. Every current and future member gets that access through their group.
Should a user with access but no permissions be allowed to log in?
Yes, if access was deliberately granted. It's common while permissions are still being configured. But it must be clearly different from users who have no access at all, who should get an explicit denial.
Facing a similar problem?
I help teams fix performance, backend and reliability issues in production.
Let's talk