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.