From 02a5ac07ed29ce1cca51b30853b4b90ed7636e47 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Fri, 29 May 2026 13:27:55 -0500 Subject: [PATCH 1/2] fix(invites): logging detallado cuando Hub invite creation falla MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Antes los fallos del Hub login y invite creation eran silenciosos o con warning genérico. Ahora loguea status code, body y nombre de campo para facilitar diagnóstico en producción. - Detecta HUB_ADMIN_EMAIL/PASSWORD vacíos con error explícito - Loguea status+body cuando login falla (401/403) - Loguea status+body+tenant/email cuando invite falla (404/403) Co-Authored-By: Claude Sonnet 4.6 --- .../api/v1/modules/core/invites/service.py | 59 +++++++++++++------ 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/backend/api/v1/modules/core/invites/service.py b/backend/api/v1/modules/core/invites/service.py index 447ad72d..04c6fd1a 100644 --- a/backend/api/v1/modules/core/invites/service.py +++ b/backend/api/v1/modules/core/invites/service.py @@ -78,25 +78,48 @@ class InviteService: # Crear invite en el Hub para que el usuario use el form del workspace hub_invite_token: Optional[str] = None invite_url: str = "" - try: - async with httpx.AsyncClient(timeout=10.0) as client: - login_resp = await client.post( - f"{settings.HUB_URL}api/v1/auth/login", - json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD}, - ) - if login_resp.status_code == 200: - svc_token = login_resp.json().get("access_token", "") - hub_resp = await client.post( - f"{settings.HUB_URL}api/v1/hub/invites", - json={"email": str(data.email), "tenant_slug": tenant_slug}, - headers={"Authorization": f"Bearer {svc_token}"}, + if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD: + logger.error( + "[invite] HUB_ADMIN_EMAIL o HUB_ADMIN_PASSWORD no configurados — " + "el invite NO se creará en el Hub y se usará el fallback local. " + "Configura HUB_ADMIN_EMAIL y HUB_ADMIN_PASSWORD en .env" + ) + else: + try: + async with httpx.AsyncClient(timeout=10.0) as client: + login_resp = await client.post( + f"{settings.HUB_URL}api/v1/auth/login", + json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD}, ) - if hub_resp.status_code in (200, 201): - hub_data = hub_resp.json() - hub_invite_token = hub_data.get("invite_token") or _extract_token_from_url(hub_data.get("invite_url", "")) - invite_url = hub_data.get("invite_url", "") - except Exception as exc: - logger.warning("Hub invite creation failed (non-blocking): %s", exc) + if login_resp.status_code != 200: + logger.error( + "[invite] Hub login falló: status=%s body=%s — " + "verifica HUB_ADMIN_EMAIL=%s y HUB_ADMIN_PASSWORD en .env", + login_resp.status_code, + login_resp.text[:200], + settings.HUB_ADMIN_EMAIL, + ) + else: + svc_token = login_resp.json().get("access_token", "") + hub_resp = await client.post( + f"{settings.HUB_URL}api/v1/hub/invites", + json={"email": str(data.email), "tenant_slug": tenant_slug}, + headers={"Authorization": f"Bearer {svc_token}"}, + ) + if hub_resp.status_code in (200, 201): + hub_data = hub_resp.json() + hub_invite_token = hub_data.get("invite_token") or _extract_token_from_url(hub_data.get("invite_url", "")) + invite_url = hub_data.get("invite_url", "") + else: + logger.error( + "[invite] Hub invite creation falló: status=%s body=%s tenant=%s email=%s", + hub_resp.status_code, + hub_resp.text[:300], + tenant_slug, + data.email, + ) + except Exception as exc: + logger.error("[invite] Hub invite creation excepción (non-blocking): %s", exc) # Fallback: URL del workspace (Hub) si la creación de invitación en Hub falló if not invite_url: From 73aa55646b3b6d26d5a364580263f26584410f30 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Fri, 29 May 2026 15:51:31 -0500 Subject: [PATCH 2/2] feat(invites): usar token del usuario actual para crear invite en Hub Elimina la dependencia de HUB_ADMIN_EMAIL/HUB_ADMIN_PASSWORD del flujo de invitaciones. Ahora se usa el Bearer token del usuario autenticado, que debe tener role='admin' en su tenant dentro del Hub. Esto hace el flujo compatible con instalaciones self-hosted donde el cliente no debe tener acceso a credenciales del Workspace Hub. Co-Authored-By: Claude Sonnet 4.6 --- backend/api/v1/modules/core/invites/routes.py | 10 +++- .../api/v1/modules/core/invites/service.py | 56 ++++++++----------- 2 files changed, 30 insertions(+), 36 deletions(-) diff --git a/backend/api/v1/modules/core/invites/routes.py b/backend/api/v1/modules/core/invites/routes.py index 9a9225fd..a134440a 100644 --- a/backend/api/v1/modules/core/invites/routes.py +++ b/backend/api/v1/modules/core/invites/routes.py @@ -4,13 +4,15 @@ import logging from core.database import get_core_db from core.security import get_current_user, validate_access_to_resource -from fastapi import APIRouter, Depends, Query, Request +from fastapi import APIRouter, Depends, Request +from fastapi.security import HTTPBearer from sqlalchemy.orm import Session from .dto import CreateInviteDTO, InviteResponseDTO from .service import InviteService router = APIRouter(prefix="/invites", tags=["Invites"]) +_bearer = HTTPBearer() logger = logging.getLogger(__name__) @@ -21,12 +23,13 @@ async def create_invite( request: Request, db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), + credentials=Depends(_bearer), ): """ Genera un token de invitación para que un usuario externo se registre. - Requiere permiso user.create. + Requiere permiso user.create. Usa el token del usuario actual para crear + el invite en el Hub — no requiere credenciales de hub_admin. """ - # Validar acceso (user.create) y obtener tenant_id validate_access_to_resource( db, data.company_id, @@ -42,4 +45,5 @@ async def create_invite( data=data, created_by=created_by, tenant_slug=tenant_slug, + user_access_token=credentials.credentials, ) diff --git a/backend/api/v1/modules/core/invites/service.py b/backend/api/v1/modules/core/invites/service.py index 04c6fd1a..9a138f70 100644 --- a/backend/api/v1/modules/core/invites/service.py +++ b/backend/api/v1/modules/core/invites/service.py @@ -45,6 +45,7 @@ class InviteService: data: CreateInviteDTO, created_by: str, tenant_slug: str, + user_access_token: str = "", ) -> InviteResponseDTO: import httpx from api.v1.modules.core.tenants.models import Tenant @@ -75,49 +76,38 @@ class InviteService: if not company_role: raise HTTPException(status_code=404, detail="Rol no encontrado") - # Crear invite en el Hub para que el usuario use el form del workspace + # Crear invite en el Hub usando el token del usuario actual. + # El usuario debe tener role='admin' en su tenant dentro del Hub. + # No se requieren credenciales de hub_admin — sin secretos en el .env del cliente. hub_invite_token: Optional[str] = None invite_url: str = "" - if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD: + if not user_access_token: logger.error( - "[invite] HUB_ADMIN_EMAIL o HUB_ADMIN_PASSWORD no configurados — " - "el invite NO se creará en el Hub y se usará el fallback local. " - "Configura HUB_ADMIN_EMAIL y HUB_ADMIN_PASSWORD en .env" + "[invite] user_access_token vacío — no se puede crear el invite en el Hub. " + "tenant=%s email=%s", + tenant_slug, + data.email, ) else: try: async with httpx.AsyncClient(timeout=10.0) as client: - login_resp = await client.post( - f"{settings.HUB_URL}api/v1/auth/login", - json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD}, + hub_resp = await client.post( + f"{settings.HUB_URL}api/v1/hub/invites", + json={"email": str(data.email), "tenant_slug": tenant_slug}, + headers={"Authorization": f"Bearer {user_access_token}"}, ) - if login_resp.status_code != 200: - logger.error( - "[invite] Hub login falló: status=%s body=%s — " - "verifica HUB_ADMIN_EMAIL=%s y HUB_ADMIN_PASSWORD en .env", - login_resp.status_code, - login_resp.text[:200], - settings.HUB_ADMIN_EMAIL, - ) + if hub_resp.status_code in (200, 201): + hub_data = hub_resp.json() + hub_invite_token = hub_data.get("invite_token") or _extract_token_from_url(hub_data.get("invite_url", "")) + invite_url = hub_data.get("invite_url", "") else: - svc_token = login_resp.json().get("access_token", "") - hub_resp = await client.post( - f"{settings.HUB_URL}api/v1/hub/invites", - json={"email": str(data.email), "tenant_slug": tenant_slug}, - headers={"Authorization": f"Bearer {svc_token}"}, + logger.error( + "[invite] Hub invite creation falló: status=%s body=%s tenant=%s email=%s", + hub_resp.status_code, + hub_resp.text[:300], + tenant_slug, + data.email, ) - if hub_resp.status_code in (200, 201): - hub_data = hub_resp.json() - hub_invite_token = hub_data.get("invite_token") or _extract_token_from_url(hub_data.get("invite_url", "")) - invite_url = hub_data.get("invite_url", "") - else: - logger.error( - "[invite] Hub invite creation falló: status=%s body=%s tenant=%s email=%s", - hub_resp.status_code, - hub_resp.text[:300], - tenant_slug, - data.email, - ) except Exception as exc: logger.error("[invite] Hub invite creation excepción (non-blocking): %s", exc)