""" 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 from app.core.database import AsyncSessionLocal 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/auth/login", "/v1/auth/login", "/api/v1/auth/refresh", "/v1/auth/refresh", "/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") # Si no hay headers de tenant if not tenant_id and not tenant_slug: if settings.ENVIRONMENT == "production": return JSONResponse( status_code=400, content={"detail": "Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"} ) # En desarrollo, continuar sin tenant con advertencia logger.warning( "Request without tenant information", path=request.url.path, method=request.method, ) return await call_next(request) # Validar tenant contra la base de datos try: async with AsyncSessionLocal() as session: if tenant_id: result = await session.execute( select(Tenant).where(Tenant.id == tenant_id) ) 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)