Every permissions system starts with three roles and a switch statement. Two years later there are forty roles, eleven of them are named after a single customer, and nobody can answer whether a given user can see a given record without running the application. The failure is predictable because roles encode who someone is, while almost every real requirement is about the relationship between that person and a specific object.
01Role explosion is the visible symptom
The first sign of trouble is roles multiplying. `manager` becomes `regional_manager`, then `regional_manager_readonly`, then `regional_manager_emea_with_export`. Each new role exists because a customer needed a combination the existing set could not express, and each one has to be understood by every future engineer who touches an authorisation check.
The second sign is checks drifting apart. The same rule is implemented in the API handler, in a background job, in the export path and in the admin tool, and one of them is subtly wrong. Nobody notices until a customer sees another tenant's data, at which point the question 'where is this rule defined' has four answers. Both symptoms trace to the same cause: authorisation logic living inside application code rather than being a thing the system can reason about.
02The requirement roles cannot express
Take a request that sounds trivial: a user can edit a document if they own it, or if they are an editor on the folder containing it, or if they belong to a team that was granted access to the parent workspace, unless the document is locked, and always if they are an organisation administrator. There is no set of roles that expresses this, because the answer depends on a graph of relationships between the user and this particular object.
This is the shape most business requirements actually take, and it is why teams end up writing bespoke queries that join four tables inside every handler. The permission is not an attribute of the user - it is a path through the data. Once you recognise that, the architecture question becomes how to represent and evaluate those paths consistently rather than how to name more roles.
03The three models and where each one lands
Three models cover essentially all production systems, and they compose rather than compete. Most mature systems use roles for coarse capability, attributes for contextual conditions, and relationships for object-level decisions.
| Model | Decides on | Good at | Breaks when |
|---|---|---|---|
| RBAC | The actor's assigned role | Coarse capability; simple admin UI | Permissions depend on the specific object |
| ABAC | Attributes of actor, resource and context | Conditional rules - time, location, clearance, tenant | Rules multiply and become hard to audit or reverse |
| ReBAC | Relationship graph between actor and object | Hierarchies, sharing, inheritance, group nesting | Graph depth and write amplification at scale |
| Hybrid (typical) | Roles for capability, relationships for objects | Matches how products actually work | Requires a real policy layer, not scattered ifs |
04Centralise the decision, not the data
The structural fix is to make authorisation a component with an interface rather than logic sprinkled through handlers. A policy decision point takes the actor, the action, the resource and some context, and returns allow or deny. Every call site asks the same component the same way, which means there is exactly one place to change a rule and exactly one place to audit it.
The mistake is centralising the data too. If your policy service has to fetch resource state on every call, you have added a network hop and a hard dependency to every request in the system. The workable pattern is to pass the data with the query, or to replicate the relationship tuples the policy engine needs into its own store and keep them current. Which one you pick determines your latency profile more than the policy language does.
- One decision interface, called identically from API, jobs, exports and admin tooling.
- Policy as versioned artefacts in source control, reviewed like code.
- Deny by default; an unmatched request is not an allow.
- Log every decision with the inputs, not just the outcome.
05The list problem nobody plans for
Checking a single object is easy. Listing everything a user can see is the query that breaks naive implementations, because you cannot fetch a page of results and then filter it - you get short pages, wrong pagination and O(n) policy calls. This is the point at which teams discover their elegant policy service does not fit their access patterns.
There are two viable answers. Either the authorisation system can return the set of accessible resource identifiers so you can filter in the database, or it can produce a predicate that you push into the query itself. Anything else means loading candidate rows to discard them, which is fine at a thousand rows and untenable at a million. Decide this before you pick a policy engine, because retrofitting it is a rewrite.
06Migrating without a big bang
Do not rewrite authorisation across the application in one change. Introduce the decision interface, implement it initially by calling the existing role checks, and move call sites over one at a time. Behaviour is unchanged, so the risk is low, and you end up with a complete inventory of every authorisation decision the system makes - which most teams have never had.
Then run the new model in shadow mode: evaluate both the old and new logic, serve the old answer, and log disagreements. The disagreements are your specification. In every migration we have run, the shadow log surfaced existing bugs in the old rules within days, and fixing those before cutover is what makes the switch uneventful.
Topics
Lena Voss
Lead Architect · SyncTrix
Writes about the engineering decisions behind production systems - architecture, delivery and the trade-offs that only show up at scale.
Building something like this?
SyncTrix engineers AI, SaaS, platform and cloud systems for enterprises and high-growth teams. Tell us what you're shipping and we'll scope it with you.