RBAC that survives the org chart
July 2026 · 5 min read
Every RBAC system starts the same way: three roles — admin, manager, viewer — and an if statement in each handler. And every RBAC system that starts that way ends the same way too: forty roles with names like regional-ops-admin-readonly-v2, and nobody can answer the only question that matters: who can do what?
When I built a role-scoped admin panel for internal operations, the design that held up came down to four decisions. None of them are exotic; all of them fight the same enemy — the org chart, which will reorganize itself long before your code does.
1. Permissions are resource + action. Roles are just bundles.
The unit of access is never 'manager'. It's refunds:approve, cards:issue, users:read. Roles exist purely as named bundles of those scopes — convenient labels for humans, meaningless to the enforcement code.
// Roles are data, not code.
const roles = {
"ops-admin": ["cards:issue", "cards:revoke", "refunds:approve", "users:read"],
"support": ["users:read", "refunds:request"],
"viewer": ["users:read"],
};
// Enforcement only ever sees scopes:
function can(operator, scope) {
return resolveScopes(operator.roles).includes(scope);
}When the org chart changes — and it will — you edit bundles. The business code, and its tests, never move. We learned this the expensive way: we started with coarse roles baked into handlers, and paid for a migration once the role matrix grew.
2. Resolution happens in one layer, not in every handler
Scattered role checks are how permission bugs are born: someone adds an endpoint, forgets the check, and the gap ships. Centralize resolution — middleware that turns 'who is this' into 'what may they do' — and make handlers declare what they require.
// The handler declares; the layer enforces.
router.post(
"/cards/:id/revoke",
requireScope("cards:revoke"), // ← the only access control line
revokeCard,
);One layer means one place to audit, one place to test, and one place a security review has to read. It also enables the property that saved us repeatedly: an endpoint with no declared scope fails closed.
3. Deny by default — access is an allowlist, always
Allow-by-default with exclusions sounds friendlier and is a standing invitation to incident reports. Deny-by-default flips the failure mode: forgetting something means a support ticket ('I can't see X') instead of a breach ('who could see X?'). One of those is annoying; the other is a disclosure.
It also changes the review culture. Granting access becomes a visible diff to a role bundle that someone approves — not the absence of an exclusion nobody remembered to add.
4. Audit the denials, not just the actions
Everyone logs successful actions — who did what, when. The underrated half is logging denials. A viewer probing refunds:approve five times in a minute is either a confused human, a broken client, or a compromised account. All three are things you want to know about, and the allowed-actions log shows you none of them.
- Allowed action → audit log (who, what, when, on which resource)
- Denied attempt → same log, flagged — denials are the security signal
- The UI only renders actions the operator's scopes permit, so honest users rarely see a denial at all
The result we measured: routine admin tasks got ~30% faster, because operators finally had exactly the tools their role needed — no more, no less, and no waiting on an engineer with production access.
If you remember one thing: build for the reorg. Roles are what the org chart looks like today; scopes are what your system actually does. Only one of those is stable enough to build on.
Questions or war stories of your own? Email me — I read everything.