Solution
How to build role based access that survives five years
Access control starts as three roles and an if statement. It ends as a spreadsheet nobody trusts, where the only safe way to give somebody a capability is to copy an existing user and hope.
The short answer
Grant permissions on actions rather than on roles, and treat roles as named bundles of permissions rather than as a type of user. Check in exactly one place. Derive the navigation from the same permissions the server enforces. And separate capability from authority, so holding a permission never means having the final say over a specific decision.
How it rots
Version one has three roles and a check.
if (user.role === 'admin') { ... }
Then finance needs to see invoices but not edit users, so there is a fourth role. Then one person needs
one capability from another role, so there is a fifth role that is mostly a copy. Then somebody adds
|| user.role === 'director' to nineteen call sites, and misses two.
Three years later, granting a new capability means finding every check that mentions a related role, and the safest known method is to copy an existing user’s configuration and hope.
The root cause is that the code asks who someone is rather than what they may do.
Rule 1. Permissions on actions, roles as bundles
Name the permission after the action, not after the person.
invoice.read
invoice.create
invoice.approve
user.manage
report.export
A role is then a named set of those, and nothing more. Adding a role becomes a data change rather than a deploy, because no check anywhere mentions a role.
// never
if (user.role === 'finance_manager')
// always
if (can(user, 'invoice.approve'))
The immediate benefit is that a new requirement of the form “these two people also need to export reports” is answered by editing a bundle, at three in the afternoon, without a release.
A useful convention: manage implies the rest. Holding invoice.manage implies read, create and
update, expanded at grant time so the stored grants stay explicit and auditable. This removes most of
the combinatorial clutter without introducing implicit behaviour at check time.
Rule 2. One place to check
Every authorisation decision goes through a single function. Not a middleware for routes and a different helper for services and an inline check in a report.
can(user, 'invoice.approve', { tenantId, schoolId })
One implementation means one place to add scoping, one place to add logging, and one place to fix a bug. Scattered checks mean a bug is fixed in the four places somebody thought of.
It also gives you something valuable for free: log every denial, with the permission and the context. When somebody says they cannot do something they should be able to do, the answer is a query rather than an investigation.
Rule 3. Scope is part of the permission, not a separate check
In any system with tenants, branches, departments or regions, a permission is meaningless without a scope. Somebody may approve invoices for one school, not everywhere.
Fold the scope into the grant, and into the check:
grant: user 42 can invoice.approve in school 7
check: can(user, 'invoice.approve', { schoolId: 7 })
The failure mode when scope is a separate check is severe and quiet. The permission check passes, the scope check is forgotten in one code path, and somebody approves something in a branch they have never visited. Because both checks individually look correct in review, this is hard to catch by reading.
Rule 4. Separate capability from authority
The rule that matters most in systems with money, and the one most often missed.
A permission is a capability: this person is the kind of person who approves invoices. It should get them into the screen.
Authority over this specific request is different. It should come from being named in the workflow for that request, not from holding the permission.
The reason is blunt. Permissions get assigned in bulk, by people in a hurry, often by copying a role. If capability implies authority, one careless copy hands somebody the power to sign off on real money, and nobody notices until an auditor asks.
Keep them separate and a permissions mistake is a visibility mistake, which is recoverable, rather than a money mistake, which is not. This is developed further in the approval engine.
Deriving the interface from the same source
After login, send the client the user’s effective permission list. Build the navigation and the controls from it.
server: authoritative check on every request, always
client: hides what the user cannot do, purely so they are not shown a door that is locked
Two things follow. The client check is never security, it is courtesy, and the server check is never optional. And because both read the same list, they cannot drift, which is what stops the situation where a menu item exists but every click returns a denial.
Keeping it honest over time
Three habits that prevent the rot from returning.
Generate the permission constants from the definitions, so a typo in a permission name is a build error rather than a check that silently never passes. A misspelled permission that always returns false is a bug that hides for months, because the failure looks like a policy decision.
Audit unused permissions quarterly. A permission nobody holds is either dead or a feature nobody found.
Test the negative cases. It is easy to assert that an admin can do a thing. The tests that matter assert that a user without the permission cannot, and that a user with the permission but the wrong scope cannot either. The second one is the case that leaks.
What good looks like
Adding a role is a data change. Adding a capability to a person takes a minute. Nobody can explain a denial by saying the system is confusing, because the log says exactly which permission was missing at which scope. And nobody has ever been able to approve something by accident because their role was copied from somebody else’s.
Questions people actually ask
- Why not check the role directly?
- Because every new role then requires touching every check. With permissions the check never changes and a new role is a data change, which is the difference between a deploy and an afternoon.
- How do you stop the navigation and the server disagreeing?
- Derive both from the same permission list, sent to the client after login. The client hides what the user cannot do and the server enforces it. If they are two lists maintained separately, they will diverge and users will meet buttons that fail.
- What about a super admin?
- Give it every permission explicitly rather than a bypass branch. A bypass is untestable, unauditable, and it is the first thing an attacker looks for. An explicit grant of everything behaves identically and is visible.