Restructure with Turborepo
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from src.core.logging import get_logger
|
||||
from src.domain.entities import AuditLogEntity
|
||||
from src.domain.interfaces import IAuditLogRepository
|
||||
from src.infrastructure.database.session import async_session_factory
|
||||
|
||||
logger = get_logger("audit_middleware")
|
||||
|
||||
WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
IGNORED_PATHS = {"/docs", "/redoc", "/openapi.json", "/metrics", "/api/v1/system/health"}
|
||||
|
||||
|
||||
class AuditMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
response = await call_next(request)
|
||||
|
||||
if request.method in WRITE_METHODS and not self._should_ignore(request.url.path):
|
||||
try:
|
||||
await self._record_audit(request, response)
|
||||
except Exception as exc:
|
||||
logger.warning("audit_log_failed", extra={"error": str(exc)})
|
||||
|
||||
return response
|
||||
|
||||
def _should_ignore(self, path: str) -> bool:
|
||||
return any(path.startswith(p) for p in IGNORED_PATHS)
|
||||
|
||||
async def _record_audit(self, request: Request, response: Response) -> None:
|
||||
actor_id = None
|
||||
actor_type = "anonymous"
|
||||
|
||||
if hasattr(request.state, "user") and request.state.user:
|
||||
user = request.state.user
|
||||
try:
|
||||
actor_id = uuid.UUID(user.subject)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
actor_type = user.role.value if hasattr(user.role, "value") else str(user.role)
|
||||
|
||||
resource_type = self._extract_resource_type(request.url.path)
|
||||
action = f"{request.method.lower()}_{resource_type}"
|
||||
resource_id = self._extract_resource_id(request.url.path)
|
||||
ip_address = request.client.host if request.client else None
|
||||
user_agent = request.headers.get("user-agent", "")
|
||||
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
from src.infrastructure.database.repositories import AuditLogRepository
|
||||
repo = AuditLogRepository(session)
|
||||
entity = AuditLogEntity(
|
||||
actor_id=actor_id,
|
||||
actor_type=actor_type,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
)
|
||||
await repo.add(entity)
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
logger.warning("audit_db_write_failed", extra={"error": str(exc)})
|
||||
|
||||
def _extract_resource_type(self, path: str) -> str:
|
||||
parts = path.strip("/").split("/")
|
||||
if len(parts) >= 3:
|
||||
return parts[2]
|
||||
if len(parts) >= 1:
|
||||
return parts[-1]
|
||||
return "unknown"
|
||||
|
||||
def _extract_resource_id(self, path: str) -> str | None:
|
||||
parts = path.strip("/").split("/")
|
||||
for part in reversed(parts[3:] if len(parts) > 3 else parts):
|
||||
try:
|
||||
uuid.UUID(part)
|
||||
return part
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
@@ -0,0 +1,36 @@
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from src.core.logging import get_logger
|
||||
|
||||
logger = get_logger("request_logging")
|
||||
|
||||
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
|
||||
request.state.request_id = request_id
|
||||
|
||||
start = time.perf_counter()
|
||||
response = await call_next(request)
|
||||
duration_ms = (time.perf_counter() - start) * 1000
|
||||
|
||||
logger.info(
|
||||
"request_completed",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status_code": response.status_code,
|
||||
"duration_ms": round(duration_ms, 2),
|
||||
"client_ip": request.client.host if request.client else None,
|
||||
"user_agent": request.headers.get("user-agent", ""),
|
||||
},
|
||||
)
|
||||
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
return response
|
||||
@@ -0,0 +1,53 @@
|
||||
import time
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
|
||||
from src.infrastructure.config.settings import settings
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(self, app, requests_per_minute: int | None = None):
|
||||
super().__init__(app)
|
||||
self._rpm = requests_per_minute or settings.RATE_LIMIT_PER_MINUTE
|
||||
self._clients: dict[str, list[float]] = {}
|
||||
|
||||
async def dispatch(
|
||||
self, request: Request, call_next: RequestResponseEndpoint
|
||||
) -> Response:
|
||||
if request.method == "OPTIONS":
|
||||
return await call_next(request)
|
||||
|
||||
path = request.url.path
|
||||
if path.startswith("/docs") or path.startswith("/redoc") or path.startswith("/openapi") or path.startswith("/metrics"):
|
||||
return await call_next(request)
|
||||
|
||||
client_key = self._get_client_key(request)
|
||||
now = time.monotonic()
|
||||
window = self._clients.setdefault(client_key, [])
|
||||
window[:] = [t for t in window if now - t < 60]
|
||||
|
||||
if len(window) >= self._rpm:
|
||||
return Response(
|
||||
content='{"error":{"code":"RATE_LIMIT_EXCEEDED","message":"Rate limit exceeded. Try again later."}}',
|
||||
status_code=429,
|
||||
media_type="application/json",
|
||||
headers={"Retry-After": "60"},
|
||||
)
|
||||
|
||||
window.append(now)
|
||||
response = await call_next(request)
|
||||
remaining = self._rpm - len(window)
|
||||
response.headers["X-RateLimit-Limit"] = str(self._rpm)
|
||||
response.headers["X-RateLimit-Remaining"] = str(max(0, remaining))
|
||||
response.headers["X-RateLimit-Reset"] = str(int(time.time()) + 60)
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _get_client_key(request: Request) -> str:
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
if request.client:
|
||||
return request.client.host
|
||||
return "unknown"
|
||||
Reference in New Issue
Block a user