Download CV WhatsApp

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.

Who this is for
Developers building a CRM, HR system or admin panel with more than one kind of user. The examples use NestJS, Prisma and Firebase, but the ideas apply to any stack.

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_stage or report: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.

PermissionCounsellorTeam leadAdmissionsPartner agentAdmin
student:readOwnTeamAllOwn referralsAll
student:update_stageOwnTeamAll—All
document:view_passportOwnTeamAll—All
report:export—TeamAll—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:

schema.prisma
model User { id String @id @default(cuid()) email String @unique roleId String role Role @relation(fields: [roleId], references: [id]) branchId String? // used for "team" scope } model Role { id String @id @default(cuid()) name String @unique // "counsellor", "team_lead", ... users User[] permissions RolePermission[] } model Permission { id String @id @default(cuid()) key String @unique // "student:read", "student:read_team" roles RolePermission[] } model RolePermission { roleId String permissionId String role Role @relation(fields: [roleId], references: [id]) permission Permission @relation(fields: [permissionId], references: [id]) @@id([roleId, permissionId]) }

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:

permissions.guard.ts
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, SetMetadata } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; export const PERMS_KEY = 'permissions'; export const RequirePermissions = (...perms: string[]) => SetMetadata(PERMS_KEY, perms); @Injectable() export class PermissionsGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(ctx: ExecutionContext): boolean { const required = this.reflector.getAllAndOverride<string[]>(PERMS_KEY, [ctx.getHandler(), ctx.getClass()]); // Deny by default: a route with no declared permission is a bug, not a public route. if (!required || required.length === 0) throw new ForbiddenException('No permission declared for this route'); const user = ctx.switchToHttp().getRequest().user; // set by your auth guard const granted = new Set<string>(user?.permissions ?? []); if (!required.every((p) => granted.has(p))) throw new ForbiddenException('Missing permission'); return true; } }
students.controller.ts
@Controller('students') @UseGuards(JwtAuthGuard, PermissionsGuard) export class StudentsController { constructor(private readonly students: StudentsService) {} @Get() @RequirePermissions('student:read') findAll(@Req() req) { return this.students.findAllFor(req.user); } @Patch(':id/stage') @RequirePermissions('student:update_stage') moveStage(@Req() req, @Param('id') id: string, @Body() dto: MoveStageDto) { return this.students.moveStage(req.user, id, dto); } }

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:

students.scope.ts
import { Prisma } from '@prisma/client'; export function studentScope(user: AuthUser): Prisma.StudentWhereInput { const has = (p: string) => user.permissions.includes(p); if (has('student:read_all')) return {}; if (has('student:read_team')) return { branchId: user.branchId }; return { counsellorId: user.id }; // own records only } // every read goes through the scope const students = await prisma.student.findMany({ where: { AND: [studentScope(user), filters] }, });
Common leak
The list screen is scoped, but the CSV export, the global search or a dashboard total queries the table directly. Test those three on purpose with a low-privilege account.

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:

firestore.rules
rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { function signedIn() { return request.auth != null; } function role() { return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role; } match /staff/{staffId} { allow read: if signedIn() && role() in ['admin', 'compliance']; allow write: if signedIn() && role() == 'admin'; } // users may read their own profile but never change their own role match /users/{uid} { allow read: if signedIn() && request.auth.uid == uid; allow write: if signedIn() && role() == 'admin'; } } }

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.

schema.prisma
model AuditLog { id String @id @default(cuid()) actorId String action String // "student.stage_changed", "user.role_changed" entity String // "Student" entityId String before Json? after Json? ip String? createdAt DateTime @default(now()) @@index([entity, entityId]) }

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

Design
A permission matrix agreed with the people who use the system. Code checks permissions, not role names. Export is a separate permission.
Server
Every route has a permission, and routes without one are denied. Single-record routes check scope too.
Data
One scope function used by lists, search, exports and dashboards. Tested with a low-privilege account.
People
MFA for all staff, same-day removal for leavers, a quarterly access review and an audit log of every change.

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.

#RBAC #CRM #NestJS #Prisma #Firebase #Security