From 5c4161e5d8b65e3f156a6d7ad8a60c6fd3a6aeaf Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Wed, 6 May 2026 08:20:39 -0500 Subject: [PATCH] feat: enhance user deletion process with access token and hub tenant ID propagation --- backend/api/v1/modules/core/users/routes.py | 18 +++- backend/api/v1/modules/core/users/service.py | 98 +++++++++++++++---- .../components/sidebar/team-switcher.svelte | 3 + frontend/src/routes/auth/sso/+page.server.ts | 8 ++ .../src/routes/dashboard/users/+page.svelte | 8 +- 5 files changed, 114 insertions(+), 21 deletions(-) diff --git a/backend/api/v1/modules/core/users/routes.py b/backend/api/v1/modules/core/users/routes.py index 3d4a4e6c..2e9d2289 100644 --- a/backend/api/v1/modules/core/users/routes.py +++ b/backend/api/v1/modules/core/users/routes.py @@ -238,6 +238,7 @@ async def get_user_tenant_count( @router.delete("/{user_id}") async def delete_user_route( + request: Request, user_id: str, company_id: int = Query(..., description="Company ID"), soft_delete: bool = Query( @@ -257,8 +258,23 @@ async def delete_user_route( scope='all': de todos los tenants en los que aparece el usuario. """ tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.delete"]) + auth_header = request.headers.get("Authorization") or "" + token = ( + auth_header[7:].strip() + if auth_header.lower().startswith("bearer ") + else auth_header.strip() + ) + hub_tid = resolve_hub_tenant_id_for_api( + tenant_id, request.headers.get("X-Tenant-Override") + ) service = UserService(db, tenant_id, company_id) - await service.delete_user(user_id, soft_delete=soft_delete, scope=scope) + await service.delete_user( + user_id, + soft_delete=soft_delete, + scope=scope, + access_token=token or None, + hub_tenant_id=hub_tid, + ) return {"message": "User deleted successfully"} diff --git a/backend/api/v1/modules/core/users/service.py b/backend/api/v1/modules/core/users/service.py index c847ba3c..a4ca98bf 100644 --- a/backend/api/v1/modules/core/users/service.py +++ b/backend/api/v1/modules/core/users/service.py @@ -286,6 +286,10 @@ class UserService: kc = u.get("keycloak_user_id") if not kc: continue + # Filtrar usuarios soft-deleted localmente (is_active=False en user_tenants local) + local_ut_check = local_by_kc.get(kc) + if local_ut_check is not None and not local_ut_check.is_active: + continue if needle: blob = " ".join( [ @@ -395,16 +399,24 @@ class UserService: return _normalize_user({"id": user_id}, user_tenant.role, user_tenant) def get_user_tenant_count(self, user_id: str) -> int: - """Cuenta en cuántos tenants está registrado el usuario.""" + """Cuenta en cuántos tenants activos está registrado el usuario.""" return ( self.db.query(func.count(UserTenant.id)) - .filter(UserTenant.keycloak_user_id == user_id) + .filter( + UserTenant.keycloak_user_id == user_id, + UserTenant.is_active == True, + ) .scalar() or 0 ) async def delete_user( - self, user_id: str, soft_delete: bool = True, scope: str = "current" + self, + user_id: str, + soft_delete: bool = True, + scope: str = "current", + access_token: Optional[str] = None, + hub_tenant_id: Optional[int] = None, ) -> None: """ Elimina/Desactiva usuario. @@ -419,33 +431,81 @@ class UserService: ) if not rows: raise HTTPException(status_code=404, detail="User not found") - if soft_delete: - for row in rows: - row.is_active = False - self.db.commit() - else: - # TODO: Call Hub to delete from Keycloak - for row in rows: - self.db.delete(row) - self.db.commit() + now = datetime.utcnow() + # Collect unique hub_tenant_ids to notify Hub for each tenant + hub_tenant_ids = {row.tenant_id for row in rows} + for row in rows: + row.is_active = False + if not soft_delete: + row.deleted_at = now + self.db.commit() + # Propagate to Hub for every tenant the user belonged to + if access_token: + for tid in hub_tenant_ids: + await self._hub_remove_user(user_id, tid, soft_delete, access_token) return # scope == "current" (default) user_tenant = self.db.query(UserTenant).filter( - and_(UserTenant.keycloak_user_id == user_id, UserTenant.tenant_id == self.tenant_id) + and_( + UserTenant.keycloak_user_id == user_id, + UserTenant.tenant_id == self.tenant_id, + UserTenant.company_id == self.company_id, + ) ).first() if not user_tenant: - raise HTTPException(status_code=404, detail="User not found") - - if soft_delete: - user_tenant.is_active = False + # No local record — user exists in Hub but not synced locally yet. + # Create tombstone so user is filtered from future listings. + user_tenant = UserTenant( + keycloak_user_id=user_id, + tenant_id=self.tenant_id, + company_id=self.company_id, + is_active=False, + deleted_at=None if soft_delete else datetime.utcnow(), + ) + self.db.add(user_tenant) self.db.commit() else: - # TODO: Call Hub to delete from Keycloak - self.db.delete(user_tenant) + user_tenant.is_active = False + if not soft_delete: + user_tenant.deleted_at = datetime.utcnow() self.db.commit() + # Propagate to Hub + if access_token and hub_tenant_id: + await self._hub_remove_user(user_id, hub_tenant_id, soft_delete, access_token) + + async def _hub_remove_user( + self, + user_id: str, + hub_tenant_id: int, + soft_delete: bool, + access_token: str, + ) -> None: + """Calls Hub POST /api/v1/hub/user-tenants/remove to sync the deletion.""" + base = (settings.HUB_URL or "").rstrip("/") + url = f"{base}/api/v1/hub/user-tenants/remove" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post( + url, + json={ + "keycloak_user_id": user_id, + "tenant_id": hub_tenant_id, + "soft_delete": soft_delete, + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + if resp.status_code >= 400: + logger.warning( + "Hub remove user-tenant returned %s for user %s tenant %s: %s", + resp.status_code, user_id, hub_tenant_id, resp.text[:200], + ) + except Exception as exc: + logger.error("Error calling Hub remove user-tenant: %s", exc) + # Do not raise — local deletion already committed; Hub sync is best-effort. + async def change_password(self, user_id: str, password: str, temporary: bool = True) -> None: """Cambia contraseña vía Hub""" try: diff --git a/frontend/src/lib/components/sidebar/team-switcher.svelte b/frontend/src/lib/components/sidebar/team-switcher.svelte index c18b3458..35dca939 100644 --- a/frontend/src/lib/components/sidebar/team-switcher.svelte +++ b/frontend/src/lib/components/sidebar/team-switcher.svelte @@ -62,6 +62,9 @@ if (res.ok) { companyStore.clear(); await invalidateAll(); + // Reinicializar el store con las compañías del nuevo tenant + // (loadCompanies sin args hace fetch al backend que ya tiene la nueva cookie de tenant) + await companyStore.loadCompanies(); } else { const err = await res.json().catch(() => ({})); console.error('[team-switcher] switch-tenant error:', err); diff --git a/frontend/src/routes/auth/sso/+page.server.ts b/frontend/src/routes/auth/sso/+page.server.ts index 83df5cc7..13175323 100644 --- a/frontend/src/routes/auth/sso/+page.server.ts +++ b/frontend/src/routes/auth/sso/+page.server.ts @@ -16,6 +16,14 @@ export const load: PageServerLoad = async ({ url, cookies }) => { throw redirect(303, '/login?error=sso_missing_token'); } + // If there is already a valid session, skip the exchange to avoid + // re-using a one-time relay token (e.g. browser tab reload or prefetch). + const existingToken = cookies.get('access_token') || cookies.get('access_token_0'); + if (existingToken) { + console.log('[SSO] sesión existente detectada, redirigiendo sin exchange'); + throw redirect(303, '/dashboard'); + } + // SSO exchange must call Hub backend, not Anexo76 backend. // Use INTERNAL_HUB_URL for server-to-server communication. let hubUrl = process.env.INTERNAL_HUB_URL; diff --git a/frontend/src/routes/dashboard/users/+page.svelte b/frontend/src/routes/dashboard/users/+page.svelte index 5d92a654..c309cf2d 100644 --- a/frontend/src/routes/dashboard/users/+page.svelte +++ b/frontend/src/routes/dashboard/users/+page.svelte @@ -2569,7 +2569,13 @@ Cancelar - {#if !deleteLoadingCount && deleteUserTenantCount > 1} + {#if deleteLoadingCount} + + + {:else if deleteUserTenantCount > 1}