192 lines
6.1 KiB
Python
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
|