What Is an Access Control List System?
An Access Control List (ACL) system determines whether a given subject is permitted to perform a specific action on a specific resource. It is the foundational mechanism behind file system permissions, cloud IAM policies, API gateway authorization, and multi-tenant SaaS authorization layers. At low level, it must handle role hierarchies, explicit deny overrides, and high-throughput permission lookups with minimal latency.
Permission Model
Every permission check resolves three components:
- Subject: the actor — a User, Group, or Role identified by (subject_type, subject_id).
- Resource: the object being accessed — identified by (resource_type, resource_id). A wildcard resource_id of
*applies to all instances of a type. - Action: the operation — read, write, delete, admin, or a domain-specific verb like
publish.
The decision is binary: allow or deny. The evaluation engine collects all ACL entries matching the triple and applies a precedence rule.
Role Hierarchy and Permission Inheritance
Roles are organized in a directed acyclic graph. A child role inherits all permissions granted to its parent roles. This allows an editor role to inherit from viewer without duplicating entries.
Transitive role resolution is done with a recursive CTE:
-- Resolve all ancestor roles for a given user
WITH RECURSIVE role_tree AS (
SELECT rm.role_id
FROM RoleMembership rm
WHERE rm.user_id = :user_id
UNION ALL
SELECT r.parent_role_id
FROM Role r
JOIN role_tree rt ON r.id = rt.role_id
WHERE r.parent_role_id IS NOT NULL
)
SELECT DISTINCT role_id FROM role_tree;
This query returns the full set of roles applicable to a user, including transitive ancestors. The result feeds into the ACL entry lookup.
ACL Entry Structure
Each entry records a permission grant or denial:
CREATE TABLE AclEntry (
id BIGSERIAL PRIMARY KEY,
subject_type VARCHAR(16) NOT NULL, -- 'user', 'group', 'role'
subject_id BIGINT NOT NULL,
resource_type VARCHAR(64) NOT NULL,
resource_id VARCHAR(64) NOT NULL, -- '*' for wildcard
action VARCHAR(32) NOT NULL,
effect VARCHAR(8) NOT NULL -- 'allow' or 'deny'
);
CREATE INDEX idx_acl_lookup ON AclEntry (subject_type, subject_id, resource_type, resource_id, action);
Entries exist at user, group, or role level. A user's effective ACL is the union of entries for that user directly plus all groups they belong to plus all roles (and ancestor roles) they hold.
Supporting Schema
CREATE TABLE Role (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(64) UNIQUE NOT NULL,
parent_role_id BIGINT REFERENCES Role(id)
);
CREATE TABLE RoleMembership (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL REFERENCES Role(id),
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE GroupMembership (
user_id BIGINT NOT NULL,
group_id BIGINT NOT NULL,
PRIMARY KEY (user_id, group_id)
);
CREATE TABLE Permission (
id BIGSERIAL PRIMARY KEY,
role_id BIGINT NOT NULL REFERENCES Role(id),
resource_type VARCHAR(64) NOT NULL,
resource_id VARCHAR(64) NOT NULL,
action VARCHAR(32) NOT NULL
);
Policy Evaluation Order
After collecting all applicable ACL entries for a (subject, resource, action) triple, the engine applies this precedence chain:
- Explicit deny at user level — highest priority, terminates evaluation immediately.
- Explicit allow at user level.
- Explicit deny at group level.
- Explicit allow at group level.
- Deny/allow inherited through role hierarchy — traversed from child to ancestor.
- Default deny — if no entry matches, access is denied.
This mirrors AWS IAM semantics: an explicit deny always wins, regardless of where the allow originates.
Python Implementation
import redis
import json
from db import get_db
cache = redis.Redis(host='localhost', port=6379, db=0)
CACHE_TTL = 60 # seconds
def get_effective_roles(user_id: int) -> list[int]:
"""Return all role IDs applicable to user_id via recursive CTE."""
db = get_db()
rows = db.execute("""
WITH RECURSIVE role_tree AS (
SELECT rm.role_id FROM RoleMembership rm WHERE rm.user_id = %s
UNION ALL
SELECT r.parent_role_id FROM Role r
JOIN role_tree rt ON r.id = rt.role_id
WHERE r.parent_role_id IS NOT NULL
)
SELECT DISTINCT role_id FROM role_tree
""", (user_id,)).fetchall()
return [r['role_id'] for r in rows]
def get_group_ids(user_id: int) -> list[int]:
db = get_db()
rows = db.execute(
"SELECT group_id FROM GroupMembership WHERE user_id = %s", (user_id,)
).fetchall()
return [r['group_id'] for r in rows]
def evaluate_acl(entries: list[dict]) -> str:
"""Apply precedence: explicit deny > explicit allow > inherited deny > inherited allow > deny."""
for effect_priority in ['deny', 'allow']:
for entry in entries:
if entry['effect'] == effect_priority:
return effect_priority
return 'deny'
def check_permission(
subject_id: int,
resource_type: str,
resource_id: str,
action: str
) -> bool:
cache_key = f"acl:{subject_id}:{resource_type}:{resource_id}:{action}"
cached = cache.get(cache_key)
if cached:
return json.loads(cached) == 'allow'
db = get_db()
role_ids = get_effective_roles(subject_id)
group_ids = get_group_ids(subject_id)
entries = db.execute("""
SELECT subject_type, subject_id, effect
FROM AclEntry
WHERE action = %s
AND resource_type = %s
AND (resource_id = %s OR resource_id = '*')
AND (
(subject_type = 'user' AND subject_id = %s) OR
(subject_type = 'group' AND subject_id = ANY(%s)) OR
(subject_type = 'role' AND subject_id = ANY(%s))
)
ORDER BY
CASE subject_type WHEN 'user' THEN 0 WHEN 'group' THEN 1 ELSE 2 END,
CASE effect WHEN 'deny' THEN 0 ELSE 1 END
""", (action, resource_type, resource_id, subject_id, group_ids, role_ids)).fetchall()
decision = evaluate_acl([dict(e) for e in entries])
cache.setex(cache_key, CACHE_TTL, json.dumps(decision))
return decision == 'allow'
def invalidate_permission_cache(subject_id: int):
"""Call after any ACL or role membership change for this subject."""
pattern = f"acl:{subject_id}:*"
for key in cache.scan_iter(pattern):
cache.delete(key)
Hot-Path Caching Strategy
Permission checks sit on the hot path of every API request. Evaluating recursive CTEs and multi-table joins on every call is too slow. The cache layer stores the final allow/deny decision keyed by (subject_id, resource_type, resource_id, action) in Redis with a 60-second TTL.
Invalidation is triggered by any write to AclEntry, RoleMembership, or GroupMembership. Rather than fine-grained key tracking, the system scans and deletes all cache keys matching the affected subject_id. This is acceptable because role/permission changes are rare compared to read frequency.
For very large deployments, a pub/sub channel broadcasts invalidation messages to all application servers, each of which maintains a local in-process LRU cache on top of Redis. The two-tier cache (local LRU + Redis) reduces Redis round-trips for the most frequently checked permissions.
Wildcard Resource Permissions
An AclEntry with resource_id = * applies to all instances of that resource_type. The lookup query checks both the specific resource_id and *. Specific entries take precedence over wildcards by applying user-level > group-level > role-level ordering first, then deny > allow within each tier.
See also: Meta Interview Guide 2026: Facebook, Instagram, WhatsApp Engineering
See also: Atlassian Interview Guide