From 056909b17f15752573581803d4f6a5872b0185ac Mon Sep 17 00:00:00 2001 From: acazares Date: Thu, 15 Jan 2026 08:10:01 -0600 Subject: [PATCH] feat(users): enhance user management with company-specific API calls and access validation --- backend/api/v1/modules/core/users/routes.py | 72 +++++++++---------- backend/core/security.py | 2 +- frontend/src/lib/api/dashboard/users.ts | 28 ++++---- .../src/routes/dashboard/users/+page.svelte | 54 +++++++++++--- 4 files changed, 95 insertions(+), 61 deletions(-) diff --git a/backend/api/v1/modules/core/users/routes.py b/backend/api/v1/modules/core/users/routes.py index 773773f9..594355f5 100644 --- a/backend/api/v1/modules/core/users/routes.py +++ b/backend/api/v1/modules/core/users/routes.py @@ -8,7 +8,7 @@ import uuid from pathlib import Path from core.database import get_core_db -from core.security import get_current_user, get_tenant_from_token +from core.security import get_current_user, validate_access_to_resource from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from sqlalchemy.orm import Session @@ -26,37 +26,11 @@ from .service import UserService router = APIRouter(prefix="/users", tags=["Users"]) -def get_user_service( - db: Session = Depends(get_core_db), - current_user: dict = Depends(get_current_user), -) -> UserService: - """Dependency para obtener servicio de usuarios con el tenant y company del usuario actual""" - # Obtener keycloak_user_id 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") - - # Buscar el user_tenant activo del usuario - 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" - ) - - return UserService(db, user_tenant.tenant_id, user_tenant.company_id) - - @router.get("/stats", response_model=UserStatsDTO) def get_user_statistics( - service: UserService = Depends(get_user_service), + 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 @@ -68,21 +42,27 @@ def get_user_statistics( - Usuarios disponibles - Porcentaje de uso """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = UserService(db, tenant_id, company_id) return service.get_user_stats() @router.get("/", response_model=UserListResponseDTO) def list_users( + 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"), - service: UserService = Depends(get_user_service), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """ Lista todos los usuarios del tenant con paginación Se puede filtrar por término de búsqueda (busca en username, email, nombre) """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = UserService(db, tenant_id, company_id) result = service.get_tenant_users(page=page, page_size=page_size, search=search) return result @@ -90,20 +70,26 @@ def list_users( @router.get("/{user_id}", response_model=UserResponseDTO) def get_user( user_id: str, - service: UserService = Depends(get_user_service), + 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 El usuario debe pertenecer al tenant actual """ + tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"]) + service = UserService(db, tenant_id, company_id) return service.get_user(user_id) @router.post("/", response_model=UserResponseDTO, status_code=201) def create_user( data: CreateUserRequestDTO, - service: UserService = Depends(get_user_service), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """ Crea un nuevo usuario en Keycloak y lo asocia al tenant @@ -115,6 +101,8 @@ def create_user( Nota: El tenant_id se obtiene automáticamente del servicio (del token del usuario actual) """ + tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.create"]) + service = UserService(db, tenant_id, company_id) user = service.create_user( email=data.email, username=data.username, @@ -132,7 +120,9 @@ def create_user( def update_user( user_id: str, data: UpdateUserRequestDTO, - service: UserService = Depends(get_user_service), + 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 @@ -144,6 +134,8 @@ def update_user( - Rol en el tenant - Perfil (avatar, teléfono, bio, preferencias) """ + tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.update"]) + service = UserService(db, tenant_id, company_id) user = service.update_user( user_id=user_id, first_name=data.first_name, @@ -163,11 +155,13 @@ def update_user( @router.delete("/{user_id}") def delete_user( 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", ), - service: UserService = Depends(get_user_service), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """ Elimina un usuario del tenant @@ -175,6 +169,8 @@ def delete_user( - soft_delete=True: Solo desactiva la relación (recomendado) - soft_delete=False: Elimina permanentemente de Keycloak """ + tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.delete"]) + service = UserService(db, tenant_id, company_id) service.delete_user(user_id, soft_delete=soft_delete) return {"message": "User deleted successfully"} @@ -183,7 +179,9 @@ def delete_user( def change_user_password( user_id: str, data: ChangePasswordRequestDTO, - service: UserService = Depends(get_user_service), + 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 @@ -191,6 +189,8 @@ def change_user_password( - temporary=True: Usuario debe cambiar la contraseña en el próximo login - temporary=False: Contraseña permanente """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + service = UserService(db, tenant_id, company_id) service.change_password(user_id, data.password, data.temporary) return {"message": "Password changed successfully"} diff --git a/backend/core/security.py b/backend/core/security.py index 1c7f68c1..85111961 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -180,7 +180,7 @@ def validate_access_to_resource( company_id: int, current_user: Dict[str, Any], required_permissions: Optional[list[str]] = None, - require_all: bool = False, + require_all: bool = True, ) -> int: """ Valida que el usuario tenga acceso a un recurso específico basado en company_id diff --git a/frontend/src/lib/api/dashboard/users.ts b/frontend/src/lib/api/dashboard/users.ts index ce8badd9..f8992b0a 100644 --- a/frontend/src/lib/api/dashboard/users.ts +++ b/frontend/src/lib/api/dashboard/users.ts @@ -62,8 +62,8 @@ export const usersAPI = { /** * Obtiene estadísticas de usuarios del tenant */ - async getStats(): Promise { - const response = await api.get('/v1/core/users/stats'); + async getStats(companyId: number): Promise { + const response = await api.get(`/v1/core/users/stats?company_id=${companyId}`); if (response.error) { throw new Error(response.error); } @@ -73,17 +73,18 @@ export const usersAPI = { /** * Lista usuarios del tenant con paginación */ - async list(params?: { + async list(companyId: number, params?: { page?: number; page_size?: number; search?: string; }): Promise { const queryParams = new URLSearchParams(); + queryParams.set('company_id', companyId.toString()); if (params?.page) queryParams.set('page', params.page.toString()); if (params?.page_size) queryParams.set('page_size', params.page_size.toString()); if (params?.search) queryParams.set('search', params.search); - const endpoint = `/v1/core/users/${queryParams.toString() ? `?${queryParams}` : ''}`; + const endpoint = `/v1/core/users/?${queryParams}`; const response = await api.get(endpoint); if (response.error) { throw new Error(response.error); @@ -94,8 +95,8 @@ export const usersAPI = { /** * Obtiene un usuario específico */ - async get(userId: string): Promise { - const response = await api.get(`/v1/core/users/${userId}`); + async get(userId: string, companyId: number): Promise { + const response = await api.get(`/v1/core/users/${userId}?company_id=${companyId}`); if (response.error) { throw new Error(response.error); } @@ -105,8 +106,8 @@ export const usersAPI = { /** * Crea un nuevo usuario */ - async create(data: CreateUserRequest): Promise { - const response = await api.post('/v1/core/users/', data); + async create(data: CreateUserRequest, companyId: number): Promise { + const response = await api.post(`/v1/core/users/?company_id=${companyId}`, data); if (response.error) { throw new Error(response.error); } @@ -116,8 +117,8 @@ export const usersAPI = { /** * Actualiza un usuario existente */ - async update(userId: string, data: UpdateUserRequest): Promise { - const response = await api.put(`/v1/core/users/${userId}`, data); + async update(userId: string, data: UpdateUserRequest, companyId: number): Promise { + const response = await api.put(`/v1/core/users/${userId}?company_id=${companyId}`, data); if (response.error) { throw new Error(response.error); } @@ -127,8 +128,9 @@ export const usersAPI = { /** * Elimina un usuario */ - async delete(userId: string, softDelete: boolean = true): Promise { + async delete(userId: string, companyId: number, softDelete: boolean = true): Promise { const queryParams = new URLSearchParams(); + queryParams.set('company_id', companyId.toString()); queryParams.set('soft_delete', softDelete.toString()); const response = await api.delete(`/v1/core/users/${userId}?${queryParams}`); @@ -140,8 +142,8 @@ export const usersAPI = { /** * Cambia la contraseña de un usuario */ - async changePassword(userId: string, data: ChangePasswordRequest): Promise { - const response = await api.post(`/v1/core/users/${userId}/change-password`, data); + async changePassword(userId: string, data: ChangePasswordRequest, companyId: number): Promise { + const response = await api.post(`/v1/core/users/${userId}/change-password?company_id=${companyId}`, data); if (response.error) { throw new Error(response.error); } diff --git a/frontend/src/routes/dashboard/users/+page.svelte b/frontend/src/routes/dashboard/users/+page.svelte index 3b4a7a3a..4da992e5 100644 --- a/frontend/src/routes/dashboard/users/+page.svelte +++ b/frontend/src/routes/dashboard/users/+page.svelte @@ -1,6 +1,6 @@