RBAC that survives contact with product
Every access control system starts with three roles: admin, editor, viewer. Eighteen
months later there are fourteen, two of them are called manager_v2, and nobody can
explain the difference.
Roles are the interface, not the model
The fix that worked for us was inverting the relationship. Permissions are the primitive; roles are just named bundles of them.
export const PERMISSIONS = {
INVOICE_READ: 'invoice:read',
INVOICE_APPROVE: 'invoice:approve',
USER_INVITE: 'user:invite',
} as const;
Code checks permissions. Only the admin UI knows roles exist:
@RequirePermission(PERMISSIONS.INVOICE_APPROVE)
@Post(':id/approve')
approve(@Param('id') id: string) {
return this.invoices.approve(id);
}
When product asks for "a role that can approve invoices but not invite users", nothing in the codebase changes. That is the whole point.
Why this matters more than it sounds
- New roles become configuration, not deployments.
- Permission checks read like the requirement they implement.
- Removing a capability from a role no longer means auditing every controller.
The part everyone skips
Write down what happens when a check fails. A 403 is not a design — it is a status code.
Does the UI hide the button, disable it with a tooltip, or show it and fail loudly? Decide
once, centrally, or you will ship all three.
Caching, briefly
Permission lookups happen on every request, so they get cached per user with a short TTL and an explicit bust on role change. Beware the obvious trap: an admin revoking access expects it to be immediate. A 15-minute TTL is a security incident waiting for a timestamp.