FastAPI / Intermediate: application integration and security / Lesson 11
Resource authorization and tenant scope
Apply a trusted actor to the actual resource and operation, with deny-by-default behavior and tests that exercise the real policy.
On this page
Lesson goals
- Explain the assumptions and boundaries of resource authorization and tenant scope.
- Interpret both complete examples and justify their observed outputs.
- Diagnose the listed failures and apply the worked solutions while keeping identity, state, and lifecycle ownership explicit.
Identity is the beginning of the decision
Authentication establishes who the actor is; authorization decides whether that actor may perform this operation on this resource now. A valid login is not permission to read every object. Define policy inputs such as tenant, ownership, role, operation, and resource state explicitly. The first pure policy example allows an owner or a separately trusted administrator while denying anonymous and unrelated actors. It uses synthetic server-side objects, not a clientโs claimed role. Never copy owner_id or is_admin from a request body into trusted context just because Pydantic accepted its type.
def may_edit(actor, document):
return bool(actor and (actor["role"] == "admin" or actor["id"] == document["owner_id"]))
document = {"owner_id": 7}
print(may_edit(None, document))
print(may_edit({"id": 7, "role": "user"}, document))
print(may_edit({"id": 8, "role": "user"}, document))
print(may_edit({"id": 8, "role": "admin"}, document))
False
True
False
True
The result matrix is deny, allow, deny, allow. The role and actor are trusted synthetic inputs; reading them from an unverified request would invalidate the policy boundary.
Load and authorize the actual target
A route identifier must be parsed, resolved under the intended tenant or scope, and checked against the operation policy. The second example composes resource lookup and ownership as a dependency, then keeps that real policy active while the test changes the trusted synthetic actor. An absent object and an existing forbidden object follow the declared 404 and 403 contract. Some applications intentionally hide existence; choose that policy consistently instead of leaking it accidentally through different error timing or output. Do not authorize one object and later fetch a different unchecked object through another repository path.
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException
from fastapi.testclient import TestClient
def current_user(): return {"id": 7}
def authorized_document(document_id: int, user: Annotated[dict, Depends(current_user)]):
document = {1: {"id": 1, "owner_id": 7, "text": "public lab text"}}.get(document_id)
if document is None: raise HTTPException(status_code=404, detail="Missing")
if document["owner_id"] != user["id"]: raise HTTPException(status_code=403, detail="Forbidden")
return document
app = FastAPI()
@app.get("/documents/{document_id}")
def document(value: Annotated[dict, Depends(authorized_document)]): return {"id": value["id"]}
with TestClient(app) as client:
print(client.get("/documents/1").status_code)
app.dependency_overrides[current_user] = lambda: {"id": 8}
try:
print(client.get("/documents/1").status_code)
finally:
app.dependency_overrides.clear()
200
403
The synthetic owner succeeds and another synthetic user is forbidden while the actual lookup and policy remain active. current_user is a lab provider, not a real authenticator.
Keep authorization close to state change
Ownership or membership can change between an early check and a later write. When that matters, bind the authorization condition to the transactional repository operation rather than trusting stale loaded state. A permissions check is not a database transaction and dependency injection does not make an asynchronous sequence atomic. For multi-tenant data, query and update within the approved tenant scope selected by the server. Caches must include the relevant authorization context or be limited to data that is truly shared. A response model can hide fields, but it cannot replace a missing permission check on the object itself.
Review every exposure path
Read, update, delete, list, export, attachment, and background operations can each expose a resource. A protected detail route does not secure an unfiltered list or download route. OAuth2 scopes or roles describe authority only under the verified identity and application policy; they are not a substitute for ownership or tenant evaluation. Keep privileged actions auditable through safe actor, operation, and resource references without logging credentials or sensitive payloads. Review router-level and endpoint-level dependencies together, including routes introduced during refactoring. A policy omitted from one operation can defeat the protection of all the others.
Test an authorization matrix
Include anonymous, owner, unrelated user, wrong tenant, privileged actor, and relevant resource-state cases. Override identity or repository inputs only where needed and retain the policy under test. Verify public status and absence of unauthorized data, not just that a helper returned false. All examples are isolated and use public synthetic data, with no real token, user account, database, or network server. A good policy is explainable in business terms and has explicit trusted inputs. Its correctness must be rechecked as roles, resource states, and persistence behavior evolve rather than assumed from a route decorator forever.
Common errors and fixes
Every authenticated user can access every object
- Why it happens
- Login checks replaced resource authorization.
- How to fix it
- Evaluate actor, target, tenant, and operation explicitly.
- Prevent it next time
- Test unrelated-user and cross-tenant requests.
A client chooses its own trusted owner
- Why it happens
- A body or header claim became server identity.
- How to fix it
- Derive authority from a verified principal and stored target.
- Prevent it next time
- Keep input models separate from identity context.
An early authorization check becomes stale
- Why it happens
- State changed before the write completed.
- How to fix it
- Bind relevant conditions to the transactional operation.
- Prevent it next time
- Test competing ownership or membership changes.
Check your understanding
1. A detail endpoint checks ownership but its export route does not. Is the resource protected?
Show worked solution
No. Every exposure path must enforce the relevant policy. Enumerate list, export, attachment, mutation, and background flows and test them with an unrelated actor. Securing one route does not compensate for another returning the same data without authorization.
2. Why is overriding the ownership function with always true a weak authorization test?
Show worked solution
It removes the behavior the test should protect. Instead supply controlled principals and resources and execute the real policy, asserting both allowance and denial. That catches missing route attachment and policy regressions at the public HTTP boundary.
Key takeaways
- Authentication establishes who the actor is; authorization decides whether that actor may perform this operation on this resource now.
- Ownership or membership can change between an early check and a later write.
- Include anonymous, owner, unrelated user, wrong tenant, privileged actor, and relevant resource-state cases.