Skip to content

RBAC that can't drift

5 min read

  • auth
  • architecture
  • react
When navigation and route guards are written separately, they disagree. Derive both from one permission map and the disagreement becomes impossible.

Nav visibility and route authorisation are the same question asked twice: may this person open this module? Ask it once — one permission map — and project both answers from it. Then adding a permission is one edit rather than two edits and a thing to remember, and a menu item the guard would refuse becomes unrepresentable instead of merely unlikely.

Switch roles, then switch the wiring to broken and watch the two answers come apart:

Interactive. Switch role, then switch wiring, then click the item marked 403. Full version, with the walkthrough.

The bug this prevents is not a crash

A menu item stays visible after someone loses the permission behind it. They click it. They get a 403.

Nothing threw. Nothing was logged. No monitor fired, because from the server's point of view the guard did exactly its job. Two lists simply fell out of step, and the only person who noticed is now sitting on an error page wondering whether the application is broken or whether something was taken away from them. Both readings damage trust, and the second one generates a ticket to IT that nobody can close.

This is the characteristic shape of authorisation bugs in the frontend. They do not look like failures. They look like the product being confusing.

Why the second list gets written

In broken mode above, the menu comes from a hand-written array. It is worth being clear that this is not a straw man — it is what a menu looks like when somebody writes it while building the app, and it has three properties that make it very hard to argue against at the time:

It is correct on the day it ships. It is trivially readable, which matters when a designer asks where a section lives. And it is written by a developer who is looking at the app as an admin, so every item they can see belongs in the list.

It goes wrong later, quietly, in the commit where a permission is renamed and only the guard is updated. Nobody is careless. The drift is structural: two lists that encode the same fact will diverge, and the interval is a function of team size, not diligence.

Notice which role is stranded in the demo. Switch to broken mode as an admin and the count is zero — the menu is perfect. It only breaks for viewers and editors, which is to say it only breaks for the people who are not you.

One map, two projections

permissions.ts
export const MODULES = {
  dashboard: { label: "Dashboard", permission: "dashboard.read" },
  reports: { label: "Reports", permission: "reports.read" },
  audit: { label: "Audit log", permission: "audit.read" },
  billing: { label: "Billing", permission: "billing.manage" },
  settings: { label: "Settings", permission: "settings.manage" },
} as const;
 
export type ModuleId = keyof typeof MODULES;

The guard:

permissions.ts
export function canAccess(role: Role, module: ModuleId): boolean {
  return grantedFor(role).has(MODULES[module].permission);
}

The navigation — the same map, the same answer, a different projection:

permissions.ts
export function visibleModules(role: Role): ModuleId[] {
  return MODULE_IDS.filter((id) => canAccess(role, id));
}

That is the entire pattern. The menu is not checked against the permissions; the menu is the permissions, filtered. There is no second list to fall out of step because there is no second list.

The property this buys is worth stating precisely, because it is the property the whole post is about:

permissions.test.ts
it.each(ROLES)("strands nothing for %s", (role) => {
  const offered = visibleModules(role);
  const refused = offered.filter((id) => !canAccess(role, id));
 
  expect(refused).toEqual([]);
});

That test cannot fail while the menu is derived. It is not testing behaviour, it is asserting that a category of bug has no way to exist — and it will start failing the day someone reintroduces a hand-written list, which is exactly when you want to hear about it.

Roles are not permissions, and conflating them is the next bug

The map above is keyed by permission strings, and roles map to sets of them:

permissions.ts
export const GRANTS: Record<Role, readonly string[]> = {
  viewer: ["dashboard.read", "reports.read"],
  editor: ["dashboard.read", "reports.read", "audit.read"],
  admin: ["dashboard.read", "reports.read", "audit.read",
          "billing.manage", "settings.manage"],
};

The shortcut is to skip the permissions and check the role directly — if (role === "admin") scattered through the components. It works until the day someone needs an editor who can also see billing, and then every one of those checks becomes role === "admin" || role === "billing-editor", and you find them by grepping and you miss one.

Permissions are the stable vocabulary. Roles are a bundle of them that product changes its mind about. Naming the permission at the point of use means a new role is a new row in GRANTS and nothing else changes.

Making the map the only way in

Deriving the menu is half of it. The other half is closing the door on the alternative, because a convention nobody can violate is worth more than a convention everybody agrees with.

Two mechanisms do most of that work. The first is that ModuleId is a union derived from the map's own keys, so a route name that is not in the map is a type error rather than a string that quietly matches nothing:

permissions.ts
export type ModuleId = keyof typeof MODULES;
export const MODULE_IDS = Object.keys(MODULES) as ModuleId[];

The second is that nothing outside this module reads GRANTS. Components ask canAccess(role, id); they never receive a permission string to compare themselves. That sounds like a stylistic preference and is not — a component holding a raw permission string is a component that can drift, because the string it holds is a copy.

Two things this pattern does not do

Hiding a menu item is not authorisation. It is a courtesy — it stops people walking into doors that are locked. The lock is the route guard, and the real lock is the server, which must check the same permission again because the client is a suggestion. If your API trusts the frontend's word about what a user may do, none of this matters and you have a much more urgent problem.

A derived menu is only as expressive as the map. Ordering, grouping, a module that should be visible but disabled rather than hidden, a section header with no route of its own — each of those has to become a property of the map, because there is nowhere else for it to live. That is the actual cost of the pattern and it is paid upfront, in design, at the moment you would rather be shipping.

I think it is worth paying, and I will defend the stronger version of that: a hand-written menu is not a shortcut, it is a decision to hold two facts in sync manually for the life of the product. Teams do not fail at that because they are sloppy. They fail because manual synchronisation has no failure mode that shows up before a user hits it.

The redirect nobody writes

One more thing the demo does that the pattern description usually leaves out. When a role changes and the current route closes behind you, a correct guard does not leave you sitting on a 403:

use-guard.ts
function onRoleChange(next: Role) {
  setRole(next);
 
  // Somewhere they can be, not an error page they cannot leave.
  if (!canAccess(next, currentModule)) {
    const fallback = visibleModules(next)[0];
    if (fallback) navigate(fallback);
  }
}

Roles change under people more often than you would think — an admin downgrading their own session to check something, an SSO group sync landing mid-session, a support agent's shift ending. Landing them on the first thing they can open costs four lines and is the difference between a permission model that feels considered and one that feels hostile.

The full demo shows the map, the menu and the guard side by side, with a live count of the items the guard would refuse. The case study covers how this fits alongside the auth layer in a multi-tenant application.