Never Use Role Hierarchies
Check capabilities, not role names—a practical intro to RBAC
You've built an app. You have a member and an admin role. Your platform grows, so you create another role for slightly elevated permissions. Your customer, a few weeks later, asks for a role like the member role, but can manage billing. You think:
No problem, I'll create an Accountant role
This continues for a few more weeks, and you're stuck with 6 roles that are all inconvenient in some way — and none of them are technically higher than the others, none flexible enough, and at every call site you check: does the user have this role, or this role, or this role... until you miss a spot and have a security vulnerability somewhere.
At Gumloop, this is exactly what happened to us. As the platform scaled, this pattern didn't scale with us. We realized that, while in our heads we knew what was going on, our code was asking the wrong questions.
§The Wrong Question
While working across a number of codebases, I realized that the most common check of whether a user can do something looks like this:
1type Role = 'admin' | 'owner' | 'accountant' | 'manager' | 'member' | 'viewer';2
3interface User {4 id: string;5 role: Role;6 organizationId: string;7}8
9const BILLING_ALLOWED_ROLES: Role[] = ['admin', 'owner', 'accountant'];10
11async function getBillingOverview(user: User) {12 if (!BILLING_ALLOWED_ROLES.includes(user.role)) {13 throw new Error(14 `User ${user.id} with role "${user.role}" cannot access billing`,15 );16 }17
18 // ...19}20
21// A common attempt to "fix" this by ranking roles numerically —22// same fundamental problem: the code still asks "who is the user?"23// instead of "what can the user do?"24const ROLE_HIERARCHY: Record<Role, number> = {25 admin: 5,26 owner: 4,27 manager: 3,28 accountant: 2,29 member: 1,30 viewer: 0,31};32
33async function getBillingOverviewV2(user: User) {34 if (ROLE_HIERARCHY[user.role] < ROLE_HIERARCHY['manager']) {35 throw new Error(36 `User ${user.id} with role "${user.role}" cannot access billing`,37 );38 }39
40 // ...41}42
43export default {44 getBillingOverview,45 getBillingOverviewV2,46};While this is often the first intuition of how to gate something, it's encoding information in the code while asking Does this user have a hardcoded set of roles we want to allow for this feature? However, across your entire codebase, you've built a mental model of exactly what each role is allowed to do. The question you mean to ask is Is this user allowed to execute this action?
So, why don't you write code to ask the right question?
§The Right Question
Call sites should check capabilities, not role names. We can make this efficient by keeping a dictionary of what permission keys each role has.
1type Role = 'admin' | 'owner' | 'accountant' | 'manager' | 'member' | 'viewer';2
3const ROLE_PERMISSIONS = {4 owner: [5 'billing:read',6 'billing:write',7 'members:read',8 'members:invite',9 'members:remove',10 // ...11 ],12 admin: [13 'members:read',14 'members:invite',15 // ...16 ],17 accountant: [18 'billing:read',19 'billing:write',20 // ...21 ],22 manager: [23 'members:read',24 'members:invite',25 // ...26 ],27 member: ['members:read', 'projects:read', 'projects:write'],28 viewer: ['members:read', 'projects:read'],29};30
31/**32 * @reference https://www.jacobparis.com/content/simple-rbac33 */34function can(role: Role, ...required: string[]): boolean {35 const granted = ROLE_PERMISSIONS[role];36 return required.every((p) => granted.includes(p));37}38
39async function getBillingOverview(user: {40 id: string;41 role: Role;42 organizationId: string;43}) {44 if (!can(user.role, 'billing:read')) {45 throw new Error(`User ${user.id} does not have permission to read billing`);46 }47
48 // ...49}50
51export default {52 can,53 getBillingOverview,54};With this pattern, adding a permission to a role is one line in one place, reliably propagating throughout the entire codebase, and requires zero changes to application code. Your roles aren't inherently hierarchical, allowing you to create bundles of permissions for the feature families across your product. However, conflicting roles may still exist... and to that, the solution is to allow users to have multiple roles and evaluate their permissions as the union of the permissions all their roles grant them. At scale, in an enterprise product, this has been proven to be the most battle-tested practice. These systems are used by companies like Microsoft, Google, Amazon, Uber, and probably every other enterprise you rely on day-to-day.
There are steps you can take towards having this be exceptionally type safe as well, but something like the simple example above works at scale for single-tenant applications. We call this 'Role-Based Access Control', or 'RBAC'.
§Hierarchy is not always the wrong mental model
Perhaps the title Never Use Role Hierarchies is exaggerated. In fact, there are moments where a mental model of role hierarchy is more effective than the composable, flat set of roles I presented above.
When thinking about how to share a single item with people, or with projects, organizations, or publicly, role hierarchies make sense:
- Owner can do anything
- Editor can do anything the viewer can do + write
- Viewer can open the item
That's fine because the scope is narrow and the permissions are genuinely ordered. The real problem is using role rank as a proxy for permission checks at the ingress points of features. The hierarchy can exist in your data model, but it should never leak into your authorization logic.
§Conclusion
The examples provided above work when every user belongs to one organization, and permissions are global. However, the questions change when we consider per-item sharing mechanisms, and cross-org/cross-project sharing as well. Oftentimes, these are the patterns that emerge in collaborative products, and these are important patterns to get right.
Lucky for you, I'm writing about them below:
- Item Sharing—Access Control List
- (Coming Soon) Two Permission Systems, One Answer (Layered ACL + RBAC)