fix(core): usar token KC (valkey) para listar usuarios del tenant, no la sesión local

list_users/stats llamaban al Hub (users-with-info) con el Bearer de la app, que con
el patrón SIWEB es la sesión local (HS256) → el Hub la rechaza (401) → 0 usuarios.
Nuevo core.hub_token.get_hub_access_token: toma el token KC de la sesión (valkey vía
crm_sid) y lo refresca si está por expirar. list_users y stats lo usan para el Hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ernesto Herrera
2026-07-17 16:03:37 -06:00
parent cd3f8c62a4
commit 868724d1f2
2 changed files with 79 additions and 0 deletions

View File

@@ -53,12 +53,17 @@ async def get_user_statistics(
""" """
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"]) tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user)) service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
from core.hub_token import get_hub_access_token
auth_header = request.headers.get("Authorization") or "" auth_header = request.headers.get("Authorization") or ""
token = ( token = (
auth_header[7:].strip() auth_header[7:].strip()
if auth_header.lower().startswith("bearer ") if auth_header.lower().startswith("bearer ")
else auth_header.strip() else auth_header.strip()
) )
kc_token = await get_hub_access_token(request)
if kc_token:
token = kc_token
hub_tid = resolve_hub_tenant_id_for_api( hub_tid = resolve_hub_tenant_id_for_api(
tenant_id, request.headers.get("X-Tenant-Override") tenant_id, request.headers.get("X-Tenant-Override")
) )
@@ -84,12 +89,19 @@ async def list_users(
""" """
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"]) tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user)) service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
# El Bearer de la app puede ser la sesión local (SIWEB), que el Hub no acepta.
# Para listar usuarios del tenant se usa el token KC de la sesión (valkey), refrescado.
from core.hub_token import get_hub_access_token
auth_header = request.headers.get("Authorization") or "" auth_header = request.headers.get("Authorization") or ""
token = ( token = (
auth_header[7:].strip() auth_header[7:].strip()
if auth_header.lower().startswith("bearer ") if auth_header.lower().startswith("bearer ")
else auth_header.strip() else auth_header.strip()
) )
kc_token = await get_hub_access_token(request)
if kc_token:
token = kc_token
hub_tid = resolve_hub_tenant_id_for_api( hub_tid = resolve_hub_tenant_id_for_api(
tenant_id, request.headers.get("X-Tenant-Override") tenant_id, request.headers.get("X-Tenant-Override")
) )

67
backend/core/hub_token.py Normal file
View File

@@ -0,0 +1,67 @@
"""
Obtención de un access token de Keycloak VÁLIDO para llamar a la API del Hub.
Con el patrón de sesión local (SIWEB) el Bearer de la app es un JWT propio (HS256)
que el Hub NO entiende. Para las llamadas server→Hub se usa el token KC guardado en
la sesión (valkey, vía cookie crm_sid), refrescándolo si está por expirar.
"""
import logging
import time
from typing import Optional
import httpx
from jose import jwt
from core.config import settings
from core import session_store
logger = logging.getLogger(__name__)
def _kc_exp_ok(token: str, leeway_seconds: int = 30) -> bool:
"""True si el token KC no está expirado (con margen)."""
try:
claims = jwt.get_unverified_claims(token)
exp = claims.get("exp")
return isinstance(exp, (int, float)) and (int(exp) - int(time.time())) > leeway_seconds
except Exception:
return False
async def get_hub_access_token(request) -> Optional[str]:
"""
Devuelve un access token KC válido tomado de la sesión (valkey vía crm_sid),
refrescándolo contra el Hub si está por expirar. None si no hay sesión.
Best-effort: si el refresh falla, devuelve el token guardado (puede estar vencido).
"""
sid = request.cookies.get("crm_sid") if request is not None else None
if not sid:
return None
sess = session_store.get_session(sid)
if not sess:
return None
access = sess.get("access_token")
refresh = sess.get("refresh_token")
if access and _kc_exp_ok(access):
return access
if refresh:
try:
async with httpx.AsyncClient(timeout=8.0) as client:
r = await client.post(
f"{settings.HUB_URL}api/v1/auth/refresh",
json={"refresh_token": refresh},
)
if r.status_code == 200:
data = r.json()
new_access = data.get("access_token") or access
session_store.update_session_tokens(sid, new_access, data.get("refresh_token") or refresh)
return new_access
logger.info("get_hub_access_token: Hub refresh devolvió %s", r.status_code)
except Exception as exc:
logger.warning("get_hub_access_token: refresh falló: %s", exc)
return access