Role-based access control for CRMs and internal tools: a practical guide
Roles, permissions and scope for a CRM or internal tool: how to plan them, model them in the database and enforce them on the server, with code for NestJS, Prisma and Firebase.
Every CRM ends up holding information that not everyone should see: personal documents, passport and visa details, salaries, partner commissions. The moment more than a handful of people use the system, “everyone is an admin” stops being a shortcut and becomes a risk.
Role-based access control (RBAC) is the usual answer. The idea is simple, but it is easy to get wrong in ways that only show up later: a check that exists in the interface but not on the server, a report that leaks other teams’ data, or a permissions table nobody understands any more. This guide is the checklist I use when designing access for CRMs and internal tools.
Three ideas: roles, permissions and scope
Most access rules can be described with three separate questions. Keeping them separate is what keeps the system understandable.
- Permission: what action? Small, specific verbs on a type of record, such as
student:read,student:update_stageorreport:export. - Role: who usually does it? A named bundle of permissions that matches a real job: counsellor, team lead, admissions, finance, partner agent, admin.
- Scope: on which records? Own records, the team’s or branch’s records, or everything.
Code should check permissions, never role names. if (user.role === 'manager') scattered across a codebase is what makes access impossible to change later. When a new role appears, you should only have to edit data, not code.
Start with a permission matrix, not code
Before writing anything, sit with the people who will use the system and fill in a table like this one. It becomes the specification, and later the seed data.
| Permission | Counsellor | Team lead | Admissions | Partner agent | Admin |
|---|---|---|---|---|---|
| student:read | Own | Team | All | Own referrals | All |
| student:update_stage | Own | Team | All | — | All |
| document:view_passport | Own | Team | All | — | All |
| report:export | — | Team | All | — | All |
| user:manage | — | — | — | — | All |
Two things usually come out of this exercise. First, “export” is its own permission: being able to read a list on screen is very different from downloading all of it. Second, outside users such as partner agents need their own role with a much narrower scope, not a restricted version of a staff role.
Model it in the database
A role and permission model that lives in the database lets an admin adjust access without a deploy. In Prisma it looks like this:
Scope gets its own keys next to the action: student:read lets a user open the student list at all, while student:read_team or student:read_all widen which records they see. A user with neither sees only their own. It keeps the table flat and easy to read in the admin screen. When a user signs in, load their permission keys once and put them in the session or token, so every request does not need extra queries.
Enforce it on the server, every time
Hiding a button is good for usability, but it is not security. Anyone can call your API directly, so every route must check permissions on the server. In NestJS a small decorator and guard do the job:
Deny-by-default is the most important line. When someone adds a new endpoint and forgets the decorator, the endpoint is closed rather than open, and the mistake shows up immediately in testing.
Scope: the part most systems forget
A permission check answers “may this user read students?” It does not answer “which students?” That second question has to be applied to every query, including search, exports and dashboards. Put it in one function and reuse it:
Also check scope on single-record routes. GET /students/:id must confirm that the record falls inside the user’s scope, otherwise anyone can walk through IDs one by one.
If you are on Firebase
With Firestore there is often no API server in between, so the rules are the security layer. Keep the role on a user document and check it in the rules:
Apply the same idea to Cloud Storage rules for uploaded documents, and test both with the Firebase emulator before deploying. A rule that only exists in the front end is not a rule.
MFA and sessions
- Require MFA for every staff account, not only admins. Most account takeovers start with a reused password on an ordinary account.
- Keep sessions short for powerful roles and ask for MFA again before sensitive actions such as changing roles or exporting everything.
- Remove access on the day someone leaves. Make disabling a user one click, and have it end their active sessions.
- Review access every quarter. Export the list of users with their roles and ask each team lead to confirm it. Roles drift as people change jobs.
Keep an audit log
Sooner or later someone asks “who changed this?” An audit log answers it, and it is much easier to build at the start than to add later.
Write to it from the service layer in the same transaction as the change, record role and permission changes as well as data changes, and never allow updates or deletes on the table from the application.
Checklist before you go live
None of this is complicated on its own. What makes it work is doing all of it, consistently, from the first version. If you are planning a CRM or internal tool and want a second opinion on the access model, send me a message.