Skip to content
CISA lists exploited flaws in the Linux kernel and VMware ESX
Black Shard

Loading

    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.

    Black Shard Guides

    Updated 16 min read

    Share
    On this page
    1. Authorisation is one question with four parts
    2. RBAC vs ABAC vs relationship-based access control: what each model answers
    3. Why role explosion happens, and why admin is three separate jobs
    4. Where should the permission check live?
    5. Deny by default and the enumerate-then-filter trap
    6. Delegated administration belongs in the model
    7. Support access, impersonation and the consent question
    8. How do you test an authorisation model?
    9. Migrating a model that is already wrong
    10. How we build and review this

    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 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.

    • Model: Role-based (RBAC)

      Answers well
      Coarse capability. Can this kind of user perform this kind of action at all
      Where it collapses
      Anything scoped to one record or one shared object, which forces a new role per scope and multiplies role names
      Cost at query time
      Cheap. Roles resolve from the session with no extra reads
      How you test it
      One allow case per role per endpoint, plus a refusal case for the role directly below it
    • Model: Attribute-based (ABAC)

      Answers well
      Conditions on the request and the record, such as state, classification, time window, or declared purpose
      Where it collapses
      Rule interaction. Once rules compose, nobody can say who reaches a record without running the engine
      Cost at query time
      Needs the resource and its attributes loaded before the decision, which makes list filtering expensive
      How you test it
      Table-driven cases per rule, plus recorded decisions replayed against fixtures after any rule change
    • Model: Relationship-based (ReBAC)

      Answers well
      Per-record and inherited access. This user, this document, through this folder, team or matter
      Where it collapses
      Deep or cyclic graphs, and stale edges left behind when the business object is removed by another code path
      Cost at query time
      A traversal per check, usually mitigated by caching and by a reverse index built for list queries
      How you test it
      Fixture graphs with a declared allow set and deny set, re-checked after every write that changes a relationship
    • Model: Per-record access lists (ACLs)

      Answers well
      Explicit sharing that a user performs, sees and can undo
      Where it collapses
      Bulk change and departures, because entitlement lives in thousands of rows with no rule that explains them
      Cost at query time
      Cheap per record, expensive to enumerate or to answer who can see what across the estate
      How you test it
      Grant, 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 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 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 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 runs: Edge or API gateway

      What it catches
      Unauthenticated traffic, coarse route bans, tenant routing by host or token audience, rate abuse
      What it structurally cannot catch
      Anything depending on the record, because the gateway has not read it and does not know who owns it
      Failure mode when it is the only check
      Every authenticated caller can reach every object the route is able to address
    • Where the check runs: Service entry: controller, handler or resolver

      What it catches
      Action-level permission for this caller against the identifiers present in this request
      What it structurally cannot catch
      Access arriving by an internal path that never touches the handler, such as a queue consumer, scheduled job or import
      Failure mode when it is the only check
      Background workers, exports and admin scripts become the unguarded route into the same data
    • Where the check runs: Domain layer: the object that owns the invariant

      What it catches
      Every path into the operation, including jobs and consumers, with full business context available to the decision
      What it structurally cannot catch
      Reads that bypass the domain entirely, such as reporting queries, analytics extracts and migration scripts
      Failure mode when it is the only check
      Analytics, support tooling and data exports see everything the schema holds
    • Where the check runs: Data layer: query scope, row policy or view

      What it catches
      Reads and writes from any caller, including ad hoc sessions and anything written outside the application
      What it structurally cannot catch
      The reason a decision went the way it did, and any rule needing request context the session does not carry
      Failure mode when it is the only check
      The 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 consistent 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.

    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, an AML gate on each matter and the AML registers over an append-only audit ledger.

    The staff portal we built for Stone Leaf Capital, an AFSL holder in funds management and corporate advisory, 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. 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.

    Tell us what you need built.