first
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
"""Correlation id + observability middleware.
|
||||
|
||||
Every inbound request gets a ``correlation_id`` which is:
|
||||
1. Injected as a structlog context variable for every log line.
|
||||
2. Returned to the caller via the ``X-Correlation-Id`` response header.
|
||||
3. Propagated to downstream calls (events, workers) that happen within
|
||||
the request lifecycle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
import structlog
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
CORRELATION_HEADER = "X-Correlation-Id"
|
||||
TENANT_HEADER = "X-ASF-Tenant-Id"
|
||||
USER_HEADER = "X-ASF-User-Id"
|
||||
|
||||
# Deterministic UUID namespace for dev-trust string IDs.
|
||||
# Any non-UUID string supplied via X-ASF headers is mapped to a stable UUIDv5
|
||||
# so that the domain model (which uses UUID IDs internally) stays consistent
|
||||
# while still allowing human-friendly strings like "t-demo-001" during local dev.
|
||||
_ASP_NAMESPACE = uuid.UUID("9a8f2d58-14b4-40e1-93d3-1e4c2f0e6a22")
|
||||
|
||||
|
||||
def _coerce_uuid(value: str) -> uuid.UUID:
|
||||
"""Accept raw UUID hex strings or arbitrary opaque identifiers (UUID5 fallback)."""
|
||||
try:
|
||||
return uuid.UUID(value)
|
||||
except ValueError:
|
||||
return uuid.uuid5(_ASP_NAMESPACE, value)
|
||||
|
||||
correlation_id_var: ContextVar[str | None] = ContextVar(
|
||||
"correlation_id", default=None
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestContext:
|
||||
"""Tenant + identity context extracted from the authenticated call.
|
||||
|
||||
For the bootstrap phase we accept ``X-ASF-Tenant-Id`` / ``X-ASF-User-Id``
|
||||
headers directly when ``dev_trust_tenant_header`` is true in settings.
|
||||
|
||||
In production these fields MUST come from a validated JWT / session —
|
||||
NEVER from raw client headers.
|
||||
"""
|
||||
|
||||
tenant_id: uuid.UUID | None
|
||||
user_id: uuid.UUID | None
|
||||
correlation_id: str
|
||||
roles: list[str]
|
||||
|
||||
def require_tenant(self) -> uuid.UUID:
|
||||
if self.tenant_id is None:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=401, detail="Missing authenticated tenant context")
|
||||
return self.tenant_id
|
||||
|
||||
def require_user(self) -> uuid.UUID:
|
||||
if self.user_id is None:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=401, detail="Missing authenticated user")
|
||||
return self.user_id
|
||||
|
||||
|
||||
request_context_var: ContextVar[RequestContext | None] = ContextVar(
|
||||
"request_context", default=None
|
||||
)
|
||||
|
||||
|
||||
def get_current_context() -> RequestContext:
|
||||
ctx = request_context_var.get()
|
||||
if ctx is None:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=401, detail="No request context available")
|
||||
return ctx
|
||||
|
||||
|
||||
class CorrelationMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(
|
||||
self,
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[Response]],
|
||||
) -> Response:
|
||||
cid = request.headers.get(CORRELATION_HEADER) or uuid.uuid4().hex
|
||||
correlation_id_var.set(cid)
|
||||
|
||||
with structlog.contextvars.bound_contextvars(correlation_id=cid):
|
||||
try:
|
||||
ctx = _extract_context(request, cid)
|
||||
except _ContextError as exc:
|
||||
# BaseHTTPMiddleware runs before FastAPI exception handlers.
|
||||
# Build the error response directly instead of raising HTTPException.
|
||||
resp = JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"error": exc.kind, "message": exc.message},
|
||||
)
|
||||
resp.headers[CORRELATION_HEADER] = cid
|
||||
return resp
|
||||
|
||||
request_context_var.set(ctx)
|
||||
structlog.contextvars.bind_contextvars(
|
||||
tenant_id=str(ctx.tenant_id) if ctx.tenant_id else None,
|
||||
user_id=str(ctx.user_id) if ctx.user_id else None,
|
||||
)
|
||||
|
||||
response = await call_next(request)
|
||||
response.headers[CORRELATION_HEADER] = cid
|
||||
return response
|
||||
|
||||
|
||||
class _ContextError(Exception):
|
||||
"""Raised internally by _extract_context to short-circuit auth failures."""
|
||||
|
||||
def __init__(self, status_code: int, kind: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.kind = kind
|
||||
self.message = message
|
||||
|
||||
|
||||
def _extract_context(request: Request, cid: str) -> RequestContext:
|
||||
from ..config import get_settings
|
||||
|
||||
s = get_settings()
|
||||
tenant_id: uuid.UUID | None = None
|
||||
user_id: uuid.UUID | None = None
|
||||
roles: list[str] = []
|
||||
|
||||
# ---- Bootstrap-trust mode for local dev ----
|
||||
if s.dev_trust_tenant_header and TENANT_HEADER in request.headers:
|
||||
raw = request.headers[TENANT_HEADER].strip()
|
||||
if not raw:
|
||||
raise _ContextError(400, "invalid_header", f"Empty {TENANT_HEADER}")
|
||||
tenant_id = _coerce_uuid(raw)
|
||||
|
||||
if s.dev_trust_user_header and USER_HEADER in request.headers:
|
||||
raw = request.headers[USER_HEADER].strip()
|
||||
if not raw:
|
||||
raise _ContextError(400, "invalid_header", f"Empty {USER_HEADER}")
|
||||
user_id = _coerce_uuid(raw)
|
||||
roles = ["admin"]
|
||||
|
||||
return RequestContext(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
correlation_id=cid,
|
||||
roles=roles,
|
||||
)
|
||||
Reference in New Issue
Block a user