Files
kite/backend/main.py
T
curo1305 abe8f8ee90 feat(06-02): wire CorrelationIDMiddleware + config fields + promote 5 test stubs
- backend/config.py: add log_level: str = "INFO" and log_json: bool = False
  fields under Observability (Phase 6 — D-01) comment; pydantic-settings reads
  LOG_LEVEL / LOG_JSON env vars automatically
- backend/main.py: add imports (uuid, time, structlog, ASGIApp/Receive/Scope/Send,
  setup_logging); add CorrelationIDMiddleware raw-ASGI class (NOT BaseHTTPMiddleware
  — avoids streaming buffering); call setup_logging() as first lifespan statement;
  register CorrelationIDMiddleware LAST so it runs FIRST (Starlette reverse order)
- backend/tests/test_logging.py: remove all 5 xfail decorators; replace single-line
  bodies with real assertions for JSON renderer, contextvar binding, X-Correlation-ID
  header, no-bleed between requests, uvicorn.access propagate=False
2026-06-03 18:47:49 +02:00

265 lines
11 KiB
Python

import asyncio
import time
import uuid
from contextlib import asynccontextmanager
import structlog
from redis import asyncio as aioredis
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from minio import Minio
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from sqlalchemy import text
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response as StarletteResponse
from starlette.types import ASGIApp, Receive, Scope, Send
from api.auth import limiter as auth_limiter
from api.documents import router as documents_router
from api.topics import router as topics_router
from config import settings
from db.session import AsyncSessionLocal, engine
from services.logging import setup_logging
# ── CSP / Security headers middleware ────────────────────────────────────────
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Add Content-Security-Policy, X-Frame-Options, and X-Content-Type-Options
to every response (SEC-05, T-02-14).
"""
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"frame-ancestors 'none'"
)
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
return response
# ── Origin validation middleware (SEC-01, T-02-11) ────────────────────────────
class OriginValidationMiddleware(BaseHTTPMiddleware):
"""Reject state-changing requests from Origins not in settings.cors_origins.
For any non-idempotent method (not GET/HEAD/OPTIONS): if the Origin header
is present and not in the allowed list, return 403.
Placed BEFORE CORSMiddleware so it runs first (Starlette applies middleware
in reverse insertion order — last added runs first).
"""
async def dispatch(self, request: Request, call_next):
if request.method not in {"GET", "HEAD", "OPTIONS"}:
origin = request.headers.get("Origin")
if origin is not None and origin not in settings.cors_origins:
return StarletteResponse(content="Forbidden", status_code=403)
return await call_next(request)
# ── Correlation ID Middleware ─────────────────────────────────────────────────
class CorrelationIDMiddleware:
"""Generate per-request UUID correlation ID; bind to structlog contextvars.
Uses raw ASGI (NOT BaseHTTPMiddleware) to avoid response-body buffering
issues with streaming responses (RESEARCH.md Anti-Patterns).
Execution order (Starlette reverse-insertion):
Registered LAST in app.add_middleware() so it runs FIRST on every request.
Per-request:
1. clear_contextvars() — Pitfall 2 guard; prevents context bleed between
requests handled by the same worker.
2. bind_contextvars(correlation_id, path, method) — available in all
downstream log calls.
3. Appends X-Correlation-ID to the response headers.
4. After response: bind duration_ms for final log emission.
"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
correlation_id = str(uuid.uuid4())
start_ns = time.perf_counter_ns()
# Pitfall 2: MUST be first — clears any context from a prior request
# on the same worker before binding new values.
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(
correlation_id=correlation_id,
path=scope.get("path", ""),
method=scope.get("method", ""),
)
async def send_with_header(message: dict) -> None:
if message["type"] == "http.response.start":
headers = list(message.get("headers", []))
headers.append((b"x-correlation-id", correlation_id.encode()))
message = {**message, "headers": headers}
await send(message)
await self.app(scope, receive, send_with_header)
# Bind duration after the response so downstream loggers can emit it.
duration_ms = (time.perf_counter_ns() - start_ns) / 1_000_000
structlog.contextvars.bind_contextvars(duration_ms=round(duration_ms, 2))
# ── Lifespan ──────────────────────────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
"""FastAPI lifespan: initialize MinIO, Redis, and admin bootstrap at startup.
D-07: bucket auto-create ensures the docuvault bucket exists on every reboot.
MinIO client stored on app.state.minio for use in the /health endpoint.
Redis stored on app.state.redis for per-account rate limiting (SEC-02) and
TOTP replay prevention (AUTH-08).
Admin bootstrap (D-04): idempotent, runs only if no users exist.
"""
# Initialize structured logging first — all subsequent log calls use the
# configured renderer (JSON in production, console in dev).
setup_logging(json_logs=settings.log_json, log_level=settings.log_level)
# MinIO bucket initialization (RESEARCH.md Pattern 4)
minio_client = Minio(
settings.minio_endpoint,
access_key=settings.minio_access_key,
secret_key=settings.minio_secret_key,
secure=False,
)
exists = await asyncio.to_thread(minio_client.bucket_exists, settings.minio_bucket)
if not exists:
await asyncio.to_thread(minio_client.make_bucket, settings.minio_bucket)
app.state.minio = minio_client
# Redis init for per-account rate limiting + TOTP replay prevention
app.state.redis = await aioredis.from_url(settings.redis_url)
# Admin bootstrap (D-04)
from services.auth import bootstrap_admin # noqa: PLC0415
async with AsyncSessionLocal() as session:
await bootstrap_admin(session)
yield
# Shutdown: close pooled connections and Redis
await app.state.redis.close()
await engine.dispose()
# ── Application factory ───────────────────────────────────────────────────────
app = FastAPI(title="Document Scanner API", version="1.0.0", lifespan=lifespan)
# Rate limiter state (slowapi)
app.state.limiter = auth_limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)
# ── Middleware registration order (Starlette: last added = first to run) ───────
# Desired execution order (request path): Origin → CORS → SecurityHeaders → route
# Insertion order (last registered = first to run): SecurityHeaders → CORS → Origin
# Result: register SecurityHeaders first, then CORS, then Origin last.
# 1. Security headers (CSP etc.) — runs last in the chain
app.add_middleware(SecurityHeadersMiddleware)
# 2. CORS — updated to use settings.cors_origins (D-09); wildcard removed (T-02-15)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True, # Required for httpOnly cookie flow
allow_methods=["*"],
allow_headers=["*"],
)
# 3. Origin validation — runs second (added second-to-last), before CORS and route handlers
app.add_middleware(OriginValidationMiddleware)
# 4. CorrelationID — added LAST so it runs FIRST (Starlette reverse-insertion order)
app.add_middleware(CorrelationIDMiddleware)
# ── Routes ────────────────────────────────────────────────────────────────────
@app.get("/health")
async def health(request: Request):
"""Extended health probe: reports PostgreSQL and MinIO connectivity (D-07).
Always returns HTTP 200 — 'degraded' status signals a partial outage without
causing load-balancer retries.
Note (T-01-05-03): error strings expose Python exception class names — acceptable
for an internal/dev endpoint in Phase 1. Phase 2 will trim to 'error' or
'unhealthy' once the endpoint is internet-facing.
"""
checks: dict = {}
# PostgreSQL probe
try:
async with AsyncSessionLocal() as session:
await session.execute(text("SELECT 1"))
checks["postgres"] = "ok"
except Exception as e:
checks["postgres"] = f"error: {type(e).__name__}: {e}"
# MinIO probe
try:
ok = await asyncio.to_thread(
request.app.state.minio.bucket_exists, settings.minio_bucket
)
checks["minio"] = "ok" if ok else "error: bucket missing"
except Exception as e:
checks["minio"] = f"error: {type(e).__name__}: {e}"
status_val = "ok" if all(v == "ok" for v in checks.values()) else "degraded"
return {"status": status_val, "checks": checks}
# ── Include routers ───────────────────────────────────────────────────────────
app.include_router(documents_router)
app.include_router(topics_router)
# Phase 2: auth and admin routers
from api.auth import router as auth_router # noqa: E402
from api.admin import router as admin_router # noqa: E402
app.include_router(auth_router)
app.include_router(admin_router)
# Phase 4: folders router (FOLD-01..05) and document-move endpoint
from api.folders import router as folders_router # noqa: E402
from api.folders import document_move_router as document_move_router # noqa: E402
app.include_router(folders_router)
app.include_router(document_move_router)
# Phase 4: shares router (SHARE-01..05)
from api.shares import router as shares_router # noqa: E402
app.include_router(shares_router)
# Phase 4: audit log viewer + CSV export (ADMIN-06)
from api.audit import router as audit_router # noqa: E402
app.include_router(audit_router)
# Phase 5: cloud storage backend connection management (CLOUD-01..07)
from api.cloud import router as cloud_router, users_router as cloud_users_router # noqa: E402
app.include_router(cloud_router)
app.include_router(cloud_users_router)