RBAC design
Badly designed RBAC is guaranteed rework. This is the process and the model that work.
The model: roles + granular permissions (not pure roles)
Pure roles ("admin can do everything, editor can edit") break at the first real exception. The house model:
- Permission = atomic action on a domain (e.g.
task:create, document:approve, report:export).
- Role = named set of permissions (N:N role ↔ permission).
- User receives roles (N:N user ↔ role), and the system resolves the effective permission set.
model Role {
id String @id @default(uuid())
workspaceId String
name String
permissions RolePermission[]
}
model Permission {
id String @id @default(uuid())
domain String // e.g. "task", "document", "report"
action String // e.g. "create", "read", "update", "approve", "delete"
roles RolePermission[]
@@unique([domain, action])
}
model RolePermission {
roleId String
permissionId String
role Role @relation(fields: [roleId], references: [id])
permission Permission @relation(fields: [permissionId], references: [id])
@@id([roleId, permissionId])
}
Server-side check via guard/decorator (@RequirePermission('document:approve')). The frontend uses permissions only to hide buttons — never as real control.
The process: matrix before code
Do not implement RBAC straight into code. The correct flow:
- Map the domains of the system (the "areas": tasks, documents, users, reports, settings...). Medium systems have 8–15 domains.
- Map the real roles of the client's operation (not the ones you imagine). Usually 5–8 roles are enough; more than that smells like roles that should be standalone permissions.
- Build the role × domain matrix in a readable document (table: rows = roles, columns = domains, cells = allowed actions).
- Submit it for approval by the architect/tech lead and, when it makes sense, the client. The matrix is cheap to change; code and migrations are not.
- Only after approval, implement — and if implementing into an existing system, verify the real codebase first (do not implement against an imagined codebase).
Known traps
- Implicit permission: an undocumented "everyone can read" becomes a security hole when requirements change. Every permission is explicit in the matrix.
- The ever-growing God role: the "admin" role that accumulates everything. Acceptable, but record that it is a super-user by design.
- RBAC without tenant: in a multi-tenant system, roles and assignments belong to the workspace (
workspaceId on Role). Global roles only with a recorded decision.
- Premature granularity: start with standard actions (create/read/update/delete + the 2–3 domain-specific business actions, like "approve"). Do not invent 40 actions per domain on day 1.
1---2name: rbac-design3description: Role-based access control design with roles + granular permissions — role × domain matrix, N:N database modeling and an approval process before implementation. Use whenever the project involves permissions, user roles, access control, admin panels or "who can do what" questions in the system.4---56# RBAC design78Badly designed RBAC is guaranteed rework. This is the process and the model that work.910## The model: roles + granular permissions (not pure roles)1112Pure roles ("admin can do everything, editor can edit") break at the first real exception. The house model:1314- **Permission** = atomic action on a domain (e.g. `task:create`, `document:approve`, `report:export`).15- **Role** = named set of permissions (N:N role ↔ permission).16- **User** receives roles (N:N user ↔ role), and the system resolves the effective permission set.1718```prisma19model Role {20 id String @id @default(uuid())21 workspaceId String22 name String23 permissions RolePermission[]24}2526model Permission {27 id String @id @default(uuid())28 domain String // e.g. "task", "document", "report"29 action String // e.g. "create", "read", "update", "approve", "delete"30 roles RolePermission[]31 @@unique([domain, action])32}3334model RolePermission {35 roleId String36 permissionId String37 role Role @relation(fields: [roleId], references: [id])38 permission Permission @relation(fields: [permissionId], references: [id])39 @@id([roleId, permissionId])40}41```4243Server-side check via guard/decorator (`@RequirePermission('document:approve')`). The frontend uses permissions only to hide buttons — never as real control.4445## The process: matrix before code4647**Do not implement RBAC straight into code.** The correct flow:48491. **Map the domains** of the system (the "areas": tasks, documents, users, reports, settings...). Medium systems have 8–15 domains.502. **Map the real roles** of the client's operation (not the ones you imagine). Usually 5–8 roles are enough; more than that smells like roles that should be standalone permissions.513. **Build the role × domain matrix** in a readable document (table: rows = roles, columns = domains, cells = allowed actions).524. **Submit it for approval** by the architect/tech lead and, when it makes sense, the client. The matrix is cheap to change; code and migrations are not.535. **Only after approval**, implement — and if implementing into an existing system, verify the real codebase first (do not implement against an imagined codebase).5455## Known traps5657- **Implicit permission**: an undocumented "everyone can read" becomes a security hole when requirements change. Every permission is explicit in the matrix.58- **The ever-growing God role**: the "admin" role that accumulates everything. Acceptable, but record that it is a super-user by design.59- **RBAC without tenant**: in a multi-tenant system, roles and assignments belong to the workspace (`workspaceId` on `Role`). Global roles only with a recorded decision.60- **Premature granularity**: start with standard actions (create/read/update/delete + the 2–3 domain-specific business actions, like "approve"). Do not invent 40 actions per domain on day 1.