feat: enhance user deletion process with access token and hub tenant ID propagation

This commit is contained in:
2026-05-06 08:20:39 -05:00
parent 7ea8a4ba8d
commit 5c4161e5d8
5 changed files with 114 additions and 21 deletions

View File

@@ -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"}

View File

@@ -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: