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:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user