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 555ffd9..9993b29 100644 --- a/api/src/cartsnitch_api/main.py +++ b/api/src/cartsnitch_api/main.py @@ -10,6 +10,7 @@ from cartsnitch_api.database import dispose_engine 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 @@ -43,6 +44,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) diff --git a/package-lock.json b/package-lock.json index 709106e..f15c280 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "tailwindcss": "^4.0.0", "typescript": "^5.7.3", "typescript-eslint": "^8.56.1", - "vite": "^6.3.5", + "vite": "^6.4.2", "vite-plugin-pwa": "^0.21.2", "vitest": "^3.2.4" } @@ -1867,6 +1867,40 @@ "node": ">=18" } }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -2669,6 +2703,25 @@ "node": ">=18" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@noble/ciphers": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", @@ -3659,6 +3712,17 @@ } } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -4166,33 +4230,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, "node_modules/@vitest/pretty-format": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", @@ -9473,6 +9510,14 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -10020,6 +10065,33 @@ } } }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index bd2fd0c..e946241 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "tailwindcss": "^4.0.0", "typescript": "^5.7.3", "typescript-eslint": "^8.56.1", - "vite": "^6.3.5", + "vite": "^6.4.2", "vite-plugin-pwa": "^0.21.2", "vitest": "^3.2.4" },