Spaces:
Sleeping
Sleeping
Phase 2: Implement Audit Middleware
Browse filesCore implementation:
- Created services/audit_service/ package
- AuditServiceConfig: Path exclusion, log type mapping
- AuditMiddleware: Automatic request/response logging
- Integrated into app.py middleware chain
Middleware order: Auth β Audit β Credit β Application
Features:
- Automatic logging of all requests/responses
- Configurable path exclusions (/health, /docs, etc.)
- No manual audit calls needed in endpoints
- Async logging doesn't block responses
- Duration tracking for performance monitoring
Next: Testing and removing manual audit calls
- app.py +19 -1
- services/audit_service/__init__.py +20 -0
- services/audit_service/config.py +105 -0
- services/audit_service/middleware.py +133 -0
app.py
CHANGED
|
@@ -118,6 +118,20 @@ async def lifespan(app: FastAPI):
|
|
| 118 |
)
|
| 119 |
logger.info("β
Credit Service configured")
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
# Check for RESET_DB environment variable
|
| 122 |
if os.getenv("RESET_DB", "").lower() == "true":
|
| 123 |
logger.warning(f"RESET_DB is set to true. Skipping download and clearing local database ({DB_FILENAME}).")
|
|
@@ -180,7 +194,11 @@ app.add_middleware(
|
|
| 180 |
allow_headers=["*"],
|
| 181 |
)
|
| 182 |
|
| 183 |
-
# Add
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
from services.credit_service import CreditMiddleware
|
| 185 |
app.add_middleware(CreditMiddleware)
|
| 186 |
|
|
|
|
| 118 |
)
|
| 119 |
logger.info("β
Credit Service configured")
|
| 120 |
|
| 121 |
+
# Register Audit Service configuration
|
| 122 |
+
from services.audit_service import AuditServiceConfig
|
| 123 |
+
AuditServiceConfig.register(
|
| 124 |
+
excluded_paths=[
|
| 125 |
+
"/health",
|
| 126 |
+
"/docs",
|
| 127 |
+
"/openapi.json",
|
| 128 |
+
"/redoc"
|
| 129 |
+
],
|
| 130 |
+
log_all_requests=True,
|
| 131 |
+
log_response_bodies=False # Privacy: don't log response bodies
|
| 132 |
+
)
|
| 133 |
+
logger.info("β
Audit Service configured")
|
| 134 |
+
|
| 135 |
# Check for RESET_DB environment variable
|
| 136 |
if os.getenv("RESET_DB", "").lower() == "true":
|
| 137 |
logger.warning(f"RESET_DB is set to true. Skipping download and clearing local database ({DB_FILENAME}).")
|
|
|
|
| 194 |
allow_headers=["*"],
|
| 195 |
)
|
| 196 |
|
| 197 |
+
# Add Audit Middleware (executes second - after auth, before credit)
|
| 198 |
+
from services.audit_service import AuditMiddleware
|
| 199 |
+
app.add_middleware(AuditMiddleware)
|
| 200 |
+
|
| 201 |
+
# Add Credit Middleware (executes third - after auth and audit)
|
| 202 |
from services.credit_service import CreditMiddleware
|
| 203 |
app.add_middleware(CreditMiddleware)
|
| 204 |
|
services/audit_service/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Audit Service Package
|
| 3 |
+
|
| 4 |
+
Provides automatic request/response logging via middleware.
|
| 5 |
+
"""
|
| 6 |
+
from services.audit_service.config import AuditServiceConfig
|
| 7 |
+
from services.audit_service.middleware import AuditMiddleware
|
| 8 |
+
|
| 9 |
+
# Import the existing AuditService from parent
|
| 10 |
+
import sys
|
| 11 |
+
import os
|
| 12 |
+
# Add parent directory to path to import audit_service.py
|
| 13 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
| 14 |
+
from audit_service import AuditService
|
| 15 |
+
|
| 16 |
+
__all__ = [
|
| 17 |
+
'AuditServiceConfig',
|
| 18 |
+
'AuditMiddleware',
|
| 19 |
+
'AuditService',
|
| 20 |
+
]
|
services/audit_service/config.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Audit Service Configuration
|
| 3 |
+
|
| 4 |
+
Configures automatic request/response logging via middleware.
|
| 5 |
+
"""
|
| 6 |
+
from typing import Dict, List, Optional, Set
|
| 7 |
+
import logging
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class AuditServiceConfig:
|
| 13 |
+
"""Configuration for automatic audit logging."""
|
| 14 |
+
|
| 15 |
+
_excluded_paths: Set[str] = set()
|
| 16 |
+
_log_all_requests: bool = True
|
| 17 |
+
_log_response_bodies: bool = False
|
| 18 |
+
_batch_size: int = 10
|
| 19 |
+
_log_types: Dict[str, str] = {}
|
| 20 |
+
|
| 21 |
+
@classmethod
|
| 22 |
+
def register(
|
| 23 |
+
cls,
|
| 24 |
+
excluded_paths: Optional[List[str]] = None,
|
| 25 |
+
log_all_requests: bool = True,
|
| 26 |
+
log_response_bodies: bool = False,
|
| 27 |
+
batch_size: int = 10,
|
| 28 |
+
log_types: Optional[Dict[str, str]] = None
|
| 29 |
+
) -> None:
|
| 30 |
+
"""
|
| 31 |
+
Register audit service configuration.
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
excluded_paths: Paths to exclude from logging (e.g., /health, /docs)
|
| 35 |
+
log_all_requests: If True, log all requests. If False, only log errors
|
| 36 |
+
log_response_bodies: If True, include response body in logs (privacy risk!)
|
| 37 |
+
batch_size: Number of logs to batch before committing
|
| 38 |
+
log_types: Map of path patterns to log types (client/server)
|
| 39 |
+
|
| 40 |
+
Example:
|
| 41 |
+
AuditServiceConfig.register(
|
| 42 |
+
excluded_paths=["/health", "/docs", "/openapi.json"],
|
| 43 |
+
log_all_requests=True,
|
| 44 |
+
log_response_bodies=False
|
| 45 |
+
)
|
| 46 |
+
"""
|
| 47 |
+
cls._excluded_paths = set(excluded_paths or [])
|
| 48 |
+
cls._log_all_requests = log_all_requests
|
| 49 |
+
cls._log_response_bodies = log_response_bodies
|
| 50 |
+
cls._batch_size = batch_size
|
| 51 |
+
cls._log_types = log_types or {}
|
| 52 |
+
|
| 53 |
+
logger.info(
|
| 54 |
+
f"Audit Service configured: "
|
| 55 |
+
f"excluded_paths={len(cls._excluded_paths)}, "
|
| 56 |
+
f"log_all={log_all_requests}, "
|
| 57 |
+
f"log_bodies={log_response_bodies}"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
@classmethod
|
| 61 |
+
def is_excluded(cls, path: str) -> bool:
|
| 62 |
+
"""Check if a path should be excluded from logging."""
|
| 63 |
+
# Exact match
|
| 64 |
+
if path in cls._excluded_paths:
|
| 65 |
+
return True
|
| 66 |
+
|
| 67 |
+
# Prefix match for wildcard patterns
|
| 68 |
+
for excluded in cls._excluded_paths:
|
| 69 |
+
if excluded.endswith("*") and path.startswith(excluded[:-1]):
|
| 70 |
+
return True
|
| 71 |
+
|
| 72 |
+
return False
|
| 73 |
+
|
| 74 |
+
@classmethod
|
| 75 |
+
def should_log(cls, path: str, status_code: int) -> bool:
|
| 76 |
+
"""Determine if request should be logged."""
|
| 77 |
+
if cls.is_excluded(path):
|
| 78 |
+
return False
|
| 79 |
+
|
| 80 |
+
if cls._log_all_requests:
|
| 81 |
+
return True
|
| 82 |
+
|
| 83 |
+
# Only log errors if not logging all
|
| 84 |
+
return status_code >= 400
|
| 85 |
+
|
| 86 |
+
@classmethod
|
| 87 |
+
def get_log_type(cls, path: str) -> str:
|
| 88 |
+
"""Get log type for a path (client/server)."""
|
| 89 |
+
for pattern, log_type in cls._log_types.items():
|
| 90 |
+
if pattern in path or path.startswith(pattern):
|
| 91 |
+
return log_type
|
| 92 |
+
|
| 93 |
+
# Default to server log type
|
| 94 |
+
return "server"
|
| 95 |
+
|
| 96 |
+
@classmethod
|
| 97 |
+
def get_config(cls) -> dict:
|
| 98 |
+
"""Get current configuration."""
|
| 99 |
+
return {
|
| 100 |
+
"excluded_paths": list(cls._excluded_paths),
|
| 101 |
+
"log_all_requests": cls._log_all_requests,
|
| 102 |
+
"log_response_bodies": cls._log_response_bodies,
|
| 103 |
+
"batch_size": cls._batch_size,
|
| 104 |
+
"log_types": cls._log_types
|
| 105 |
+
}
|
services/audit_service/middleware.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Audit Middleware - Automatic request/response logging
|
| 3 |
+
|
| 4 |
+
Automatically logs all API requests and responses to AuditLog table.
|
| 5 |
+
Similar to CreditMiddleware pattern.
|
| 6 |
+
"""
|
| 7 |
+
import time
|
| 8 |
+
import logging
|
| 9 |
+
from typing import Optional
|
| 10 |
+
from fastapi import Request, Response
|
| 11 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 12 |
+
from starlette.types import ASGIApp
|
| 13 |
+
|
| 14 |
+
from core.database import async_session_maker
|
| 15 |
+
from services.audit_service.config import AuditServiceConfig
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class AuditMiddleware(BaseHTTPMiddleware):
|
| 21 |
+
"""
|
| 22 |
+
Middleware for automatic audit logging.
|
| 23 |
+
|
| 24 |
+
Logs all requests/responses unless excluded in configuration.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def __init__(self, app: ASGIApp):
|
| 28 |
+
super().__init__(app)
|
| 29 |
+
|
| 30 |
+
async def dispatch(self, request: Request, call_next):
|
| 31 |
+
"""
|
| 32 |
+
Process request and automatically log.
|
| 33 |
+
|
| 34 |
+
Flow:
|
| 35 |
+
1. Check if path should be logged
|
| 36 |
+
2. Capture request metadata
|
| 37 |
+
3. Process request
|
| 38 |
+
4. Log based on response
|
| 39 |
+
"""
|
| 40 |
+
path = request.url.path
|
| 41 |
+
method = request.method
|
| 42 |
+
|
| 43 |
+
# Skip excluded paths
|
| 44 |
+
if AuditServiceConfig.is_excluded(path):
|
| 45 |
+
return await call_next(request)
|
| 46 |
+
|
| 47 |
+
# Skip OPTIONS requests (CORS preflight)
|
| 48 |
+
if method == "OPTIONS":
|
| 49 |
+
return await call_next(request)
|
| 50 |
+
|
| 51 |
+
# Capture request start time
|
| 52 |
+
start_time = time.time()
|
| 53 |
+
|
| 54 |
+
# Get user if authenticated (set by AuthMiddleware)
|
| 55 |
+
user = getattr(request.state, 'user', None)
|
| 56 |
+
user_id = user.id if user else None
|
| 57 |
+
|
| 58 |
+
# Process request
|
| 59 |
+
response = await call_next(request)
|
| 60 |
+
|
| 61 |
+
# Calculate duration
|
| 62 |
+
duration_ms = (time.time() - start_time) * 1000
|
| 63 |
+
|
| 64 |
+
# Determine if we should log
|
| 65 |
+
if AuditServiceConfig.should_log(path, response.status_code):
|
| 66 |
+
# Log asynchronously (don't block response)
|
| 67 |
+
try:
|
| 68 |
+
await self._log_request(
|
| 69 |
+
request=request,
|
| 70 |
+
response=response,
|
| 71 |
+
user_id=user_id,
|
| 72 |
+
duration_ms=duration_ms
|
| 73 |
+
)
|
| 74 |
+
except Exception as e:
|
| 75 |
+
# Don't fail request if logging fails
|
| 76 |
+
logger.error(f"Failed to log request: {e}", exc_info=True)
|
| 77 |
+
|
| 78 |
+
return response
|
| 79 |
+
|
| 80 |
+
async def _log_request(
|
| 81 |
+
self,
|
| 82 |
+
request: Request,
|
| 83 |
+
response: Response,
|
| 84 |
+
user_id: Optional[int],
|
| 85 |
+
duration_ms: float
|
| 86 |
+
):
|
| 87 |
+
"""Log request to database."""
|
| 88 |
+
from services.audit_service import AuditService
|
| 89 |
+
|
| 90 |
+
# Determine action from method + path
|
| 91 |
+
action = f"{request.method}:{request.url.path}"
|
| 92 |
+
|
| 93 |
+
# Determine status
|
| 94 |
+
if response.status_code < 400:
|
| 95 |
+
status = "success"
|
| 96 |
+
else:
|
| 97 |
+
status = "failure"
|
| 98 |
+
|
| 99 |
+
# Get log type from config
|
| 100 |
+
log_type = AuditServiceConfig.get_log_type(request.url.path)
|
| 101 |
+
|
| 102 |
+
# Build details
|
| 103 |
+
details = {
|
| 104 |
+
"method": request.method,
|
| 105 |
+
"path": str(request.url.path),
|
| 106 |
+
"query_params": dict(request.query_params),
|
| 107 |
+
"status_code": response.status_code,
|
| 108 |
+
"duration_ms": round(duration_ms, 2)
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
# Add response body if configured (privacy risk!)
|
| 112 |
+
if AuditServiceConfig._log_response_bodies:
|
| 113 |
+
# Note: This requires streaming the response body
|
| 114 |
+
# For now, skip this to avoid complexity
|
| 115 |
+
pass
|
| 116 |
+
|
| 117 |
+
# Create database session and log
|
| 118 |
+
async with async_session_maker() as db:
|
| 119 |
+
try:
|
| 120 |
+
await AuditService.log_event(
|
| 121 |
+
db=db,
|
| 122 |
+
action=action,
|
| 123 |
+
status=status,
|
| 124 |
+
user_id=user_id,
|
| 125 |
+
client_user_id=None,
|
| 126 |
+
details=details,
|
| 127 |
+
request=request,
|
| 128 |
+
log_type=log_type
|
| 129 |
+
)
|
| 130 |
+
await db.commit()
|
| 131 |
+
except Exception as e:
|
| 132 |
+
logger.error(f"Failed to commit audit log: {e}")
|
| 133 |
+
await db.rollback()
|