Black Shard

Insights25 July 2026

RBAC vs ABAC, and where the permission check belongs

Authorisation is one question with four parts, and the decision that shapes everything after it is whether that question gets answered in one place or re-answered in every handler. Two reference tables compare the models and the layers a check can sit in.

A pin tumbler lock cylinder disassembled into its pin stacks and springs, laid out in order on dark slate under a single cold cyan light

Authorisation is one question with four parts

Every authorisation check in every system answers the same question: may this subject perform this action on this resource in this context. Subject is the authenticated principal, action is the operation in domain terms, and resource is the specific thing being acted on, usually named by something the caller supplied. Context is everything else the decision depends on: the state of the record, the tenant, the time, and the reason for access. Most codebases never write that question down, so every handler assembles its own version from whatever happens to be in scope.

The decision that determines whether the system survives a customer's security review is whether that question is answered in one place with a stated input contract, or re-derived in every handler. Answered once, you can enumerate what the system permits without reading code, replay a past decision to explain it, and change a rule in a single edit. Scattered, entitlement becomes the sum of every conditional anyone has written.

Choosing between the named models comes second. Which model you need follows from the shape of the four inputs your product actually has, so naming the inputs first tells you which model can express them and which one you would be bending. Broken access control has been the first category in the OWASP Top 10 since the 2021 edition and held that position again in the 2025 one, and the reason is structural. A single missing check is the visible defect, and the cause is that no one place was ever designated to hold the decision, so the check has to be remembered by whoever writes the next handler.

Two adjacent problems sit outside this. Federating the subject in from a customer's identity provider is one design, and provisioning accounts from a customer directory is another. Neither decides what a known subject may do.

RBAC vs ABAC vs relationship-based access control: what each model answers

The models in common use differ in which of the four inputs they can express. Role-based access control compresses the subject into a role and largely ignores resource identity. Attribute-based access control, the model set out in NIST Special Publication 800-162, evaluates named attributes of the subject, the resource, the action and the environment against written policy. Relationship-based access control makes the edge between a subject and one specific resource the unit of storage, and per-record access lists store the answer itself against the record with no rule standing behind it.

Teams are rarely damaged by choosing the wrong model. They are damaged by choosing implicitly, so that three models end up coexisting in one codebase and nobody can say which is authoritative when they disagree. Most products settle at roles for coarse capability, relationships for record reach, and a small number of attribute conditions on top for record state and time-bound access. That combination is defensible once it is written down and reviewed as one thing, and unpredictable when it accumulated feature by feature.

Query cost eliminates more designs than expressiveness does. A point check is cheap in every model, and the expensive question is enumeration, which is the same decision run across thousands of rows with sorting and pagination on top. Roles compile into a database predicate easily because the scope is a small set of identifiers. Relationships compile only if you maintain a reverse index from subject to reachable resources, which carries its own consistency story. Rules depending on data outside the record usually cannot compile at all, which forces fetch-then-filter, and that is where pagination starts lying about totals.

ModelAnswers wellWhere it collapsesCost at query timeHow you test it
Role-based (RBAC)Coarse capability. Can this kind of user perform this kind of action at allAnything scoped to one record or one shared object, which forces a new role per scope and multiplies role namesCheap. Roles resolve from the session with no extra readsOne allow case per role per endpoint, plus a refusal case for the role directly below it
Attribute-based (ABAC)Conditions on the request and the record, such as state, classification, time window, or declared purposeRule interaction. Once rules compose, nobody can say who reaches a record without running the engineNeeds the resource and its attributes loaded before the decision, which makes list filtering expensiveTable-driven cases per rule, plus recorded decisions replayed against fixtures after any rule change
Relationship-based (ReBAC)Per-record and inherited access. This user, this document, through this folder, team or matterDeep or cyclic graphs, and stale edges left behind when the business object is removed by another code pathA traversal per check, usually mitigated by caching and by a reverse index built for list queriesFixture graphs with a declared allow set and deny set, re-checked after every write that changes a relationship
Per-record access lists (ACLs)Explicit sharing that a user performs, sees and can undoBulk change and departures, because entitlement lives in thousands of rows with no rule that explains themCheap per record, expensive to enumerate or to answer who can see what across the estateGrant, revoke and inheritance cases per resource type, plus a sweep for grants pointing at deleted subjects

Authorisation models compared: what each answers, where it collapses, what it costs at query time, and how it is tested

Why role explosion happens, and why admin is three separate jobs

Role explosion has a mechanical cause. Roles multiply as the product of function and scope, so the moment a requirement reads billing administrator for the eastern region, the scope has been encoded into the role name. Every new segment, resource type or region multiplies the set again. The symptoms are recognisable: role names carrying a qualifier, roles created during the onboarding of one customer, and a list nobody will delete from because no one can prove a role is unused.

The fix is to separate the permission from the assignment. A permission is a verb against a resource type and should be a closed, reviewable list. An assignment binds a bundle of permissions to a subject over a scope, and the scope is data on the assignment, so no role name ever has to carry it. The test of whether you have done this properly is whether a customer's unusual requirement can be met by writing a row, with no deployment and no new role name that will outlive the customer.

The word administrator hides at least three jobs, and collapsing them into one role is a fault we find often in code review. A tenant administrator is the customer's own person, managing their organisation's users and settings, legitimately entitled to their organisation's data. Support impersonation is your staff member acting inside a customer's account to resolve a specific problem, entitled only for a reason and only for a window. A platform operator is your engineer who changes configuration and runs migrations, entitled to the system but not to the content inside it. Fused into one role, every audit entry says administrator, a support action cannot be told apart from a customer action after the fact, and there is no honest answer to a reviewer asking which of your staff can read their records.

Splitting them means separate identities, separate credential paths and separate audit streams, and it means accepting that an operator who can change a configuration value should not thereby be able to read a tenant's records. It also makes the Essential Eight's restrict administrative privileges strategy implementable inside your own product, since that strategy turns on validating a request for a specific privilege and being able to withdraw it later. A privilege fused to three jobs cannot be withdrawn from one of them without breaking the other two.

Where should the permission check actually live?

Placement is a separate decision from model choice, and it is the one that decides what your system structurally cannot catch. Each of the four layers in the table below has real coverage and a blind spot that does not close with more effort spent at that layer. The choice is not exclusive, and most systems carry checks at more than one, which works as long as everybody knows which layer is authoritative and which ones are reinforcement.

The placement that survives is the authoritative check inside the domain operation, so that every entry point reaches the same gate and the other layers act as defence in depth. The handler check stays for early rejection and clear errors. The data-layer filter stays because someone will eventually write a query outside the domain, and a row policy holds against code that has not been written yet. Enforcing tenant isolation inside the database is one mechanism for that layer, and it sits underneath the model without replacing it.

Two traps are worth naming. The first is middleware keyed on route patterns, which reads as a central decision point and behaves as a distributed one, because route tables drift, a new endpoint ships without a matching rule, and the request passes since nothing matched to deny it. Any pattern-matching gate needs an explicit default deny plus a startup assertion that every registered route resolves to a rule, and without both you have a convention that nothing enforces. The second is caching a decision with no invalidation path, so a revoked grant or a removed relationship keeps working until a token or a cache entry expires, and the real revocation time becomes the longest lifetime anywhere in the stack.

Whichever layer holds the decision has to emit a record: subject, action, resource identifier, the rule that decided, the answer, and the tenant the request ran in. A system that enforces correctly and records nothing cannot answer the question that matters after an incident, which is whether a named person saw a particular record and on what authority.

Where the check runsWhat it catchesWhat it structurally cannot catchFailure mode when it is the only check
Edge or API gatewayUnauthenticated traffic, coarse route bans, tenant routing by host or token audience, rate abuseAnything depending on the record, because the gateway has not read it and does not know who owns itEvery authenticated caller can reach every object the route is able to address
Service entry: controller, handler or resolverAction-level permission for this caller against the identifiers present in this requestAccess arriving by an internal path that never touches the handler, such as a queue consumer, scheduled job or importBackground workers, exports and admin scripts become the unguarded route into the same data
Domain layer: the object that owns the invariantEvery path into the operation, including jobs and consumers, with full business context available to the decisionReads that bypass the domain entirely, such as reporting queries, analytics extracts and migration scriptsAnalytics, support tooling and data exports see everything the schema holds
Data layer: query scope, row policy or viewReads and writes from any caller, including ad hoc sessions and anything written outside the applicationThe reason a decision went the way it did, and any rule needing request context the session does not carryThe system is correct and unexplainable, returning the right rows with no reconstructable justification

Where the authorisation check runs: coverage, blind spots, and the failure mode when a layer is the only check

Deny by default and the enumerate-then-filter trap

Deny by default has to be built into the mechanism, because a habit developers maintain by hand fails the first time someone new writes a handler. An unknown action, an unregistered resource type or a subject with no matching assignment resolves to a refusal, and a new resource type stays unreachable until someone writes its policy. Where the absence of a rule produces a pass, every future feature is exposed the day it ships, and the model only describes the code that existed when somebody last audited it.

The most common leak in shipped applications is that the detail endpoint checks and the list endpoint does not, because the list is assumed to be scoped already. Filtering in the response mapper after the query has run leaks total counts, pagination behaviour, sort position and often the identifiers themselves. The same gap opens in search indexes populated without the tenant key, in exports and dashboard aggregates, in autocomplete endpoints written as a lightweight lookup, in webhook payloads assembled by a job with no subject, and in error responses that distinguish a record which does not exist from one the caller may not see.

Field-level access survives longest, because the record is legitimately readable and one field on it is not. A support agent may need to confirm a client exists without reading the matter description, and a clinician's delegate may need appointment times without the clinical note. When the model only answers at the level of whole records, that requirement gets met by hand inside a serialiser, and the second serialiser written for the mobile client will not have it.

The rule that keeps this honest is that the predicate deciding a list is the same predicate deciding the single record. Written twice they will diverge. Derive the query scope from the policy or generate both from one definition, then assert the equivalence in a test, because a code review comment does not survive the next refactor.

Delegated administration belongs in the model

Every multi-tenant product eventually needs the customer's own people to manage their own users. When that arrives as a support request, your staff make entitlement decisions inside an organisation whose structure they do not know, and each decision is one of your employees changing who can see somebody's personal information. Designing delegation into the model gives the customer's administrator a bounded grant power the system enforces, instead of an informal channel a support queue enforces.

Four constraints make delegation safe, and each one has to be decided explicitly. A delegated administrator can only grant permissions they hold themselves, or delegation becomes a privilege escalation path. Grants are scoped to their organisation or unit and expressed as data on the assignment. Every change to entitlement is an audited action carrying actor and before and after state, because entitlement history is the first thing anyone asks for when a record turns out to have been read by the wrong person. And an administrator cannot widen their own scope, which sounds obvious and is routinely possible in shipped products because the grant path and the self-service path share code.

The decision most often left undecided is whether a tenant administrator can read restricted content or only manage who else can. In a law firm that is the difference between managing users and reading a matter the firm has walled off. In clinical software it is the difference between an administrator and a person reading a patient record. Products that never decide it default to yes, then discover during a customer's security review that they shipped an answer nobody chose. Delegated grants also need to be revocable in one action when a person leaves, which means entitlement hangs off the identity and is never copied into the records it touched.

Support access, impersonation and the consent question

Support staff genuinely need to see what a customer sees. The design question is what your model says about that access, and the answer needs three properties: a stated reason with a consent state, an expiry, and an audit entry naming the individual staff member and not a shared support role. An impersonation feature that grants a standing capability to a shared support account leaves you unable to say who looked, when, or why.

Impersonation should be a distinct principal that carries both identities through every decision and into every audit entry, so the record reads as the named staff member acting as the named user. Grant it read-only by default, with a separate and explicit escalation for actions taken on the customer's behalf. Bind the grant to the ticket or matter that justified it, expire it on a clock, since logout is not an event you can depend on, and show a persistent indicator in the interface so nobody forgets whose account they are inside. Where the sensitivity of the data warrants it, the customer approves the grant, which makes support access something the customer issues each time.

This is where Australian obligations attach directly to a design decision. Australian Privacy Principle 11 requires reasonable steps to protect personal information from unauthorised access, and access by your own staff outside a legitimate purpose is unauthorised access. Once you have grounds to suspect that happened, the Notifiable Data Breaches scheme in the Privacy Act 1988 gives you thirty calendar days from becoming aware of those grounds to assess it, and where serious harm is likely you notify the OAIC and the individuals affected. Health information and legal matter records raise the stakes, because the sensitivity of the data feeds the harm assessment you have to complete inside that window. What you can produce in those thirty days is set by the design: an access carrying a recorded reason, an expiry, a consent state and a named staff member can be assessed on the evidence, while a shared administrator login leaves you assessing an event you cannot reconstruct.

How do you test an authorisation model?

The positive tests are the ones teams write, and they are the least valuable tests in the suite. Confirming that a manager can approve tells you the happy path is wired up and nothing else. The findings that matter come from the two negative cases every endpoint should carry: the same action attempted by a subject from another tenant, and the same action attempted by a subject one rung down or without the relationship the resource requires.

A suite worth having covers more than the HTTP surface, because the entry points that skip the handler are exactly the ones the model forgot. Automated tooling cannot close the gap either: a scanner can tell that a request succeeded, but it cannot tell that the record it just read belongs to another customer. These cases have to be written as fixtures with declared expectations.

  • A route inventory test enumerates every registered route, job and event consumer at startup and fails when one has no policy attached, which is the control that keeps pace with new features.
  • Fixtures create two tenants holding identically shaped data, because a cross-tenant read that returns nothing proves nothing when the second tenant was empty to begin with.
  • Negative cases are generated from the permission table, so a new role or endpoint fails the build until its expected refusals are declared.
  • A list and detail equivalence test asserts that the set of identifiers a listing returns is exactly the set the detail endpoint will serve to that same subject.
  • A field-level case covers a record the subject may read carrying at least one field the subject may not, run against every serialiser the record passes through.
  • Non-HTTP entry points each run under a named subject in tests, including scheduled exports, queue consumers, import routines and administrative scripts.
  • Support and delegated grants are tested at their edges: an expired grant refuses, a revoked grant refuses immediately, and a grant scoped to one organisation refuses a resource in another.

Migrating a model that is already wrong

Nobody rebuilds authorisation in one release, and the attempts that try tend to ship a second inconsistent model alongside the first. Start with an inventory. Enumerate every route, job, consumer and script, and for each one record where the subject comes from, what action it performs, what resource it touches, and where the current check lives or that there is none. It is usually the first time anyone has seen the whole entitlement surface in one place.

Then run the new decision in shadow mode. Compute the new answer alongside the existing behaviour, log both with the full input, and change nothing about what the system does. The disagreements sort into three piles. The new model denies something the old code allowed and should not have, which is an exposure you have been carrying and belongs in the security backlog on its own clock. The new model denies something the old code was right to allow, which is a gap in your rules, usually a legitimate path nobody wrote down. The new model allows something the old code denied, which means a rule is broader than the behaviour you have been shipping and needs narrowing before enforcement moves anywhere. Run the shadow long enough to catch the monthly and quarterly jobs, because scheduled work is where the undocumented paths hide.

Flip enforcement one route at a time, in order of resource sensitivity, most sensitive first, and leave the old check in place until the new decision has been authoritative through a full business cycle. Keep the shadow log afterwards, because it has become the decision record, and that record is what lets you answer a customer's question about one specific access six months later.

How we build and review this

In the systems we build and run, the permission decision has to be reconstructable from the audit record, which forces the model into the first design pass. The operations and compliance portal we built and run for GRM LAW carries intake, conflicts checks, a matter register and AML/CTF readiness over an append-only audit ledger. The staff portal we built for Stone Leaf Capital, an Australian capital-markets firm, carries critical-event tracking and policy modules over a compliance audit log capturing actor, action, and before and after state. Aurii, our own clinical software venture, runs tamper-evident audit trails over tenant health data hosted in Australia on Azure. Three different sectors put the same requirement on the build: name who was permitted to act on a given record, and show the rule that permitted them.

On a build that means the four inputs are written down before the first handler, and delegated administration and support access are designed in at the start, because retrofitting them means re-deciding entitlement for every record already in the system. On a review we come at it from the other direction: read the code for where the decision is made and how many places make it, then test with two accounts at different privilege levels in different tenants, which is how object-level flaws surface.

The check to run on your own system is to take one record, list every subject who can reach it, and name the rule that grants each of them access. Where that list can only be assembled by reading handler code, the answer moves with every merge and nobody can tell a customer what it will be next month. Producing it from a stated model is far cheaper to build early than to reconstruct once a security reviewer has asked for it.

Ship software you can defend.

Australia-wide, from our Brisbane head office. Someone will contact you as soon as possible.

Open a briefinfo@blackshard.com.au