Patterns — Implementing Authorization
Contents
- Permission matrix and RBAC tables
- Central authorize() and deny-by-default middleware
- Ownership scoping (IDOR prevention)
- Multi-tenant isolation with RLS backstop
- Audit logging
- Gotchas
Permission matrix and RBAC tables
sql
CREATE TABLE roles (id serial PRIMARY KEY, name text UNIQUE NOT NULL);
CREATE TABLE permissions (id serial PRIMARY KEY, name text UNIQUE NOT NULL); -- "reports:read"
CREATE TABLE role_permissions (
role_id int REFERENCES roles, permission_id int REFERENCES permissions,
PRIMARY KEY (role_id, permission_id));
CREATE TABLE user_roles (
user_id bigint REFERENCES users, role_id int REFERENCES roles,
PRIMARY KEY (user_id, role_id));Name permissions resource:action; keep the matrix in a checked-in doc so
reviews see permission changes as diffs.
Central authorize() and deny-by-default middleware
python
class Forbidden(Exception): ...
def authorize(caller, action: str, resource=None):
if action not in caller.permissions:
raise Forbidden(action)
if resource is not None and hasattr(resource, "owner_id"):
if resource.owner_id != caller.id and "override:ownership" not in caller.permissions:
raise Forbidden(f"{action} on foreign resource")
# Deny-by-default: routes must declare a policy or be rejected.
@app.middleware("http")
async def enforce_policy(request, call_next):
endpoint = request.scope.get("endpoint")
if endpoint is None or not getattr(endpoint, "_policy", None):
return JSONResponse({"detail": "no policy declared"}, status_code=403)
return await call_next(request)
def require(perm: str):
def deco(fn):
fn._policy = perm
@wraps(fn)
async def inner(request, *a, **kw):
authorize(request.state.caller, perm)
return await fn(request, *a, **kw)
return inner
return decoOwnership scoping (IDOR prevention)
python
# Scope in the query itself — a forgotten route check then returns 404, not a leak.
def get_document(db, doc_id: int, caller):
row = db.execute(
"SELECT * FROM documents WHERE id = :id AND owner_id = :owner",
{"id": doc_id, "owner": caller.id}).fetchone()
if row is None:
raise NotFound # don't reveal existence of others' docs
return rowMulti-tenant isolation with RLS backstop
python
# tenant_id comes from the verified session/token — NEVER from the request.
tenant_id = caller.tenant_id
db.execute("SET app.tenant_id = :t", {"t": tenant_id})sql
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::bigint);
-- Backstop: even a query missing the WHERE clause cannot cross tenants.Audit logging
python
def audit(caller, action, target, request):
audit_log.insert( # append-only table / stream
actor=caller.id, action=action, target=target,
ip=request.client.host, at=utcnow())
# Call on: role grants, exports, deletions, impersonation, settings changes.Gotchas
- Checking the route but not the query — a second entry point (GraphQL, admin API, background job) reuses the unscoped query and leaks. Scope at the data layer.
- tenant_id taken from the URL/body — attacker just edits it. Only the token/session is trusted.
- Caching authorization results too long — demoted admins keep power; cap policy caches at ~60 s or bust on role change.
is_adminboolean creep — one flag becomes god-mode everywhere and can't be audited; use named permissions even for admins.- RLS silently disabled for table owners — Postgres table owners bypass RLS unless
FORCE ROW LEVEL SECURITYis set; app roles must not own the tables. - 404-vs-403 inconsistency — mixing them per route lets attackers map which IDs exist; decide per resource class and enforce in one place.