Lab 01 — Tenant-Aware Authorization Engine
Difficulty: 4/5 | Runs locally: yes
Pairs with the Phase 02 WARMUP Chapters 5 and 10 (authN vs authZ, the authorization model). This is the flagship lab of the phase.
Why This Lab Exists (Purpose & Goal)
Broken object-level authorization (BOLA / IDOR) is, year after year, the number-one web API vulnerability — it tops the OWASP API Security list, and it is the bug behind a large share of mass data exposures. It is also the bug developers most consistently get wrong, because the happy path looks correct: the user is logged in, the code returns the record. The goal of this lab is to build a centralized authorization decision point that gets it right — one that refuses to let a logged-in user read an object they have no relationship to — and, just as important, to internalize why the naive version fails.
The Concept, In the Weeds
The fatal confusion is treating authentication (who are you?) as if it answered authorization (may you do this to this object?). A route authenticates the user, then trusts a user-controlled object ID from the URL without checking the user's relationship to that object:
GET /api/invoices/8123 # authenticated as user A (tenant 1)
# invoice 8123 belongs to tenant 2 → MUST be denied
The correct decision is a function of far more than identity:
subject + tenant + action + resource + relationship + token(iss/aud/exp) + account-state + classification
This lab forces the full conjunction, deny-by-default: tenant must match; the action must be granted by the role; the relationship (owner / shared-with / admin) must permit it; the token's issuer, audience, and expiry must be valid; a disabled subject always loses; a restricted classification blocks a service identity. The non-obvious lessons:
- A valid token is not authorization. A token proves someone issued it; it does not prove it was issued for this service (audience) or that the subject may touch this object.
- Role alone does not grant an object. "Editor" is not "editor of this document." Object-level
authorization is per-object, which is why a
readershared on one doc cannot read another. - Coverage is the hard part. The check must apply to batch, search, export, GraphQL resolvers, background jobs, cache hits, counts, and even error messages — every path that returns data.
Why This Matters for Protecting the Company
A single missing object-level check exposes every customer's data through a working, authenticated API — no exploit, no malware, just an incrementing ID. This is precisely how multi-tenant SaaS companies suffer their most damaging breaches. When you can design and enforce a centralized authZ decision point — and write a test for every route × role × tenant so a new endpoint missing a check fails CI — you are protecting the company's most valuable asset (customer data) against its most common catastrophic failure. The discipline of "centralize the semantics and audit, cover every data path" is the durable, scalable fix, versus playing whack-a-mole per endpoint.
How This Is Used on the Job
This is the core of an application-security design review and of building authorization platforms (the Google Zanzibar / relationship-based-authorization model). You'll extend the same matrix to REST, GraphQL, gRPC, and WebSocket fixtures — because the transport changes but the object-level decision must not.
Attack / Failure Cases You Must Handle
BOLA / cross-tenant access by ID; role abuse (action not granted); token mix-up (wrong issuer/ audience) and expiry; a disabled subject; a confused-deputy service identity reading restricted content; relationship-denied (role without object relationship).
Build It
Implement authorize(principal, action, resource, …) as a deny-by-default conjunction returning a
(allowed, reason) pair — the reason names the failed control and contains no token or document
content. Add a test for every route × role × tenant combination.
LAB_MODULE=solution pytest -q
Secure Implementation Patterns
The anti-pattern (the bug that ships in production). Authentication is checked; authorization on the object is not:
@app.get("/api/invoices/{invoice_id}")
def get_invoice(invoice_id: int, user = Depends(current_user)):
return db.invoices.get(invoice_id) # BOLA: any authenticated user reads ANY invoice
The secure pattern — a deny-by-default conjunction (mirrors solution.py). Evaluate the full
tuple, ordering checks cheapest/most-decisive first, and return the failed control so the decision
is explainable and auditable:
def authorize(principal, action, resource, *, expected_issuer, expected_audience, now):
# 1. token CONTEXT before anything trusts the principal (Phase 02 Ch.8)
if principal.disabled: return False, "subject-disabled"
if (principal.issuer != expected_issuer # iss
or principal.audience != expected_audience): # aud: token minted FOR THIS service
return False, "invalid-token-context"
if principal.expires_at <= now: return False, "token-expired"
# 2. TENANT isolation — the hard multi-tenant boundary
if principal.tenant != resource.tenant: return False, "tenant-mismatch"
# 3. ROLE grants the ACTION (role->actions map, not free-form strings)
allowed = {a for r in principal.roles for a in ROLE_ACTIONS.get(r, ())}
if action not in allowed: return False, "action-not-granted"
# 4. RELATIONSHIP — role alone is not "owner of THIS object"
if action == "read" and principal.subject != resource.owner \
and principal.subject not in resource.shared_with \
and not {"admin", "service-indexer"} & principal.roles:
return False, "relationship-denied"
# 5. CLASSIFICATION — a service identity must not read restricted data (confused deputy)
if resource.classification == "restricted" and "service-indexer" in principal.roles:
return False, "restricted-service-access"
return True, "allowed"
Enforce it so it can't be forgotten. The decision point is worthless if a route can skip it. Centralize it (middleware/decorator that fails closed on any unguarded route) and prove coverage in CI:
# A route absent from the authorization matrix must FAIL the build, not silently allow.
def test_every_data_route_is_in_the_authz_matrix():
for route in app.data_routes():
assert route.path in AUTHZ_MATRIX, f"{route.path} has no object-level authZ"
Production practices to carry forward:
- Return 404, not 403, for unauthorized objects — a 403 confirms the object exists, an
enumeration oracle. (
resource-not-foundto the client; the real reason in the audit log.) - The tenant filter must be server-derived, never caller-supplied — push it into the query
(
WHERE tenant_id = :caller_tenant), so even a missed code-path check can't cross tenants at the data layer (defense in depth). - Cover every data path: batch, search, export, GraphQL resolvers, background jobs, cache reads,
counts, and error messages — apply the same
authorize()everywhere. - Audit the decision with
subject, tenant, action, resource_id, policy_version, decision, reason— and never the bearer token or document content. - Model ownership explicitly (ReBAC / Zanzibar) for graph relationships at scale, rather than
scattering
if owner == subjectacross the codebase.
Validation — What You Should Be Able to Do Now
- Diagnose an "I changed the URL ID and saw someone else's data" report instantly as BOLA, name the root cause (trusting a user-controlled object ID post-auth), and give the fix (full-tuple object-level authZ, modeled ownership, per-route×tenant tests).
- Explain why a valid TLS connection and a valid token are still not authorization.
- List the non-obvious data paths object-level authZ must cover (batch, search, export, GraphQL, jobs, cache, counts, errors).
- Explain why returning 404 not 403 on an unauthorized object avoids leaking its existence.
The Broader Perspective
This lab teaches the most important distinction in application security — authentication establishes identity; authorization decides what that identity may do to a specific resource — and the architectural answer: a policy decision point that evaluates a rich tuple deny-by-default, applied uniformly to every data path, with audit. You met this shape in Phase 00 (the engagement guard) and you will meet it again as cloud IAM (Phase 07), Kubernetes RBAC (Phase 07), and AI-agent tool gating (Phase 11). Object-level authorization is the same idea pushed down to the individual record — and recognizing it everywhere is what makes you effective across the whole stack.
Interview Angle
- "A user reads another tenant's invoice by changing the URL ID. Root cause and fix?" — BOLA: the route authenticated but trusted a user-controlled object ID. Fix: evaluate the full tuple on every data path, model ownership explicitly (ReBAC), return 404, test per route×role×tenant so missing checks fail CI, and add a detection on cross-tenant access.
- "Where besides direct GETs must object-level authZ apply?" — Batch, search, export, GraphQL, jobs, cache, counts, and error messages.
Extension (Stretch)
Apply the same decision to REST, GraphQL, gRPC, and WebSocket fixtures; add policy-decision audit logs that never contain bearer tokens or document content.
References
- Phase 02 WARMUP Chapters 5, 10; OWASP API Security Top 10 (API1: BOLA).
- Google "Zanzibar" (relationship-based authorization); OWASP Authorization Cheat Sheet.