Design a role and permission system
Users, roles, permissions and resource scopes with inheritance and denies.
- Specification and rule objects — Business rules as composable objects you can and, or and not.
- Composite — Treating a tree of parts and wholes uniformly.
- Layers and boundaries — Keeping domain logic away from storage and interfaces.
- Designing method signatures — Inputs, outputs, errors and names that make misuse hard.
- Proxy — A stand-in that controls access: lazy, cached, protected or remote.
01The design, in the six-step framework
1. Requirements and use cases
A role-based access system answers one question, thousands of times a second: may this user do this action on this resource? Everything else is administration of the data behind that answer.
- An admin defines permissions as an action on a resource type, such as
doc:read. - An admin creates roles that bundle permissions and may inherit from other roles.
- An admin grants a role to a user at a scope in a tree: organisation, project or a single document.
- A grant at a parent scope applies to everything beneath it.
- An admin adds an explicit deny, which beats any allow.
- A service checks access and, for support, gets an explanation of why.
Out of scope: authentication, groups of users (a straightforward extra), and attribute-based rules on arbitrary fields. Non-functional constraints: check is fast and side-effect free; deny always wins; role cycles are refused; application code calls one method and never reads grants directly.
2. Core entities and relationships
- Permission: an immutable action name.
- Role: a node in a role tree with direct permissions and parent roles; a composite.
- Scope: a resource path such as
/org/acme/proj/x/doc/9. - Grant: (subject, role, scope, effect) where the effect is allow or deny.
- PolicyStore: holds roles and grants, indexed by user.
- Authorizer: evaluates a request; contains the decision rules.
- Decision: the result, with the grant that caused it.
- GuardedDocuments: a proxy that puts checks in front of a real service.
The store composes roles and grants, which are its data. A role aggregates parent roles because a parent exists independently and may be shared by many children, which makes this a directed acyclic graph rather than a strict tree.
3. Interfaces and key methods
class Role:
def effective(self) -> frozenset[str]:
"""Own permissions plus everything from parents, transitively."""
class PolicyStore:
def add_role(self, name: str, permissions: set[str], parents: Optional[list[str]] = None) -> Role:
"""Refuses unknown parents, so a cycle cannot be created."""
def grant(self, user: str, role: str, scope: str, effect: Effect = Effect.ALLOW) -> None: ...
def revoke(self, user: str, role: str, scope: str) -> None: ...
def grants_for(self, user: str) -> list[Grant]: ...
class Authorizer:
def check(self, user: str, permission: str, resource: str) -> Decision:
"""Any matching deny refuses; else any matching allow permits; else refuse."""
class DocumentService(Protocol):
def read(self, user: str, doc_id: str) -> str: ...
def delete(self, user: str, doc_id: str) -> None: ...
Invariants: the default is deny; a deny that matches always beats any allow; a scope covers a resource only on a path-segment boundary, so /org/a does not cover /org/ab; role inheritance is acyclic.
4. Design patterns and the choices behind them
Roles as a composite
An editor is everything a viewer can do plus more. A composite lets a role be treated the same whether it is a leaf or a bundle: effective() walks the tree. Copying permissions into each role is simpler and drifts apart at the first change.
Rules as specifications with a fixed combining order
Each grant is effectively a specification: does it match this user, permission and resource? The combining rule lives in one place, the Authorizer, so it can be explained: deny overrides, then allow, then default deny. Order-dependent firewall-style lists are the rejected alternative; they make every insertion a risk.
A proxy as the enforcement point
Checks scattered through business code get forgotten on one path. A proxy implements the same interface as the real service and checks before forwarding, so the protected object cannot be reached unguarded if only the proxy is handed out. This is layering: policy decision here, policy data below, business logic beneath.
A decision, not a boolean
Support needs to know why access was refused. Returning a Decision with the reason follows the make misuse hard rule: check stays side-effect free, and the proxy raises.
5. Core flow and code
GuardedDocuments.readcallsauthz.check(user, "doc:read", path).- The authorizer fetches the user's grants and keeps those whose scope covers the resource.
- For each, it computes
role.effective(), memoised on the role, and tests the permission. - Any match with DENY returns a refusal at once. Otherwise any ALLOW permits.
- No match returns the default refusal; the proxy raises
Forbidden.
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional, Protocol
class Forbidden(Exception): ...
class Effect(Enum):
ALLOW = "allow"
DENY = "deny"
@dataclass(frozen=True)
class Scope:
path: str
def covers(self, resource: str) -> bool:
base = self.path.rstrip("/")
return resource == base or resource.startswith(base + "/")
class Role:
def __init__(self, name: str, permissions: set[str], parents: list[Role]) -> None:
self.name, self.permissions, self.parents = name, frozenset(permissions), parents
self._effective: Optional[frozenset[str]] = None
def effective(self) -> frozenset[str]:
if self._effective is None:
out = set(self.permissions)
for p in self.parents:
out |= p.effective()
self._effective = frozenset(out)
return self._effective
@dataclass(frozen=True)
class Grant:
role: str
scope: Scope
effect: Effect = Effect.ALLOW
@dataclass(frozen=True)
class Decision:
allowed: bool
reason: str
class PolicyStore:
def __init__(self) -> None:
self.roles: dict[str, Role] = {}
self.grants: dict[str, list[Grant]] = {}
def add_role(self, name: str, permissions: set[str], parents: Optional[list[str]] = None) -> Role:
if name in self.roles:
raise ValueError("role exists")
# parents must already exist, so inheritance can never form a cycle
role = Role(name, permissions, [self.roles[p] for p in (parents or [])])
self.roles[name] = role
return role
def grant(self, user: str, role: str, scope: str, effect: Effect = Effect.ALLOW) -> None:
if role not in self.roles:
raise KeyError(role)
self.grants.setdefault(user, []).append(Grant(role, Scope(scope), effect))
def revoke(self, user: str, role: str, scope: str) -> None:
self.grants[user] = [g for g in self.grants.get(user, [])
if not (g.role == role and g.scope.path == scope)]
def grants_for(self, user: str) -> list[Grant]:
return list(self.grants.get(user, []))
class Authorizer:
def __init__(self, store: PolicyStore) -> None:
self.store = store
def check(self, user: str, permission: str, resource: str) -> Decision:
allow: Optional[Grant] = None
for g in self.store.grants_for(user):
if not g.scope.covers(resource):
continue
if permission not in self.store.roles[g.role].effective():
continue
if g.effect is Effect.DENY:
return Decision(False, f"denied by {g.role} at {g.scope.path}")
allow = allow or g
if allow:
return Decision(True, f"allowed by {allow.role} at {allow.scope.path}")
return Decision(False, "no matching grant")
class DocumentService(Protocol):
def read(self, user: str, doc_id: str) -> str: ...
def delete(self, user: str, doc_id: str) -> None: ...
class RealDocuments:
def __init__(self) -> None:
self.docs: dict[str, str] = {}
def read(self, user: str, doc_id: str) -> str:
return self.docs[doc_id]
def delete(self, user: str, doc_id: str) -> None:
del self.docs[doc_id]
class GuardedDocuments:
"""Proxy: same interface, every call authorised first."""
def __init__(self, inner: DocumentService, authz: Authorizer, root: str) -> None:
self.inner, self.authz, self.root = inner, authz, root
def _require(self, user: str, permission: str, doc_id: str) -> None:
d = self.authz.check(user, permission, f"{self.root}/{doc_id}")
if not d.allowed:
raise Forbidden(d.reason)
def read(self, user: str, doc_id: str) -> str:
self._require(user, "doc:read", doc_id)
return self.inner.read(user, doc_id)
def delete(self, user: str, doc_id: str) -> None:
self._require(user, "doc:delete", doc_id)
self.inner.delete(user, doc_id)
6. Extensions, concurrency and what interviewers probe
- Prefix bug: a plain
startswithlets/org/acover/org/ab.Scope.coverscompares on a boundary. This is a real class of security bug, so point at it. - Groups: a group is a subject too.
grants_forunions a user's grants with those of their groups;Authorizerdoes not change. - Performance: a user with many grants makes each check linear. A cache keyed by (user, permission, resource) cuts it, and must be invalidated by a policy version on every grant or revoke. Stale allows are the danger, so keep the lifetime short.
- Access control trees: a reported variant walks a resource tree. Scopes as paths already give that; a trie of scopes finds matching grants in time proportional to path depth.
- Concurrency: reads vastly outnumber writes, so a read-write lock or a copy-on-write swap of the policy fits.
- Test first: an inherited permission is allowed; a deny at a child scope beats an allow at the parent; no grant is refused;
/org/adoes not cover/org/ab; the proxy never calls the inner service on refusal. - Audit: log every refusal and every administrative change with the reason from
Decision; that is why the reason exists. Answering "who can read this document" is the reverse question, which needs an index from scope to grants rather than from user to grants. - Common mistake: defaulting to allow when no rule matches. Fail closed.
02Where this question was reported
- Codemia · Codemia Advanced
- techprep.app · TechPrep LLD 'Access Control Tree' and 'Access Management System'