Skip to content

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

fastapi-topaz generate-policies --app myapp.main:app --output 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)

fastapi-topaz policy-diff --app myapp.main:app --policies policies/

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

fastapi-topaz policy-map --app myapp.main:app --format markdown

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 its exclude_methods. These are read from app.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