---
title: 'Attribute-Based Access Control'
source: 'https://academia.sh/en/courses/authentication-and-authorization/attribute-based-access-control'
course: 'Authentication and Authorization'
language: en
updated: '2026-08-19T05:19:32+00:00'
license: 'CC BY-SA 4.0'
---

# Attribute-Based Access Control

Deciding from subject, object, action, and context attributes, deny priority's role in combining rules, the attribute-based model's correct and wrong decisions on the same scenario set, and the consequence of where an attribute is read from.

In the previous lesson's eight scenarios, four decisions came out wrong, and three of
them shared the same cause: the role-based model cannot see the request's context. Who
owns the record, which branch the staff member works in, what time the request
arrived — all of this is information known at decision time, but the permission's name
has no place for it.

**Attribute-based access control** turns this information into the decision's input.
What produces the decision is no longer membership in a permission list, but a rule that
operates on attribute values.

## Four Sets of Attributes

The model gathers attributes from four sources at decision time.

**Subject attributes** belong to the person making the request: their code, role,
the branch they work at, seniority, which factors authenticated them.

**Object attributes** belong to the record being accessed: its type, owner, the branch
it belongs to, its confidentiality class, its creation date.

**Action** is the operation to be carried out; it carries the same meaning as in the
role-based model.

**Context attributes** belong neither to the subject nor to the object; they belong to
the request itself: the time, the network location, the device the request arrived
from, the session's age.

A rule is a condition over the values of these four sets. The rule "can view its own
record" is the equality of the subject's code with the object's owner; the branch rule
is the equality of two branch attributes. These equalities were the thing the
role-based model could not express, because it never looked at the record at all.

## Combining Rules

More than one rule can match the same request, and their outcomes can conflict. The
combination rule says what happens in that case. The default choice is **deny
priority**: if one of the matching rules says deny, the decision is deny, no matter how
many rules say allow. If no rule matches, the decision is still deny — allow is
something that must be granted explicitly.

The evaluator below applies four rules with this combination and re-decides the
previous lesson's eight scenarios. The last column shows which rule produced the
decision.

```bash
cat > abac.mjs <<'EOF'
const SUBJECTS = {
  "U-1001": { role: "member", branch: "S-02" }, "U-1002": { role: "member", branch: "S-02" },
  "U-1005": { role: "member", branch: "S-01" }, "P-2001": { role: "clerk", branch: "S-02" },
  "Y-3001": { role: "branch_manager", branch: "S-02" },
};

// Common scenario set from the previous lesson.
const SCENARIOS = [
  ["S1", "U-1001", "view",   "loan:501", { owner: "U-1001", branch: "S-02" }, "10:15", true],
  ["S2", "U-1001", "view",   "loan:502", { owner: "U-1002", branch: "S-02" }, "10:16", false],
  ["S3", "P-2001", "view",   "loan:502", { owner: "U-1002", branch: "S-02" }, "10:20", true],
  ["S4", "P-2001", "view",   "loan:503", { owner: "U-1005", branch: "S-01" }, "10:21", false],
  ["S5", "P-2001", "update", "loan:502", { owner: "U-1002", branch: "S-02" }, "22:40", false],
  ["S6", "Y-3001", "delete", "fine:77",  { owner: "U-1002", branch: "S-02" }, "11:05", true],
  ["S7", "U-1002", "view",   "list:9",   { owner: "U-1001", branch: "S-02" }, "12:00", true],
  ["S8", "U-1005", "view",   "list:9",   { owner: "U-1001", branch: "S-02" }, "12:01", false],
];

const duringWorkingHours = (time) => time >= "08:30" && time <= "18:00";
const WRITE_ACTIONS = new Set(["update", "create", "delete"]);

const RULES = [
  { name: "R1 own record", effect: "allow",
    condition: (o, e, n) => e === "view" && n.owner === o.code },
  { name: "R2 branch staff", effect: "allow",
    condition: (o, e, n) => ["clerk", "branch_manager"].includes(o.role) &&
      n.branch === o.branch && ["view", "update", "create"].includes(e) },
  { name: "R3 fine deletion", effect: "allow",
    condition: (o, e, n) => o.role === "branch_manager" && n.branch === o.branch &&
      e === "delete" && n.type === "fine" },
  { name: "R4 off-hours write", effect: "deny",
    condition: (o, e, n, b) => WRITE_ACTIONS.has(e) && !duringWorkingHours(b.time) && o.role !== "branch_manager" },
];

function abac(subjectCode, action, objectName, objectAttrs, time) {
  const o = { code: subjectCode, ...SUBJECTS[subjectCode] };
  const n = { type: objectName.split(":")[0], ...objectAttrs };
  const b = { time };
  const matched = RULES.filter((r) => r.condition(o, action, n, b));
  const deny = matched.find((r) => r.effect === "deny");
  const allow = matched.find((r) => r.effect === "allow");
  if (deny) return [false, `${deny.name} (deny wins)`];
  if (allow) return [true, allow.name];
  return [false, "no matching rule"];
}

console.log("scenario | ABAC  | expect | match | rule that decided");
console.log("---------|-------|--------|-------|----------------------------");
let matched = 0;
for (const [name, subject, action, object, attrs, time, expected] of SCENARIOS) {
  const [decision, reason] = abac(subject, action, object, attrs, time);
  if (decision === expected) matched++;
  console.log(`${name}       | ${(decision ? "allow" : "deny").padEnd(5)} | ` +
    `${(expected ? "allow" : "deny").padEnd(6)} | ${(decision === expected ? "+" : "-").padEnd(5)} | ${reason}`);
}
console.log(`\nABAC correct decisions: ${matched}/${SCENARIOS.length}`);
EOF
node abac.mjs
```

```text
scenario | ABAC  | expect | match | rule that decided
---------|-------|--------|-------|----------------------------
S1       | allow | allow  | +     | R1 own record
S2       | deny  | deny   | +     | no matching rule
S3       | allow | allow  | +     | R2 branch staff
S4       | deny  | deny   | +     | no matching rule
S5       | deny  | deny   | +     | R4 off-hours write (deny wins)
S6       | allow | allow  | +     | R3 fine deletion
S7       | deny  | allow  | -     | no matching rule
S8       | deny  | deny   | +     | no matching rule

ABAC correct decisions: 7/8
```

Three of the four decisions that came out wrong in the role-based model are now
fixed. S2 and S4, by comparing the ownership and branch attributes; S5, by the context
attribute. S8's decision is now also given for the correct reason: the list is not
U-1005's, so no rule matches.

The reason column is the model's second gain. Every decision can be produced together
with the name of the rule that gave it; the answer to "why was it denied" can be written
to a log. In the role-based model, that answer was nothing more than "that permission
was not in the role."

## Where the Model Falls Short

S7 is still wrong. U-1001 shared reading list number nine with U-1002; U-1002 should be
able to see that list. In the attribute-based model, the way to write this constraint is
to turn the sharing into an attribute: a `sharedWith` field is added to the object, and
the rule checks whether the subject's code is in that list.

This path works at small scale and jams at two points. The first is transitivity: if
U-1002 shares the list with its own working group, the group's members should also gain
access. The attribute list can only express this by including every group member one by
one, and the attribute must be updated every time the group changes. The second is
indirection: a constraint like "the records opened by staff working at the branch a
branch manager manages" rests on a link two steps away and does not fit into a single
attribute field.

Both cases are the signal that the constraint is not an attribute but a
**relationship**. That is the subject of the next lesson.

## Where the Attribute Comes From, and How Fresh It Is

The real operating question of the attribute-based model is not how the rules are
written but where the attributes are read from. There are two options: the values are
carried inside the identity token, or they are read from the record at decision time.

Reading from the token is fast and requires no extra query. Its cost is that the value
is the value at the moment the token was issued. The example below decides two requests
from a clerk who has changed branches, using both sources.

```bash
cat > freshness.mjs <<'EOF'
const RECORD = { "P-2001": { role: "clerk", branch: "S-01" } };   // moved from S-02 to S-01 at 12:00
const TOKEN = { code: "P-2001", role: "clerk", branch: "S-02", issuedAt: "09:00" };

const rule = (o, n) => o.role === "clerk" && n.branch === o.branch;

const requests = [
  ["loan:502 (branch S-02)", { branch: "S-02" }, false],   // should no longer see it
  ["loan:503 (branch S-01)", { branch: "S-01" }, true],    // should see it now
];

console.log("attribute source          | " + requests.map((i) => i[0]).join(" | "));
console.log("--------------------------|-----------------------|-----------------------");
for (const [name, o] of [["from token (09:00)", { ...TOKEN }],
                         ["from record (live)", { code: "P-2001", ...RECORD["P-2001"] }]]) {
  const cells = requests.map(([, n, expected]) => {
    const decision = rule(o, n);
    return `${decision ? "allow" : "deny"} ${decision === expected ? "(correct)" : "(WRONG)"}`.padEnd(21);
  });
  console.log((name.padEnd(25) + " | " + cells.join(" | ")).trimEnd());
}
EOF
node freshness.mjs
```

```text
attribute source          | loan:502 (branch S-02) | loan:503 (branch S-01)
--------------------------|-----------------------|-----------------------
from token (09:00)        | allow (WRONG)         | deny (WRONG)
from record (live)        | deny (correct)        | allow (correct)
```

The stale value in the token produces a wrong decision in both directions at once: it
allows access to a record the clerk should no longer see, and denies access to the
record it should see. The second one surfaces as a support ticket and gets fixed; the
first one never surfaces at all.

The decision rule reads: **an attribute that changes the decision, and that can change,
is read at decision time.** Only attributes that never change, or whose change within
the token's lifetime is acceptable, are carried in the token. The shorter the token's
lifetime, the less this distinction matters — the lifetime decision in the refresh-token
lesson is the same trade-off wearing a different face here.

Reading from the record costs one query per request. This cost is bounded by reading the
attributes once per request and caching them for the request's duration; the cache's
lifetime must not exceed the request's lifetime.

## The Auditability of Rules

The expressive power the attribute-based model gains comes with a cost: seeing what the
rules do together gets harder. In the role-based model, an account's effective
permission set could be computed; in attribute-based rules there is no such set, because
the decision depends on the request itself.

Two audit procedures are used instead.

**The scenario set.** This lesson's table is itself an audit tool: known requests and
expected answers are kept as a set, and the set is re-run every time a rule changes. A
minus appearing in the match column means the rule's text needs to be examined.

**Rule coverage.** A rule that never matches in any scenario is either unnecessary or
written incorrectly; a rule that matches in every scenario is most likely too broad. How
many times each rule matches can be counted, and this count says something about how the
rule is written.

A third rule is one of writing discipline: rules must be free of side effects and must
produce the same decision for the same input every time. A rule that writes a record,
calls an external service, or uses a random value during the decision cannot be re-run
and cannot be audited.

## Summary

- Attribute-based access control produces the decision from subject, object, action, and
  context attributes; the ownership and branch constraints that could not be expressed
  in the role-based model are written here as equality conditions.
- Deny takes priority when combining rules, and the decision is deny if no rule matches.
- The model can produce the reason behind every decision; this makes it possible to log
  the reason for a denial.
- Transitive and indirect links do not fit into an attribute field; these constraints
  are relationships, not attributes.
- An attribute that changes the decision is read at decision time if it can change; a
  stale value carried in the token produces a wrong decision in both directions at once.

## Next Step

S7 has now been decided wrong two lessons in a row. A shared list's accessibility
belongs neither to the subject nor to the object — it is the link between the two. The
next lesson takes up relationship-based access control, which turns these links into
first-class data: objects and subjects form a graph, and the access question turns into
a reachability query over that graph.
