| name | authorization |
| description | Design access control — RBAC for coarse function-level checks, Postgres Row Level Security (RLS) for row-level data isolation, ABAC pushed to the app/policy layer. Use when adding permissions, building multi-user data access, or when one user can see another's data. Not for establishing who the caller is (use authentication) or tenant isolation specifically (use multitenancy-audit). |
| license | MIT |
Authorization
Purpose
Control what an authenticated caller can do and which rows they can touch — using the right mechanism at the right layer so a missing check can't leak another user's data (the #1 API vulnerability).
Universal — RBAC vs ABAC, the function-level-vs-row-level distinction, and "enforce at the data layer when possible" are authz principles; Postgres RLS is the default enforcement primitive.
Procedure
-
Separate coarse (function) from fine (row) authorization
- Function-level (RBAC): "can this role call this endpoint?" — roles + permission checks at the API layer
- Row-level (data isolation): "can this user see THIS row?" — enforce at the DB with RLS
- Most BOLA bugs come from checking function-level but forgetting row-level
-
Use RBAC for role/permission gates
- Define roles → permissions; check at the controller/guard layer
- Keep roles coarse; avoid permission explosion
-
Use Postgres RLS for row-level isolation
- Enable RLS on user-data tables; write policies (
USING / WITH CHECK)
- The DB enforces it even if the app forgets a
WHERE user_id = ? — defense in depth
- Inject the user/tenant context per request (
SET LOCAL app.current_user) inside the transaction
-
Push ABAC (attribute/context rules) to the app or a policy engine
- RLS struggles with complex attribute/context-aware rules (time-of-day, resource state, relationship graphs)
- For those, use an app-layer policy or a dedicated engine (OPA/Oso/Cerbos)
- Don't contort RLS into doing ABAC
-
Check authorization at the right layer — never client-only
- Client-side hiding of buttons is UX, not security
- Every mutating/reading endpoint enforces server-side (RBAC) + DB (RLS)
5b. Verify ownership at every level of a nested resource
/orgs/:o/teams/:t/members/:m — checking only that the caller owns :o lets them touch any :t or :m. Re-verify the chain: m ∈ t ∈ o ∈ caller-orgs
- RLS predicates that only check the leaf table miss the parent relationship — write the policy against the full join, or enforce at the controller as well
5c. Mind the cost of per-request authorization
- A permission check that hits the DB on every request becomes an N+1-shaped bottleneck under load
- Cache the decision briefly (per-request memoization for repeated checks in one request; a short TTL session cache for stable permissions). The cache is an optimization — RLS still enforces at the DB
- Validate (validation loop)
- As user A, attempt to read/modify user B's resource by ID → verify denied (BOLA test)
- Disable RLS temporarily in a test → verify the app-layer check still denies (defense in depth)
- If cross-user access succeeds → row-level check missing; add RLS policy + re-test
Anti-patterns
| ❌ Anti-pattern | ✅ Correct |
|---|
| Function-level check only (no row check) | RBAC (function) + RLS (row) together |
Relying on app WHERE user_id = ? alone | RLS as defense-in-depth at the DB |
| Forcing ABAC rules into RLS policies | Push attribute/context rules to app/policy engine |
| Client-side permission enforcement | Server + DB enforcement |
RLS USING (true) (effectively disabled) | Real tenant/user predicate |
| Checking ownership only at the parent of a nested resource | Verify the full chain (m ∈ t ∈ o ∈ caller-orgs) |
| Permission check hitting DB on every request | Per-request memoization or short-TTL session cache (RLS still enforces at DB) |
Severity tiers
| Tier | Examples | Action SLA |
|---|
| Critical | BOLA — user can access another user's data by changing an ID; RLS disabled or USING (true) on a user-data table | Block release; fix immediately |
| Major | Function-level check present but row-level missing; ABAC rule enforced only client-side | Fix this sprint |
| Minor | Over-granular roles; policy duplication between layers | Schedule within 2 sprints |
Stop & Ask (AI must pause for user approval)
- Before enabling or disabling RLS on a populated table — a missing default policy can hide or leak all rows
- Before changing a policy's
USING / WITH CHECK expression — confirm the new predicate is at least as restrictive (or intentionally widened, with reasoning)
- Before altering role definitions on a production-active role — in-flight sessions may break
Completion Criteria
Output
- Authz implementation: RBAC guards + RLS policies
- Policy doc: roles → permissions matrix + RLS policy list
- Commit format:
feat(authz): RLS policy for <table> / fix(authz): add row-level check to <endpoint>
Implementation
TypeScript + Supabase / Postgres + NestJS (default)
- RLS (Supabase):
ALTER TABLE x ENABLE ROW LEVEL SECURITY; CREATE POLICY ... USING (auth.uid() = user_id)
- Custom claims RBAC: Supabase JWT custom claims → role checks
- NestJS:
@UseGuards(RolesGuard) for function-level; RLS handles row-level
- Tenant context:
SET LOCAL inside the request transaction (see multitenancy-audit)
Other stacks
- Python / FastAPI: dependency-injected permission checks; Postgres RLS via SQLAlchemy
SET LOCAL
- Go: middleware RBAC; RLS via
SET LOCAL per request; Casbin for policy
- Universal: RLS is Postgres (also in other RDBMS); the RBAC/ABAC/row-vs-function distinction is universal; OPA/Oso/Cerbos are language-agnostic policy engines
Related skills
authentication — authz comes after authn establishes identity
multitenancy-audit — tenant isolation is RLS applied to a tenant_id
backend-security-audit — broken object-level authz (BOLA) is OWASP API #1
Reference
- Key insight encoded: Use RBAC (roles) for coarse function-level checks and RLS for row-level data isolation; RLS struggles with attribute/context-aware (ABAC) rules, so push those to the app or a policy engine.