System Design: Access Control and Authorization — RBAC, ABAC, and Policy Enforcement (2025)

Authorization Models

ACL (Access Control List): per-resource list of who can do what. Simple but does not scale — updating access for a role change requires touching every resource. RBAC (Role-Based Access Control): users are assigned roles; roles have permissions; permissions are (resource, action) pairs. Scalable for enterprise: changing a role’s permissions updates access for all users with that role. ABAC (Attribute-Based Access Control): policies evaluate attributes of the subject (user department, clearance), resource (classification, owner), action, and environment (time of day, IP range). Most expressive but more complex to implement and audit. ReBAC (Relationship-Based Access Control): Google Zanzibar model — permissions are derived from relationships in a graph. “User A can view document D if A is a viewer of D, or A is a member of a group that is a viewer of D.”

RBAC Data Model

-- Core RBAC tables
CREATE TABLE users (user_id UUID PRIMARY KEY, email TEXT, ...);
CREATE TABLE roles (role_id UUID PRIMARY KEY, name TEXT, description TEXT);
CREATE TABLE permissions (
    perm_id UUID PRIMARY KEY,
    resource_type TEXT,   -- "document", "project", "billing"
    resource_id UUID,     -- NULL = applies to all resources of this type
    action TEXT           -- "read", "write", "delete", "admin"
);
CREATE TABLE role_permissions (role_id UUID, perm_id UUID, PRIMARY KEY (role_id, perm_id));
CREATE TABLE user_roles (
    user_id UUID, role_id UUID,
    scope_type TEXT,      -- "global", "organization", "project"
    scope_id UUID,        -- NULL for global
    PRIMARY KEY (user_id, role_id, scope_type, COALESCE(scope_id, '00000000-0000-0000-0000-000000000000'::UUID))
);

Scoped roles: a user can be ADMIN of project A but VIEWER of project B. The scope_type and scope_id columns capture this. Authorization check: does user U have permission to perform action A on resource R of type T?

SELECT 1
FROM user_roles ur
JOIN role_permissions rp ON ur.role_id = rp.role_id
JOIN permissions p ON rp.perm_id = p.perm_id
WHERE ur.user_id = $user_id
  AND p.action = $action
  AND p.resource_type = $resource_type
  AND (p.resource_id IS NULL OR p.resource_id = $resource_id)
  AND (ur.scope_type = 'global'
       OR (ur.scope_type = $scope_type AND ur.scope_id = $scope_id))
LIMIT 1;

Policy Decision Point (PDP) and Policy Enforcement Point (PEP)

The PDP evaluates authorization requests and returns ALLOW/DENY. The PEP intercepts requests at service boundaries and calls the PDP. Separation of concerns: services contain no authorization logic — they delegate to the PDP. Implementation choices: Centralized PDP: a dedicated authorization service (Open Policy Agent, AWS IAM, custom). Simple operationally but adds network latency to every authorization check. Cache at the PEP: store allow/deny decisions in a local cache with short TTL (< 60s) to avoid per-request network calls. Embedded PDP: the policy engine runs as a sidecar or in-process. Eliminates network latency; policy updates are pushed to all instances. Open Policy Agent (OPA) supports this with bundle distribution.

Google Zanzibar-Style ReBAC

Zanzibar stores tuples: (object, relation, user). Example: (doc:readme, viewer, user:alice) or (doc:readme, viewer, group:eng#member). Authorization check: “Can user:alice view doc:readme?” resolves by checking all viewer tuples for doc:readme, recursively expanding group memberships. The result is a graph traversal. Scale challenges: the tuple store has billions of rows (Google handles trillions). Key optimizations: Zookies (consistency tokens): each write returns a token encoding the write timestamp. Subsequent reads include the token and are guaranteed to see that write (solves the “new enemy” problem where a permission removal is not yet visible). Namespace configs: schema definitions for each relation type, enabling query optimization. Leopard index: precomputed group-membership expansions to avoid deep recursive traversals at query time. Open-source implementations: Ory Keto, SpiceDB, Authzed.


{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How do you implement scoped roles (e.g., admin of one project, viewer of another)?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Scoped roles add a scope dimension to role assignments: each user_role record has scope_type (global, organization, project) and scope_id (NULL for global, or the specific entity ID). Authorization check: the query must match either a global role assignment OR a role assignment scoped to the specific resource being accessed. This allows a user to be PROJECT_ADMIN for project A (can do anything in project A) and PROJECT_VIEWER for project B (read-only in project B) simultaneously. The scope is typically hierarchical: a role granted at the organization level implicitly applies to all projects in that organization — implement this by checking scope at each level in the hierarchy during authorization.”}},{“@type”:”Question”,”name”:”How does Open Policy Agent (OPA) separate policy from application code?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”OPA is a general-purpose policy engine. Application code calls OPA's API with input (the authorization request: user, action, resource, context). OPA evaluates Rego policies (a declarative policy language) against the input and a data store, returning ALLOW or DENY. The policy file (.rego) is managed separately from application code — it can be updated without redeploying the application. OPA can run as: a sidecar (low-latency, in-pod), a centralized service (easier policy management), or compiled into a WASM module (embedded). Policy bundle distribution: OPA instances periodically pull policy bundles from a central OPA bundle server (an S3 bucket or dedicated service), ensuring all instances use the current policy version without downtime.”}},{“@type”:”Question”,”name”:”What is the Policy Enforcement Point (PEP) and where should it sit in a microservices architecture?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”The PEP intercepts requests and enforces the authorization decision from the PDP. Placement options: (1) API Gateway PEP: enforces coarse-grained access (is this user authenticated? Do they have the right role for this endpoint?) before routing to services. Fast, centralized, but limited context. (2) Service-level PEP: each service calls OPA/the PDP before processing a request. Fine-grained — can use request body and resource state. (3) Sidecar PEP: an authorization proxy sidecar intercepts all traffic to the service, calls OPA, and forwards or blocks. Keeps services free of authorization logic. Best practice: layer them — API gateway for coarse checks (rate limiting, authentication), service-level OPA for fine-grained policy (resource-level permissions, row-level security).”}},{“@type”:”Question”,”name”:”How do you handle permission inheritance in hierarchical resources (e.g., folder → file)?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Hierarchical permission inheritance: a user with access to a parent resource (folder) implicitly has the same access to all child resources (files) unless explicitly overridden. Implementation: the authorization check traverses the resource hierarchy. For "can user U read file F?": check if U has direct read permission on F. If not: check if U has read permission on F's parent folder. Repeat up the hierarchy until root. Store parent_id on each resource for traversal. For performance: cache the full permission set per (user, resource) with a short TTL. Invalidation: when a parent's permissions change, recursively invalidate all children's cache entries (or use a generation counter in the cache key). In Zanzibar: parent inheritance is expressed as a userset rewrite rule: file#viewer includes folder#viewer — the authorization engine expands this automatically.”}},{“@type”:”Question”,”name”:”How do you audit authorization decisions for compliance?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Every authorization decision (ALLOW and DENY) should be logged for compliance and forensics. Log fields: timestamp, request_id (trace), user_id, action, resource_type, resource_id, decision (ALLOW/DENY), policy_version (which policy file was evaluated), evaluation_time_ms, and the key attributes that drove the decision (matched role, matched policy rule). Ship logs to an immutable append-only store (S3 + Athena, or a SIEM like Splunk). For PCI-DSS and SOC 2: retain audit logs for at least 1 year, with 3 months immediately accessible. Alerting: trigger alerts on unusual patterns — a user with DENY decisions at high frequency (potential brute-force), or a user accessing resources outside their normal scope (potential insider threat or account compromise).”}}]}

See also: Atlassian Interview Prep

See also: Databricks Interview Prep

See also: Stripe Interview Prep

Scroll to Top