Merge pull request 'fix/rls-fix' (#379) from fix/rls-fix into development

Reviewed-on: ADUANASOFT/anexo76#379
This commit is contained in:
2026-05-08 20:36:46 +00:00
6 changed files with 150 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.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"

View File

@@ -3,6 +3,7 @@ from typing import Dict, Any, Optional
from core.config import settings
from core.database import get_core_db
from core.exceptions import BaseAPIException
from core.security import collect_user_role_names, get_current_user, validate_access_to_resource
from fastapi import APIRouter, Depends, HTTPException, Query, Path
from sqlalchemy import func, or_, and_
@@ -69,6 +70,8 @@ def get_creation_data(
return InvoiceCatalogService.get_creation_data(db, tenant_id, company_id)
except HTTPException:
raise
except BaseAPIException:
raise
except Exception as e:
logger.exception("get_creation_data failed: %s", e)
raise HTTPException(status_code=500, detail=f"Error al cargar datos de creación: {str(e)}")
@@ -97,6 +100,8 @@ def get_edition_data(
return data
except HTTPException:
raise
except BaseAPIException:
raise
except Exception as e:
logger.exception("get_edition_data failed: %s", e)
raise HTTPException(status_code=500, detail=f"Error al cargar datos de edición: {str(e)}")
@@ -191,6 +196,8 @@ def create_invoice(
return services.InvoiceService.create(db, data, tenant_id, company_id)
except HTTPException:
raise
except BaseAPIException:
raise
except Exception as e:
logger.exception("create_invoice failed: %s", e)
raise HTTPException(status_code=500, detail=f"Error al guardar factura: {str(e)}")
@@ -337,6 +344,8 @@ def list_invoices(
"page": page,
"page_size": page_size
}
except BaseAPIException:
raise
except Exception as e:
logger.exception("list_invoices failed: %s", e)
raise HTTPException(status_code=500, detail=f"Internal server error in invoices list: {str(e)}")

View File

@@ -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(

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

View File

@@ -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):
"""

View File

@@ -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}