AuthzGENERALSCALE-SPECIFIC

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.

What actually happensHow to build it

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.

The question

When is "what role are you" a sufficient authorization model, and what does it fail to say?

The requirement

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.

The obvious build

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.

Why it breaks

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.

How it breaks in production
  • 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.
  • 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.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

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:actionticket: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.role column 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.

Role names in data, permission names in code
1type Permission = 'ticket:read' | 'ticket:reply' | 'ticket:close' | 'ticket:reassign' | 'user:manage'
2
3// data, not code — editable without a deploy
4const 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:read
9 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 B
16 for (const p of ROLE_PERMISSIONS[a.role] ?? []) out.add(p)
17 }
18 return out
19}
20
21// call sites name capabilities, never roles
22require(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.

RequirementExpressible as a role?What it actually needs
Agents may reply to ticketsYesA permission check
Agents may reply only to their own ticketsNoAn object rule on assignee (Object-Level Authorization)
Leads may close tickets in their teamNoA scoped role, plus an object rule on team
Nobody may approve their own expenseNoA relation between principal and object
Finance may export up to £10kNoAn attribute rule on amount (Attribute-Based Access Control)
Admins may manage users in their workspacePartlyA role scoped to a workspace (Multi-Tenancy)
Contractors may not access anything after their end dateNoA 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.

Symptoms, and the model change each one asks for
TriggerSymptomCauseResponse
Role names carry a region, team or workspacelead_eu, lead_us, admin_acmeRoles are global; the product is scopedMake assignment (principal, role, scope) and drop the suffix (Multi-Tenancy).
Role names carry an exceptionagent_no_pii, lead_readonlyOne permission does not fit the inherited setAbandon the hierarchy; give each role an explicit permission list.
Role names carry a threshold or conditionapprover_10k, temp_contractor_q3The rule depends on an attribute or on timeMove that dimension to an attribute rule (Attribute-Based Access Control).
Everyone in the company has adminNo denials in the logs, everGranularity was too painful to administerFewer, better-named roles with a real grant workflow beats fine-grained roles nobody uses correctly.
Roles checked by string in dozens of filesRenaming a role is a two-day refactorNo permission indirectionIntroduce 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

Failure modes
  • 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).
What can race
  • 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.
Security
  • 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:manage is 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).
Misreads
  • "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

How you see it in production
  • Report denials by permission name. ticket:close denied 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:manage is 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).
What changes at 10x and 100x
  • 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).
What this costs
  • 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.

Domains that do not exist yet
  • 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.