Lesson 20 / 23
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.
Contents
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.
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
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.
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
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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.