FastAPI#

examples/fastapi_rag is a reference RAG service: a FastAPI app whose /search and /ask endpoints use only chunks the caller may read. Copy its structure rather than its demo parts (an in-memory index and a stand-in model). Its tests run in CI against the real app over HTTP.

pip install needtoknow fastapi uvicorn pyjwt
export NEEDTOKNOW_DEMO_JWT_SECRET=$(python -c "import secrets; print(secrets.token_hex(32))")
uvicorn --factory rag_service:create_app --app-dir examples/fastapi_rag

What it shows#

The subject comes from the verified token. A dependency checks the bearer token’s signature, issuer, audience and expiry (with PyJWT, accepting only the algorithm the service expects), and builds the Subject from its claims: sub for the user, groups, and tid for the tenant. Nothing else in the request is read for identity.

def caller(credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)]) -> Subject:
    claims = jwt.decode(
        credentials.credentials,
        settings.jwt_secret,
        algorithms=["HS256"],
        audience=settings.audience,
        issuer=settings.issuer,
        options={"require": ["exp", "iss", "aud", "sub", "tid"]},
    )
    return Subject(claims["sub"], claims.get("groups", []), {"tenant": claims["tid"]})

If the dependency is defined inside a function, as in the example’s create_app, leave out from __future__ import annotations in that module. FastAPI resolves string annotations against the module’s globals, so it cannot find a local caller, and treats subject as a required query parameter instead, so a request without that parameter fails with 422 and the token is never checked. The example’s tests catch it.

The body cannot name anyone. Request models forbid unknown fields, so a body with user, groups, tenant or subject is refused with 422, and the query string is never read.

Tenants stay apart. The authorizer is AllOf(TenantAuthorizer(), MetadataACLAuthorizer()) behind a CachingAuthorizer, so the same user and group names in another tenant see only that tenant’s chunks.

Only permitted chunks reach the model. /ask retrieves first and passes the model the result, nothing else.

Failures say nothing. An AuthorizationError becomes a 503 with a fixed message; the details go to the server log. Each retrieval is logged as a structured RetrievalEvent, without the query or the text.

What the tests check#

An engineer reads engineering documents and not HR’s, and HR the reverse; the same names in another tenant see only that tenant; a body or query string naming another user, group or tenant changes nothing; a missing, malformed, wrongly signed, expired, unsigned or wrongly addressed token is refused before any search; a document whose sharing changes stops being returned on the next request, and so does one shared with a group the caller has left; the model is given exactly the permitted chunks; and a failing permission service answers 503 without naming it.

For production#

  • Verify tokens from your identity provider with its published keys (RS256 or ES256 through its JWKS endpoint), and take groups and tenant only from claims it issues.

  • Use a real vector store and permission service, and choose caching to suit how fast access must end (Revocation and consistency).

  • Send the retrieval events to your audit pipeline, and keep AuthorizationError details out of responses, as the example does (Threat model, T12).