diff --git a/backend/api/v1/modules/a76/audit_log/events.py b/backend/api/v1/modules/a76/audit_log/events.py index 3a790310..28f55422 100644 --- a/backend/api/v1/modules/a76/audit_log/events.py +++ b/backend/api/v1/modules/a76/audit_log/events.py @@ -9,7 +9,12 @@ from sqlalchemy.orm import Session from api.v1.modules.a76.general_catalogs.company.models import Company from api.v1.modules.a76.invoices.models import InvoiceHeader -from core.database import rls_company_var, rls_tenant_var +from core.database import ( + RLS_COMPANY_KEY, + RLS_TENANT_KEY, + rls_company_var, + rls_tenant_var, +) from .services.service import AuditService from .utils.serialization import serialize_for_json @@ -56,13 +61,17 @@ def _resolve_audit_company_tenant(session: Session, target) -> tuple: if company_id is not None: resolution_source = "company_self_id" if company_id is None: - company_id = rls_company_var.get() + company_id = session.info.get(RLS_COMPANY_KEY) + if company_id is None: + company_id = rls_company_var.get() if company_id is not None: resolution_source = "rls_context" tenant_id = getattr(target, "tenant_id", None) if tenant_id is None: - tenant_id = rls_tenant_var.get() + tenant_id = session.info.get(RLS_TENANT_KEY) + if tenant_id is None: + tenant_id = rls_tenant_var.get() if tenant_id is not None and resolution_source == "target": resolution_source = "rls_context" diff --git a/backend/api/v1/modules/core/tasks_tracking/dispatch.py b/backend/api/v1/modules/core/tasks_tracking/dispatch.py index a0f60d87..ac25b8e7 100644 --- a/backend/api/v1/modules/core/tasks_tracking/dispatch.py +++ b/backend/api/v1/modules/core/tasks_tracking/dispatch.py @@ -29,8 +29,10 @@ def track_and_dispatch( if company_id is not None: headers["rls_company_id"] = str(int(company_id)) - token_t = rls_tenant_var.set(int(tenant_id)) - token_c = rls_company_var.set(int(company_id) if company_id is not None else None) + prev_tenant = rls_tenant_var.get() + prev_company = rls_company_var.get() + rls_tenant_var.set(int(tenant_id)) + rls_company_var.set(int(company_id) if company_id is not None else None) try: celery_task = task.apply_async( args=args or [], @@ -39,8 +41,8 @@ def track_and_dispatch( headers=headers, ) finally: - rls_tenant_var.reset(token_t) - rls_company_var.reset(token_c) + rls_tenant_var.set(prev_tenant) + rls_company_var.set(prev_company) tracker = TaskTrackerService(db) tracker.register_dispatch( diff --git a/backend/core/database.py b/backend/core/database.py index 95e937d3..ddf0269b 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -137,12 +137,13 @@ def get_core_db(request: Request = None) -> Generator[Session, None, None]: escriben ``db: Session = Depends(get_core_db)`` siguen funcionando sin cambios porque ``Request`` se resuelve en la capa de dependencia. - Replica el mismo ``(tenant_id, company_id)`` en ContextVars para código que - comparte la transacción sin la misma instancia de sesión (p. ej. listeners). + No se escriben las ContextVars de RLS aquí: las dependencias síncronas con + ``yield`` se ejecutan vía ``contextmanager_in_threadpool`` (hilo worker) y + mezclar ``ContextVar.set`` / ``reset`` entre ese hilo y el bucle asyncio + provoca ``ValueError: ... was created in a different Context``. El aislamiento + RLS se aplica con ``session.info`` (véase ``after_begin`` y audit listeners). """ tenant_id, company_id = _extract_rls_context(request) - token_t = rls_tenant_var.set(tenant_id) - token_c = rls_company_var.set(company_id) db = CoreSessionLocal() db.info[RLS_TENANT_KEY] = tenant_id db.info[RLS_COMPANY_KEY] = company_id @@ -150,15 +151,15 @@ def get_core_db(request: Request = None) -> Generator[Session, None, None]: yield db finally: db.close() - rls_tenant_var.reset(token_t) - rls_company_var.reset(token_c) async def get_async_core_db(request: Request = None) -> AsyncGenerator[AsyncSession, None]: """Dependency async para obtener sesión con contexto RLS.""" tenant_id, company_id = _extract_rls_context(request) - token_t = rls_tenant_var.set(tenant_id) - token_c = rls_company_var.set(company_id) + prev_tenant = rls_tenant_var.get() + prev_company = rls_company_var.get() + rls_tenant_var.set(tenant_id) + rls_company_var.set(company_id) try: async with AsyncCoreSessionLocal() as session: session.info[RLS_TENANT_KEY] = tenant_id @@ -168,8 +169,8 @@ async def get_async_core_db(request: Request = None) -> AsyncGenerator[AsyncSess finally: await session.close() finally: - rls_tenant_var.reset(token_t) - rls_company_var.reset(token_c) + rls_tenant_var.set(prev_tenant) + rls_company_var.set(prev_company) @contextmanager diff --git a/backend/core/middleware.py b/backend/core/middleware.py index 654feadf..ff51166f 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -160,117 +160,11 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): logger.info("[license] tenant override propagated to Hub: %s", tenant_override) try: - # Validación contra el Hub Central async with httpx.AsyncClient(timeout=5.0) as client: response = await client.get( f"{settings.HUB_URL}api/v1/auth/verify-license", headers=hub_headers ) - - logger.info(f"🔑 verify-license → status={response.status_code} body={response.text[:300]}") - - if response.status_code == 404: - # Endpoint no existe en este Hub — dejar pasar - return await call_next(request) - - if response.status_code == 200: - data = response.json() - - # Escenario 1: sin licencia asignada o licencia inactiva - if not data.get("valid", False): - message = data.get("message", "Sin licencia asignada para este tenant") - detail = data.get("detail") - reason = data.get("reason") - # Si el Hub reporta token inválido/expirado, devolver 401 para que - # el frontend dispare el auto-refresh (solo se activa con 401/403, no 402). - if _is_token_issue_message(message, detail, reason): - logger.warning( - "[license] token expirado/invalido detectado por verify-license; devolviendo 401 para silent refresh | message=%s detail=%s reason=%s", - message, - detail, - reason, - ) - return JSONResponse( - status_code=401, - content={ - "error": "TOKEN_EXPIRED", - "message": message, - "status_code": 401, - } - ) - - logger.warning( - "[license] licencia invalida para tenant=%s | message=%s", - data.get("tenant_slug"), - message, - ) - return JSONResponse( - status_code=402, - content={ - "error": "LICENSE_ERROR", - "message": message, - "status_code": 402, - } - ) - - # Escenario 2: licencia vencida (verificación local de expires_at) - expires_at_str = data.get("expires_at") - if expires_at_str: - try: - expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00")) - if expires_at.tzinfo is None: - expires_at = expires_at.replace(tzinfo=timezone.utc) - if expires_at < datetime.now(timezone.utc): - logger.warning( - "[license] licencia expirada para tenant=%s | expires_at=%s", - data.get("tenant_slug"), - expires_at_str, - ) - return JSONResponse( - status_code=402, - content={ - "error": "LICENSE_EXPIRED", - "message": f"La licencia venció el {expires_at.strftime('%d/%m/%Y')}. Renueva tu suscripción.", - "status_code": 402, - } - ) - except (ValueError, TypeError): - pass # Si no se puede parsear, dejamos pasar — el Hub es la fuente de verdad - - request.state.license_info = data - return await call_next(request) # <--- Único camino al éxito - - elif response.status_code == 401: - logger.warning("[license] Hub verify-license devolvio 401 (token invalido/expirado)") - return JSONResponse( - status_code=401, - content={ - "error": "TOKEN_EXPIRED", - "message": "Token inválido o expirado.", - "status_code": 401, - } - ) - - elif response.status_code == 403: - return JSONResponse( - status_code=403, - content={ - "error": "FORBIDDEN", - "message": "El Tenant no tiene permisos en el Hub central.", - "status_code": 403, - } - ) - else: - logger.error(f"Hub error status: {response.status_code}") - return JSONResponse( - status_code=503, - content={ - "error": "HUB_ERROR", - "message": "Error en el servidor de licencias.", - "status_code": 503, - } - ) - except (httpx.ConnectError, httpx.TimeoutException) as e: logger.critical(f"❌ CRITICAL: Hub unreachable: {str(e)}") return JSONResponse( @@ -282,12 +176,120 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): } ) except Exception as e: - logger.error(f"Unexpected license error: {str(e)}") + logger.exception("Hub verify-license request failed: %s", e) return JSONResponse( status_code=500, - content={"error": "VALIDATION_ERROR", "message": "Error interno de validación.", "status_code": 500} + content={ + "error": "VALIDATION_ERROR", + "message": "Error interno al contactar el servicio de licencias.", + "status_code": 500, + } ) + logger.info(f"🔑 verify-license → status={response.status_code} body={response.text[:300]}") + + if response.status_code == 404: + # Endpoint no existe en este Hub — dejar pasar + return await call_next(request) + + if response.status_code == 200: + data = response.json() + + # Escenario 1: sin licencia asignada o licencia inactiva + if not data.get("valid", False): + message = data.get("message", "Sin licencia asignada para este tenant") + detail = data.get("detail") + reason = data.get("reason") + # Si el Hub reporta token inválido/expirado, devolver 401 para que + # el frontend dispare el auto-refresh (solo se activa con 401/403, no 402). + if _is_token_issue_message(message, detail, reason): + logger.warning( + "[license] token expirado/invalido detectado por verify-license; devolviendo 401 para silent refresh | message=%s detail=%s reason=%s", + message, + detail, + reason, + ) + return JSONResponse( + status_code=401, + content={ + "error": "TOKEN_EXPIRED", + "message": message, + "status_code": 401, + } + ) + + logger.warning( + "[license] licencia invalida para tenant=%s | message=%s", + data.get("tenant_slug"), + message, + ) + return JSONResponse( + status_code=402, + content={ + "error": "LICENSE_ERROR", + "message": message, + "status_code": 402, + } + ) + + # Escenario 2: licencia vencida (verificación local de expires_at) + expires_at_str = data.get("expires_at") + if expires_at_str: + try: + expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00")) + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + if expires_at < datetime.now(timezone.utc): + logger.warning( + "[license] licencia expirada para tenant=%s | expires_at=%s", + data.get("tenant_slug"), + expires_at_str, + ) + return JSONResponse( + status_code=402, + content={ + "error": "LICENSE_EXPIRED", + "message": f"La licencia venció el {expires_at.strftime('%d/%m/%Y')}. Renueva tu suscripción.", + "status_code": 402, + } + ) + except (ValueError, TypeError): + pass # Si no se puede parsear, dejamos pasar — el Hub es la fuente de verdad + + request.state.license_info = data + return await call_next(request) + + if response.status_code == 401: + logger.warning("[license] Hub verify-license devolvio 401 (token invalido/expirado)") + return JSONResponse( + status_code=401, + content={ + "error": "TOKEN_EXPIRED", + "message": "Token inválido o expirado.", + "status_code": 401, + } + ) + + if response.status_code == 403: + return JSONResponse( + status_code=403, + content={ + "error": "FORBIDDEN", + "message": "El Tenant no tiene permisos en el Hub central.", + "status_code": 403, + } + ) + + logger.error(f"Hub error status: {response.status_code}") + return JSONResponse( + status_code=503, + content={ + "error": "HUB_ERROR", + "message": "Error en el servidor de licencias.", + "status_code": 503, + } + ) + class RequestLoggingMiddleware(BaseHTTPMiddleware): """ diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0edc6fd1..45064db3 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -346,6 +346,8 @@ services: - NODE_ENV=${NODE_ENV:-production} - VITE_API_URL=${VITE_API_URL:-https://anexo76-dev.aduanasoft.com/api} - INTERNAL_API_URL=${INTERNAL_API_URL:-http://backend:8000/api/} + - INTERNAL_HUB_URL=https://workspace.aduanasoft.com + - HUB_URL=https://workspace.aduanasoft.com - VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL:-https://anexo76-dev.aduanasoft.com/kcauth/} - VITE_KEYCLOAK_REALM=${VITE_KEYCLOAK_REALM:-master} - VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID:-anexo76-frontend}