
RBAC for a Custom CRM: React + Node.js Design Guide
The design in five lines
- 1Check permissions, not roles: code asks can(user, "invoice:approve"), never user.role === "manager".
- 2Add record-level rules on top: a sales rep can edit a lead, but only leads they own or in their branch.
- 3Enforce on the server, in middleware and in queries. The React side only hides buttons for a better UI.
Access control is where most custom CRMs and ERPs accumulate their worst technical debt. It usually starts as if (user.role === "admin") in a few places. Two years later there are 200 of those checks, a "senior sales rep who can also approve discounts under 10%" role nobody can implement cleanly, and a reporting endpoint that forgot to check anything. This guide lays out a role-based access control design that holds up as the business grows: a permission model, a database schema, record-level rules, an Express middleware, query scoping, and a React component for the UI side. The code is TypeScript, but the model works in any stack.
The Model: Users, Roles, Permissions
Keep three ideas separate. A permission is one action on one kind of resource, written as a string like lead:edit or invoice:approve. A role is a named set of permissions, such as "Sales Rep" or "Accounts Manager". A user has one or more roles.
The most important rule: application code checks permissions, never role names. A role name in an if statement hard-codes today's org chart into your code. A permission check lets the business create a new role tomorrow from existing permissions, without a deploy.
Five tables cover most CRMs. Seed permissions from code; let admins manage roles.
CREATE TABLE permissions ( key text PRIMARY KEY, -- 'lead:edit', 'invoice:approve' description text NOT NULL ); CREATE TABLE roles ( id serial PRIMARY KEY, name text UNIQUE NOT NULL -- 'Sales Rep', 'Accounts Manager' ); CREATE TABLE role_permissions ( role_id int REFERENCES roles(id) ON DELETE CASCADE, permission_key text REFERENCES permissions(key) ON DELETE CASCADE, PRIMARY KEY (role_id, permission_key) ); CREATE TABLE user_roles ( user_id int REFERENCES users(id) ON DELETE CASCADE, role_id int REFERENCES roles(id) ON DELETE CASCADE, PRIMARY KEY (user_id, role_id) ); -- users table already exists; add the attributes record rules need ALTER TABLE users ADD COLUMN branch_id int;
- Define the list of permission keys in code (one constant per resource and action) and sync it to the permissions table on deploy. That way a typo in a check fails type-checking instead of silently denying.
- Load a user's effective permissions once per request (or cache them briefly) as a Set of strings.
Record-Level Rules
Pure RBAC answers "can this user edit leads?" CRMs almost always need a second question: "can this user edit this lead?" Express those rules as scopes attached to a permission, not as new roles. Common scopes:
A simple way to store this is to put the scope in the permission key itself, such as lead:edit:own, lead:edit:branch and lead:edit:all. The check then becomes: does the user hold the permission at a scope that covers this record? Amount limits ("approve discounts up to 10%") work the same way: keep them as data on the role, not as new code paths.
| scope | meaning | example |
|---|---|---|
| own | Only records the user owns | Sales rep edits their own leads |
| branch | Records in the user's branch or team | Branch manager sees all branch deals |
| all | Every record | Head of sales sees everything |
The Server Check
Put all permission logic in one module. Every route and every background job calls it. Nothing else in the codebase reads role names.
authz.ts: one function decides; routes declare what they need.
import type { Request, Response, NextFunction } from "express";
type Scope = "own" | "branch" | "all";
type User = { id: number; branchId: number | null; permissions: Set<string> };
type OwnedRecord = { ownerId: number; branchId: number | null };
const ORDER: Scope[] = ["own", "branch", "all"];
/** Highest scope the user holds for an action, or null if none. */
export function scopeFor(user: User, action: string): Scope | null {
for (const scope of [...ORDER].reverse()) {
if (user.permissions.has(`${action}:${scope}`)) return scope;
}
return null;
}
export function can(user: User, action: string, record?: OwnedRecord): boolean {
const scope = scopeFor(user, action);
if (!scope) return false;
if (!record || scope === "all") return true;
if (scope === "branch") return record.branchId !== null && record.branchId === user.branchId;
return record.ownerId === user.id; // "own"
}
/** Route-level guard: the user needs the action at some scope. */
export function requirePermission(action: string) {
return (req: Request, res: Response, next: NextFunction) => {
const user = req.user as User | undefined;
if (user && scopeFor(user, action)) return next();
logDenial(user?.id, action, req.originalUrl);
return res.status(403).json({ error: "forbidden" });
};
}Scope the Queries, Not Just the Endpoints
List and report endpoints leak data more often than edit endpoints, because they return many records at once. Apply the same scope to the query so users can only ever fetch what they're allowed to see:
Translate the user's scope into a WHERE clause for list endpoints.
export function leadVisibilityFilter(user: User): { sql: string; params: unknown[] } {
switch (scopeFor(user, "lead:view")) {
case "all": return { sql: "TRUE", params: [] };
case "branch": return { sql: "branch_id = $1", params: [user.branchId] };
case "own": return { sql: "owner_id = $1", params: [user.id] };
default: return { sql: "FALSE", params: [] };
}
}
// GET /leads
const f = leadVisibilityFilter(req.user);
const { rows } = await db.query(
`SELECT id, name, stage FROM leads WHERE ${f.sql} ORDER BY updated_at DESC LIMIT 50`,
f.params,
);- Exports, dashboards, search and autocomplete endpoints need the same filter. They're the ones most often forgotten.
- If you use PostgreSQL row-level security, you can enforce the same rules in the database as a second line of defence.
The React Side: Hide, Don't Enforce
The frontend needs permissions only to avoid showing buttons that would fail. Send the user's permission keys to the client after login and wrap actions in a small component. Never treat this as security: anyone can call your API directly.
Record-level decisions ("can I edit this particular lead?") are best returned by the API alongside the record, for example as a can_edit flag computed with the same can() function. That keeps one source of truth instead of reimplementing the rules in the browser.
A permission context and a <Can> wrapper for buttons and menu items.
import { createContext, useContext, type ReactNode } from "react";
const PermissionContext = createContext<Set<string>>(new Set());
export function PermissionProvider({ keys, children }: { keys: string[]; children: ReactNode }) {
return <PermissionContext.Provider value={new Set(keys)}>{children}</PermissionContext.Provider>;
}
export function useCan(action: string) {
const perms = useContext(PermissionContext);
return ["own", "branch", "all"].some((s) => perms.has(`${action}:${s}`));
}
export function Can({ action, children }: { action: string; children: ReactNode }) {
return useCan(action) ? <>{children}</> : null;
}
// Usage
<Can action="invoice:approve">
<button type="button" onClick={approve}>Approve invoice</button>
</Can>Audit Denials and Role Changes
Two events belong in your audit log from day one: every 403 your authorization layer returns, and every change to roles, role permissions or user roles. Denials show you misconfigured roles (and occasional probing) before users report them. Role changes answer the question every auditor eventually asks: who gave this person access, and when? Our ERP audit log guide covers a PostgreSQL design that records them automatically.
Frequently Asked Questions
Strategic Summary
Final Thoughts
Good access control in a custom CRM comes down to a few disciplined choices: check permissions, never role names; add record scopes instead of inventing roles; enforce everything on the server, including list and export queries; and log denials and role changes. The React layer then only has to hide what the server would refuse anyway. If you're planning a CRM build, see custom CRM vs ready-made for Indian SMBs and what a custom ERP costs. --- Last reviewed: November 10, 2026. Code targets Express 4/5 with node-postgres and React 18/19.
How do you model permissions in your CRM or ERP? Roles in code, a permissions table, or a policy engine? Tell me what's held up.
Next Up
Continue your research
Sources & Research
OWASP Top 10: A01 Broken Access Control
https://owasp.org/Top10/A01_2021-Broken_Access_Control/
OWASP Authorization Cheat Sheet
https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
PostgreSQL Documentation: Row security policies
https://www.postgresql.org/docs/current/ddl-rowsecurity.html
NIST: Role Based Access Control project
https://csrc.nist.gov/projects/role-based-access-control





