← All posts
Backend8 min readProject: Central auth service

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.

#Authorization#RBAC#Access Control#Data Modeling#Prisma#System Design

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

NestJSPrismaPostgreSQLRedis

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:

  1. Activate the group for an app. This opens the app to the group.
  2. 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:

StateLoginToken permissions
group not activated for the app403 permission deniednone (no token issued)
activated, nothing tickedsucceeds[] (may enter, may do nothing)
activated, some tickssucceedsthe 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 actionUsers refreshedApps refreshed
Activate a group for an appevery memberthat app
Deactivate a group for an appevery memberthat app
Tick / untick permissionsevery memberthat app
Delete a groupevery memberevery app the group was activated for
Rename a groupnobodynobody (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
Welcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have fun

Componentity

Thanks for visiting my portfolio. Every project here reflects my passion for building and learning. If you’re interested in working together, have a question, or just want to connect—don’t hesitate to reach out. I'm always open to meaningful conversations and new ideas.

© 2026 Zakariya Zouazou. All Rights Reserved.

Contact Us