""" Tenant Middleware - ServiceManagerWeb Middleware para manejo de multi-tenancy """ from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import Response, JSONResponse from sqlalchemy import select import structlog import uuid from app.core.database import AsyncSessionLocal, get_db from app.core.config import get_settings from app.models.tenant import Tenant, TenantStatus logger = structlog.get_logger(__name__) settings = get_settings() class TenantMiddleware(BaseHTTPMiddleware): """ Middleware para extraer y validar información del tenant. Extrae el tenant_id del header X-Tenant-ID o el slug del header X-Tenant-Slug, valida que exista en la base de datos y que esté activo, y almacena el objeto Tenant en request.state.tenant. """ # Rutas que no requieren tenant EXCLUDED_PATHS = { "/health", "/api/v1/health", "/v1/health", "/api/v1/health/detailed", "/v1/health/detailed", "/", "/api/v1/auth/login", "/v1/auth/login", "/api/v1/auth/refresh", "/v1/auth/refresh", "/api/v1/auth/logout", "/v1/auth/logout", "/api/v1/auth/forgot-password", "/v1/auth/forgot-password", "/api/v1/auth/reset-password", "/v1/auth/reset-password", "/docs", "/api/v1/docs", "/v1/docs", "/openapi.json", "/api/v1/openapi.json", "/v1/openapi.json", "/redoc", "/api/v1/redoc", "/v1/redoc", } async def dispatch(self, request: Request, call_next) -> Response: """Valida el tenant en cada request y lo almacena en request.state.""" # Inicializar state con valores por defecto request.state.tenant = None request.state.tenant_id = None request.state.tenant_slug = None # Saltar validación en rutas excluidas if request.url.path in self.EXCLUDED_PATHS or request.url.path.startswith("/docs"): return await call_next(request) # Extraer headers de tenant tenant_id = request.headers.get("X-Tenant-ID") tenant_slug = request.headers.get("X-Tenant-Slug") tenant_uuid: uuid.UUID | None = None if tenant_id: try: tenant_uuid = uuid.UUID(tenant_id) except ValueError: return JSONResponse( status_code=400, content={"detail": "Invalid X-Tenant-ID header (must be UUID)"}, ) # Si no hay headers de tenant (requerido para aislamiento multi-tenant) if not tenant_id and not tenant_slug: return JSONResponse( status_code=400, content={"detail": "Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"}, ) # Validar tenant contra la base de datos try: # Prefer DB session coming from dependency overrides (tests) when available. # Guard: in unit tests request.app may be a MagicMock, not a real FastAPI app. dependency_overrides = getattr(request.app, "dependency_overrides", None) override_get_db = None if isinstance(dependency_overrides, dict): override_get_db = dependency_overrides.get(get_db) if override_get_db is not None: agen = override_get_db() session = await agen.__anext__() try: if tenant_uuid is not None: result = await session.execute( select(Tenant).where(Tenant.id == tenant_uuid) ) else: result = await session.execute( select(Tenant).where(Tenant.slug == tenant_slug) ) tenant = result.scalars().first() finally: await agen.aclose() else: async with AsyncSessionLocal() as session: if tenant_uuid is not None: result = await session.execute( select(Tenant).where(Tenant.id == tenant_uuid) ) else: result = await session.execute( select(Tenant).where(Tenant.slug == tenant_slug) ) tenant = result.scalars().first() if tenant is None: logger.warning( "Tenant not found", tenant_id=tenant_id, tenant_slug=tenant_slug, path=request.url.path, ) return JSONResponse( status_code=404, content={"detail": "Tenant not found"} ) if tenant.status != TenantStatus.ACTIVE: logger.warning( "Tenant is not active", tenant_id=str(tenant.id), tenant_slug=tenant.slug, status=tenant.status, path=request.url.path, ) return JSONResponse( status_code=403, content={"detail": f"Tenant is {tenant.status.value}"} ) # Almacenar tenant validado en el state request.state.tenant = tenant request.state.tenant_id = str(tenant.id) request.state.tenant_slug = tenant.slug logger.debug( "Tenant validated", tenant_id=str(tenant.id), tenant_slug=tenant.slug, path=request.url.path, ) except Exception as exc: logger.error( "Error validating tenant", error=str(exc), path=request.url.path, ) return JSONResponse( status_code=503, content={"detail": "Service temporarily unavailable"} ) return await call_next(request)