AuthzGENERALSCALE-SPECIFIC

Attribute-Based Access Control

Rules over attributes of the principal, the resource and the context — more expressive than roles, and correspondingly harder to reason about.

What actually happensHow to build it

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.

The question

When do roles stop being enough, and what does moving to attribute rules actually cost?

The requirement

Document management for a law firm. A lawyer may read a matter file if they are on the matter team, the file is not marked privileged, or they hold a privilege waiver; conflicts-of-interest walls must block access even for partners; and everything must stop at the client engagement end date.

The obvious build

Keep adding roles. partner, partner_with_privilege, associate_matter_123 — one role per combination that comes up, granted per person, cleaned up later.

Why it breaks

The combinations multiply: three attributes with four values each is more roles than the firm has staff, and each one has to be granted and revoked by hand.

How it breaks in production
  • The combinations multiply: three attributes with four values each is more roles than the firm has staff, and each one has to be granted and revoked by hand.
  • Nothing expresses time. "Until the engagement ends" is a date on the matter, and no role changes by itself at midnight.
  • Ethical walls are exclusions, not grants. RBAC adds permissions; it has no natural way to say "this person specifically must not, regardless of role" (Role-Based Access Control).
  • The rule lives in nobody's head. Compliance asks "who can read this file and why", and the only answer is a list of role names whose meanings were never written down.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • An ABAC rule is a predicate over attributes: of the principal (department, clearance, employment status), of the resource (owner, classification, state, amount), and of the context (time, IP, request purpose, approval already recorded).
  • A policy is a set of such rules plus a combining algorithm: what happens when one rule permits and another denies. Deny-overrides is the usual and the safest choice — an explicit deny beats any permit (Fail Open vs Fail Closed).
  • Attributes have to be *available at decision time*. That is the practical constraint that shapes everything: a rule about the resource requires loading the resource, and a rule about the principal's manager requires a second lookup.
  • RBAC is a special case of ABAC where the only principal attribute considered is the role. In practice most real systems are hybrids: roles for the coarse gate, attribute rules for the conditions.
  • Policy can be expressed in application code (a function per action), in a data-driven rule table, or in a dedicated policy language evaluated by an engine. All three are ABAC; they differ in who can change a rule and how well you can explain a decision.
  • The characteristic ABAC problem is explainability: with enough rules, "why was this denied" and "who can access this" stop being obvious, and need tooling to answer (ABAC and Policy-Based Authorization).

A rule is a predicate with a reason

The shape that keeps attribute policies manageable is a list of named rules, each returning a decision and its own name, combined with deny-overrides. Named rules make denials explainable and make the policy reviewable by someone who does not read the language it is written in.

Deny-overrides, explicit reasons, snapshotted attributes
1type Decision = { allow: boolean; rule: string }
2
3const rules = [
4 // deny rules first in intent, but the combiner is what enforces precedence
5 (p: Principal, r: MatterFile, c: Ctx): Decision | null =>
6 c.walls.some((w) => w.person === p.id && w.matter === r.matterId)
7 ? { allow: false, rule: 'ethical-wall' } : null,
8
9 (p: Principal, r: MatterFile, c: Ctx): Decision | null =>
10 c.now > r.engagementEndsAt ? { allow: false, rule: 'engagement-ended' } : null,
11
12 (p: Principal, r: MatterFile): Decision | null =>
13 r.teamIds.includes(p.id) && !r.privileged
14 ? { allow: true, rule: 'matter-team' } : null,
15
16 (p: Principal, r: MatterFile): Decision | null =>
17 r.privileged && p.waivers.includes(r.matterId)
18 ? { allow: true, rule: 'privilege-waiver' } : null,
19]
20
21export function canRead(p: Principal, r: MatterFile, c: Ctx): Decision {
22 const fired = rules.map((f) => f(p, r, c)).filter((d): d is Decision => d !== null)
23 const denial = fired.find((d) => !d.allow)
24 if (denial) return denial // deny overrides
25 return fired.find((d) => d.allow) ?? { allow: false, rule: 'no-matching-rule' }
26}

Three properties are doing the work: a rule returns null when it has no opinion (so silence is never a permit), any deny wins regardless of order, and the fallthrough is a deny with a name. The context c is passed in as a snapshot so every rule sees the same instant and the same wall list.

Roles or attributes, or both

The question is rarely "which model" and usually "what is the smallest addition to what we have". Reach for an attribute rule when a requirement mentions a condition — a state, a threshold, a date, a relationship — and for a role when it mentions a job.

How should this rule be expressed?

What does the requirement actually quantify over?

Role permission

when A job title implies a set of capabilities: "support agents may reply to tickets".

cost Cannot mention any specific object, condition, or time (Role-Based Access Control).

Ownership / relationship check

when The rule is "their own" or "their team's". This is the majority of real product rules.

cost Requires loading the object; belongs in the query where possible (Object-Level Authorization).

Attribute predicate in application code

when A handful of conditions — amount thresholds, resource state, employment status — in one service.

cost Rules live in a deployable artefact; a change needs a release.

Data-driven rule table

when Non-engineers must add rules, and the rule shapes are known and bounded.

cost You have built a small policy language, including its bugs and its lack of a type checker.

External policy engine

when Multiple services share a rule set, or policy must be versioned and audited independently of application releases.

cost A dependency in the request path with its own latency, availability and failure mode — and it must fail closed.

Relationship / graph authorization service

when Per-object sharing at scale, and you need to answer "who can access X" as a query, not by evaluation.

cost Substantial infrastructure: a replicated index, consistency semantics, and a new source of truth for access.

The failure that looks like nothing

Attribute rules fail quietly. A predicate over a value that is missing, stale or attacker-supplied still returns a boolean, and the request still completes. These are the cases worth writing explicit tests for, because none of them produces an error.

Attribute-level failures and their responses
TriggerSymptomCauseResponse
Attribute absent from a partially-loaded resourceRule evaluates against undefinedThe policy assumed a field the query did not selectType the policy input so a missing attribute is a compile error, or validate the input object before evaluating.
Attribute taken from the requestA caller grants themselves access by sending a fieldUntrusted input used as a policy attributeAttributes come from the principal record and the loaded resource only (The Trust Boundary).
Employment status cached in the sessionA suspended employee keeps reading filesAttribute freshness tied to session lifetimeLoad revocation-sensitive attributes per request, or keep the TTL shorter than your required revocation time.
Policy engine times outLatency spike, then either 5xx or, worse, successNo timeout policy, or a fail-open catchBounded timeout, fail closed, and a circuit breaker that returns 503 rather than allowing (Timeouts).
A permit rule added for one caseAn unrelated deny stops being enforcedPermit-overrides combiningDeny-overrides, plus a test that asserts each deny rule still denies.
Each rule triggers its own lookupOne endpoint, six queries, all authorizationAttributes fetched lazily inside rulesLoad the attribute snapshot once and pass it to the policy (Eager Loading and Batching).

How to build it

Most important first.

  • Start hybrid. Use roles for the coarse gate and attribute predicates for the conditions; do not rewrite a working role model because one rule needed an amount threshold.
  • Write policies as pure functions of (principal, resource, context) returning a decision with a reason. The reason is not a nicety — it is what makes denials debuggable and audits possible.
  • Make deny explicit and let it win. Conflict resolution that depends on rule ordering is a source of accidental permits.
  • Keep the attribute set small and stable, and treat adding an attribute as a schema decision. Every attribute is something that must be loaded, kept fresh, and reasoned about in every rule.
  • Test policies as data: a table of (principal, resource, context) → expected decision, run as ordinary unit tests. Policy is the part of a system where exhaustive tests are actually feasible.
  • Decide early whether policy is code or configuration. Configuration means non-engineers can change access rules — sometimes exactly the point, sometimes an unreviewed path to production (Feature Flags: Rollout, Kill Switches and Debt has the same tension).

What can go wrong

Failure modes
  • A missing attribute evaluating to undefined and a comparison silently passing. user.clearance >= file.classification with undefined on either side is false in JS but a TypeError in Python — and a false that reads like a deny may equally be a permit under a different combining rule.
  • Stale attributes: employment status cached in a session, a matter team updated but not propagated, so a rule that is correct evaluates against wrong facts.
  • Rule ordering dependence, where adding a permit rule silently overrides an intended deny.
  • Policy evaluation adding a database read per rule, turning one endpoint into six queries (The N+1 Query Problem applied to permission lookups).
  • An external policy engine becoming a hard dependency in the request path with no fallback; when it is slow, every request is slow, and the tempting fix is to fail open (Circuit Breakers).
  • Policies expressive enough that nobody can predict their behaviour, so exceptions are handled by adding a bypass rule that quietly grants broad access.
What can race
  • An attribute changes between evaluation and action — a matter closes, an employee is suspended, a wall is erected. Long-running operations should re-evaluate before the final write (Where the Transaction Boundary Goes).
  • Two rules evaluated against attributes read at different moments can produce a combination that was never simultaneously true. Load the attribute set once, decide from that snapshot.
Security
  • If an attribute used in a rule is attacker-controllable, an attacker gets exactly the permission that attribute guards. A purpose or department field taken from the request body is a self-service grant — attributes must come from trusted stores, not from the caller (The Trust Boundary).
  • If the combining algorithm is permit-overrides, an attacker gets access whenever *any* rule matches: an ethical wall, a suspension, or a data-classification deny is nullified by one unrelated permit.
  • If a missing attribute is treated as satisfying the rule, an attacker gets access by causing the attribute to be absent — deleting an optional profile field, or hitting a path where the resource is partially loaded.
  • If the policy engine fails open on timeout, an attacker gets a denial-of-service-to-authorization-bypass chain: make the engine slow, then act freely. Policy evaluation must fail closed.
  • Explainability is a security property, not a convenience. A policy set nobody can reason about will contain a permit nobody intended, and it will not be found by reading it.
Misreads
  • "ABAC replaces RBAC." Nearly every production system is a hybrid: a role gate plus attribute conditions. Ripping out roles is rarely the improvement it appears to be.
  • "A policy engine gives us authorization." It gives you a place to write and evaluate rules. Loading correct attributes and enforcing the result are still yours (Where the Check Belongs).
  • "More expressive is better." Expressiveness you cannot audit is a liability. The right policy language is the least powerful one that states your rules.
  • "Attributes are just columns." Attributes must be trustworthy, current and available at decision time — three properties an arbitrary column does not automatically have.

Operating it

How you see it in production
  • Emit the deciding rule id with every decision. denied by rule ethical-wall-v2 is actionable; 403 is not.
  • Count decisions per rule. A rule that has never fired is either dead or wrong, and both are worth knowing (Label Sets That Survive a Year — keep rule ids bounded, they are a cardinality risk).
  • Track policy evaluation latency separately from handler latency. Authorization becoming the slowest part of a request is common and usually invisible in an endpoint-level metric (Tracing From the Backend's Side).
  • Keep a decision log for high-value resources — principal, resource, decision, rule, attributes used. Compliance questions are almost always "who accessed this, and under what rule" (Audit Logs for Privileged Actions).
What changes at 10x and 100x
  • Per-request cost grows with the number of attributes that must be fetched, not with the number of rules. Ten rules over three loaded attributes is cheap; three rules that each need a different lookup is not.
  • At 100x, batch and colocate attribute loading — fetch what the policy needs alongside the resource in one query rather than as separate reads.
  • "Who can access X" and "what can P access" are reverse queries that a predicate-based policy cannot answer by evaluation. At scale these need a materialized index, which is a substantial piece of infrastructure and the main reason relationship-based systems exist.
What this costs
  • Expressiveness costs comprehensibility. Every rule you add makes the system able to say more and makes the aggregate behaviour harder to predict.
  • A policy engine gives you a language, a test harness and central management — plus a new dependency, a new deployment artefact, and a new place for an outage.
  • Attribute freshness costs reads. The alternative — caching attributes — costs correctness in exactly the situations (suspension, revocation, wall erected today) where the rule mattered most.
  • Policy as configuration lets the business change rules without a deploy, which also means access rules can change without code review (Configuration: Separating Code From Environment).

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • GENERALAttribute predicates over principal, resource and context are a modelling technique, not a technology. You can do ABAC with an if statement.
  • SCALE-SPECIFICA dedicated policy engine pays off when several services must share one rule set, or when non-engineers own the rules. For one service with fifteen rules, a policy module in your own language is easier to test, deploy and debug — and has no availability story to build.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.

Performancecardinality
Domains that do not exist yet
  • Policy languages and formal reasoning — deciding whether a policy set is safe (no unintended permit) is a program-analysis problem, and the reason production policy languages are deliberately restricted.