Files
plantillas-proyectos/backend/core/middleware.py

192 lines
6.1 KiB
Python

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:
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