How to Generate and Validate Policies¶
Auto-generate Rego policy skeletons from FastAPI routes and validate at startup or in CI.
CLI Commands¶
Generate Policies¶
Output:
Scanning routes...
Generated policies/myapp/GET/documents.rego
Generated policies/myapp/POST/documents.rego
Generated policies/myapp/check.rego (ReBAC)
Generated 3 policies from 2 routes
Options:
fastapi-topaz generate-policies \
--app myapp.main:app \
--output policies/ \
--config myapp.config:config \
--overwrite \
--dry-run \
--format flat
Existing policy files are kept by default, so hand-edited policies survive a rerun; pass --overwrite to replace them.
Routes covered by a policy group or by default_policy get no skeleton: the middleware resolves them to the group or default policy, so a per-route file would never be evaluated and could hide that the effective policy is less strict. {root}.check is always generated.
Typed path parameters drop their converter in the policy path, so /files/{path:path} maps to myapp.GET.files.__path and /items/{id:int} to myapp.GET.items.__id.
Validate Policies (CI Integration)¶
Output:
Scanning routes... 12 routes found
Scanning policies... 10 policies found
Missing policies (routes without policies):
- myapp.DELETE.documents.__id
Route: DELETE /documents/{id}
Orphaned policies (no matching route):
- myapp.GET.old_endpoint
Summary: 1 missing, 1 orphaned
Exit code: 1
A route matched by a policy group is resolved by the first matching group only, like the middleware: if that group's .rego file is missing, the route is reported missing even when a later group or the default policy has a file.
Generate Route Map¶
Output:
| Route | Method | Policy Path | Status |
|-------|--------|-------------|--------|
| /documents | GET | myapp.GET.documents | OK |
| /documents | POST | myapp.POST.documents | OK |
| /documents/{id} | DELETE | myapp.DELETE.documents.__id | Missing |
Documentation Routes¶
The OpenAPI and docs routes (/openapi.json, /docs, /docs/oauth2-redirect, /redoc) are scanned like any other route, because the middleware authorizes them unless you exclude them. To leave them out, disable them on the app (FastAPI(openapi_url=None, docs_url=None, redoc_url=None)); scan_routes(exclude_paths=...) also accepts a set of exact paths to skip.
Frontend Routes and Mounts¶
Frontend routes (app.frontend() / router.frontend()) and mounts (app.mount()) are authorized by the middleware with a prefix-only policy path, so they appear in generated policies, policy-diff, the rights matrix and check too. A frontend route gets one GET entry for its mount path (e.g. myapp.GET.app). A mount accepts every method, so it gets one entry each for GET, POST, PUT, PATCH and DELETE (OPTIONS and HEAD are excluded by the middleware by default).
Startup Validation¶
@app.on_event("startup")
async def startup():
result = await config.validate_policies_from_app(app)
if not result.valid:
for missing in result.missing_policies:
print(f"Missing policy: {missing.policy_path}")
print(f" Route: {missing.method} {missing.path}")
raise RuntimeError(
f"Missing {len(result.missing_policies)} policies. "
"Run 'fastapi-topaz generate-policies' to create them."
)
Quick validation (one-liner):
@app.on_event("startup")
async def startup():
await config.validate_policies_from_app(app, fail_on_missing=True)
Generated Rego Template¶
Policy route (GET /documents):
package myapp.GET.documents
import rego.v1
default allowed = false
# Route: GET /documents
# Generated by fastapi-topaz
allowed if {
input.identity.type == "IDENTITY_TYPE_SUB"
}
ReBAC policy (myapp.check):
package myapp.check
import rego.v1
default allowed = false
allowed if {
ds.check({
"object_type": input.resource.object_type,
"object_id": input.resource.object_id,
"relation": input.resource.relation,
"subject_type": input.resource.subject_type,
"subject_id": input.identity.value,
})
}
CI/CD Integration¶
GitHub Actions¶
name: Policy Validation
on: [push, pull_request]
jobs:
validate-policies:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: '3.11'
- run: pip install fastapi-topaz
- run: |
fastapi-topaz policy-diff \
--app myapp.main:app \
--policies policies/ \
--strict
Pre-commit Hook¶
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: policy-diff
name: Validate Topaz policies
entry: fastapi-topaz policy-diff --app myapp.main:app --policies policies/
language: system
pass_filenames: false
Python API¶
from fastapi_topaz.codegen import generate_policies, policy_diff
# Generate policies
policies = generate_policies(app, config)
for policy_path, rego_content in policies.items():
print(f"# {policy_path}\n{rego_content}\n")
# Check diff
diff = await policy_diff(app, config, policies_dir="policies/")
print(f"Missing: {diff.missing}")
print(f"Orphaned: {diff.orphaned}")
OpenAPI Annotation¶
annotate_openapi() stamps each route's resolved policy path into the OpenAPI schema as x-authz-policy / x-authz-source extensions, so API consumers and reviewers can see which policy guards each operation. Call it after route registration and before the first app.openapi() access (the schema is cached on first build):
from fastapi_topaz import annotate_openapi
count = annotate_openapi(app, config, policies_dir="policies/")
print(f"Annotated {count} routes")
Resolution follows the same chain as the rights matrix: explicit .rego file (when policies_dir is given) > policy group > default policy > generated path. Existing openapi_extra values on routes are preserved. Only API routes are annotated; Starlette routes such as the docs pages are not part of the schema.
When the methods of one route resolve differently (for example a policy group that matches only GET), x-authz-policy and x-authz-source are written as objects keyed by method, sorted by method name, instead of strings:
"x-authz-policy": {"GET": "myapp.reads", "POST": "myapp.POST.items"},
"x-authz-source": {"GET": "group", "POST": "generated"}
Routes the Middleware Skips¶
annotate_openapi(), the rights matrix, policy-diff and check report routes that TopazMiddleware never authorizes as skipped, rather than claiming a policy guards them:
- endpoints decorated with
@skip_middleware; - routes or routers with
Depends(SkipMiddleware); - routes matching the installed middleware's
exclude_paths, or using one of itsexclude_methods. These are read fromapp.add_middleware(TopazMiddleware, ...).
In OpenAPI a skipped operation has x-authz-source: skipped and no x-authz-policy. policy-diff lists skipped routes in their own section, so they are neither missing nor covered.
exclude_paths patterns are matched against route templates such as /documents/{id}, not concrete URLs. A pattern written for concrete URLs, such as ^/documents/\d+$, doesn't match the template, so that route is still reported as authorized. That errs on the cautious side.
On FastAPI 0.137 and later, run annotate_openapi() after all routes are registered, including routes added to a router after it was passed to include_router(): FastAPI rebuilds that router's route contexts and the annotations on them are lost.
See Also¶
- CLI Reference - Complete CLI documentation
- API Reference - Python API