Role-Based Access Control
Roles group permissions so people can be granted a job, not a list. What roles cannot express is anything about the object.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
When is "what role are you" a sufficient authorization model, and what does it fail to say?
A support tool. Agents can read tickets and reply. Team leads can also reassign and close. Admins can do all of that and manage users. New hires should get the right access on day one without an engineer writing code.
Add a role column with values agent, lead, admin, and check it where it matters: if (user.role !== 'admin') return 403. It is one column and it reads exactly like the requirement.
The requirements stop being nested. Billing needs to close tickets but must not read message bodies, which is not "less than a lead" or "more than an agent" — the linear hierarchy breaks and role >= lead comparisons start producing wrong answers.
- The requirements stop being nested. Billing needs to close tickets but must not read message bodies, which is not "less than a lead" or "more than an agent" — the linear hierarchy breaks and
role >= leadcomparisons start producing wrong answers. - Someone needs one extra capability, so a role is cloned:
lead,lead_with_export,lead_eu. Role explosion arrives quietly and then becomes the dominant maintenance cost. - A role check cannot express "their own tickets" or "tickets in their team". So either every agent can read every ticket, or the object rule is bolted on somewhere else and the role becomes decorative (Object-Level Authorization).
- Scattered string comparisons mean adding a role requires a code change and a deploy, which is exactly what the requirement asked to avoid.
- The literal
'admin'appears in 40 places. Renaming it, or splitting it, is now a refactor with security consequences.
What is actually happening
- RBAC has three tables, whether or not you make them explicit: principals, roles, and permissions, with principal→role and role→permission relations. The point is the indirection: you grant a job, and the job carries a set of capabilities.
- Code checks permissions, not roles.
require('ticket:close')survives a role being renamed, split or merged;if (role === 'lead')does not. - Permissions are usually named
resource:action—ticket:read,ticket:close,user:manage. The naming is doing real work: it is the vocabulary your product's access model is written in, and it is hard to change later. - Roles may be scoped: a user can be an admin of workspace A and a viewer of workspace B. The moment that is true, the assignment is (principal, role, scope) and a global
user.rolecolumn is no longer sufficient (Multi-Tenancy). - Role hierarchies (admin implies lead implies agent) are convenient and become wrong as soon as one permission does not nest. Explicit permission sets per role are more verbose and stay correct.
- RBAC answers "may this kind of user perform this kind of action". It says nothing about *which* object, which is a different check running in a different place — the two are complementary, not alternatives.
Check permissions, store roles
The single change that makes RBAC survive contact with a growing product is putting one level of indirection between the code and the role name. Code asks for a capability; data decides which roles carry it.
This is what lets a new role be created by an administrator rather than an engineer, lets a role be split without touching handlers, and makes "what does this role actually grant" a query rather than an archaeology exercise.
1type Permission = 'ticket:read' | 'ticket:reply' | 'ticket:close' | 'ticket:reassign' | 'user:manage'2 3// data, not code — editable without a deploy4const ROLE_PERMISSIONS: Record<string, Permission[]> = {5 agent: ['ticket:read', 'ticket:reply'],6 lead: ['ticket:read', 'ticket:reply', 'ticket:close', 'ticket:reassign'],7 admin: ['ticket:read', 'ticket:reply', 'ticket:close', 'ticket:reassign', 'user:manage'],8 // billing does NOT inherit from agent: no ticket:read9 billing: ['ticket:close'],10}11 12function permissionsOf(assignments: RoleAssignment[], scope: string): Set<Permission> {13 const out = new Set<Permission>()14 for (const a of assignments) {15 if (a.scope !== scope) continue // scoped: admin of A is not admin of B16 for (const p of ROLE_PERMISSIONS[a.role] ?? []) out.add(p)17 }18 return out19}20 21// call sites name capabilities, never roles22require(principal, 'ticket:close', { scope: ticket.workspaceId })The billing role is the point of the example: it holds ticket:close without ticket:read. Any model built on "lead is more than agent" cannot represent it, and an ordered comparison would grant it message bodies it must not have.
What roles cannot say
Roles quantify over *kinds* of user and *kinds* of action. Every requirement that mentions a particular object — their own, their team's, still in draft, under a threshold — is outside what a role can express, and trying to encode it produces the role explosion.
| Requirement | Expressible as a role? | What it actually needs |
|---|---|---|
| Agents may reply to tickets | Yes | A permission check |
| Agents may reply only to their own tickets | No | An object rule on assignee (Object-Level Authorization) |
| Leads may close tickets in their team | No | A scoped role, plus an object rule on team |
| Nobody may approve their own expense | No | A relation between principal and object |
| Finance may export up to £10k | No | An attribute rule on amount (Attribute-Based Access Control) |
| Admins may manage users in their workspace | Partly | A role scoped to a workspace (Multi-Tenancy) |
| Contractors may not access anything after their end date | No | A context rule on time |
Role explosion, and what it is telling you
Role explosion is not a discipline failure — it is a model mismatch producing a visible symptom. Each cloned role is an attribute rule someone could not express, encoded as a name. lead_eu_no_export is really role = lead AND region = EU AND NOT export.
The useful response is not to ban new roles. It is to notice which dimension keeps appearing in the names, and lift that dimension into a scoped assignment or an attribute rule.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Role names carry a region, team or workspace | lead_eu, lead_us, admin_acme | Roles are global; the product is scoped | Make assignment (principal, role, scope) and drop the suffix (Multi-Tenancy). |
| Role names carry an exception | agent_no_pii, lead_readonly | One permission does not fit the inherited set | Abandon the hierarchy; give each role an explicit permission list. |
| Role names carry a threshold or condition | approver_10k, temp_contractor_q3 | The rule depends on an attribute or on time | Move that dimension to an attribute rule (Attribute-Based Access Control). |
| Everyone in the company has admin | No denials in the logs, ever | Granularity was too painful to administer | Fewer, better-named roles with a real grant workflow beats fine-grained roles nobody uses correctly. |
| Roles checked by string in dozens of files | Renaming a role is a two-day refactor | No permission indirection | Introduce permission names and migrate call sites; the role table becomes data. |
How to build it
Most important first.
- Check permissions in code; map roles to permissions in data. One
has(principal, 'ticket:close')call site, a table that says which roles include it. - Define permissions from the product's verbs, not from your routes. Routes change; "close a ticket" does not.
- Make role assignment scoped from the start if your product has workspaces, teams or tenants. Retrofitting scope onto a global role column is a migration through every check (Schema Migrations from the Application Side).
- Prefer a flat set of roles with explicit permission lists to a hierarchy with inheritance. Verbosity here is cheaper than the day a permission fails to nest.
- Keep the number of roles small and the number of permissions large. Roles are for humans to reason about; permissions are for code.
- Always pair the role check with an object check for anything user-owned. RBAC decides that agents may close tickets; something else must decide *this* ticket (Object-Level Authorization).
What can go wrong
- Role string comparisons spread through handlers, so a role change is a code change and a missed call site is a silent hole.
- Ordered role comparisons (
ROLES.indexOf(user.role) >= ROLES.indexOf('lead')) once permissions stop being nested — the comparison keeps compiling and starts being wrong. - A cached permission set that outlives a revocation, so a removed role remains effective for the cache lifetime (Cache Invalidation).
- A user with several roles and no defined combination rule: does the union apply, or does the most restrictive win? Undefined behaviour here is usually resolved by accident.
- A "super admin" role used for operational tasks that also passes every check, so incidents are handled by a principal with no meaningful restriction and no useful audit distinction (Least Privilege).
- Roles stored in a token issued at login, so a permission change is invisible until re-login (Token Authentication and the Revocation Problem).
- A role revoked while a request holding an expanded permission set is in flight. The request completes with the old permissions — acceptable for reads, worth re-checking inside the transaction for destructive actions.
- If the role is read from a client-controlled field, an attacker gets immediate self-promotion to any role your code names — the whole model collapses into "whatever the request says". The role must come from the principal record or a verified claim (Mass Assignment and Over-Posting).
- If permissions are only checked at the route and never against the object, an attacker gets everything their role can do *to everyone's data*: an agent reads every customer's tickets, which is the same breach as no authorization at all, bounded by role.
- If role assignment endpoints are not themselves permission-checked, an attacker gets escalation by calling the API that grants roles.
user:manageis the most valuable permission in the system and is routinely protected less carefully than the features. - If a hierarchy is assumed and one permission does not nest, an attacker gets exactly that permission: billing staff granted "lead-level" access inherit ticket-body reads that policy said they must not have. The bug is a data-exposure violation with no error attached.
- Over-broad roles are the mechanism behind most insider incidents. "Everyone is an admin because it was easier" is an authorization decision, made by default (Least Privilege).
- "RBAC is authorization." RBAC is the coarse half. Without an object-level check it permits every user to act on every object within their role (Object-Level Authorization).
- "More roles means finer control." Beyond a point more roles means less control, because nobody can say what any given role grants (Attribute-Based Access Control is often what was actually needed).
- "Admin means trusted." Admin means powerful. The two are frequently different people, and the audit log is what distinguishes them afterwards.
- "Roles are hierarchical." Sometimes. The requirement that breaks the hierarchy usually arrives from finance, legal or support, and it arrives.
Operating it
- Report denials by permission name.
ticket:closedenied 400 times a day means either your role mapping is wrong or a client is broken — either way it is visible. - Audit-log every role grant and revoke with actor, subject, role, scope and time. This is the record that answers "how did they get access" months later (Audit Logs for Privileged Actions).
- Periodically export the principal→role→permission expansion and review it. The count of principals holding
user:manageis a number a team should be able to say out loud. - Alert on grants of high-value permissions outside normal hours or from unusual sources (Suspicious Login Detection).
- Permission expansion per request is a lookup; caching it briefly is usually fine and the staleness window is the revocation delay you are accepting.
- At 100x users, the role table does not grow — the assignment table does. Scoped assignments (user × workspace × role) grow multiplicatively and want an index on (principal, scope).
- Organizational scale, not traffic, is what breaks RBAC. Fifteen roles is manageable; two hundred generated roles means the model has stopped matching the requirements and attribute-based rules are being simulated by cloning (Attribute-Based Access Control).
- The role→permission indirection costs a lookup and a level of misdirection: you can no longer read a handler and know who may call it, only which permission is required.
- Fine-grained permissions are more precise and harder for humans to administer. A UI showing 80 checkboxes gets clicked through, which produces over-granting.
- Storing roles in a token makes checks free and revocation slow. Loading them per request makes revocation immediate and adds a dependency to every request.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALThe principal/role/permission decomposition is stack-independent. The storage and the check are ordinary application code in any language.
- SCALE-SPECIFICRBAC alone holds well up to roughly a few dozen roles and a workforce-shaped access model. Products with per-object sharing (a document shared with three named people) exceed what roles can express long before that, and need relationship or attribute rules regardless of size (Attribute-Based Access Control).
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.
- — Identity governance — periodic access review and certification is the organizational process that keeps a role model honest; the backend only has to make the expansion queryable.