Role-based access control is the model everyone reaches for first. It works right up until the organization gets real, and a contractor's approval chain is very real. You start clean: project manager, regional manager, admin. Then a project manager needs to approve change orders, but only on their own projects. Then one region wants billing visible to a role that cannot see it elsewhere. Then someone goes on vacation and a colleague has to act in their place for two weeks. Each exception is reasonable. Stacked up, they turn role checks into a thicket of special cases scattered across the code, and nobody can answer the one question that matters when the system gates money: who can do this, and why.
So early on I made a decision that looked like extra work and was actually the cheap path. I moved authorization off roles and onto permission keys. Instead of asking whether a user is a manager, the code asks whether they hold a named permission. The role stops being the unit of authorization. The permission becomes it.
The road I did not take was to keep patching the role model. I have seen where that road ends: a requireManager here, an inline "unless it is their own project" there, a special case for the delegate, another for the region, until authorization is an emergent property of a dozen scattered checks that no one fully understands and no one dares change. On a system that decides who can move a contractor's money, "no one dares change it" is not a place you want to be.
A permission key is only worth anything if one authoritative place decides whether an identity holds it. That place is a single authorization service, roughly sixteen hundred lines, exposing one core question: given an identity and a permission key, do they have it. It answers against the backing security tables, with a five-minute in-process cache so the check is cheap enough to run everywhere without hammering the database. The keys live in a central registry, a couple hundred lines that is the single source of truth for what permissions exist, split into module-visibility keys that decide what a user sees and platform-admin keys that gate the dangerous operations. Because there is one registry, the permission surface is enumerable. I can read the file and know every door the system has, which a role model spread across middleware never lets you do.
Enforcement happens in two places, and that is deliberate. Route middleware declares the permission a route needs, including a project-scoped form that checks the permission against the specific project in the request rather than globally. That catches the broad case at the edge. Then the handler itself checks again, through small module helpers wrapped around the same core question, because some decisions depend on data the route has not loaded yet. The rule gets enforced where it can be evaluated correctly, not only where it is convenient.
Two capabilities are where this earns the migration cost.
The first is audit. Every denial is written to a log. When someone says they could not do something they expected to, I do not guess and I do not go spelunking through middleware. There is a record of the exact permission that was checked and refused. A denial is often more interesting than an approval, and authorization you cannot explain is authorization you cannot defend.
The second is delegation. Real organizations need one person to act for another within bounds, the vacation case, the coverage case. Because authority is expressed as permissions evaluated by one engine instead of roles baked into checks, a delegation layer sits on top and resolves who may act on whose behalf, inside the rules. In the role model that would have been one more special case carved into the same dozen checks. Here it is a clean addition.
The granularity is the obvious sell. The real win is that authorization became something I can reason about: one engine that answers every access question, one registry that lists every permission, a log that explains every refusal. Roles model the org chart you drew on day one. Permissions model the access your system actually grants, which is the only model that survives contact with how a real company runs.
This is the kind of unglamorous core a business actually needs before it trusts software to run its operations, and building it right is part of what I do as an embedded engineer putting AI and custom systems to work inside a company. If your authorization has quietly become a thicket nobody wants to touch, that is a solvable problem, and a good first conversation at /services.