GAP·MAP

Authorization models

backend · Backend Securitymiddlesenior~8 min read

covers RBAC and role explosion · ABAC and policy engines · ReBAC and Zanzibar · where authorization lives · multi-tenant isolation · BOLA and confused deputy

[ middle depth ]

What RBAC is and where roles run out

Authorization decides what an already-authenticated caller may do. The default model is RBAC: attach permissions to a few named roles (admin, billing, member), attach users to roles, check the role at the door. It fits coarse, org-wide capabilities and almost every app starts here correctly.

It runs out when access depends on the specific object, not a global capability. "Can this user edit this one document" is not a role, it is a fact about one user and one document. Teams try to force it into RBAC by minting a role per object (doc_1042_editor), and the role count then grows with objects times permissions. That is role explosion: a million documents become millions of roles, and every share writes a new one. A role hierarchy (editor implies viewer) does not save it, because you still need a separate pair of roles for every document.

Authorization runs on the server, for every object

Two different checks get confused constantly.

  • Function-level: may this caller reach this endpoint at all. A role check in route middleware answers this.
  • Object-level: may this caller act on this specific record. The middleware never answered this.

An authenticated request that clears the role check can still read /documents/1042 belonging to someone else if nothing verified access to id 1042. That gap is the most common and highest-impact API vulnerability, Broken Object Level Authorization (BOLA, formerly called IDOR). The owasp-top-10 module owns the vulnerability catalog; here the point is architectural: the object-level check has to run on the server for every object access, keyed to the resource id and the caller.

Unpredictable ids do not fix it. Switching to random UUIDs makes ids harder to guess, but obscurity is not authorization: an unguessable id raises the bar and still needs the per-object check behind it. OWASP treats object ids (integers, UUIDs, or strings alike) as attacker-manipulable and recommends random GUIDs as one hardening measure that sits alongside a mandatory object-level check (CWE-639, authorization bypass through a user-controlled key, is the citation for "obscurity is not authorization"). If the check is missing, a leaked or logged id still gets in.

Filtering a list is an authorization problem

Single-record endpoints are the easy case. The one that quietly ships broken is the list:

-- returns every document in the tenant, then the UI "hides" the rest
SELECT * FROM documents WHERE tenant_id = $tenant;

Scoping by tenant_id keeps other companies out. It does nothing about which documents this user may see inside the tenant, so a plain member gets every colleague's private draft. Hiding rows in the client is worse than nothing: the data already left the server and sits in the browser's network tab.

The list has to be filtered by the authorization policy before it is sent. Either push the predicate into the query:

SELECT d.* FROM documents d
JOIN document_shares s ON s.document_id = d.id
WHERE d.tenant_id = $tenant AND s.user_id = $caller;

or ask your authorization system for the set of ids this user can see and select those. Doing the filter after fetch, or in the client, is the shape of a real breach.

When RBAC is the wrong shape: ABAC and ReBAC

Two models exist for what roles cannot express, and you will be asked to name them.

ABAC (attribute-based) evaluates a policy over attributes of the subject, the resource, the action, and the environment: "a manager in the same department may approve, before 6pm". The policy carries the rules instead of a role list, and it often runs in a dedicated policy engine.

ReBAC (relationship-based) is what Google Drive style sharing wants. Access is a relationship: anne is an editor of doc:1042, doc:1042 is in folder:specs, and a folder's viewers cascade to its documents. Google's Zanzibar is the reference design and the senior notes cover it. The practical takeaway for now: per-object sharing and inherited access are relationships, and modelling them as roles is the mistake to avoid. RBAC still earns its place for the coarse org roles on top.

[ senior depth ]

ReBAC and the Zanzibar model

Google built Zanzibar because Drive, YouTube, Photos, and Calendar all needed the same thing: per-object sharing with inherited access, at a scale where a role per object is absurd. The model is relationship-based. Every grant is a relation tuple, written ⟨object⟩#⟨relation⟩@⟨user⟩:

doc:roadmap#viewer@user:anne          # anne is a direct viewer
doc:roadmap#parent@folder:specs       # roadmap lives in folder specs
folder:specs#editor@group:eng#member  # every member of group eng edits specs

The last tuple points at a userset (group:eng#member), not a single user, so group membership is itself expressed as relations. What turns a handful of tuples into effective access is the namespace's userset rewrite rules. computed_userset makes one relation imply another on the same object (an editor is a viewer). tuple_to_userset walks a relation to another object and reads a relation there (a document's viewers include its parent folder's viewers). So you store the folder grant once and every document inside inherits it, no per-document fan-out.

specs: parenteng: editoranne: memberdoc:roadmapfolderfolder:specsgroupgroup:enguser
fig · How a ReBAC check resolves anne's access to a doc

Zanzibar exposes a small API. Check(object#relation@user) answers one question by resolving that graph on demand. Expand returns the effective userset tree for an object-relation, following the rewrite rules, which is what powers a share dialog. Read and Write manipulate raw tuples. Two properties matter in interviews. First, consistency: the new enemy problem is what happens when an ACL revocation and a later content change are evaluated out of order, so a removed user reads new content. Zanzibar solves it with zookies, opaque consistency tokens that pin a Check to a snapshot new enough to have observed a causally prior ACL write, backed by Spanner's globally ordered timestamps. Second, the forward Check is cheap but the reverse question is not, covered below.

Open source implementations track the paper closely: OpenFGA (a CNCF project) and SpiceDB both take relation tuples and answer Check/Expand plus reverse queries on top of an ordinary database.

Policy engines and policy-as-code

ABAC and rule-heavy authorization usually run in a general-purpose policy engine rather than in scattered if statements. Open Policy Agent (OPA) is the common one, graduated in the CNCF, with its policy language Rego. A rule reads request input and external data and returns a decision:

package authz

default allow := false

allow if {
    input.action == "read"
    input.resource.tenant == input.user.tenant
    input.user.clearance >= input.resource.classification
}

OPA 1.0 (December 2024) made the if keyword and contains for multi-value rules mandatory, so pre-1.0 rules without them read as dated. The architectural idea is the split between the decision point (PDP, where policy is evaluated) and the enforcement points (PEP, the services that call it and act on the answer). Policy becomes code and data: versioned in git, unit-tested, reviewed, deployed independently of the services it governs. The tradeoff is a decision dependency in your request path (mitigated by running OPA as a sidecar and pushing data to it) and the discipline of keeping the engine's copy of attributes fresh.

Rego and Zanzibar solve different shapes. Rego shines when decisions turn on attributes and computed conditions; a Zanzibar service shines when access is a large, changing graph of relationships. Real systems combine them.

Why listing what a user can see is the hard direction

Check (does anne view this doc) is a point query and fast. The endpoint that returns "every document anne can see" asks the reverse question, and it is a different cost class. Zanzibar's original paper did not even include it; OpenFGA added ListObjects and ListUsers later, and both cap results (OpenFGA defaults to 1000) precisely because the answer can fan out across the whole graph.

Wrong "we have a Check API, so the list endpoint just calls Check on everything." Fetching all rows and calling Check per row is an N+1 against the authorization service and does not scale. Right push authorization into the retrieval. Either the authorization service returns the set of authorized ids and you SELECT ... WHERE id IN (...), or you replicate the relationship data close enough to the query to join the predicate in SQL. This is the query problem, and it is where a naive ReBAC rollout falls over: the model is elegant for writes and Checks, but list screens force you to design the reverse path as its own problem.

Multi-tenant isolation is a separate boundary

In-tenant authorization decides which colleague sees which document. Tenant isolation decides that company A never sees company B, and a bug there is a headline, so it gets defended in depth rather than trusted to one WHERE clause. The spectrum is silo (a database or schema per tenant, strongest isolation, most expensive), pool (shared tables keyed by tenant_id, cheapest, the usual default), and bridge (pool the long tail, silo the few large or regulated tenants).

For the pool model, back the application filter with a database guardrail. Postgres row-level security enforces the tenant predicate in the engine, so an app query that forgets its WHERE tenant_id still returns nothing across tenants:

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON documents
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

The application still sets app.tenant_id per connection, and RLS is the backstop behind that primary application control. Treating tenant isolation as just another authorization rule in the same code path is how a single missing filter turns into cross-tenant data exposure.

Confused deputy and auditability

The confused deputy, named by Norm Hardy in 1988, is an authorization failure where a privileged intermediary is tricked into misusing its authority on behalf of a less-privileged caller. The service authenticates the caller but then acts with its own ambient authority against the target, never checking that the caller was allowed to reach that target. Server-side request forgery is one instance (the owasp-top-10 module maps SSRF here). The general defense is to pass and check the caller's authority for the specific action, not to let a deputy act on its own standing.

Authorization also has to be auditable. A production system reconstructs, after the fact, who could access what and why: decision logs from the policy engine (OPA emits structured decision logs), the tuple history in a Zanzibar-style store, and access-denial alerts. This is both an incident-response need and a compliance one, and it is a fair senior question: if a customer asks "who had access to this record last March," a design with authorization scattered across ad hoc if checks and no decision log cannot answer.

dig deeper

Primary sources behind these notes - the specs and official docs worth reading in full.

gapmap pro

You've read the Authorization models notes. An interviewer will ask you to prove them.

Know you're ready. Don't hope.

The notes are free: every topic, senior depth. Pro is what turns reading into readiness you can prove:

  • Mock interviews on your topics

    An AI interviewer that pushes back with follow-ups and grades you honestly. Per module, fair-use unlimited.

  • Voice test interviews

    The dress rehearsal, out loud: questions from your map, a transcript, and a per-answer verdict.

  • A plan to your date

    Ready or not ready, per module, as the interview approaches. Recomposed whenever your goal changes.

  • A mentor inside every lesson

    Stuck on the senior notes? Ask, drill deeper, get re-quizzed on the spot.

Pro $20/mo · Pro+ $50/mo · every study note on the site stays free

builds on

more Backend Security

was this useful?