From ce8b042e84f0e3330616ee10d36751f081062fa6 Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Fri, 17 Jul 2026 12:27:27 -0600 Subject: [PATCH] =?UTF-8?q?fix(auth):=20hornear=20is=5Fhub=5Fadmin=20(auto?= =?UTF-8?q?ritativo=20del=20Hub)=20en=20la=20sesi=C3=B3n=20local?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_company (y otros checks) usan is_hub_admin, pero la sesión local se emitía desde el token KC crudo, que no trae ese claim → el hub_admin sin tenant recibía 403 al crear compañía. Ahora la sesión se emite con los claims de /auth/me del Hub (is_hub_admin, roles), con fallback al decode del token KC si el Hub no responde. Se mantiene intacto el control de autorización (solo hub_admin crea fuera de su tenant). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/api/v1/modules/core/auth/routes.py | 2 ++ backend/api/v1/modules/core/auth/service.py | 31 +++++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/backend/api/v1/modules/core/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py index e313a7e..3dfe7eb 100644 --- a/backend/api/v1/modules/core/auth/routes.py +++ b/backend/api/v1/modules/core/auth/routes.py @@ -559,6 +559,8 @@ async def create_company( if not tenant: raise HTTPException(status_code=404, detail="Tenant no encontrado.") + # Autorización: hub_admin (atestado por el Hub) puede crear en cualquier tenant; + # un usuario ligado a un tenant, solo en el suyo. if not is_hub_admin(current_user): own = resolve_effective_tenant_id_from_user(current_user) if own is None or int(own) != tid: diff --git a/backend/api/v1/modules/core/auth/service.py b/backend/api/v1/modules/core/auth/service.py index 040e220..8c707be 100644 --- a/backend/api/v1/modules/core/auth/service.py +++ b/backend/api/v1/modules/core/auth/service.py @@ -235,7 +235,7 @@ class AuthService: return claims def _session_claims_from_kc(self, data: Dict[str, Any]) -> Dict[str, Any]: - """Construye los claims de la sesión local a partir de la respuesta KC del Hub.""" + """Construye los claims de la sesión local a partir del token KC (decode).""" kc_claims = self._decode_kc_user_from_token(data.get("access_token", "")) claims: Dict[str, Any] = dict(kc_claims) # tenant_id/tenant_slug explícitos del Hub tienen precedencia sobre el token @@ -245,6 +245,33 @@ class AuthService: claims["tenant_slug"] = data.get("tenant_slug") return claims + async def _session_claims(self, data: Dict[str, Any]) -> Dict[str, Any]: + """ + Claims AUTORITATIVOS para la sesión local: se prefiere /auth/me del Hub (trae + is_hub_admin, roles, etc. que el token KC crudo no incluye). Si el Hub no + responde, se cae al decode del token KC. Así la sesión local sabe si el + usuario es hub_admin sin volver a consultar al Hub en cada request. + """ + from core.security import verify_token + + claims: Dict[str, Any] = {} + try: + info = await verify_token(data.get("access_token", "")) + if isinstance(info, dict): + claims = dict(info) + except Exception as exc: + logger.warning("session_claims: /auth/me no disponible, uso decode KC: %s", exc) + + if not claims: + return self._session_claims_from_kc(data) + + # tenant_id/tenant_slug explícitos del Hub tienen precedencia. + if data.get("tenant_id") is not None: + claims["tenant_id"] = data.get("tenant_id") + if data.get("tenant_slug") is not None: + claims["tenant_slug"] = data.get("tenant_slug") + return claims + async def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO: """ Refresca la sesión. @@ -321,7 +348,7 @@ class AuthService: from core import local_session, session_store start = int(prev_sst) if prev_sst else int(datetime.now(timezone.utc).timestamp()) - claims = self._session_claims_from_kc(data) + claims = await self._session_claims(data) new_access = data.get("access_token", "") new_refresh = data.get("refresh_token", "") # Reutiliza la sesión de valkey si ya existía; si no, la crea.