Merge pull request 'fix/redirect-invitation-users' (#391) from fix/redirect-invitation-users into development

Reviewed-on: ADUANASOFT/anexo76#391
This commit is contained in:
2026-05-12 15:37:30 +00:00
5 changed files with 191 additions and 15 deletions

View File

@@ -220,6 +220,11 @@ async def lazy_link(
)
except Exception:
pass
try:
claims = service._decode_kc_user_from_token(credentials.credentials)
service._backfill_company_roles(claims.get("sub", ""))
except Exception:
pass
return {"ok": True}

View File

@@ -146,6 +146,16 @@ class AuthService:
except Exception as exc:
logger.warning("Lazy-link invite check failed (non-blocking): %s", exc)
# Backfill: crear UserCompanyRole faltantes para usuarios ya registrados
try:
login_sub = data.get("sub") or data.get("user_id")
if not login_sub:
claims = self._decode_kc_user_from_token(data.get("access_token", ""))
login_sub = claims.get("sub")
self._backfill_company_roles(login_sub)
except Exception as exc:
logger.warning("backfill_company_roles failed on login (non-blocking): %s", exc)
# Sync de perfil/avatar desde Workspace usando el mismo bearer.
# No bloquea login si Workspace no responde.
access_token = data.get("access_token")
@@ -470,7 +480,7 @@ class AuthService:
logger.error("Hub admin create error during invite flow: %s", exc)
raise HTTPException(status_code=503, detail="Error al crear usuario en el sistema de autenticación")
# 4. Crear fila UserTenant local
# 4. Crear fila UserTenant y UserCompanyRole local
if hub_user_id and invite_result.company_id:
try:
ut = UserTenant(
@@ -483,11 +493,47 @@ class AuthService:
last_name=register_data.last_name,
)
self.db.add(ut)
self.db.commit()
self.db.flush()
except Exception as exc:
logger.warning("Could not create UserTenant (may already exist): %s", exc)
self.db.rollback()
# Asignar UserCompanyRole para que el usuario tenga permisos resueltos
try:
from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole
company_role_obj = (
self.db.query(CompanyRole)
.filter(
CompanyRole.code == invite_result.role,
CompanyRole.company_id == invite_result.company_id,
CompanyRole.is_active == True,
)
.first()
)
if company_role_obj:
existing_ucr = (
self.db.query(UserCompanyRole)
.filter(
UserCompanyRole.user_id == hub_user_id,
UserCompanyRole.company_role_id == company_role_obj.id,
UserCompanyRole.company_id == invite_result.company_id,
)
.first()
)
if not existing_ucr:
ucr = UserCompanyRole(
user_id=hub_user_id,
company_role_id=company_role_obj.id,
company_id=invite_result.company_id,
tenant_id=tenant.id,
is_active=True,
)
self.db.add(ucr)
self.db.commit()
except Exception as exc:
logger.warning("Could not create UserCompanyRole for invited user: %s", exc)
self.db.rollback()
# 5. Consumir invite token
invite_service.consume_by_id(invite_result.invite_id)
@@ -509,7 +555,19 @@ class AuthService:
json=exchange_data.model_dump()
)
if response.status_code == 200:
return TokenResponseDTO(**response.json())
data = response.json()
# Lazy-link: crear UserTenant si hay invite pendiente
try:
await self._link_pending_invite("", access_token=data.get("access_token", ""))
except Exception as exc:
logger.warning("exchange_code lazy-link failed (non-blocking): %s", exc)
# Backfill: crear UserCompanyRole faltantes para usuarios ya registrados
try:
ec_claims = self._decode_kc_user_from_token(data.get("access_token", ""))
self._backfill_company_roles(ec_claims.get("sub") or data.get("sub"))
except Exception as exc:
logger.warning("backfill_company_roles failed on exchange_code (non-blocking): %s", exc)
return TokenResponseDTO(**data)
raise HTTPException(status_code=response.status_code, detail="Code exchange failed")
except Exception as e:
logger.error(f"Exchange code error: {str(e)}")
@@ -545,6 +603,17 @@ class AuthService:
)
if response.status_code == 200:
data = response.json()
# Lazy-link: crear UserTenant si hay invite pendiente (usuario registrado vía workspace)
try:
await self._link_pending_invite("", access_token=data.get("access_token", ""))
except Exception as exc:
logger.warning("sso_exchange lazy-link failed (non-blocking): %s", exc)
# Backfill: crear UserCompanyRole faltantes para usuarios ya registrados
try:
sso_claims = self._decode_kc_user_from_token(data.get("access_token", ""))
self._backfill_company_roles(sso_claims.get("sub") or data.get("sub"))
except Exception as exc:
logger.warning("backfill_company_roles failed on sso_exchange (non-blocking): %s", exc)
return TokenResponseDTO(
access_token=data["access_token"],
refresh_token=data["refresh_token"],
@@ -580,11 +649,10 @@ class AuthService:
hub_user_id = None
user_email = username_or_email
# Si tenemos el access_token, extraer info del JWT directamente
# Si tenemos el access_token, extraer info del JWT directamente (sin red)
if access_token:
try:
from core.security import verify_token
claims = await verify_token(access_token)
claims = self._decode_kc_user_from_token(access_token)
hub_user_id = claims.get("sub")
user_email = claims.get("email") or username_or_email
except Exception as exc:
@@ -592,6 +660,8 @@ class AuthService:
# Sin access_token: buscar usuario en el Hub vía service account
if not hub_user_id:
if not user_email:
return # Sin email ni hub_user_id no podemos buscar el invite
try:
async with httpx.AsyncClient(timeout=10.0) as client:
login_resp = await client.post(
@@ -669,8 +739,7 @@ class AuthService:
is_active=True,
)
self.db.add(ut)
pending.used_at = now
self.db.commit()
self.db.flush()
logger.info(
"Lazy-link: UserTenant created for user=%s tenant=%s role=%s",
hub_user_id,
@@ -681,3 +750,108 @@ class AuthService:
logger.warning("_link_pending_invite: could not create UserTenant: %s", exc)
self.db.rollback()
# Asignar UserCompanyRole para que el usuario tenga permisos resueltos
if pending.company_id:
try:
from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole
company_role_obj = (
self.db.query(CompanyRole)
.filter(
CompanyRole.code == pending.role,
CompanyRole.company_id == pending.company_id,
CompanyRole.is_active == True,
)
.first()
)
if company_role_obj:
existing_ucr = (
self.db.query(UserCompanyRole)
.filter(
UserCompanyRole.user_id == hub_user_id,
UserCompanyRole.company_role_id == company_role_obj.id,
UserCompanyRole.company_id == pending.company_id,
)
.first()
)
if not existing_ucr:
ucr = UserCompanyRole(
user_id=hub_user_id,
company_role_id=company_role_obj.id,
company_id=pending.company_id,
tenant_id=tenant.id,
is_active=True,
)
self.db.add(ucr)
except Exception as exc:
logger.warning("_link_pending_invite: could not create UserCompanyRole: %s", exc)
pending.used_at = now
self.db.commit()
def _backfill_company_roles(self, hub_user_id: str) -> None:
"""
Self-healing: para usuarios ya registrados vía invitación que tienen UserTenant
pero no UserCompanyRole (creados antes del fix del flujo de invitación).
Por cada UserTenant activo con role y company_id busca el CompanyRole y crea
el UserCompanyRole si no existe. Non-blocking.
"""
if not hub_user_id:
return
try:
from api.v1.modules.core.user_tenant.models import UserTenant
from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole
user_tenants = (
self.db.query(UserTenant)
.filter(
UserTenant.keycloak_user_id == hub_user_id,
UserTenant.is_active == True,
UserTenant.company_id.isnot(None),
UserTenant.role.isnot(None),
)
.all()
)
changed = False
for ut in user_tenants:
company_role_obj = (
self.db.query(CompanyRole)
.filter(
CompanyRole.code == ut.role,
CompanyRole.company_id == ut.company_id,
CompanyRole.is_active == True,
)
.first()
)
if not company_role_obj:
continue
existing = (
self.db.query(UserCompanyRole)
.filter(
UserCompanyRole.user_id == hub_user_id,
UserCompanyRole.company_role_id == company_role_obj.id,
UserCompanyRole.company_id == ut.company_id,
)
.first()
)
if not existing:
self.db.add(UserCompanyRole(
user_id=hub_user_id,
company_role_id=company_role_obj.id,
company_id=ut.company_id,
tenant_id=ut.tenant_id,
is_active=True,
))
changed = True
logger.info(
"backfill: UserCompanyRole created for user=%s company=%s role=%s",
hub_user_id, ut.company_id, ut.role,
)
if changed:
self.db.commit()
except Exception as exc:
logger.warning("_backfill_company_roles failed (non-blocking): %s", exc)
self.db.rollback()

View File

@@ -4,7 +4,6 @@ import logging
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from core.config import settings
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
@@ -37,12 +36,10 @@ async def create_invite(
tenant_slug: str = current_user.get("tenant_slug") or ""
created_by: str = current_user.get("sub") or ""
base_url = settings.APP_PUBLIC_URL.rstrip("/")
service = InviteService(db)
return await service.create_invite(
data=data,
created_by=created_by,
tenant_slug=tenant_slug,
base_url=base_url,
)

View File

@@ -45,7 +45,6 @@ class InviteService:
data: CreateInviteDTO,
created_by: str,
tenant_slug: str,
base_url: str,
) -> InviteResponseDTO:
import httpx
from api.v1.modules.core.tenants.models import Tenant
@@ -99,10 +98,11 @@ class InviteService:
except Exception as exc:
logger.warning("Hub invite creation failed (non-blocking): %s", exc)
# Fallback: URL local si el Hub falló
# Fallback: URL del workspace (Hub) si la creación de invitación en Hub falló
if not invite_url:
hub_base = settings.HUB_URL.rstrip("/")
invite_url = (
f"{base_url}/register"
f"{hub_base}/register"
f"?invite_token={token_plain}"
f"&tenant={tenant_slug}"
f"&email={data.email}"