feat: plantilla base workspace SaaS
Some checks failed
Build Producción & Push a Harbor / test (push) Failing after 3s
Build Producción & Push a Harbor / build (push) Has been skipped
Aduanasoft/plantillas-proyectos/pipeline/head There was a failure building this commit

This commit is contained in:
2026-07-21 13:59:00 -05:00
commit bdd089954b
470 changed files with 70022 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
"""
Módulo de gestión de usuarios (Keycloak)
"""

View File

@@ -0,0 +1,105 @@
"""
DTOs para gestión de usuarios de Keycloak
"""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, EmailStr, Field, field_validator
class CreateUserRequestDTO(BaseModel):
"""Request para crear un nuevo usuario en Keycloak"""
email: EmailStr = Field(..., description="Email del usuario")
username: str = Field(
..., min_length=3, max_length=50, description="Nombre de usuario"
)
first_name: str = Field(..., min_length=1, max_length=100, description="Nombre")
last_name: str = Field(..., min_length=1, max_length=100, description="Apellido")
password: str = Field(..., min_length=8, description="Contraseña temporal")
role: Optional[str] = Field(None, description="Rol del usuario en el tenant")
enabled: bool = Field(True, description="Si el usuario está habilitado")
email_verified: bool = Field(False, description="Si el email está verificado")
class UpdateUserRequestDTO(BaseModel):
"""Request para actualizar un usuario en Keycloak"""
first_name: Optional[str] = Field(None, max_length=100)
last_name: Optional[str] = Field(None, max_length=100)
email: Optional[str] = Field(None, max_length=255)
enabled: Optional[bool] = None
email_verified: Optional[bool] = None
role: Optional[str] = Field(None, description="Rol del usuario en el tenant actual")
# Campos de perfil
avatar_url: Optional[str] = Field(
None, max_length=500, description="URL del avatar"
)
phone: Optional[str] = Field(None, max_length=20, description="Teléfono")
bio: Optional[str] = Field(None, description="Biografía")
preferences: Optional[dict] = Field(None, description="Preferencias del usuario")
@field_validator("first_name", "last_name", "email")
@classmethod
def validate_non_empty_string(cls, v: Optional[str]) -> Optional[str]:
"""Valida que si el string está presente, no esté vacío"""
if v is not None and v.strip() == "":
return None # Convertir strings vacíos a None
return v
class UserResponseDTO(BaseModel):
"""Response con información de usuario de Keycloak"""
id: str = Field(..., description="ID de Keycloak del usuario")
username: str
email: str = Field(default="", description="Email del usuario")
first_name: str = Field(default="", description="Nombre del usuario")
last_name: str = Field(default="", description="Apellido del usuario")
enabled: bool
email_verified: bool
created_timestamp: Optional[int] = None
role: Optional[str] = Field(None, description="Rol del usuario en el tenant actual")
# Campos de perfil
avatar_url: Optional[str] = Field(None, description="URL del avatar")
phone: Optional[str] = Field(None, description="Teléfono")
bio: Optional[str] = Field(None, description="Biografía")
preferences: Optional[dict] = Field(
default_factory=dict, description="Preferencias"
)
class Config:
from_attributes = True
class UserListResponseDTO(BaseModel):
"""Response con lista de usuarios"""
users: List[UserResponseDTO]
total: int
page: int
page_size: int
total_pages: int
class ChangePasswordRequestDTO(BaseModel):
"""Request para cambiar contraseña de un usuario"""
password: str = Field(..., min_length=8, description="Nueva contraseña")
temporary: bool = Field(
True, description="Si es temporal (usuario debe cambiarla al login)"
)
class UserStatsDTO(BaseModel):
"""Estadísticas de usuarios del tenant"""
total_users: int
active_users: int
inactive_users: int
max_users_allowed: int
users_available: int
usage_percentage: float

View File

@@ -0,0 +1,478 @@
"""
Rutas para gestión de usuarios de Keycloak
"""
import logging
import mimetypes
from typing import Optional
import os
import uuid
from pathlib import Path
from core.config import settings
from core.database import get_core_db
from core.s3_keys import public_user_avatar_api_path, user_avatar_key
from core.storage_s3 import delete_object_if_exists, get_object_bytes, put_object_bytes
from core.security import (
get_current_user,
is_hub_admin,
resolve_hub_tenant_id_for_api,
validate_access_to_resource,
)
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from fastapi.responses import Response
from sqlalchemy.orm import Session
from ..user_tenant.models import UserTenant
from .dto import (
ChangePasswordRequestDTO,
CreateUserRequestDTO,
UpdateUserRequestDTO,
UserListResponseDTO,
UserResponseDTO,
UserStatsDTO,
)
from .service import UserService
router = APIRouter(prefix="/users", tags=["Users"])
logger = logging.getLogger(__name__)
_AVATAR_EXT = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
@router.get("/stats", response_model=UserStatsDTO)
async def get_user_statistics(
request: Request,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Obtiene estadísticas de usuarios del tenant actual
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
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")
)
return service.get_user_stats(
access_token=token or None,
hub_tenant_id=hub_tid,
x_tenant_override=request.headers.get("X-Tenant-Override"),
)
@router.get("/", response_model=UserListResponseDTO)
async def list_users(
request: Request,
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(20, ge=1, le=100, description="Tamaño de página"),
search: Optional[str] = Query(None, description="Término de búsqueda"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Lista todos los usuarios del tenant con paginación
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
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")
)
result = await service.get_tenant_users(
page=page,
page_size=page_size,
search=search,
access_token=token,
hub_tenant_id=hub_tid,
x_tenant_override=request.headers.get("X-Tenant-Override"),
)
return result
@router.get("/avatar/{tenant_id}/{keycloak_user_id}")
def get_user_avatar_image(
tenant_id: int,
keycloak_user_id: str,
db: Session = Depends(get_core_db),
):
"""
Sirve la imagen de avatar (público para poder usarla en <img src> sin Bearer).
El almacenamiento interno puede ser clave S3 o ruta bajo uploads/.
"""
ut = (
db.query(UserTenant)
.filter(
UserTenant.tenant_id == tenant_id,
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.is_active == True,
)
.first()
)
if not ut or not ut.avatar_url:
raise HTTPException(status_code=404, detail="Avatar not found")
raw = ut.avatar_url
if raw.startswith("tenants/"):
try:
data = get_object_bytes(raw)
except Exception:
raise HTTPException(status_code=404, detail="Avatar not found")
media = mimetypes.guess_type(raw)[0] or "image/jpeg"
return Response(content=data, media_type=media)
rel = raw.lstrip("/")
path = Path(rel)
if not path.is_file():
path = Path.cwd() / rel
if not path.is_file():
alt = Path("/app") / rel
if alt.is_file():
path = alt
if not path.is_file():
raise HTTPException(status_code=404, detail="Avatar file not found")
data = path.read_bytes()
media = mimetypes.guess_type(str(path))[0] or "image/jpeg"
return Response(content=data, media_type=media)
# === Endpoints de Perfil del Usuario Actual ===
@router.get("/me/profile", response_model=UserResponseDTO)
async def get_my_profile(
request: Request,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Obtiene el perfil completo del usuario actual
"""
keycloak_user_id = current_user.get("sub")
if not keycloak_user_id:
raise HTTPException(status_code=400, detail="User ID not found in token")
# Obtener user_tenant para crear servicio
user_tenant = (
db.query(UserTenant)
.filter(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.is_active == True,
)
.first()
)
if not user_tenant:
raise HTTPException(
status_code=400, detail="User does not belong to any tenant"
)
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
auth_header = request.headers.get("Authorization") or ""
access_token = (
auth_header[7:].strip()
if auth_header.lower().startswith("bearer ")
else auth_header.strip() or None
)
return await service.get_current_user_profile(
keycloak_user_id,
current_user=current_user,
access_token=access_token,
)
@router.put("/me/profile", response_model=UserResponseDTO)
async def update_my_profile(
request: Request,
data: UpdateUserRequestDTO,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Actualiza el perfil del usuario actual.
Campos editables: first_name, last_name, phone.
Email, username y otros campos de identidad solo se cambian desde el Hub.
"""
keycloak_user_id = current_user.get("sub")
if not keycloak_user_id:
raise HTTPException(status_code=400, detail="User ID not found in token")
# Obtener user_tenant
user_tenant = (
db.query(UserTenant)
.filter(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.is_active == True,
)
.first()
)
if not user_tenant:
raise HTTPException(
status_code=400, detail="User does not belong to any tenant"
)
# Para sesiones autenticadas vía Workspace/Hub, la foto de perfil viene del Hub
# y no debe mutarse localmente en Anexo76.
if current_user.get("sub"):
raise HTTPException(
status_code=409,
detail="Avatar is managed by Workspace for this user",
)
auth_header = request.headers.get("Authorization") or ""
access_token = (
auth_header[7:].strip()
if auth_header.lower().startswith("bearer ")
else auth_header.strip() or None
)
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
return await service.update_current_user_profile(
keycloak_user_id=keycloak_user_id,
current_user=current_user,
access_token=access_token,
first_name=data.first_name,
last_name=data.last_name,
phone=data.phone,
)
@router.post("/me/avatar")
async def upload_my_avatar(
file: UploadFile = File(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Sube un avatar para el usuario actual.
Con MinIO guarda en tenants/{tid}/users/{sub}/avatar.{ext} y persiste la clave en UserTenant.
Retorna URL pública para <img src> (GET /users/avatar/...).
"""
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="El archivo debe ser una imagen")
keycloak_user_id = current_user.get("sub")
if not keycloak_user_id:
raise HTTPException(status_code=400, detail="User ID not found in token")
user_tenant = (
db.query(UserTenant)
.filter(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.is_active == True,
)
.first()
)
if not user_tenant:
raise HTTPException(
status_code=400, detail="User does not belong to any tenant"
)
ext = Path(file.filename or "image.jpg").suffix.lower() or ".jpg"
if ext not in _AVATAR_EXT:
raise HTTPException(
status_code=400,
detail=f"Extensión no permitida. Use: {', '.join(sorted(_AVATAR_EXT))}",
)
contents = await file.read()
if len(contents) > 2 * 1024 * 1024:
raise HTTPException(status_code=400, detail="La imagen debe ser menor a 2MB")
tenant_id = user_tenant.tenant_id
try:
if settings.use_s3_object_storage:
if user_tenant.avatar_url and str(user_tenant.avatar_url).startswith(
"tenants/"
):
delete_object_if_exists(str(user_tenant.avatar_url))
key = user_avatar_key(tenant_id, keycloak_user_id, ext)
ct = file.content_type or mimetypes.guess_type(f"x{ext}")[0] or "image/jpeg"
put_object_bytes(key, contents, content_type=ct)
user_tenant.avatar_url = key
logger.info(
"User avatar stored in S3 key=%s bytes=%s", key, len(contents)
)
else:
upload_dir = Path("uploads/avatars")
upload_dir.mkdir(parents=True, exist_ok=True)
filename = f"{keycloak_user_id}{ext}"
file_path = upload_dir / filename
with open(file_path, "wb") as f:
f.write(contents)
user_tenant.avatar_url = f"/uploads/avatars/{filename}"
db.add(user_tenant)
db.commit()
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except Exception as e:
db.rollback()
raise HTTPException(
status_code=500, detail=f"Error al guardar el avatar: {str(e)}"
) from e
public_url = public_user_avatar_api_path(tenant_id, keycloak_user_id)
return {"avatar_url": public_url}
@router.get("/{user_id}", response_model=UserResponseDTO)
async def get_user_detail(
user_id: str,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Obtiene información detallada de un usuario específico
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
return await service.get_user(user_id)
@router.post("/", response_model=UserResponseDTO, status_code=201)
async def create_new_user(
data: CreateUserRequestDTO,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Crea un nuevo usuario a través del Hub y lo asocia al tenant
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.create"])
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
user = await service.create_user(
email=data.email,
username=data.username,
first_name=data.first_name,
last_name=data.last_name,
password=data.password,
role=data.role,
enabled=data.enabled,
email_verified=data.email_verified,
)
return user
@router.put("/{user_id}", response_model=UserResponseDTO)
async def update_user_detail(
user_id: str,
data: UpdateUserRequestDTO,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Actualiza información de un usuario
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.update"])
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
user = await service.update_user(
user_id=user_id,
first_name=data.first_name,
last_name=data.last_name,
email=data.email,
enabled=data.enabled,
email_verified=data.email_verified,
role=data.role,
avatar_url=data.avatar_url,
phone=data.phone,
bio=data.bio,
preferences=data.preferences,
)
return user
@router.get("/{user_id}/tenant-count")
async def get_user_tenant_count(
user_id: str,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Retorna en cuántos tenants está registrado el usuario.
"""
tenant_id = validate_access_to_resource(
db, company_id, current_user, required_permissions=["user.view"]
)
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
count = service.get_user_tenant_count(user_id)
return {"tenant_count": 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(
True,
description="Si es True, solo desactiva. Si es False, elimina permanentemente",
),
scope: str = Query(
"current",
description="'current' para borrar solo del tenant activo, 'all' para borrar de todos los tenants",
),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Elimina un usuario del tenant.
scope='current' (default): solo del tenant activo.
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, is_hub_admin=is_hub_admin(current_user))
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"}
@router.post("/{user_id}/change-password")
async def change_user_password(
user_id: str,
data: ChangePasswordRequestDTO,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Cambia la contraseña de un usuario a través del Hub
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.update"])
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
await service.change_password(user_id, data.password, data.temporary)
return {"message": "Password changed successfully"}

View File

@@ -0,0 +1,808 @@
import logging
import httpx
from datetime import datetime
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
from fastapi import HTTPException
from sqlalchemy import and_, func
from sqlalchemy.orm import Session
from core.config import settings
from ..licenses.models import License, LicenseStatus
from ..user_tenant.models import UserTenant
logger = logging.getLogger(__name__)
def _is_valid_http_url(url: Optional[str]) -> bool:
if not url or not isinstance(url, str):
return False
parsed = urlparse(url.strip())
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
def _legacy_avatar_public_url(user_tenant: Optional[Any]) -> Optional[str]:
if not user_tenant or not user_tenant.avatar_url:
return None
avatar_out = str(user_tenant.avatar_url)
if avatar_out.startswith("http://") or avatar_out.startswith("https://"):
return avatar_out if _is_valid_http_url(avatar_out) else None
from core.s3_keys import public_user_avatar_api_path
# Entregamos siempre el endpoint público del backend para assets locales/S3.
return public_user_avatar_api_path(
user_tenant.tenant_id,
user_tenant.keycloak_user_id,
)
def _normalize_user(
user_data: Dict[str, Any],
role: Optional[str] = None,
user_tenant: Optional[Any] = None,
) -> Dict[str, Any]:
"""
Normaliza los datos de usuario al formato esperado por el DTO.
Prioridad para nombre/apellido: caché local (user_tenant) > JWT claims > campo 'name'.
"""
name_parts = (user_data.get("name") or "").split(" ", 1)
# Caché local tiene prioridad — se actualiza al guardar perfil desde Anexo76
local_first = getattr(user_tenant, "first_name", None) if user_tenant else None
local_last = getattr(user_tenant, "last_name", None) if user_tenant else None
normalized = {
"id": user_data.get("id") or user_data.get("sub"),
"username": user_data.get("username") or user_data.get("preferred_username", ""),
"email": user_data.get("email", ""),
"first_name": local_first or user_data.get("firstName") or user_data.get("given_name") or (name_parts[0] if name_parts else ""),
"last_name": local_last or user_data.get("lastName") or user_data.get("family_name") or (name_parts[1] if len(name_parts) > 1 else ""),
"enabled": user_data.get("enabled", True),
"email_verified": user_data.get("emailVerified") or user_data.get("email_verified", False),
"created_timestamp": user_data.get("createdTimestamp"),
"role": role,
}
# Agregar campos de perfil si user_tenant está disponible
if user_tenant:
workspace_avatar = (
user_tenant.workspace_avatar_url
if _is_valid_http_url(user_tenant.workspace_avatar_url)
else None
)
legacy_avatar = _legacy_avatar_public_url(user_tenant)
avatar_out = workspace_avatar or legacy_avatar
normalized.update(
{
"avatar_url": avatar_out,
"workspace_avatar_url": workspace_avatar,
"legacy_avatar_url": legacy_avatar,
"workspace_user_id": user_tenant.workspace_user_id,
"phone": user_tenant.phone,
"bio": user_tenant.bio,
"preferences": user_tenant.preferences or {},
}
)
return normalized
class UserService:
"""Servicio para gestionar usuarios vía Hub"""
def __init__(self, db: Session, tenant_id: int = None, company_id: int = None, *, is_hub_admin: bool = False):
self.db = db
self.tenant_id = tenant_id
self.company_id = company_id
self.is_hub_admin = is_hub_admin
def _get_license(self) -> License:
"""Obtiene la licencia del tenant actual"""
license = (
self.db.query(License).filter(License.tenant_id == self.tenant_id).first()
)
if not license:
raise HTTPException(
status_code=404, detail="License not found for this tenant"
)
if license.status != LicenseStatus.ACTIVE:
raise HTTPException(
status_code=403,
detail=f"License is not active. Current status: {license.status.value}",
)
# Verificar si la licencia está vigente
now = datetime.now(license.expires_at.tzinfo)
if license.expires_at < now:
raise HTTPException(status_code=403, detail="License has expired")
return license
def _check_user_limit(self) -> None:
"""Verifica si se puede crear un nuevo usuario según la licencia"""
if self.is_hub_admin:
return
license = self._get_license()
# Contar usuarios activos del tenant
active_users = (
self.db.query(func.count(UserTenant.id))
.filter(
and_(
UserTenant.tenant_id == self.tenant_id,
UserTenant.is_active == True,
)
)
.scalar()
)
# max_users=NULL en BD indica licencia sin cuota (ilimitada).
# Comparar con None lanzaría TypeError — salida temprana explícita.
if license.max_users is None:
return
if active_users >= license.max_users:
raise HTTPException(
status_code=403,
detail=f"User limit reached. Your license allows {license.max_users} users. "
f"Currently active: {active_users}. Please upgrade your license.",
)
async def create_user(
self,
email: str,
username: str,
first_name: str,
last_name: str,
password: str,
role: Optional[str] = None,
enabled: bool = True,
email_verified: bool = False,
) -> Dict[str, Any]:
"""
Crea un nuevo usuario a través del Hub y lo asocia localmente
"""
# Verificar límite de usuarios
self._check_user_limit()
try:
# Mandar al Hub para creación en Keycloak
async with httpx.AsyncClient(timeout=10.0) as client:
hub_response = await client.post(
f"{settings.HUB_URL}api/v1/auth/register",
json={
"email": email,
"username": username,
"first_name": first_name,
"last_name": last_name,
"password": password,
"tenant_slug": "default", # TODO: Get real slug if needed
}
)
if hub_response.status_code != 201:
logger.error(f"Hub registration failed: {hub_response.text}")
raise HTTPException(status_code=hub_response.status_code, detail="Failed to create user in Hub")
user_data = hub_response.json()
user_id = user_data.get("user_id")
# Obtener company_id — implementa con tu modelo de compañía si company_id es None.
if not self.company_id:
raise HTTPException(status_code=400, detail="company_id requerido")
company_id = self.company_id
# Crear relación local
user_tenant = UserTenant(
keycloak_user_id=user_id,
tenant_id=self.tenant_id,
company_id=company_id,
role=role,
is_active=True,
)
self.db.add(user_tenant)
self.db.commit()
return _normalize_user(user_data, role, user_tenant)
except Exception as e:
logger.error(f"Error creating user: {str(e)}")
self.db.rollback()
if isinstance(e, HTTPException):
raise e
raise HTTPException(status_code=500, detail=str(e))
async def _fetch_hub_tenant_users_with_info(
self,
access_token: str,
hub_tenant_id: int,
x_tenant_override: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Lista usuarios del tenant desde Aduanasoft Hub (Keycloak + user_tenants)."""
base = (settings.HUB_URL or "").rstrip("/")
url = f"{base}/api/v1/hub/user-tenants/tenant/{hub_tenant_id}/users-with-info"
headers: Dict[str, Any] = {"Authorization": f"Bearer {access_token}"}
if x_tenant_override and str(x_tenant_override).strip():
headers["X-Tenant-Override"] = str(x_tenant_override).strip()
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, headers=headers)
if response.status_code == 401:
raise HTTPException(status_code=401, detail="No autorizado en el Hub")
if response.status_code == 403:
raise HTTPException(
status_code=403, detail="Sin permiso para listar usuarios del tenant en el Hub"
)
if response.status_code >= 400:
logger.error(
"Hub users-with-info error status=%s body=%s",
response.status_code,
response.text[:500],
)
raise HTTPException(
status_code=502,
detail="No se pudo obtener el catálogo de usuarios desde el Hub",
)
data = response.json()
if not isinstance(data, list):
raise HTTPException(
status_code=502, detail="Respuesta inválida del Hub al listar usuarios"
)
return data
async def get_tenant_users(
self,
page: int = 1,
page_size: int = 20,
search: Optional[str] = None,
*,
access_token: str,
hub_tenant_id: int,
x_tenant_override: Optional[str] = None,
) -> Dict[str, Any]:
"""
Usuarios del tenant: fuente de verdad Aduanasoft Hub; roles de compañía y
perfil extendido desde BD local (user_tenants / user_company_roles).
"""
try:
from ..permissions.models import UserCompanyRole
from sqlalchemy.orm import joinedload
if not access_token or not hub_tenant_id:
raise HTTPException(
status_code=400,
detail="Token o tenant Hub requerido para listar usuarios",
)
hub_rows = await self._fetch_hub_tenant_users_with_info(
access_token, hub_tenant_id, x_tenant_override
)
user_roles_query = (
self.db.query(UserCompanyRole)
.options(joinedload(UserCompanyRole.company_role))
.filter(
and_(
UserCompanyRole.company_id == self.company_id,
UserCompanyRole.tenant_id == self.tenant_id,
UserCompanyRole.is_active == True,
)
)
)
user_roles_map: Dict[str, List[str]] = {}
for user_role in user_roles_query.all():
uid = user_role.user_id
if uid not in user_roles_map:
user_roles_map[uid] = []
user_roles_map[uid].append(user_role.company_role.name)
local_by_kc = {
ut.keycloak_user_id: ut
for ut in self.db.query(UserTenant)
.filter(
and_(
UserTenant.tenant_id == self.tenant_id,
UserTenant.company_id == self.company_id,
)
)
.all()
}
needle = (search or "").strip().lower()
filtered: List[Dict[str, Any]] = []
for u in hub_rows:
if not u.get("is_active", True):
continue
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(
[
str(u.get("email") or ""),
str(u.get("username") or ""),
str(u.get("first_name") or ""),
str(u.get("last_name") or ""),
]
).lower()
ut_loc = local_by_kc.get(kc)
if ut_loc:
blob += f" {ut_loc.phone or ''} {ut_loc.bio or ''}".lower()
if needle not in blob:
continue
filtered.append(u)
total = len(filtered)
offset = (page - 1) * page_size
page_rows = filtered[offset : offset + page_size]
users: List[Dict[str, Any]] = []
for u in page_rows:
kc = u["keycloak_user_id"]
role_names = user_roles_map.get(kc, [])
role_str = ", ".join(role_names) if role_names else u.get("role")
local_ut = local_by_kc.get(kc)
normalized_user = _normalize_user(
{
"id": kc,
"username": u.get("username") or "",
"email": u.get("email") or "",
"firstName": u.get("first_name") or "",
"lastName": u.get("last_name") or "",
"enabled": u.get("is_active", True),
"emailVerified": False,
},
role_str,
local_ut,
)
users.append(normalized_user)
total_pages = max(1, (total + page_size - 1) // page_size) if total else 1
return {
"users": users,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting tenant users: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error getting users: {str(e)}"
) from e
async def get_user(self, user_id: str) -> Dict[str, Any]:
"""Obtiene un usuario específico"""
from ..permissions.models import UserCompanyRole
from sqlalchemy.orm import joinedload
user_tenant = self.db.query(UserTenant).filter(
and_(
UserTenant.keycloak_user_id == user_id,
UserTenant.tenant_id == self.tenant_id,
UserTenant.is_active == True,
)
).first()
if not user_tenant:
raise HTTPException(status_code=404, detail="User not found")
# Roles locales
user_roles = self.db.query(UserCompanyRole).options(joinedload(UserCompanyRole.company_role)).filter(
and_(
UserCompanyRole.user_id == user_id,
UserCompanyRole.company_id == self.company_id,
UserCompanyRole.tenant_id == self.tenant_id,
UserCompanyRole.is_active == True
)
).all()
roles = [ur.company_role.name for ur in user_roles]
role_str = ", ".join(roles) if roles else None
# TODO: Call Hub if more info is needed
return _normalize_user({"id": user_id}, role_str, user_tenant)
async def update_user(self, user_id: str, **kwargs) -> Dict[str, Any]:
"""Actualiza información local del usuario (e identidad vía Hub si se implementa)"""
user_tenant = self.db.query(UserTenant).filter(
and_(UserTenant.keycloak_user_id == user_id, UserTenant.tenant_id == self.tenant_id)
).first()
if not user_tenant:
raise HTTPException(status_code=404, detail="User not found")
# Actualizar campos locales
for field in ["role", "avatar_url", "phone", "bio", "preferences"]:
if field in kwargs and kwargs[field] is not None:
setattr(user_tenant, field, kwargs[field])
self.db.commit()
self.db.refresh(user_tenant)
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 activos está registrado el usuario."""
return (
self.db.query(func.count(UserTenant.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",
access_token: Optional[str] = None,
hub_tenant_id: Optional[int] = None,
) -> None:
"""
Elimina/Desactiva usuario.
scope='current': solo del tenant activo.
scope='all': de todos los tenants (útil cuando el usuario pertenece a múltiples tenants).
"""
if scope == "all":
rows = (
self.db.query(UserTenant)
.filter(UserTenant.keycloak_user_id == user_id)
.all()
)
if not rows:
raise HTTPException(status_code=404, detail="User not found")
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,
UserTenant.company_id == self.company_id,
)
).first()
if not user_tenant:
# 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:
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:
async with httpx.AsyncClient(timeout=10.0) as client:
await client.post(
f"{settings.HUB_URL}api/v1/auth/change-password",
json={"user_id": user_id, "password": password, "temporary": temporary}
)
except Exception as e:
logger.error(f"Error changing password: {e}")
raise HTTPException(status_code=500, detail="Error changing password")
def _count_active_user_tenants_local(self) -> int:
return (
self.db.query(func.count(UserTenant.id))
.filter(
and_(
UserTenant.tenant_id == self.tenant_id,
UserTenant.is_active == True,
)
)
.scalar()
or 0
)
def get_user_stats(
self,
access_token: Optional[str] = None,
hub_tenant_id: Optional[int] = None,
x_tenant_override: Optional[str] = None,
) -> Dict[str, Any]:
"""
Estadísticas de usuarios: cupo según licencia efectiva del Hub (verify-license)
con ``X-Tenant-Override``; activos desde users-with-info del Hub si hay token;
inactivos y fallback de conteos en BD local.
"""
max_users_allowed: Optional[int] = None # None = sin cuota (hub_admin ilimitado)
hub_max_ok = False
active_users = 0
active_from_hub = False
if access_token and hub_tenant_id:
base = (settings.HUB_URL or "").rstrip("/")
headers: Dict[str, Any] = {"Authorization": f"Bearer {access_token}"}
if x_tenant_override and str(x_tenant_override).strip():
headers["X-Tenant-Override"] = str(x_tenant_override).strip()
try:
with httpx.Client(timeout=30.0) as client:
lic_resp = client.get(
f"{base}/api/v1/auth/verify-license",
headers=headers,
)
if lic_resp.status_code == 200:
lic_body = lic_resp.json()
if lic_body.get("valid"):
raw_max = lic_body.get("max_users")
# max_users=null → hub_admin sin cuota; None indica ilimitado
max_users_allowed = int(raw_max) if raw_max is not None else None
hub_max_ok = True
users_resp = client.get(
f"{base}/api/v1/hub/user-tenants/tenant/{hub_tenant_id}/users-with-info",
headers=headers,
)
if users_resp.status_code == 200:
payload = users_resp.json()
if isinstance(payload, list):
active_users = sum(
1 for row in payload if row.get("is_active", True)
)
active_from_hub = True
else:
logger.warning(
"Hub users-with-info stats: respuesta no lista"
)
else:
logger.warning(
"Hub users-with-info stats status=%s",
users_resp.status_code,
)
except Exception as e:
logger.warning("Hub stats (verify-license / users-with-info): %s", e)
if not hub_max_ok:
license = self._get_license()
max_users_allowed = license.max_users
if not active_from_hub:
active_users = self._count_active_user_tenants_local()
inactive_users = (
self.db.query(func.count(UserTenant.id))
.filter(
and_(
UserTenant.tenant_id == self.tenant_id,
UserTenant.is_active == False,
)
)
.scalar()
or 0
)
total_users = active_users + inactive_users
# Cuando max_users_allowed es None la cuota es ilimitada (hub_admin)
users_available = (
max(0, max_users_allowed - active_users)
if max_users_allowed is not None
else None
)
usage_percentage = (
(active_users / max_users_allowed * 100) if max_users_allowed else 0.0
)
return {
"total_users": total_users,
"active_users": active_users,
"inactive_users": inactive_users,
"max_users_allowed": max_users_allowed,
"users_available": users_available,
"usage_percentage": round(usage_percentage, 2),
}
async def get_current_user_profile(
self,
keycloak_user_id: str,
current_user: Dict[str, Any] = None,
access_token: Optional[str] = None,
) -> Dict[str, Any]:
"""Obtiene el perfil completo del usuario actual"""
# Use the already-verified JWT claims dict — do NOT call verify_token(uuid)
user_info = current_user or {"id": keycloak_user_id}
# Perfil "me": sincronización con cache corto (5 min).
if access_token:
from core.workspace_profile_sync import sync_workspace_profile_for_user
await sync_workspace_profile_for_user(
self.db,
access_token=access_token,
keycloak_user_id=keycloak_user_id,
tenant_id=self.tenant_id,
)
user_tenant = self.db.query(UserTenant).filter(
and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.is_active == True)
).first()
return _normalize_user(user_info, user_tenant.role if user_tenant else None, user_tenant)
async def update_current_user_profile(
self,
keycloak_user_id: str,
current_user: Dict[str, Any] = None,
access_token: Optional[str] = None,
**kwargs,
) -> Dict[str, Any]:
"""
Actualiza el perfil del usuario actual.
- first_name / last_name: persiste localmente en UserTenant Y sincroniza con Keycloak vía Hub.
- phone: persiste solo localmente.
"""
user_tenant = self.db.query(UserTenant).filter(
and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.tenant_id == self.tenant_id)
).first()
if not user_tenant:
raise HTTPException(status_code=404, detail="User not found")
# Campos locales — incluye first_name/last_name como caché
for field in ["role", "avatar_url", "phone", "bio", "preferences", "first_name", "last_name"]:
if field in kwargs and kwargs[field] is not None:
setattr(user_tenant, field, kwargs[field])
self.db.commit()
self.db.refresh(user_tenant)
# Sincronizar nombre/apellido con Keycloak vía Hub (best-effort)
first_name = kwargs.get("first_name")
last_name = kwargs.get("last_name")
if access_token and (first_name or last_name):
await self._hub_update_user_profile(keycloak_user_id, first_name, last_name, access_token)
user_info = current_user or {"id": keycloak_user_id}
return _normalize_user(user_info, user_tenant.role, user_tenant)
async def _hub_get_service_token(self) -> Optional[str]:
"""Obtiene un token de la cuenta de servicio Hub para operaciones admin."""
if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD:
return None
base = (settings.HUB_URL or "").rstrip("/")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
f"{base}/api/v1/auth/login",
json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD},
)
if resp.status_code == 200:
data = resp.json()
return data.get("access_token")
logger.warning("Hub service account login failed status=%s", resp.status_code)
except Exception as exc:
logger.warning("Hub service account login error: %s", exc)
return None
async def _hub_update_user_profile(
self,
user_id: str,
first_name: Optional[str],
last_name: Optional[str],
access_token: str,
) -> None:
"""Sincroniza nombre/apellido con Keycloak a través del Hub (best-effort, no bloquea).
Intenta primero con el token del usuario. Si el Hub devuelve 403 (el usuario no
tiene rol de Hub-admin), reintenta usando la cuenta de servicio configurada en
HUB_ADMIN_EMAIL / HUB_ADMIN_PASSWORD.
"""
base = (settings.HUB_URL or "").rstrip("/")
url = f"{base}/api/v1/hub/admins/{user_id}"
payload: Dict[str, Any] = {}
if first_name:
payload["first_name"] = first_name
if last_name:
payload["last_name"] = last_name
if not payload:
return
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.patch(
url,
json=payload,
headers={"Authorization": f"Bearer {access_token}"},
)
if resp.status_code == 403:
# El usuario no es Hub admin — reintentar con cuenta de servicio
logger.info("Hub profile sync: user token got 403, trying service account for user_id=%s", user_id)
service_token = await self._hub_get_service_token()
if service_token:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.patch(
url,
json=payload,
headers={"Authorization": f"Bearer {service_token}"},
)
else:
logger.warning(
"Hub profile sync skipped: no service account configured (HUB_ADMIN_EMAIL/HUB_ADMIN_PASSWORD)"
)
return
if resp.status_code not in (200, 204):
logger.warning(
"Hub profile sync failed status=%s body=%s",
resp.status_code,
resp.text[:300],
)
else:
logger.info("Hub profile sync OK user_id=%s", user_id)
except Exception as exc:
logger.warning("Hub profile sync error user_id=%s: %s", user_id, exc)