177 lines
6.1 KiB
Python
177 lines
6.1 KiB
Python
"""
|
|
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) |