Building an Advanced RBAC System in NestJS
A robust permissions system is often a core requirement of B2B SaaS apps. Defining who can access which resources in your application is an interesting challenge because it requires both advanced business logic and fine-grained engineering to enforce it. Nonetheless, most hands-on guides on the topic stop short of explaining advanced role-based access control and stick to simple admin/non-admin permissions.
In this article, we'll cover an advanced yet pragmatic RBAC implementation in NestJS. Our use case will be a sample project management application with admins, contributors and guests. All have different but overlapping sets of privileges. You can find the full repository for the app here.
App business logic
Let's define the business logic of our application.
There are 3 tables in the app:
Users, with a
Projects, with a
id, as well as a
restrictedboolean flag (more on that below)
- A joint table linking users and projects in a many-to-many relationship: we say that a user A is a member of project B is such a relation exists between A and B
There are 3 roles for users in the app:
- Users with
adminrole can perform CRUD actions on all resources (incl. creating and deleting users and creating and deleting projects)
- Users with
contributorrole can edit their own username, as well as list and edit projects they are members of
- Users with
guestrole can only list projects they are members of, in a read-only fashion
As an additional rule, when a project is marked as
restricted, only its members can perform actions on it. This means that some projects can be hidden even from admins if they are not members of that project.
This means that our RBAC scheme has two effects:
ALLOWcertain types of actions (allow-list)
DENYcertain types of actions, which overrides any previous allowance (block-list)
We also expose a
/permissions endpoint in our app so that a front-end consumer can use these permissions to conditionally toggle the display of some features (e.g., only admin users should see the "delete" button on users).
Now that we've defined specifications, let's outline the app architecture.
The permissions system works as follows:
- We define a custom
- Each route of each controller in the app must be decorated with this permissions declaration, as well as use the related guard
- Whenever a request hits the app, we identify the current user making the request along with its role
PermissionsGuardcomputes the permissions for the user based on his role and his memberships in the projects
- These permissions are compared against the permissions required for the controller route
- If there is no match, the route is forbidden and we return a 403
- If there is a match, the route is authorized and we inject the permissions context to make it available to downstream consumers (controller and services)
- The permissions context can be used by services to filter some resources in or out once the request has been authorized (e.g., restricted projects are not returned to admins if they are not members in them)
It is overall a rather straightforward architecture, which tries to respect NestJS conventions in the request lifecycle. Note however that we cannot perform full separation of concerns by localising the permissions logic only in the guard. Indeed, permissions logic necessarily trickles down to the ORM layer where permission data is required to scope and filter DB queries.
Again, you can find the full repository for the app here.
Please note these important disclaimers:
- The app is not completely finalized as it is actually an open-source porting of the permissions logic of a larger previous project. It focuses only on the RBAC logic and still leaves some elements up to you for implementation
- Migrations are not taken care of in the app
- There is no authentication logic in the app, only authorization logic related to RBAC
- There is no multi-tenancy in the app (all users share the same workspace)
- The ability for users to edit their username is not implemented; however using the
canActivatemethod of the injected
PERMISSIONprovider, you should be able to implement rather easily 🤓
The overall RBAC logic of this app has been battle-tested, audited and pentested in the context of its parent project; however there is no guarantee that it has been correctly extracted in this sample app, and you should always do your own research and perform in-depth testing before rolling out your own permissions system.
I have personally worked for 2 years on this RBAC system. My key learnings on the topic are:
- Build a framework from day 1: it should be super easy for all devs on your team to add new routes and permissions to your app, even if they don't get all the internals of the RBAC system
- Test extensively: the permissions logic and decorators themselves, and also the business logic of your app related to permissions
- Keep it simple: it is really easy to get lost in all subtleties of modern permissions system, with various logics that can be rolled out (groups, allow-lists, blocklists, constraints...). Identify what your business needs are before getting swallowed in implementation rabbit holes. And don't forget that debugging always takes more intellectual resources than implementation, so you should ensure that implementation is not too complex 😉
- Do your homework on theory: read extensively on the topic to get the formal basics right. Try to model your permissions system using graphs for example (see the numerous articles by Neo4J on that topic). It will force you to stick to standards while bringing clarity to your specifications
All in all, I've found this to be a very interesting topic to dive deeper into on the course of mastering NestJS. Thanks for reading; you can reach out to me on Twitter to continue the discussion if you're interested in RBAC too.