Walid Chennit

Designing an RBAC engine that doesn't make your CTO cry

What we learned building role-based access for researchers, project leads and admins in the CDTA RDIS platform.

Sep 18, 2025·11 min read·RBAC · NestJS · Architecture

The shape of the problem

RDIS manages the research lifecycle at CDTA — projects, missions, purchase requests, service approvals — for people who each see a different slice of that world. A team member should see their own project's experiments. A team lead approves requests for their team. A secretary handles administrative workflows across teams. A director signs off on budget and mission orders. A superuser configures the whole system. Seven roles in total, and the temptation with seven roles is always to reach for a big permission matrix and call it done.

That temptation is a trap. A flat matrix of role × action checks boxes fast, and then six months later nobody can explain why a chef d'équipe can approve one kind of request but not another, because the answer lives in twelve scattered if (user.role === 'chef') checks instead of one place.

What we built instead

We separated *who you are* from *what you can touch*. Roles define a set of permissions (create_mission, approve_purchase, view_team_experiments), and every protected route or mutation checks a permission, never a role name directly. That one discipline — no role === X string comparisons in business logic, ever — is what keeps the system legible as it grows, because adding an eighth role later means composing existing permissions instead of hunting down every place a role name was hardcoded.

On top of that sits scoping: a permission on its own answers "can this user approve purchases," but not "which purchases." We attach a scope resolver to actions that need one — team-level, project-level, or global — so a chef d'équipe with approve_purchase only ever sees requests inside their own team's scope, enforced at the query layer in NestJS with Prisma, not filtered after the fact in the controller.

Multi-level validation workflows fell out of this almost for free: a mission order that needs both a team lead's and a director's sign-off is just a workflow definition listing two required permissions in sequence, not a special case bolted onto the mission-order module.

The guard rail that mattered most

The single decision I'd defend hardest: permission checks live in a NestJS guard decorator at the route level, and nowhere else. It's tempting to also add a defensive check deeper in a service method 'just in case,' but that duplication is exactly how RBAC systems rot — two sources of truth that quietly drift apart until someone can do something the UI didn't mean to allow. One source of truth, enforced at the boundary, tested at the boundary.