Permissions and access control
An access check answers one question: may this subject do this action on this resource, right now? Every model (ACL, RBAC, ABAC) is a different way of storing the facts behind that answer. The rules that keep it safe are the same everywhere: deny by default, let an explicit deny override any allow, and treat a grant’s lifetime as a half-open interval [start, expiry). A check is a lookup plus a scan of the matching rules, so O(rules that could apply).
The idea in plain words
Section titled “The idea in plain words”Think of an office building. There are three ways to run the doors:
- A guest list per door (ACL, access control list). Each door has a list: “Ana, Bob, Chen may enter”. Easy to read for one door. Painful when Ana changes teams and you have to edit forty lists.
- Badges by job (RBAC, role-based access control). Doors admit roles: “engineers may enter the lab”. People are given roles. Ana changes teams, you change one role assignment.
- A rulebook (ABAC, attribute-based access control). “Anyone whose department matches the room’s department may enter during working hours.” The decision is computed from attributes of the person, the resource and the context (time, IP, device).
Whatever you store, the check has the same shape. Collect the rules that apply to this request, then combine their effects into one answer. The combining rule is where most real bugs live.
Vocabulary you’ll use out loud:
| Term | Meaning |
|---|---|
| Subject / principal | Who is asking: a user, a service, or a role they hold. |
| Action | The verb: doc:read, charge:refund. |
| Resource | The object: /team/notes, invoice_42. |
| Grant / statement | One rule: effect (allow or deny) + who + action + resource + conditions. |
| Default deny | If no rule says yes, the answer is no. |
| Authentication vs authorization | Who are you? vs what may you do? This page is the second one. |
See it
Section titled “See it”Edit the policy, pick a request, drag the clock. Each column shows whether that part of the statement matched. Then switch the combining rule: the same matching statements can produce opposite answers.
| # | effect | principal | action | resource prefix | expires | match | |
|---|---|---|---|---|---|---|---|
| 1 | ✓ | ✓ | ✓ | ✓ | yes | ||
| 2 | ✓ | ✗ | ✗ | ✓ | no | ||
| 3 | ✓ | ✗ | ✗ | ✓ | no | ||
| 4 | ✗ | ✗ | ✓ | ✓ | no |
Things to try:
- Preset “ana deletes a legal doc”: statement #1 allows
doc:*, #2 deniesdoc:deleteunder/team/legal/. Deny-overrides says no. Allow-overrides says yes. That’s why security systems pick deny-overrides. - Switch to first-match and move #2 above #1 with the ↑ button. Firewall rule lists work this way, and so does every “why did reordering break prod” story.
- Drag
tto 99, then 100, with “ana reads finance late”. The grant expires at 100, not after it.
When to reach for it
Section titled “When to reach for it”| The problem says… | What fits |
|---|---|
| “users can share a document with specific people” | ACL: a set of (principal, permission) per resource. |
| “admins can do everything editors can” | RBAC with a role hierarchy: walk inherited roles. |
| “managers can approve expenses from their own department under $5k” | ABAC: conditions over attributes of subject, resource and context. |
| “access to a folder implies access to everything inside it” | Relationship-based (ReBAC): permissions follow edges in a graph (owner of parent folder → viewer of child doc). |
| “temporary access”, “valid for 24 hours”, “revoked at” | Grants carry [start, expiry). Check against the request’s time, not the wall clock. |
| “audit who could access X on date D” | Keep grants as immutable records with intervals, don’t overwrite them. |
How it works
Section titled “How it works”Step 1: resolve the subject to principals (RBAC)
Section titled “Step 1: resolve the subject to principals (RBAC)”A request comes from a user, but most rules name roles. First expand the user into everything they act as: their own id plus every role they hold, plus every role those roles inherit. A role hierarchy is a directed graph, so this is a DFS with a visited set. The visited set matters: a misconfigured cycle (viewer inherits admin inherits viewer) must not hang the check.
- Push the user’s direct roles onto a stack.
- Pop a role. Skip it if already visited, which handles cycles and diamonds.
- Add that role’s own permissions to the result.
- Push the roles it inherits from, and repeat until the stack is empty.
# Role-based access: users hold roles, roles hold permissions, roles can inherit.ROLE_PERMS: dict[str, set[str]] = { "viewer": {"doc:read"}, "editor": {"doc:write"}, "admin": {"user:invite"},}INHERITS: dict[str, list[str]] = {"editor": ["viewer"], "admin": ["editor"]}
def effective_permissions(roles: list[str]) -> set[str]: """All permissions reachable through the role hierarchy (cycle-safe).""" seen: set[str] = set() perms: set[str] = set() stack = list(roles) # 1. start from the user's direct roles while stack: role = stack.pop() if role in seen: # 2. a visited set stops cycles and repeats continue seen.add(role) perms |= ROLE_PERMS.get(role, set()) # 3. collect this role's own grants stack.extend(INHERITS.get(role, [])) # 4. then walk to the roles it inherits return perms
def can(roles: list[str], perm: str) -> bool: return perm in effective_permissions(roles) # default deny: absent means no
if __name__ == "__main__": assert can(["admin"], "doc:read") # admin -> editor -> viewer assert not can(["viewer"], "doc:write") assert not can([], "doc:read") # no roles, no access INHERITS["viewer"] = ["admin"] # a cycle must not hang assert can(["viewer"], "user:invite") print("ok")import assert from "node:assert/strict";
// Role-based access: users hold roles, roles hold permissions, roles can inherit.const rolePerms = new Map<string, string[]>([ ["viewer", ["doc:read"]], ["editor", ["doc:write"]], ["admin", ["user:invite"]],]);const inherits = new Map<string, string[]>([["editor", ["viewer"]], ["admin", ["editor"]]]);
/** All permissions reachable through the role hierarchy (cycle-safe). */export function effectivePermissions(roles: string[]): Set<string> { const seen = new Set<string>(); const perms = new Set<string>(); const stack = [...roles]; // 1. start from the user's direct roles while (stack.length > 0) { const role = stack.pop()!; if (seen.has(role)) continue; // 2. a visited set stops cycles and repeats seen.add(role); for (const p of rolePerms.get(role) ?? []) perms.add(p); // 3. this role's own grants stack.push(...(inherits.get(role) ?? [])); // 4. then the roles it inherits } return perms;}
export const can = (roles: string[], perm: string): boolean => effectivePermissions(roles).has(perm); // default deny: absent means no
assert.equal(can(["admin"], "doc:read"), true); // admin -> editor -> viewerassert.equal(can(["viewer"], "doc:write"), false);assert.equal(can([], "doc:read"), false);inherits.set("viewer", ["admin"]); // a cycle must not hangassert.equal(can(["viewer"], "user:invite"), true);console.log("ok");// Role-based access: users hold roles, roles hold permissions, roles can inherit.var rolePerms = new Dictionary<string, string[]>{ ["viewer"] = ["doc:read"], ["editor"] = ["doc:write"], ["admin"] = ["user:invite"],};var inherits = new Dictionary<string, string[]> { ["editor"] = ["viewer"], ["admin"] = ["editor"] };
// All permissions reachable through the role hierarchy (cycle-safe).HashSet<string> EffectivePermissions(IEnumerable<string> roles){ var seen = new HashSet<string>(); var perms = new HashSet<string>(); var stack = new Stack<string>(roles); // 1. start from the user's direct roles while (stack.Count > 0) { var role = stack.Pop(); if (!seen.Add(role)) continue; // 2. a visited set stops cycles and repeats if (rolePerms.TryGetValue(role, out var own)) perms.UnionWith(own); // 3. own grants if (inherits.TryGetValue(role, out var parents)) // 4. inherited roles foreach (var p in parents) stack.Push(p); } return perms;}
bool Can(string[] roles, string perm) => EffectivePermissions(roles).Contains(perm); // default deny
if (!Can(["admin"], "doc:read")) throw new Exception("admin should read via inheritance");if (Can(["viewer"], "doc:write")) throw new Exception("viewer must not write");if (Can([], "doc:read")) throw new Exception("no roles, no access");inherits["viewer"] = ["admin"]; // a cycle must not hangif (!Can(["viewer"], "user:invite")) throw new Exception("cycle broke lookup");Console.WriteLine("ok");Step 2: evaluate statements (deny overrides)
Section titled “Step 2: evaluate statements (deny overrides)”Once you know the principals, a policy is a list of statements. This is the model AWS IAM documents: an explicit deny in any applicable policy overrides any allow, and with no allow at all the request is implicitly denied.
- Find every statement whose principal, action, resource and time window all match the request.
- If any matching statement is a deny, the answer is deny.
- Otherwise, if any matching statement is an allow, the answer is allow.
- Otherwise deny. No rule means no access.
from dataclasses import dataclass
@dataclass(frozen=True)class Statement: effect: str # "allow" or "deny" principal: str # a user or role name action: str # exact, or a prefix ending in "*", e.g. "doc:*" resource: str # resource prefix, e.g. "/team/" expires: int | None = None # valid while t < expires (half-open)
def matches(s: Statement, who: set[str], action: str, resource: str, t: int) -> bool: if s.principal not in who: return False if s.action.endswith("*"): ok_action = action.startswith(s.action[:-1]) else: ok_action = action == s.action in_time = s.expires is None or t < s.expires return ok_action and resource.startswith(s.resource) and in_time
def decide(policy: list[Statement], who: set[str], action: str, resource: str, t: int) -> str: hits = [s for s in policy if matches(s, who, action, resource, t)] # 1. find every match if any(s.effect == "deny" for s in hits): # 2. explicit deny wins return "deny" if any(s.effect == "allow" for s in hits): # 3. else any allow return "allow" return "deny" # 4. default deny
if __name__ == "__main__": policy = [ Statement("allow", "eng", "doc:*", "/team/"), Statement("deny", "eng", "doc:delete", "/team/legal/"), Statement("allow", "ana", "doc:read", "/finance/", expires=100), ] ana = {"ana", "eng"} # the user plus their roles assert decide(policy, ana, "doc:write", "/team/notes", 0) == "allow" assert decide(policy, ana, "doc:delete", "/team/legal/nda", 0) == "deny" # deny overrides assert decide(policy, ana, "doc:read", "/finance/q3", 99) == "allow" assert decide(policy, ana, "doc:read", "/finance/q3", 100) == "deny" # expired at 100 assert decide(policy, {"bob"}, "doc:read", "/team/x", 0) == "deny" # nothing matched print("ok")import assert from "node:assert/strict";
interface Statement { effect: "allow" | "deny"; principal: string; // a user or role name action: string; // exact, or a prefix ending in "*", e.g. "doc:*" resource: string; // resource prefix, e.g. "/team/" expires?: number; // valid while t < expires (half-open)}
function matches(s: Statement, who: Set<string>, action: string, resource: string, t: number): boolean { if (!who.has(s.principal)) return false; const okAction = s.action.endsWith("*") ? action.startsWith(s.action.slice(0, -1)) : action === s.action; const inTime = s.expires === undefined || t < s.expires; return okAction && resource.startsWith(s.resource) && inTime;}
export function decide(policy: Statement[], who: Set<string>, action: string, resource: string, t: number): "allow" | "deny" { const hits = policy.filter((s) => matches(s, who, action, resource, t)); // 1. find every match if (hits.some((s) => s.effect === "deny")) return "deny"; // 2. explicit deny wins if (hits.some((s) => s.effect === "allow")) return "allow"; // 3. else any allow return "deny"; // 4. default deny}
const policy: Statement[] = [ { effect: "allow", principal: "eng", action: "doc:*", resource: "/team/" }, { effect: "deny", principal: "eng", action: "doc:delete", resource: "/team/legal/" }, { effect: "allow", principal: "ana", action: "doc:read", resource: "/finance/", expires: 100 },];const ana = new Set(["ana", "eng"]); // the user plus their rolesassert.equal(decide(policy, ana, "doc:write", "/team/notes", 0), "allow");assert.equal(decide(policy, ana, "doc:delete", "/team/legal/nda", 0), "deny"); // deny overridesassert.equal(decide(policy, ana, "doc:read", "/finance/q3", 99), "allow");assert.equal(decide(policy, ana, "doc:read", "/finance/q3", 100), "deny"); // expired at 100assert.equal(decide(policy, new Set(["bob"]), "doc:read", "/team/x", 0), "deny"); // nothing matchedconsole.log("ok");var policy = new List<Statement>{ new("allow", "eng", "doc:*", "/team/"), new("deny", "eng", "doc:delete", "/team/legal/"), new("allow", "ana", "doc:read", "/finance/", Expires: 100),};var ana = new HashSet<string> { "ana", "eng" }; // the user plus their rolesCheck(Policy.Decide(policy, ana, "doc:write", "/team/notes", 0), "allow");Check(Policy.Decide(policy, ana, "doc:delete", "/team/legal/nda", 0), "deny"); // deny overridesCheck(Policy.Decide(policy, ana, "doc:read", "/finance/q3", 99), "allow");Check(Policy.Decide(policy, ana, "doc:read", "/finance/q3", 100), "deny"); // expired at 100Check(Policy.Decide(policy, new HashSet<string> { "bob" }, "doc:read", "/team/x", 0), "deny"); // nothing matchedConsole.WriteLine("ok");
static void Check(string got, string want) { if (got != want) throw new Exception($"{got} != {want}"); }
// Expires: valid while t < Expires (half-open). Action may end in "*" (prefix match).record Statement(string Effect, string Principal, string Action, string Resource, long? Expires = null);
static class Policy{ static bool Matches(Statement s, ISet<string> who, string action, string resource, long t) { if (!who.Contains(s.Principal)) return false; bool okAction = s.Action.EndsWith('*') ? action.StartsWith(s.Action[..^1]) : action == s.Action; bool inTime = s.Expires is null || t < s.Expires; return okAction && resource.StartsWith(s.Resource, StringComparison.Ordinal) && inTime; }
public static string Decide(List<Statement> policy, ISet<string> who, string action, string resource, long t) { var hits = policy.Where(s => Matches(s, who, action, resource, t)).ToList(); // 1. every match if (hits.Any(s => s.Effect == "deny")) return "deny"; // 2. deny wins if (hits.Any(s => s.Effect == "allow")) return "allow"; // 3. else allow return "deny"; // 4. default deny }}Note the order-independence: with deny-overrides you can shuffle the statements and the answer never changes. That’s a property worth stating, and worth a test.
What it costs
Section titled “What it costs”| Operation | Cost | Why |
|---|---|---|
| ACL check (set per resource) | O(1) average | One hash lookup of (principal, permission) in the resource’s set. |
| RBAC check, expanding roles each time | O(R + E) | Walk R roles and E inheritance edges once. |
| RBAC check, cached effective permissions | O(1) average | Precomputed set per role; pay on every role change instead. |
| Policy scan (deny-overrides) | O(S) | Look at each of S statements. Index statements by action or principal to look at fewer. |
| ReBAC (relationship graph) | O(path length), bounded | Follow edges like folder → parent. Real systems cap depth and cache heavily. |
The trade-off between models isn’t speed so much as where the complexity lives. ACLs are simple to check and hard to administer at scale. RBAC is easy to administer until you get “role explosion” (one role per team per region per product). ABAC is flexible, but its rules are harder to audit: “who can read this file?” may require evaluating a condition for every user.
Variations you’ll meet
Section titled “Variations you’ll meet”- Wildcards and prefixes.
doc:*or a resource prefix/team/. Match on boundaries: the prefix/teamwould also match/teammates/(see paths). - Combining algorithms. XACML names them: deny-overrides, permit-overrides, first-applicable, only-one-applicable. Know at least the first three and when each is used.
- Expiring grants. A grant valid for
[t, t + ttl)is live attand dead att + ttl. Store the absolute expiry, compare against the request time. See TTL and expiry. - Revocation and history. If you need “who had access at time T”, don’t delete grants. Record a revoke time and treat each grant as an interval; then a point-in-time query is an interval stab.
- Separation of duties. “The requester cannot approve their own request.” NIST RBAC calls these constraints; they’re rules about combinations, not single grants.
- Scopes and tokens. OAuth scopes are coarse permissions carried in a token; the service still does its own fine-grained check.
Pitfalls
Section titled “Pitfalls”- Default allow. Forgetting the final “else deny” turns every gap in the policy into a hole. OWASP lists deny by default as the first authorization rule.
- Checking the wrong time. Using the server clock instead of the request’s timestamp makes tests flaky and replays wrong. Inject the time.
- Inclusive expiry.
t <= expirygives one extra second of access. Pick half-open and say so. - Prefix match without a boundary.
"/finance-archive".startswith("/finance")is true. - Self-approval and confused deputies. A service acting on a user’s behalf must check the user’s permission, not its own.
- Role cycles. Without a visited set, inheritance resolution loops forever.
- Checking once, acting later. The grant can expire between check and use (TOCTOU, time of check to time of use). Check at the point of use.
In the interview
Section titled “In the interview”- Ask: is the default allow or deny? What happens when a deny and an allow both match? Are grants time-bound? Is time passed in or read from a clock?
- Ask whether roles can inherit, and whether the same user can approve their own request.
- Say the invariant: “With no applicable allow, the answer is deny. An applicable deny always wins. Order of statements doesn’t matter.”
- Keep the data model honest: a grant is a record with an interval, not a boolean you flip. That makes expiry and history fall out naturally.
- Expect follow-ups: add expiring access, add revocation, answer “what could user X access at time T”, list everyone with access to resource R (the reverse index), scale to millions of resources.
Check yourself
Section titled “Check yourself”Practice on your own
Section titled “Practice on your own”Classic exercises, not gym questions:
- Unix file permissions: given
rwxr-x---, owner, group and a user’s groups, decide read/write/execute. - Build an ACL for a shared-document service: share, unshare, list everyone who can view a document.
- Add a role hierarchy to a small RBAC model and write a test that a cycle doesn’t hang.
- Implement XACML’s first-applicable and only-one-applicable combiners next to deny-overrides; find a policy where all three disagree.
Go deeper
Section titled “Go deeper”- AWS IAM: policy evaluation logic: explicit deny, implicit deny, and how multiple policy types combine.
- Sandhu, Ferraiolo, Kuhn, The NIST Model for Role-Based Access Control (2000): core, hierarchical and constrained RBAC.
- NIST SP 800-162, Guide to Attribute Based Access Control (ABAC) Definition and Considerations.
- Pang et al., Zanzibar: Google’s Consistent, Global Authorization System (USENIX ATC 2019): relationship-based access at scale.
- OWASP Authorization Cheat Sheet: deny by default, check every request.