import logging import time from typing import Callable, Optional from fastapi import Request from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware from .config import settings from .database import scoped_core_db from .security import get_tenant_from_token, verify_token logger = logging.getLogger(__name__) def _extract_company_id(request: Request) -> Optional[int]: """Obtiene ``company_id`` activa desde header ``X-Company-Id`` o cookie. El frontend guarda la compañía activa en la cookie ``active_company_id`` (ver ``frontend/src/lib/stores/company.svelte.ts``). El header es la ruta explícita para clientes no-browser. """ header_value = request.headers.get("X-Company-Id") raw = header_value or request.cookies.get("active_company_id") if not raw: return None try: return int(raw) except (TypeError, ValueError): return None class TenantMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next: Callable): doc_prefixes = ["/api/redoc", "/api/openapi.json"] public_prefixes = [ "/api/v1/auth", "/api/v1/status", "/api/health", "/api/", "/uploads", "/api/v1/core/help-center", "/api/v1/core/users/avatar", ] path = request.url.path if any(path == prefix or path.startswith(prefix + "/") for prefix in doc_prefixes): return await call_next(request) if any(path == prefix or (prefix != "/" and path.startswith(prefix)) for prefix in public_prefixes): return await call_next(request) auth_header = request.headers.get("Authorization") if not auth_header or not auth_header.startswith("Bearer "): return JSONResponse( status_code=401, content={ "error": "HTTP_ERROR", "message": "Missing or invalid authorization header", "status_code": 401, } ) token = auth_header.split(" ")[1] try: user_info = verify_token(token) tenant_id = get_tenant_from_token(user_info) request.state.tenant_id = tenant_id request.state.user_info = user_info request.state.company_id = _extract_company_id(request) except Exception as e: logger.error(f"❌ Tenant validation error: {str(e)}") return JSONResponse( status_code=401, content={ "error": "HTTP_ERROR", "message": "Invalid authentication", "status_code": 401, } ) return await call_next(request) 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 or settings.ENVIRONMENT == "development": return await call_next(request) exempt_paths = [ "/api/docs", "/api/redoc", "/openapi.json", "/api/v1/auth", "/api/v1/auth", "/api/v1/status", "/api/v1/status", "/api/health", "/api/", "/api/v1/core/help-center", "/api/v1/core/users/avatar", ] is_exempt = False for path in exempt_paths: 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) tenant_id = getattr(request.state, "tenant_id", None) if not tenant_id: return await call_next(request) # core.licenses / core.license_usage están bajo RLS por tenant_id: # se abre la sesión con contexto explícito para que LicenseService # vea las filas del tenant actual. try: with scoped_core_db(tenant_id=tenant_id) as db: from api.v1.modules.core.licenses.service import LicenseService license_service = LicenseService(db) license_info = license_service.validate_license(tenant_id) if not license_info["is_valid"]: return JSONResponse( status_code=402, content={ "error": "HTTP_ERROR", "message": f"License validation failed: {license_info['reason']}", "status_code": 402, } ) request.state.license_info = license_info except Exception as e: logger.error(f"License validation error: {str(e)}") return JSONResponse( status_code=500, content={ "error": "HTTP_ERROR", "message": "License validation error", "status_code": 500, } ) return await call_next(request) class RequestLoggingMiddleware(BaseHTTPMiddleware): """Middleware para logging de requests.""" async def dispatch(self, request: Request, call_next: Callable): start_time = time.time() excluded_paths = [ "/api/docs", "/api/redoc", "/openapi.json", "/api/v1/status", "/api/health", ] if any( request.url.path == path or request.url.path.startswith(path + "/") for path in excluded_paths ): return await call_next(request) logger.info(f"Request: {request.method} {request.url.path}") response = await call_next(request) process_time = time.time() - start_time logger.info( f"Response: {request.method} {request.url.path} " f"Status: {response.status_code} " f"Duration: {process_time:.3f}s" ) response.headers["X-Process-Time"] = str(process_time) return response