How to Configure Authorization Middleware¶
Global request-level authorization that runs before route handlers.
Basic Configuration¶
from fastapi import FastAPI
from fastapi_topaz import TopazMiddleware
app = FastAPI()
app.add_middleware(
TopazMiddleware,
config=topaz_config,
exclude_paths=[r"^/health$", r"^/metrics$", r"^/docs", r"^/openapi\.json$"],
)
How It Works¶
flowchart TD
A[Request arrives] --> B[Middleware intercept]
B --> C{Route excluded?}
C -->|Yes| D[Pass to handler]
C -->|No| E[Extract identity]
E --> F{Has identity?}
F -->|No| G[401 Unauthorized]
F -->|Yes| H[Build policy path from route]
H --> I[Call Topaz for decision]
I --> J{Allowed?}
J -->|No| K[403 Forbidden]
J -->|Yes| D
Excluding Routes¶
By Path: exclude_paths¶
Each entry is a regular expression matched against the request path with re.match, so it is anchored at the start but not at the end:
exclude_paths=[
r"^/health$", # exactly /health
r"^/public/", # /public/foo, /public/bar/baz
r".*\.(css|js|png)$", # static assets anywhere
]
Without a trailing $, a pattern is a prefix: r"^/health" also excludes /healthcheck.
By Method: exclude_methods¶
The list applies to every route. To exclude one method of one route, use a marker instead.
Per Route: @skip_middleware and SkipMiddleware¶
from fastapi import Depends
from fastapi_topaz import SkipMiddleware, skip_middleware
@app.get("/status")
@skip_middleware
async def status():
...
# A whole router, frontend routes included
app.include_router(public_router, prefix="/public", dependencies=[Depends(SkipMiddleware)])
annotate_openapi, policy-diff and the rights matrix report all of these routes as skipped. See Routes the middleware skips.
Policy Path Resolution¶
Routes automatically map to policy paths:
| Route | Method | Policy Path |
|---|---|---|
/documents |
GET | myapp.GET.documents |
/documents |
POST | myapp.POST.documents |
/documents/{id} |
GET | myapp.GET.documents.__id |
/api/v1/users/{user_id} |
PUT | myapp.PUT.api.v1.users.__user_id |
Path parameters become __paramname.
Normalizing Hyphenated Paths¶
Rego identifiers cannot contain hyphens. If your API uses hyphenated paths
like /aircraft-programs, the generated policy path myapp.GET.aircraft-programs
will be invalid.
Use policy_path_normalizer to fix this:
from fastapi_topaz import TopazConfig, normalize_hyphens
config = TopazConfig(
...
policy_path_normalizer=normalize_hyphens,
)
| Route | Without normalizer | With normalize_hyphens |
|---|---|---|
/aircraft-programs |
myapp.GET.aircraft-programs | myapp.GET.aircraft_programs |
/user-docs/{doc-id} |
myapp.GET.user-docs.__doc-id | myapp.GET.user_docs.__doc_id |
For custom normalization, pass any Callable[[str], str]:
Combining with Dependencies¶
Middleware for broad protection, dependencies for specific checks:
# Middleware protects all non-excluded routes
app.add_middleware(
TopazMiddleware,
config=topaz_config,
exclude_paths=[r"^/health$"],
)
# Dependencies for additional ReBAC checks
@app.get("/documents/{id}")
async def get_document(
id: int,
_: None = Depends(
require_rebac_allowed(topaz_config, "document", "can_read")
),
):
...
Configuration Options¶
| Parameter | Type | Default | Description |
|---|---|---|---|
config |
TopazConfig |
required | Configuration instance |
exclude_paths |
list[str] |
None |
Regexes for request paths to skip |
exclude_methods |
list[str] |
["OPTIONS", "HEAD"] |
HTTP methods to skip |
on_missing_identity |
"deny" or "anonymous" |
"deny" |
"deny" answers 401; "anonymous" lets the policy decide |
on_denied |
Callable[[Request, str], Response] |
None |
Builds the 403 response; receives the policy path |
on_error |
"deny", "unavailable" or None |
None |
Overrides config.on_error for the middleware; see below |
policies_dir |
path | None |
Directory of .rego files for the explicit tier of the resolution chain |
Custom Denial Response¶
from fastapi import Request
from fastapi.responses import JSONResponse
def custom_denied(request: Request, policy_path: str) -> JSONResponse:
return JSONResponse(status_code=403, content={"error": "You don't have permission"})
app.add_middleware(TopazMiddleware, config=topaz_config, on_denied=custom_denied)
When the Authorizer Fails: on_error¶
When the Topaz call itself fails (authorizer unreachable, deadline exceeded, a policy that doesn't exist) and the circuit breaker gives no fallback decision, the response depends on TopazConfig(on_error=...). The middleware and every dependency use the same setting:
on_error |
Response | Notes |
|---|---|---|
"deny" (default) |
403 {"detail": "Forbidden"}, or your on_denied response |
Indistinguishable from a denial |
"unavailable" |
503 {"detail": "Authorization service unavailable"} |
Retry-After is set to the circuit breaker's recovery_timeout when one is configured |
Both fail closed: nothing is let through. Choose by what your clients and monitoring should see:
"deny"keeps outages quiet for clients, and HTTP clients and proxies don't retry 403. But an outage shows up as a burst of 403s, which security monitoring may misread."unavailable"tells clients and monitoring the truth, and 5xx alerting catches outages. Proxies, load balancers and HTTP clients may retry 503s;Retry-Aftertells them when.
config = TopazConfig(..., on_error="unavailable")
# Or only for the middleware, keeping 403 in the dependencies
app.add_middleware(TopazMiddleware, config=config, on_error="unavailable")
With expose_deny_reason=True, the body also names the policy, the source and the error type.
Mounted Sub-Applications¶
Mounted sub-apps (app.mount("/sub", sub_app)) match at the Mount itself, not at the routes inside the sub-app. The middleware therefore derives a prefix-only policy path (e.g. myapp.GET.sub for every request under /sub/...), losing the per-route granularity. If a sub-app needs per-route policies, add TopazMiddleware to the sub-app directly or protect its routes with dependencies.
Frontend Routes¶
FastAPI frontend routes (app.frontend("/app", directory="dist"), or router.frontend(...) on an included router) are matched only when no regular route matches. Like mounts, they get a prefix-only policy path from their mount path: every request under /app/..., including SPA fallback paths, evaluates myapp.GET.app. To leave a frontend public, include its router with dependencies=[Depends(SkipMiddleware)] or add its prefix to exclude_paths.
With a root frontend (app.frontend("/")), a request that FastAPI answers with a trailing-slash redirect (e.g. /items/ when only /items exists) is not checked against the frontend policy: FastAPI redirects before trying frontend routes, and the redirected request is authorized on its own.
The middleware finds frontend routes through private FastAPI internals. If a FastAPI release changes them, the middleware fails closed: requests that match no regular route get 403 and an error is logged, instead of reaching an unauthorized frontend route.
Performance¶
Middleware adds minimal overhead: - Without cache: ~10-50ms (Topaz call) - With cache hit: ~0.1-1ms
Route lookups for static paths (no path parameters) are cached internally per (method, path), so repeated requests skip the route-matching scan. Parameterized routes are matched per request.
Use DecisionCache for frequently accessed routes:
See Also¶
- Identity Providers - Configure identity extraction
- Audit Logging - Log middleware decisions
- Circuit Breaker - Handle Topaz failures