feat: implement lazy-link for pending invites and add hub_invite_token to InviteToken model

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-05-07 12:41:12 -05:00
parent 1f2f8d1f83
commit 52fec4038e
10 changed files with 261 additions and 39 deletions

View File

@@ -202,6 +202,27 @@ async def get_current_user_info(
return await service.get_user_info(credentials.credentials)
@router.post("/lazy-link", status_code=200)
async def lazy_link(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_core_db),
):
"""
Vincula un invite pendiente al usuario autenticado (lazy-link).
Se llama después de un SSO login desde el workspace para crear el UserTenant
si hay un invite_token pendiente para el email del usuario.
"""
service = AuthService(db)
try:
await service._link_pending_invite(
credentials.credentials, # username_or_email = token (fallback)
access_token=credentials.credentials,
)
except Exception:
pass
return {"ok": True}
@router.post("/logout")
async def logout(
logout_data: LogoutRequestDTO,

View File

@@ -49,7 +49,12 @@ class AuthService:
tenants=[TenantInfoDTO(**t) for t in data["tenants"]]
)
# Si devolvió tokens
# Si devolvió tokens — lazy-link: verificar si hay invite pendiente
try:
await self._link_pending_invite(login_data.username)
except Exception as exc:
logger.warning("Lazy-link invite check failed (non-blocking): %s", exc)
# AUDIT LOG: Login Success
try:
from api.v1.modules.a76.audit_log.services.service import AuditService
@@ -335,3 +340,122 @@ class AuthService:
except Exception as e:
logger.error(f"SSO exchange error: {str(e)}")
raise HTTPException(status_code=500, detail="SSO exchange error")
async def _link_pending_invite(self, username_or_email: str, access_token: str = None) -> None:
"""
Lazy-link: después de un login exitoso comprueba si existe un invite_token
pendiente para el email del usuario. Si lo hay, crea la fila UserTenant
y consume el token.
Si se provee access_token, extrae hub_user_id y email directamente del JWT
sin necesidad de un lookup extra al Hub.
"""
from datetime import datetime, timezone
from api.v1.modules.core.invites.models import InviteToken
from api.v1.modules.core.tenants.models import Tenant
from api.v1.modules.core.user_tenant.models import UserTenant
hub_user_id = None
user_email = username_or_email
# Si tenemos el access_token, extraer info del JWT directamente
if access_token:
try:
from core.security import verify_token
claims = await verify_token(access_token)
hub_user_id = claims.get("sub")
user_email = claims.get("email") or username_or_email
except Exception as exc:
logger.debug("_link_pending_invite: JWT decode failed: %s", exc)
# Sin access_token: buscar usuario en el Hub vía service account
if not hub_user_id:
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:
return
svc_token = login_resp.json().get("access_token", "")
search_resp = await client.get(
f"{settings.HUB_URL}api/v1/hub/admins",
params={"email": username_or_email},
headers={"Authorization": f"Bearer {svc_token}"},
)
if search_resp.status_code == 200:
items = search_resp.json()
items = items if isinstance(items, list) else items.get("items", [])
matches = [
u for u in items
if u.get("email", "").lower() == username_or_email.lower()
or u.get("username", "").lower() == username_or_email.lower()
]
if matches:
hub_user_id = matches[0].get("id")
user_email = matches[0].get("email", username_or_email)
if not hub_user_id:
return
except Exception as exc:
logger.debug("_link_pending_invite: hub lookup failed: %s", exc)
return
now = datetime.now(timezone.utc)
pending = (
self.db.query(InviteToken)
.filter(
InviteToken.email == user_email,
InviteToken.used_at.is_(None),
InviteToken.expires_at > now,
)
.first()
)
if not pending:
return
tenant = (
self.db.query(Tenant)
.filter(Tenant.slug == pending.tenant_slug)
.first()
)
if not tenant:
logger.warning("_link_pending_invite: tenant %s not found", pending.tenant_slug)
return
# Evitar duplicados
existing = (
self.db.query(UserTenant)
.filter(
UserTenant.keycloak_user_id == hub_user_id,
UserTenant.tenant_id == tenant.id,
)
.first()
)
if existing:
# Vincular existe, solo consumir el token
pending.used_at = now
self.db.commit()
return
try:
ut = UserTenant(
keycloak_user_id=hub_user_id,
tenant_id=tenant.id,
company_id=pending.company_id,
role=pending.role,
is_active=True,
)
self.db.add(ut)
pending.used_at = now
self.db.commit()
logger.info(
"Lazy-link: UserTenant created for user=%s tenant=%s role=%s",
hub_user_id,
tenant.slug,
pending.role,
)
except Exception as exc:
logger.warning("_link_pending_invite: could not create UserTenant: %s", exc)
self.db.rollback()

View File

@@ -37,3 +37,6 @@ class InviteToken(Base, BaseTimestampMixin):
# Específico de Anexo76: empresa destino para crear UserTenant
company_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# Token generado en el Hub (para la URL de registro del workspace)
hub_invite_token: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)

View File

@@ -27,6 +27,15 @@ def _hash_token(token_plain: str) -> str:
return hashlib.sha256(token_plain.encode()).hexdigest()
def _extract_token_from_url(url: str) -> Optional[str]:
"""Extract invite_token query param from a URL string."""
from urllib.parse import urlparse, parse_qs
parsed = urlparse(url)
params = parse_qs(parsed.query)
tokens = params.get("invite_token", [])
return tokens[0] if tokens else None
class InviteService:
def __init__(self, db: Session):
self.db = db
@@ -38,6 +47,7 @@ class InviteService:
tenant_slug: str,
base_url: str,
) -> InviteResponseDTO:
import httpx
from api.v1.modules.core.tenants.models import Tenant
tenant = (
@@ -66,6 +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
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 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)
# Fallback: URL local si el Hub falló
if not invite_url:
invite_url = (
f"{base_url}/register"
f"?invite_token={token_plain}"
f"&tenant={tenant_slug}"
f"&email={data.email}"
)
invite = InviteToken(
token_hash=token_hash,
tenant_slug=tenant_slug,
@@ -74,19 +116,13 @@ class InviteService:
created_by=created_by,
expires_at=expires_at,
company_id=data.company_id,
hub_invite_token=hub_invite_token,
)
self.db.add(invite)
self.db.commit()
self.db.refresh(invite)
invite_url = (
f"{base_url}/register"
f"?invite_token={token_plain}"
f"&tenant={tenant_slug}"
f"&email={data.email}"
)
# Enviar email (best-effort — si falla se loguea la URL para el admin)
# Enviar email (best-effort)
try:
await self._send_invite_email(
to_email=str(data.email),