Skip to content

Permissions and access control

intermediateCommon atStripeAnthropicGoogleAmazonRead firstHash mapsTimestamps and windowsPractise in the Sandbox →

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).

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.

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.

Policy evaluator
edit the policy
#effectprincipalactionresource prefixexpiresmatch
1 ✓ ✓ ✓ ✓yes
2 ✓ ✗ ✗ ✓no
3 ✓ ✗ ✗ ✓no
4 ✗ ✗ ✓ ✓no
ALLOW
No deny matched, and statement #1 allows it. Expiry is half-open: a grant with expires = 100 works at t = 99 and is gone at t = 100.
statement matchesdecided: allowdecided: deny

Things to try:

  • Preset “ana deletes a legal doc”: statement #1 allows doc:*, #2 denies doc:delete under /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 t to 99, then 100, with “ana reads finance late”. The grant expires at 100, not after 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.

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.

  1. Push the user’s direct roles onto a stack.
  2. Pop a role. Skip it if already visited, which handles cycles and diamonds.
  3. Add that role’s own permissions to the result.
  4. Push the roles it inherits from, and repeat until the stack is empty.
effective_permissions.py
# 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")

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.

  1. Find every statement whose principal, action, resource and time window all match the request.
  2. If any matching statement is a deny, the answer is deny.
  3. Otherwise, if any matching statement is an allow, the answer is allow.
  4. Otherwise deny. No rule means no access.
decide.py
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")

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.

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.

  • Wildcards and prefixes. doc:* or a resource prefix /team/. Match on boundaries: the prefix /team would 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 at t and dead at t + 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.
  • 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 <= expiry gives 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.
  • 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
pick one
A policy has 'allow eng doc:*' and 'deny eng doc:delete /legal/'. Ana is in eng. Under deny-overrides, may she delete /legal/nda? And may she read it?
Check yourself
pick one
A grant is created at t = 1000 with a TTL of 60. At which of these times is it still valid?
Check yourself
pick one
Your role hierarchy is stored by users in a config file. What must the effective-permission resolver handle that a textbook tree walk doesn't?

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.