diff --git a/api/src/cartsnitch_api/auth/dependencies.py b/api/src/cartsnitch_api/auth/dependencies.py index 5040741..ded7013 100644 --- a/api/src/cartsnitch_api/auth/dependencies.py +++ b/api/src/cartsnitch_api/auth/dependencies.py @@ -69,7 +69,9 @@ async def get_current_user( token: str | None = None # 1. Check session cookie — prefer __Secure- variant (HTTPS) over plain (HTTP dev) - cookie_token = request.cookies.get(SECURE_SESSION_COOKIE_NAME) or request.cookies.get(SESSION_COOKIE_NAME) + cookie_token = request.cookies.get(SECURE_SESSION_COOKIE_NAME) or request.cookies.get( + SESSION_COOKIE_NAME + ) if cookie_token: # Better-Auth cookie format is "token.sessionId" — extract just the token part token = cookie_token.split(".")[0] if "." in cookie_token else cookie_token @@ -86,7 +88,9 @@ async def get_current_user( detail="Authentication required", ) - return await _validate_session_token(token, db) + user_id = await _validate_session_token(token, db) + request.state.user_id = user_id + return user_id async def verify_service_key(x_service_key: str = Header()) -> None: diff --git a/api/src/cartsnitch_api/main.py b/api/src/cartsnitch_api/main.py index 6db5a0c..1aa2e74 100644 --- a/api/src/cartsnitch_api/main.py +++ b/api/src/cartsnitch_api/main.py @@ -8,6 +8,7 @@ from cartsnitch_api.auth.routes import router as auth_router from cartsnitch_api.middleware.cors import add_cors_middleware from cartsnitch_api.middleware.error_handler import add_error_handlers, add_error_monitor_middleware from cartsnitch_api.middleware.rate_limit import add_rate_limit_middleware +from cartsnitch_api.middleware.audit import add_audit_middleware from cartsnitch_api.routes.alerts import router as alerts_router from cartsnitch_api.routes.coupons import router as coupons_router from cartsnitch_api.routes.health import router as health_router @@ -40,6 +41,7 @@ def create_app() -> FastAPI: add_cors_middleware(app) add_error_monitor_middleware(app) add_rate_limit_middleware(app) + add_audit_middleware(app) # Exception handlers add_error_handlers(app) diff --git a/api/src/cartsnitch_api/middleware/audit.py b/api/src/cartsnitch_api/middleware/audit.py new file mode 100644 index 0000000..2868505 --- /dev/null +++ b/api/src/cartsnitch_api/middleware/audit.py @@ -0,0 +1,64 @@ +"""Audit logging middleware for sensitive API operations. + +Logs structured JSON for POST/PUT/PATCH/DELETE requests and GET /auth/me. +Never logs request bodies, response bodies, Authorization headers, or cookie values. +""" + +import json +import logging +import time +from collections.abc import Awaitable, Callable + +from fastapi import FastAPI, Request +from starlette.middleware.base import BaseHTTPMiddleware + +logger = logging.getLogger("cartsnitch_api.audit") + +HEALTH_PATHS = {"/health", "/healthz", "/ready"} + + +class AuditMiddleware(BaseHTTPMiddleware): + """Middleware to log structured audit events for sensitive operations.""" + + async def dispatch( + self, + request: Request, + call_next: Callable[[Request], Awaitable], + ): + if request.method == "OPTIONS" or request.url.path in HEALTH_PATHS: + return await call_next(request) + + method = request.method + path = request.url.path + + is_sensitive_write = method in {"POST", "PUT", "PATCH", "DELETE"} + is_auth_me_read = method == "GET" and path == "/auth/me" + + if not (is_sensitive_write or is_auth_me_read): + return await call_next(request) + + start = time.perf_counter() + response = await call_next(request) + duration_ms = (time.perf_counter() - start) * 1000 + + user_id = getattr(request.state, "user_id", None) + client_ip = request.client.host if request.client else "unknown" + + log_entry = { + "event": "audit", + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "user_id": user_id, + "method": method, + "path": path, + "client_ip": client_ip, + "status_code": response.status_code, + "duration_ms": round(duration_ms, 2), + } + + logger.info(json.dumps(log_entry)) + + return response + + +def add_audit_middleware(app: FastAPI) -> None: + app.add_middleware(AuditMiddleware)