feat: Implement multi-tenancy support in middleware and security layers

- Enhanced TenantMiddleware to validate tenant information from JWT tokens.
- Added LicenseValidationMiddleware to check tenant licenses before processing requests.
- Updated security utilities to extract tenant information from tokens and validate company access.
- Introduced CompanyStore to manage active company state and handle company switching in the frontend.
- Modified API routes to include company_id in requests for better resource management.
- Improved logging and error handling throughout the middleware and API layers.
- Updated frontend components to reflect changes in company management and selection.
- Added new API route for fetching user's companies with proper authentication handling.
This commit is contained in:
2025-11-11 14:00:56 -06:00
parent e1eb6bbd01
commit 52b8fcd434
242 changed files with 7067 additions and 3274 deletions

View File

@@ -4,6 +4,7 @@ Middleware personalizado para Anexo76
- Gestión de multi-tenancy
- Logging de requests
"""
from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware
from typing import Callable
@@ -22,58 +23,59 @@ class TenantMiddleware(BaseHTTPMiddleware):
"""
Middleware para identificar y validar el tenant en cada request
"""
async def dispatch(self, request: Request, call_next: Callable):
# Rutas públicas que no requieren tenant
# Permitir acceso sin autenticación a rutas de documentación y salud
doc_prefixes = [
"/api/redoc",
"/api/openapi.json"
]
public_prefixes = [
"/api/v1/auth",
"/api/v1/status",
"/api/health",
"/api/"
]
doc_prefixes = ["/api/redoc", "/api/openapi.json"]
public_prefixes = ["/api/v1/auth", "/api/v1/status", "/api/health", "/api/"]
path = request.url.path
# Permitir cualquier subruta de docs/redoc/openapi.json (por ejemplo, /api/docs, /api/docs/, /api/docs/oauth2-redirect)
if any(path == prefix or path.startswith(prefix + "/") for prefix in doc_prefixes):
if any(
path == prefix or path.startswith(prefix + "/") for prefix in doc_prefixes
):
return await call_next(request)
# Permitir rutas públicas exactas o con prefijo
if any(path == prefix or (prefix != "/" and path.startswith(prefix)) for prefix in public_prefixes):
if any(
path == prefix or (prefix != "/" and path.startswith(prefix))
for prefix in public_prefixes
):
return await call_next(request)
# Extraer token y obtener tenant
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or invalid authorization header")
raise HTTPException(
status_code=401, detail="Missing or invalid authorization header"
)
token = auth_header.split(" ")[1]
try:
user_info = verify_token(token)
tenant_id = get_tenant_from_token(user_info)
# ⚠️ NOTA: tenant_id puede ser None para usuarios SSO que aún no tienen tenant asignado
# En ese caso, el endpoint específico deberá manejarlo
if not tenant_id:
logger.warning(f"⚠️ Token sin tenant_id para usuario: {user_info.get('sub', 'unknown')}")
logger.warning(
f"⚠️ Token sin tenant_id para usuario: {user_info.get('sub', 'unknown')}"
)
# No lanzamos error aquí, dejamos que el endpoint decida qué hacer
# Agregar tenant_id al state del request (puede ser None)
request.state.tenant_id = tenant_id
request.state.user_info = user_info
except HTTPException:
# Re-lanzar HTTPException directamente
raise
except Exception as e:
logger.error(f"❌ Tenant validation error: {str(e)}")
raise HTTPException(status_code=401, detail="Invalid authentication")
response = await call_next(request)
return response
@@ -82,58 +84,60 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
"""
Middleware para validar la licencia del tenant antes de procesar requests
"""
async def dispatch(self, request: Request, call_next: Callable):
if not settings.LICENSE_CHECK_ENABLED:
return await call_next(request)
# Rutas que no requieren validación de licencia
exempt_paths = [
"/api/docs",
"/api/docs",
"/api/redoc",
"/openapi.json",
"/api/v1/auth",
"/api/v1/auth",
"/openapi.json",
"/api/v1/auth",
"/api/v1/auth",
"/api/v1/status",
"/api/v1/status",
"/api/health",
"/api/"
"/api/",
]
# Verificar si la ruta está exenta (comparación exacta o prefijo)
is_exempt = False
for path in exempt_paths:
if request.url.path == path or (path != "/" and request.url.path.startswith(path)):
if request.url.path == path or (
path != "/" and request.url.path.startswith(path)
):
is_exempt = True
break
if is_exempt:
return await call_next(request)
# Obtener tenant_id del request state (debe ser seteado por TenantMiddleware)
tenant_id = getattr(request.state, "tenant_id", None)
if not tenant_id:
return await call_next(request) # Dejamos que TenantMiddleware maneje esto
# Validar licencia
db = CoreSessionLocal()
try:
# Importar aquí para evitar imports circulares
from api.v1.modules.a76.licenses.service import LicenseService
license_service = LicenseService(db)
license_info = license_service.validate_license(tenant_id)
if not license_info["is_valid"]:
raise HTTPException(
status_code=402,
detail=f"License validation failed: {license_info['reason']}"
detail=f"License validation failed: {license_info['reason']}",
)
# Agregar info de licencia al request state
request.state.license_info = license_info
except HTTPException:
raise
except Exception as e:
@@ -141,7 +145,7 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
raise HTTPException(status_code=500, detail="License validation error")
finally:
db.close()
response = await call_next(request)
return response
@@ -150,15 +154,15 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
"""
Middleware para logging de requests
"""
async def dispatch(self, request: Request, call_next: Callable):
start_time = time.time()
# Log request
logger.info(f"Request: {request.method} {request.url.path}")
response = await call_next(request)
# Log response
process_time = time.time() - start_time
logger.info(
@@ -166,8 +170,8 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
f"Status: {response.status_code} "
f"Duration: {process_time:.3f}s"
)
# Agregar header con tiempo de procesamiento
response.headers["X-Process-Time"] = str(process_time)
return response