Update environment variables in docker-compose and refactor RLS context handling in database and middleware. Added error handling for Hub license verification.

This commit is contained in:
2026-05-08 13:51:24 -05:00
parent 9bcbc5bc02
commit 9e3274b03a
5 changed files with 141 additions and 125 deletions

View File

@@ -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.general_catalogs.company.models import Company
from api.v1.modules.a76.invoices.models import InvoiceHeader 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 .services.service import AuditService
from .utils.serialization import serialize_for_json from .utils.serialization import serialize_for_json
@@ -55,12 +60,16 @@ def _resolve_audit_company_tenant(session: Session, target) -> tuple:
company_id = getattr(target, "id", None) company_id = getattr(target, "id", None)
if company_id is not None: if company_id is not None:
resolution_source = "company_self_id" resolution_source = "company_self_id"
if company_id is None:
company_id = session.info.get(RLS_COMPANY_KEY)
if company_id is None: if company_id is None:
company_id = rls_company_var.get() company_id = rls_company_var.get()
if company_id is not None: if company_id is not None:
resolution_source = "rls_context" resolution_source = "rls_context"
tenant_id = getattr(target, "tenant_id", None) tenant_id = getattr(target, "tenant_id", None)
if tenant_id is None:
tenant_id = session.info.get(RLS_TENANT_KEY)
if tenant_id is None: if tenant_id is None:
tenant_id = rls_tenant_var.get() tenant_id = rls_tenant_var.get()
if tenant_id is not None and resolution_source == "target": if tenant_id is not None and resolution_source == "target":

View File

@@ -29,8 +29,10 @@ def track_and_dispatch(
if company_id is not None: if company_id is not None:
headers["rls_company_id"] = str(int(company_id)) headers["rls_company_id"] = str(int(company_id))
token_t = rls_tenant_var.set(int(tenant_id)) prev_tenant = rls_tenant_var.get()
token_c = rls_company_var.set(int(company_id) if company_id is not None else None) 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: try:
celery_task = task.apply_async( celery_task = task.apply_async(
args=args or [], args=args or [],
@@ -39,8 +41,8 @@ def track_and_dispatch(
headers=headers, headers=headers,
) )
finally: finally:
rls_tenant_var.reset(token_t) rls_tenant_var.set(prev_tenant)
rls_company_var.reset(token_c) rls_company_var.set(prev_company)
tracker = TaskTrackerService(db) tracker = TaskTrackerService(db)
tracker.register_dispatch( tracker.register_dispatch(

View File

@@ -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 escriben ``db: Session = Depends(get_core_db)`` siguen funcionando sin
cambios porque ``Request`` se resuelve en la capa de dependencia. cambios porque ``Request`` se resuelve en la capa de dependencia.
Replica el mismo ``(tenant_id, company_id)`` en ContextVars para código que No se escriben las ContextVars de RLS aquí: las dependencias síncronas con
comparte la transacción sin la misma instancia de sesión (p. ej. listeners). ``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) 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 = CoreSessionLocal()
db.info[RLS_TENANT_KEY] = tenant_id db.info[RLS_TENANT_KEY] = tenant_id
db.info[RLS_COMPANY_KEY] = company_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 yield db
finally: finally:
db.close() 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]: async def get_async_core_db(request: Request = None) -> AsyncGenerator[AsyncSession, None]:
"""Dependency async para obtener sesión con contexto RLS.""" """Dependency async para obtener sesión con contexto RLS."""
tenant_id, company_id = _extract_rls_context(request) tenant_id, company_id = _extract_rls_context(request)
token_t = rls_tenant_var.set(tenant_id) prev_tenant = rls_tenant_var.get()
token_c = rls_company_var.set(company_id) prev_company = rls_company_var.get()
rls_tenant_var.set(tenant_id)
rls_company_var.set(company_id)
try: try:
async with AsyncCoreSessionLocal() as session: async with AsyncCoreSessionLocal() as session:
session.info[RLS_TENANT_KEY] = tenant_id session.info[RLS_TENANT_KEY] = tenant_id
@@ -168,8 +169,8 @@ async def get_async_core_db(request: Request = None) -> AsyncGenerator[AsyncSess
finally: finally:
await session.close() await session.close()
finally: finally:
rls_tenant_var.reset(token_t) rls_tenant_var.set(prev_tenant)
rls_company_var.reset(token_c) rls_company_var.set(prev_company)
@contextmanager @contextmanager

View File

@@ -160,12 +160,31 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
logger.info("[license] tenant override propagated to Hub: %s", tenant_override) logger.info("[license] tenant override propagated to Hub: %s", tenant_override)
try: try:
# Validación contra el Hub Central
async with httpx.AsyncClient(timeout=5.0) as client: async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get( response = await client.get(
f"{settings.HUB_URL}api/v1/auth/verify-license", f"{settings.HUB_URL}api/v1/auth/verify-license",
headers=hub_headers headers=hub_headers
) )
except (httpx.ConnectError, httpx.TimeoutException) as e:
logger.critical(f"❌ CRITICAL: Hub unreachable: {str(e)}")
return JSONResponse(
status_code=503,
content={
"error": "HUB_OFFLINE",
"message": "Servicio de licencias fuera de línea. Acceso denegado.",
"status_code": 503,
}
)
except Exception as e:
logger.exception("Hub verify-license request failed: %s", e)
return JSONResponse(
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]}") logger.info(f"🔑 verify-license → status={response.status_code} body={response.text[:300]}")
@@ -238,9 +257,9 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
pass # Si no se puede parsear, dejamos pasar — el Hub es la fuente de verdad pass # Si no se puede parsear, dejamos pasar — el Hub es la fuente de verdad
request.state.license_info = data request.state.license_info = data
return await call_next(request) # <--- Único camino al éxito return await call_next(request)
elif response.status_code == 401: if response.status_code == 401:
logger.warning("[license] Hub verify-license devolvio 401 (token invalido/expirado)") logger.warning("[license] Hub verify-license devolvio 401 (token invalido/expirado)")
return JSONResponse( return JSONResponse(
status_code=401, status_code=401,
@@ -251,7 +270,7 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
} }
) )
elif response.status_code == 403: if response.status_code == 403:
return JSONResponse( return JSONResponse(
status_code=403, status_code=403,
content={ content={
@@ -260,7 +279,7 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
"status_code": 403, "status_code": 403,
} }
) )
else:
logger.error(f"Hub error status: {response.status_code}") logger.error(f"Hub error status: {response.status_code}")
return JSONResponse( return JSONResponse(
status_code=503, status_code=503,
@@ -271,23 +290,6 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
} }
) )
except (httpx.ConnectError, httpx.TimeoutException) as e:
logger.critical(f"❌ CRITICAL: Hub unreachable: {str(e)}")
return JSONResponse(
status_code=503,
content={
"error": "HUB_OFFLINE",
"message": "Servicio de licencias fuera de línea. Acceso denegado.",
"status_code": 503,
}
)
except Exception as e:
logger.error(f"Unexpected license error: {str(e)}")
return JSONResponse(
status_code=500,
content={"error": "VALIDATION_ERROR", "message": "Error interno de validación.", "status_code": 500}
)
class RequestLoggingMiddleware(BaseHTTPMiddleware): class RequestLoggingMiddleware(BaseHTTPMiddleware):
""" """

View File

@@ -346,6 +346,8 @@ services:
- NODE_ENV=${NODE_ENV:-production} - NODE_ENV=${NODE_ENV:-production}
- VITE_API_URL=${VITE_API_URL:-https://anexo76-dev.aduanasoft.com/api} - VITE_API_URL=${VITE_API_URL:-https://anexo76-dev.aduanasoft.com/api}
- INTERNAL_API_URL=${INTERNAL_API_URL:-http://backend:8000/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_URL=${VITE_KEYCLOAK_URL:-https://anexo76-dev.aduanasoft.com/kcauth/}
- VITE_KEYCLOAK_REALM=${VITE_KEYCLOAK_REALM:-master} - VITE_KEYCLOAK_REALM=${VITE_KEYCLOAK_REALM:-master}
- VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID:-anexo76-frontend} - VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID:-anexo76-frontend}