RICS / app /api /middleware.py
StormShadow308's picture
Add demo documentation and Docker setup for v2 report generation system
aad7814
Raw
History Blame Contribute Delete
3.09 kB
"""Middleware that authenticates every request and pins ``request.state.tenant_id``.
Identity is resolved from a signed bearer token (``Authorization: Bearer <token>``)
whose subject is the tenant_id. Because the token is HMAC-signed server-side, a
client cannot claim another tenant's data — this is what enforces isolation.
For local development (``DEV_MODE=true``) a raw ``X-Tenant-ID`` header is still
accepted as a fallback so existing tooling and tests keep working. In production
a valid token is mandatory.
"""
from collections.abc import Awaitable, Callable
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from app.api.auth import verify_token
from app.config import settings
# Paths that do not require authentication (exact match)
_EXEMPT: frozenset[str] = frozenset(
{
"/",
"/health",
"/docs",
"/openapi.json",
"/redoc",
"/favicon.ico",
"/auth/login",
"/auth/register",
}
)
# Path prefixes that do not require authentication
_EXEMPT_PREFIXES: tuple[str, ...] = ("/static/",)
class TenantAuthMiddleware(BaseHTTPMiddleware):
"""Authenticate the caller and expose the verified tenant on request state.
Example::
# Obtain a token, then call protected endpoints with it:
curl -H "Authorization: Bearer <token>" http://localhost:8000/documents
"""
async def dispatch(
self,
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
path = request.url.path
if path in _EXEMPT or path.startswith(_EXEMPT_PREFIXES):
return await call_next(request)
tenant_id = self._resolve_tenant(request)
if tenant_id is None:
return JSONResponse(
status_code=401,
content={"detail": "Authentication required. Log in to obtain a token."},
)
request.state.tenant_id = tenant_id
return await call_next(request)
@staticmethod
def _resolve_tenant(request: Request) -> str | None:
auth = request.headers.get("Authorization", "")
if auth.lower().startswith("bearer "):
token = auth[7:].strip()
sub = verify_token(token)
if sub:
return sub
# A present-but-invalid token must never silently fall through to
# the dev header path.
return None
# ``<img src>`` and direct links cannot send Authorization headers; the UI
# passes the same signed token as ``access_token`` (scoped to this origin).
query_token = (request.query_params.get("access_token") or "").strip()
if query_token:
sub = verify_token(query_token)
if sub:
return sub
return None
if settings.dev_mode:
raw = request.headers.get("X-Tenant-ID")
if raw and raw.strip():
return raw.strip()
return None