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

# Policy-Based Authorization

Moving the access decision out of application code into an interpreted policy document, the split between the decision point and the enforcement point, comparing the four models' decisions on the same scenario set, and testing a policy in report mode.

Three lessons built three models, and all three fell short in different places on the
same eight scenarios. The role-based model could not see who owned the record; the
attribute-based model could not see the sharing link; the relationship-based model
could not see the time. The gaps do not overlap — they complement each other.

This observation leads not to a fourth model but to a different question: where will
these rules actually live? The three evaluators built so far were each a script; in a
real application, the same rules would be scattered across endpoint handlers, inside
`if` blocks. **Policy-based authorization** moves the rules out of the code into an
interpreted document.

## Decision Point and Enforcement Point

The split has two sides. The **policy decision point** takes the request and the
relevant data and produces an allow or deny decision; it carries no domain logic, it
only interprets the policy. The **policy enforcement point** is where the decision is
applied: the endpoint handler or the middleware. It does not make the decision — it asks
and abides by the answer.

This split has three concrete payoffs. A rule change stops being a code change; the
policy document can be versioned separately and deployed independently of the code. The
decision's reason is produced in a single place, so it can be logged and tested. The
same policy can be used by more than one service; the rule is not duplicated.

The engine below builds this split. `POLICY` is a data structure and can be kept in a
separate file; the engine interprets it and carries no knowledge specific to the
library.

```bash
cat > policy.mjs <<'EOF'
const EDGES = [
  ["U-1001", "owner", "loan:501"], ["U-1002", "owner", "loan:502"],
  ["U-1005", "owner", "loan:503"], ["U-1001", "owner", "list:9"],
  ["loan:501", "belongsTo", "S-02"], ["loan:502", "belongsTo", "S-02"],
  ["loan:503", "belongsTo", "S-01"], ["fine:77", "belongsTo", "S-02"],
  ["P-2001", "worksAt", "S-02"], ["Y-3001", "manages", "S-02"],
  ["list:9", "sharedWith", "U-1002"], ["list:9", "sharedWith", "group:reading-club"],
  ["U-1006", "memberOf", "group:reading-club"],
];
const SUBJECTS = {
  "U-1001": { role: "member" }, "U-1002": { role: "member" }, "U-1005": { role: "member" },
  "P-2001": { role: "clerk" }, "Y-3001": { role: "branch_manager" },
};

// --- Policy document: data, not code. Kept and versioned separately. ---
const POLICY = {
  version: "2026-03-01",
  rules: [
    { name: "views own record", effect: "allow", action: ["view"],
      object: ["loan", "list"], relation: [["owner"]] },
    { name: "branch staff", effect: "allow", action: ["view", "update", "create"],
      object: ["loan"], role: ["clerk", "branch_manager"],
      relation: [["worksAt", "~belongsTo"], ["manages", "~belongsTo"]] },
    { name: "manager deletes fine", effect: "allow", action: ["delete"], object: ["fine"],
      role: ["branch_manager"], relation: [["manages", "~belongsTo"]] },
    { name: "shared list", effect: "allow", action: ["view"], object: ["list"],
      relation: [["~sharedWith"], ["memberOf", "~sharedWith"]] },
    { name: "off-hours write forbidden", effect: "deny", action: ["update", "create", "delete"],
      object: ["loan", "fine", "list"], outsideHours: ["08:30", "18:00"],
      exceptRole: ["branch_manager"] },
  ],
};

// --- Engine: interprets the policy, carries no domain knowledge. ---
function step(nodes, relation) {
  const reversed = relation.startsWith("~"), name = reversed ? relation.slice(1) : relation;
  const result = new Set();
  for (const [s, r, t] of EDGES) {
    if (r !== name) continue;
    if (!reversed && nodes.has(s)) result.add(t);
    if (reversed && nodes.has(t)) result.add(s);
  }
  return result;
}

const reaches = (subject, patterns, object) => patterns.some((pattern) => {
  let frontier = new Set([subject]);
  for (const r of pattern) frontier = step(frontier, r);
  return frontier.has(object);
});

function matches(rule, { subject, action, object, time }) {
  const role = SUBJECTS[subject]?.role;
  if (!rule.action.includes(action)) return false;
  if (!rule.object.includes(object.split(":")[0])) return false;
  if (rule.role && !rule.role.includes(role)) return false;
  if (rule.exceptRole && rule.exceptRole.includes(role)) return false;
  if (rule.outsideHours) {
    const [start, end] = rule.outsideHours;
    if (time >= start && time <= end) return false;
  }
  if (rule.relation && !reaches(subject, rule.relation, object)) return false;
  return true;
}

function decide(request) {
  const matched = POLICY.rules.filter((r) => matches(r, request));
  const deny = matched.find((r) => r.effect === "deny");
  if (deny) return [false, deny.name + " (deny wins)"];
  const allow = matched.find((r) => r.effect === "allow");
  return allow ? [true, allow.name] : [false, "no matching rule (default deny)"];
}

const SCENARIOS = [
  ["S1", "U-1001", "view",   "loan:501", "10:15", true],
  ["S2", "U-1001", "view",   "loan:502", "10:16", false],
  ["S3", "P-2001", "view",   "loan:502", "10:20", true],
  ["S4", "P-2001", "view",   "loan:503", "10:21", false],
  ["S5", "P-2001", "update", "loan:502", "22:40", false],
  ["S6", "Y-3001", "delete", "fine:77",  "11:05", true],
  ["S7", "U-1002", "view",   "list:9",   "12:00", true],
  ["S8", "U-1005", "view",   "list:9",   "12:01", false],
];

console.log(`policy version: ${POLICY.version}, rule count: ${POLICY.rules.length}`);
console.log("\nscenario | policy   | expect | match | rule that decided");
console.log("---------|----------|--------|-------|--------------------------------");
let matched = 0;
for (const [name, subject, action, object, time, expected] of SCENARIOS) {
  const [d, reason] = decide({ subject, action, object, time });
  if (d === expected) matched++;
  console.log(`${name}       | ${(d ? "allow" : "deny").padEnd(8)} | ` +
    `${(expected ? "allow" : "deny").padEnd(6)} | ${(d === expected ? "+" : "-").padEnd(5)} | ${reason}`);
}
console.log(`\npolicy engine correct decisions: ${matched}/${SCENARIOS.length}`);
EOF
node policy.mjs
```

```text
policy version: 2026-03-01, rule count: 5

scenario | policy   | expect | match | rule that decided
---------|----------|--------|-------|--------------------------------
S1       | allow    | allow  | +     | views own record
S2       | deny     | deny   | +     | no matching rule (default deny)
S3       | allow    | allow  | +     | branch staff
S4       | deny     | deny   | +     | no matching rule (default deny)
S5       | deny     | deny   | +     | off-hours write forbidden (deny wins)
S6       | allow    | allow  | +     | manager deletes fine
S7       | allow    | allow  | +     | shared list
S8       | deny     | deny   | +     | no matching rule (default deny)

policy engine correct decisions: 8/8
```

All eight decisions are correct. The gain does not come from a new model — it comes
from three models' expressive power joining in a single document: the `role` field
carries the role-based constraint, `outsideHours` the attribute-based one, `relation`
the relationship-based one. The engine sees these not as separate concepts but as
fields of the same rule.

Two design decisions are written into the document. **Default deny**: if no rule
matches, the decision is deny. **Deny priority**: if one of the matching rules says
deny, the ones that say allow do not count. Together, they guarantee that adding a new
rule to the policy can never quietly loosen an existing prohibition.

## Comparing the Four Models

The block below runs all four models on the same set and lines the decisions up side by
side.

```bash
cat > comparison.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" },
};
const EDGES = [
  ["U-1001", "owner", "loan:501"], ["U-1002", "owner", "loan:502"],
  ["U-1005", "owner", "loan:503"], ["U-1001", "owner", "list:9"],
  ["loan:501", "belongsTo", "S-02"], ["loan:502", "belongsTo", "S-02"],
  ["loan:503", "belongsTo", "S-01"], ["fine:77", "belongsTo", "S-02"],
  ["P-2001", "worksAt", "S-02"], ["Y-3001", "manages", "S-02"],
  ["list:9", "sharedWith", "U-1002"],
];
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 type = (n) => n.split(":")[0];
const duringWorkingHours = (t) => t >= "08:30" && t <= "18:00";
const WRITE_ACTIONS = ["update", "create", "delete"];

// 1. Role-based: the decision looks only at the role and the resource type.
const PERMISSIONS = {
  member: ["view:loan", "view:list"],
  clerk: ["view:loan", "view:list", "update:loan", "create:loan"],
  branch_manager: ["view:loan", "view:list", "update:loan",
                   "create:loan", "delete:fine", "delete:loan"],
};
const rbac = (o, e, n) => PERMISSIONS[SUBJECTS[o].role].includes(`${e}:${type(n)}`);

// 2. Attribute-based: ownership, branch, and time comparisons.
function abac(o, e, n, attrs, time) {
  const s = SUBJECTS[o];
  if (WRITE_ACTIONS.includes(e) && !duringWorkingHours(time) && s.role !== "branch_manager") return false;
  if (e === "view" && attrs.owner === o) return true;
  if (["clerk", "branch_manager"].includes(s.role) && attrs.branch === s.branch &&
      ["view", "update", "create"].includes(e)) return true;
  return s.role === "branch_manager" && attrs.branch === s.branch && e === "delete" && type(n) === "fine";
}

// 3. Relationship-based: a path pattern over the graph.
const PATTERNS = {
  "view:loan": [["owner"], ["worksAt", "~belongsTo"], ["manages", "~belongsTo"]],
  "update:loan": [["worksAt", "~belongsTo"], ["manages", "~belongsTo"]],
  "delete:fine": [["manages", "~belongsTo"]],
  "view:list": [["owner"], ["~sharedWith"]],
};
function step(set, r) {
  const reversed = r.startsWith("~"), name = reversed ? r.slice(1) : r, c = new Set();
  for (const [s, i, t] of EDGES) {
    if (i !== name) continue;
    if (!reversed && set.has(s)) c.add(t);
    if (reversed && set.has(t)) c.add(s);
  }
  return c;
}
const reaches = (o, patterns, n) => (patterns ?? []).some((p) => {
  let c = new Set([o]);
  for (const r of p) c = step(c, r);
  return c.has(n);
});
const rebac = (o, e, n) => reaches(o, PATTERNS[`${e}:${type(n)}`], n);

// 4. Policy-based: role, relation, and context in a single document.
const POLICY = [
  { effect: "deny", action: WRITE_ACTIONS, outsideHours: ["08:30", "18:00"], exceptRole: ["branch_manager"] },
  { effect: "allow", action: ["view"], pattern: [["owner"], ["~sharedWith"]] },
  { effect: "allow", action: ["view", "update", "create"],
    role: ["clerk", "branch_manager"], pattern: [["worksAt", "~belongsTo"], ["manages", "~belongsTo"]] },
  { effect: "allow", action: ["delete"], role: ["branch_manager"], pattern: [["manages", "~belongsTo"]] },
];
function policy(o, e, n, attrs, time) {
  const role = SUBJECTS[o].role;
  const matched = POLICY.filter((r) =>
    r.action.includes(e) &&
    !(r.role && !r.role.includes(role)) &&
    !(r.exceptRole && r.exceptRole.includes(role)) &&
    !(r.outsideHours && time >= r.outsideHours[0] && time <= r.outsideHours[1]) &&
    !(r.pattern && !reaches(o, r.pattern, n)));
  if (matched.some((r) => r.effect === "deny")) return false;
  return matched.some((r) => r.effect === "allow");
}

const MODELS = [["RBAC", rbac], ["ABAC", abac], ["ReBAC", rebac], ["policy", policy]];
const counter = new Map(MODELS.map(([name]) => [name, 0]));
const separator = "---------|" + MODELS.map(() => "---------").join("|") + "|-------";

console.log("scenario | " + MODELS.map(([a]) => a.padEnd(8)).join("| ") + "| expect");
console.log(separator);
for (const [name, o, e, n, attrs, time, expected] of SCENARIOS) {
  const cells = MODELS.map(([m, fn]) => {
    const d = fn(o, e, n, attrs, time);
    if (d === expected) counter.set(m, counter.get(m) + 1);
    return `${d ? "allow" : "deny "} ${d === expected ? "+" : "-"}`.padEnd(8);
  });
  console.log(`${name}       | ${cells.join("| ")}| ${expected ? "allow" : "deny"}`);
}
console.log(separator);
console.log("correct  | " + MODELS.map(([m]) => `${counter.get(m)}/8`.padEnd(8)).join("| ") + "|");
EOF
node comparison.mjs
```

```text
scenario | RBAC    | ABAC    | ReBAC   | policy  | expect
---------|---------|---------|---------|---------|-------
S1       | allow + | allow + | allow + | allow + | allow
S2       | allow - | deny  + | deny  + | deny  + | deny
S3       | allow + | allow + | allow + | allow + | allow
S4       | allow - | deny  + | deny  + | deny  + | deny
S5       | allow - | deny  + | allow - | deny  + | deny
S6       | allow + | allow + | allow + | allow + | allow
S7       | allow + | deny  - | allow + | allow + | allow
S8       | allow - | deny  + | deny  + | deny  + | deny
---------|---------|---------|---------|---------|-------
correct  | 4/8     | 7/8     | 7/8     | 8/8     |
```

How the table should be read matters. It should not be concluded that the column on
the right is "better" than the one on the left; the conclusion to draw is that each
model can express a different kind of constraint. The role-based model's four correct
answers show that when the decision ends at the resource type, no other mechanism is
needed.

The S5 and S7 columns read the models' limits in the same row: the attribute-based
model solves S5 but not S7; the relationship-based model does the opposite. The policy
column solves both, because the rule document carries both kinds of constraint as
fields.

## Operating the Policy

The policy being a separate document means it produces a deployment object outside the
code, and that has a discipline of its own.

**The policy is versioned and logged together with the decision.** The line written to
the log carries the decision itself and the name of the rule that gave it. The answer
to "why was this request denied" can be given months later too.

**A change is tested in report mode.** The same split established for the content
security policy in the Client-Side Security topic applies here too: the new policy
produces decisions against real traffic, but the decision is not enforced; only the
difference from the policy currently in force is recorded. If the diff list contains
only the expected changes, the policy switches to enforcement mode.

**The scenario set is the policy's test.** This lesson's table is a test harness; it is
run with every policy change, and a minus appearing in the match column stops the
change. A rule the policy allows and a rule it denies are added to the set with every
new rule.

**The decision data is moved to the decision point.** The engine needs access to the
relationship graph and the attribute values. If this data is queried at decision time,
every request carries an extra delay; if it is copied in advance, a freshness problem
arises. The criterion is the same as the one in the attribute-freshness lesson: data
that changes the decision and that can change is read at decision time.

## Summary

- Policy-based authorization moves access rules out of application code into an
  interpreted document; the decision point interprets the policy, and the enforcement
  point applies the decision.
- The document can carry role, attribute, and relationship constraints as fields of the
  same rule, so the expressive power of three models joins together.
- Default deny and deny priority together keep a new rule from quietly loosening an
  existing prohibition.
- On the same scenario set, the four models produce 4, 7, 7, and 8 correct decisions in
  turn; the difference is not the model's quality but the kind of constraint it can
  express.
- The policy is versioned, logged together with the decision's reason, and changes are
  tested in report mode before being switched to enforcement mode.

## Next Step

All four models so far have made the decision through the identity of the person making
the request. But the one making the request is often not the person themselves — it is
a client the person has authorized: the library's mobile app, a reporting tool, a
third-party reading-list service. A person's authority and the authority granted to a
client are not the same thing. The next lesson takes up this second layer — token
scopes — and shows how scopes map onto endpoints.
