From 2fe6a7c8ff22cb53ccd6ef647d5bf9da004610da Mon Sep 17 00:00:00 2001 From: acazares Date: Fri, 7 Nov 2025 14:38:48 -0600 Subject: [PATCH] feat: Implement user-tenant relationship management with CRUD operations and access control --- backend/api/v1/modules/a76/auth/service.py | 80 +++++- backend/api/v1/modules/a76/router.py | 2 + backend/api/v1/modules/a76/tenants/models.py | 9 +- backend/api/v1/modules/a76/user_tenant/dto.py | 59 +++++ .../api/v1/modules/a76/user_tenant/models.py | 49 ++++ .../api/v1/modules/a76/user_tenant/routes.py | 147 +++++++++++ .../api/v1/modules/a76/user_tenant/service.py | 228 ++++++++++++++++++ 7 files changed, 572 insertions(+), 2 deletions(-) create mode 100644 backend/api/v1/modules/a76/user_tenant/dto.py create mode 100644 backend/api/v1/modules/a76/user_tenant/models.py create mode 100644 backend/api/v1/modules/a76/user_tenant/routes.py create mode 100644 backend/api/v1/modules/a76/user_tenant/service.py diff --git a/backend/api/v1/modules/a76/auth/service.py b/backend/api/v1/modules/a76/auth/service.py index 24a35dd0..e5e6f1a1 100644 --- a/backend/api/v1/modules/a76/auth/service.py +++ b/backend/api/v1/modules/a76/auth/service.py @@ -49,7 +49,10 @@ class AuthService: try: # Verificar que el tenant existe from api.v1.modules.a76.tenants.service import TenantService + from api.v1.modules.a76.user_tenant.service import UserTenantService + tenant_service = TenantService(self.db) + user_tenant_service = UserTenantService(self.db) tenant = tenant_service.get_tenant_by_slug(login_data.tenant_slug) if not tenant: @@ -73,7 +76,59 @@ class AuthService: grant_type=["password"] ) - logger.info(f"User logged in: {login_data.username} (tenant: {tenant.slug})") + # Obtener información del usuario y verificar acceso al tenant + user_info = keycloak_client.userinfo(token_response["access_token"]) + user_id = user_info.get("sub") + + if user_id: + # Verificar si el usuario tiene acceso a este tenant + has_access = user_tenant_service.user_has_access_to_tenant(user_id, tenant.id) + + if not has_access: + logger.warning(f"User {user_id} tried to access tenant {tenant.id} without permission") + raise HTTPException( + status_code=403, + detail="You don't have access to this tenant" + ) + + # Actualizar el tenant_id del usuario en Keycloak basado en el slug usado + try: + # Crear instancia de KeycloakAdmin para actualizar atributos + keycloak_admin = KeycloakAdmin( + server_url=settings.KEYCLOAK_SERVER_URL, + username=settings.KEYCLOAK_ADMIN_USERNAME, + password=settings.KEYCLOAK_ADMIN_PASSWORD, + realm_name=tenant.keycloak_realm, + user_realm_name="master", + verify=True + ) + + # Obtener los datos actuales del usuario para no sobrescribirlos + current_user = keycloak_admin.get_user(user_id) + + # Obtener los atributos actuales o crear un dict vacío + current_attributes = current_user.get("attributes", {}) + + # Actualizar solo los atributos de tenant + current_attributes["tenant_id"] = [str(tenant.id)] + current_attributes["tenant_slug"] = [tenant.slug] + + # Actualizar el usuario enviando TODOS los campos para evitar que se borren + update_payload = { + "email": current_user.get("email"), + "firstName": current_user.get("firstName"), + "lastName": current_user.get("lastName"), + "enabled": current_user.get("enabled", True), + "emailVerified": current_user.get("emailVerified", False), + "attributes": current_attributes + } + + keycloak_admin.update_user(user_id=user_id, payload=update_payload) + logger.info(f"Updated tenant_id={tenant.id} for user {login_data.username}") + + except Exception as e: + # No queremos que falle el login si no se puede actualizar el atributo + logger.warning(f"Could not update tenant_id attribute: {str(e)}") return TokenResponseDTO( access_token=token_response["access_token"], @@ -249,6 +304,29 @@ class AuthService: # El rol 'user' no existe, no es un error crítico logger.warning(f"Could not assign 'user' role: {str(e)}") + # Agregar el usuario al tenant en la base de datos + try: + from api.v1.modules.a76.user_tenant.service import UserTenantService + user_tenant_service = UserTenantService(self.db) + user_tenant_service.add_user_to_tenant( + keycloak_user_id=user_id, + tenant_id=tenant.id, + role="user" # Rol por defecto + ) + logger.info(f"Added user {user_id} to tenant {tenant.id} in database") + except Exception as e: + # Si falla, hacer rollback del usuario en Keycloak + logger.error(f"Failed to add user to tenant in database: {str(e)}") + try: + keycloak_admin.delete_user(user_id) + logger.info(f"Rolled back user creation in Keycloak") + except: + pass + raise HTTPException( + status_code=500, + detail="Failed to register user in database" + ) + logger.info(f"User registered: {register_data.username} (tenant: {tenant.slug}, user_id: {user_id})") return RegisterResponseDTO( diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 53d7fd4a..25ddcbb3 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -7,6 +7,7 @@ from fastapi import APIRouter # Importar routers de módulos from .auth import router as auth_router from .tenants import router as tenants_router +from .user_tenant.routes import router as user_tenant_router from .licenses import router as licenses_router from .pedmientos.router import router as pedimentos_router from .client_and_provider import router as client_and_provider_router @@ -26,6 +27,7 @@ router = APIRouter() # Registrar módulos router.include_router(auth_router) router.include_router(tenants_router, prefix="/a76", tags=["a76 / tenants"]) +router.include_router(user_tenant_router, prefix="/a76", tags=["a76 / user-tenants"]) router.include_router(licenses_router, prefix="/a76", tags=["a76 / licenses"]) router.include_router(pedimentos_router, prefix="/a76") router.include_router(client_and_provider_router, prefix="/a76", tags=["a76 / clients and providers"]) diff --git a/backend/api/v1/modules/a76/tenants/models.py b/backend/api/v1/modules/a76/tenants/models.py index b4ab9e72..b27281d9 100644 --- a/backend/api/v1/modules/a76/tenants/models.py +++ b/backend/api/v1/modules/a76/tenants/models.py @@ -3,11 +3,15 @@ Modelos ORM para gestión de tenants """ from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, Enum as SQLEnum from sqlalchemy.sql import func -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime +from typing import List, TYPE_CHECKING from core.database import Base import enum +if TYPE_CHECKING: + from api.v1.modules.a76.user_tenant.models import UserTenant + class TenantType(enum.Enum): """Tipo de tenant según tamaño y necesidades""" @@ -49,5 +53,8 @@ class Tenant(Base): updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=func.now(), onupdate=func.now()) deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + # Relación con UserTenant + user_relations: Mapped[List["UserTenant"]] = relationship("UserTenant", back_populates="tenant") + def __repr__(self): return f"" diff --git a/backend/api/v1/modules/a76/user_tenant/dto.py b/backend/api/v1/modules/a76/user_tenant/dto.py new file mode 100644 index 00000000..4221d3cc --- /dev/null +++ b/backend/api/v1/modules/a76/user_tenant/dto.py @@ -0,0 +1,59 @@ +""" +DTOs para gestión de relaciones usuario-tenant +""" +from pydantic import BaseModel, Field +from typing import Optional +from datetime import datetime + + +class AddUserToTenantRequestDTO(BaseModel): + """Request para agregar un usuario a un tenant""" + keycloak_user_id: str = Field(..., description="ID del usuario en Keycloak") + tenant_id: int = Field(..., description="ID del tenant") + role: Optional[str] = Field(None, description="Rol del usuario en el tenant") + + +class RemoveUserFromTenantRequestDTO(BaseModel): + """Request para eliminar un usuario de un tenant""" + keycloak_user_id: str = Field(..., description="ID del usuario en Keycloak") + tenant_id: int = Field(..., description="ID del tenant") + soft_delete: bool = Field(True, description="Si True, desactiva. Si False, elimina") + + +class UpdateUserRoleRequestDTO(BaseModel): + """Request para actualizar el rol de un usuario en un tenant""" + keycloak_user_id: str = Field(..., description="ID del usuario en Keycloak") + tenant_id: int = Field(..., description="ID del tenant") + role: str = Field(..., description="Nuevo rol del usuario") + + +class UserTenantResponseDTO(BaseModel): + """Response con información de relación usuario-tenant""" + id: int + keycloak_user_id: str + tenant_id: int + is_active: bool + role: Optional[str] + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class TenantBasicInfoDTO(BaseModel): + """Información básica de un tenant""" + id: int + name: str + slug: str + is_active: bool + keycloak_realm: str + + class Config: + from_attributes = True + + +class UserTenantsResponseDTO(BaseModel): + """Response con los tenants de un usuario""" + keycloak_user_id: str + tenants: list[TenantBasicInfoDTO] diff --git a/backend/api/v1/modules/a76/user_tenant/models.py b/backend/api/v1/modules/a76/user_tenant/models.py new file mode 100644 index 00000000..e6e141d9 --- /dev/null +++ b/backend/api/v1/modules/a76/user_tenant/models.py @@ -0,0 +1,49 @@ +""" +Modelo de relación entre usuarios (Keycloak) y tenants +""" +from sqlalchemy import Integer, String, DateTime, Boolean, UniqueConstraint, ForeignKeyConstraint +from sqlalchemy.sql import func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from typing import Optional, TYPE_CHECKING +from core.database import Base + +if TYPE_CHECKING: + from api.v1.modules.a76.tenants.models import Tenant + +class UserTenant(Base): + """ + Relación muchos-a-muchos entre usuarios de Keycloak y tenants + + Un usuario puede pertenecer a múltiples tenants + Un tenant puede tener múltiples usuarios + """ + __tablename__ = "user_tenants" + __table_args__ = ( + ForeignKeyConstraint(['tenant_id'], ['a76.tenants.id']), + UniqueConstraint('keycloak_user_id', 'tenant_id', name='uq_user_tenant'), + {"schema": "a76"} + ) + + # Primary Key + id: Mapped[int] = mapped_column(primary_key=True, index=True) + + # ID del usuario en Keycloak (UUID string) + keycloak_user_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + + # ID del tenant + tenant_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + + # Estado de la relación + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + # Información adicional - Rol del usuario en este tenant (opcional) + role: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + + # Timestamps + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now()) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + # Relación con Tenant + tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="user_relations") diff --git a/backend/api/v1/modules/a76/user_tenant/routes.py b/backend/api/v1/modules/a76/user_tenant/routes.py new file mode 100644 index 00000000..cab811ba --- /dev/null +++ b/backend/api/v1/modules/a76/user_tenant/routes.py @@ -0,0 +1,147 @@ +""" +Rutas para gestión de relaciones usuario-tenant +""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List + +from core.database import get_core_db +from core.security import get_current_user +from .service import UserTenantService +from .dto import ( + AddUserToTenantRequestDTO, + RemoveUserFromTenantRequestDTO, + UpdateUserRoleRequestDTO, + UserTenantResponseDTO, + UserTenantsResponseDTO, + TenantBasicInfoDTO +) + +router = APIRouter( + prefix="/user-tenants", + tags=["User-Tenant Relations"] +) + + +@router.post("/add", response_model=UserTenantResponseDTO) +def add_user_to_tenant( + data: AddUserToTenantRequestDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Agrega un usuario a un tenant + + Requiere permisos de administrador + """ + service = UserTenantService(db) + result = service.add_user_to_tenant( + keycloak_user_id=data.keycloak_user_id, + tenant_id=data.tenant_id, + role=data.role + ) + return result + + +@router.post("/remove") +def remove_user_from_tenant( + data: RemoveUserFromTenantRequestDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Elimina un usuario de un tenant + + Requiere permisos de administrador + """ + service = UserTenantService(db) + service.remove_user_from_tenant( + keycloak_user_id=data.keycloak_user_id, + tenant_id=data.tenant_id, + soft_delete=data.soft_delete + ) + return {"message": "User removed from tenant successfully"} + + +@router.put("/update-role", response_model=UserTenantResponseDTO) +def update_user_role( + data: UpdateUserRoleRequestDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Actualiza el rol de un usuario en un tenant + + Requiere permisos de administrador + """ + service = UserTenantService(db) + result = service.update_user_role_in_tenant( + keycloak_user_id=data.keycloak_user_id, + tenant_id=data.tenant_id, + role=data.role + ) + return result + + +@router.get("/user/{keycloak_user_id}", response_model=UserTenantsResponseDTO) +def get_user_tenants( + keycloak_user_id: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Obtiene todos los tenants a los que tiene acceso un usuario + + Los usuarios solo pueden ver sus propios tenants, a menos que sean admin + """ + # Verificar que el usuario solo pueda ver sus propios tenants (excepto admin) + if current_user.get("sub") != keycloak_user_id: + # TODO: Verificar si es admin + raise HTTPException( + status_code=403, + detail="You can only view your own tenants" + ) + + service = UserTenantService(db) + tenants = service.get_user_tenants(keycloak_user_id) + + return UserTenantsResponseDTO( + keycloak_user_id=keycloak_user_id, + tenants=[TenantBasicInfoDTO.model_validate(t) for t in tenants] + ) + + +@router.get("/tenant/{tenant_id}", response_model=List[UserTenantResponseDTO]) +def get_tenant_users( + tenant_id: int, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Obtiene todos los usuarios que tienen acceso a un tenant + + Requiere permisos de administrador del tenant + """ + service = UserTenantService(db) + user_tenants = service.get_tenant_users(tenant_id) + return user_tenants + + +@router.get("/check-access/{keycloak_user_id}/{tenant_id}") +def check_user_access( + keycloak_user_id: str, + tenant_id: int, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Verifica si un usuario tiene acceso a un tenant + """ + service = UserTenantService(db) + has_access = service.user_has_access_to_tenant(keycloak_user_id, tenant_id) + + return { + "keycloak_user_id": keycloak_user_id, + "tenant_id": tenant_id, + "has_access": has_access + } diff --git a/backend/api/v1/modules/a76/user_tenant/service.py b/backend/api/v1/modules/a76/user_tenant/service.py new file mode 100644 index 00000000..f93d4c03 --- /dev/null +++ b/backend/api/v1/modules/a76/user_tenant/service.py @@ -0,0 +1,228 @@ +""" +Servicio para gestionar relaciones entre usuarios y tenants +""" +from sqlalchemy.orm import Session +from sqlalchemy import and_ +from typing import List, Optional +from fastapi import HTTPException +import logging + +from .models import UserTenant +from ..tenants.models import Tenant + +logger = logging.getLogger(__name__) + + +class UserTenantService: + """Servicio para gestionar acceso de usuarios a tenants""" + + def __init__(self, db: Session): + self.db = db + + def add_user_to_tenant( + self, + keycloak_user_id: str, + tenant_id: int, + role: Optional[str] = None + ) -> UserTenant: + """ + Agrega un usuario a un tenant + + Args: + keycloak_user_id: ID del usuario en Keycloak + tenant_id: ID del tenant + role: Rol opcional del usuario en este tenant + + Returns: + UserTenant creado + """ + # Verificar que el tenant existe + tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first() + if not tenant: + raise HTTPException(status_code=404, detail="Tenant not found") + + # Verificar si la relación ya existe + existing = self.db.query(UserTenant).filter( + and_( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.tenant_id == tenant_id + ) + ).first() + + if existing: + # Si existe pero está inactiva, reactivarla + if not existing.is_active: + existing.is_active = True + existing.role = role + self.db.commit() + self.db.refresh(existing) + logger.info(f"Reactivated user {keycloak_user_id} in tenant {tenant_id}") + return existing + else: + raise HTTPException( + status_code=409, + detail="User already has access to this tenant" + ) + + # Crear nueva relación + user_tenant = UserTenant( + keycloak_user_id=keycloak_user_id, + tenant_id=tenant_id, + role=role, + is_active=True + ) + + self.db.add(user_tenant) + self.db.commit() + self.db.refresh(user_tenant) + + logger.info(f"Added user {keycloak_user_id} to tenant {tenant_id}") + return user_tenant + + def remove_user_from_tenant( + self, + keycloak_user_id: str, + tenant_id: int, + soft_delete: bool = True + ) -> bool: + """ + Elimina un usuario de un tenant + + Args: + keycloak_user_id: ID del usuario en Keycloak + tenant_id: ID del tenant + soft_delete: Si True, solo marca como inactivo. Si False, elimina físicamente + + Returns: + True si se eliminó correctamente + """ + user_tenant = self.db.query(UserTenant).filter( + and_( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.tenant_id == tenant_id + ) + ).first() + + if not user_tenant: + raise HTTPException( + status_code=404, + detail="User-tenant relationship not found" + ) + + if soft_delete: + user_tenant.is_active = False + self.db.commit() + logger.info(f"Deactivated user {keycloak_user_id} from tenant {tenant_id}") + else: + self.db.delete(user_tenant) + self.db.commit() + logger.info(f"Deleted user {keycloak_user_id} from tenant {tenant_id}") + + return True + + def get_user_tenants(self, keycloak_user_id: str) -> List[Tenant]: + """ + Obtiene todos los tenants a los que tiene acceso un usuario + + Args: + keycloak_user_id: ID del usuario en Keycloak + + Returns: + Lista de tenants + """ + user_tenants = self.db.query(UserTenant).filter( + and_( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.is_active == True + ) + ).all() + + tenant_ids = [ut.tenant_id for ut in user_tenants] + + tenants = self.db.query(Tenant).filter( + and_( + Tenant.id.in_(tenant_ids), + Tenant.is_active == True + ) + ).all() + + return tenants + + def get_tenant_users(self, tenant_id: int) -> List[UserTenant]: + """ + Obtiene todos los usuarios que tienen acceso a un tenant + + Args: + tenant_id: ID del tenant + + Returns: + Lista de relaciones UserTenant + """ + return self.db.query(UserTenant).filter( + and_( + UserTenant.tenant_id == tenant_id, + UserTenant.is_active == True + ) + ).all() + + def user_has_access_to_tenant( + self, + keycloak_user_id: str, + tenant_id: int + ) -> bool: + """ + Verifica si un usuario tiene acceso a un tenant + + Args: + keycloak_user_id: ID del usuario en Keycloak + tenant_id: ID del tenant + + Returns: + True si tiene acceso, False en caso contrario + """ + user_tenant = self.db.query(UserTenant).filter( + and_( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.tenant_id == tenant_id, + UserTenant.is_active == True + ) + ).first() + + return user_tenant is not None + + def update_user_role_in_tenant( + self, + keycloak_user_id: str, + tenant_id: int, + role: str + ) -> UserTenant: + """ + Actualiza el rol de un usuario en un tenant + + Args: + keycloak_user_id: ID del usuario en Keycloak + tenant_id: ID del tenant + role: Nuevo rol + + Returns: + UserTenant actualizado + """ + user_tenant = self.db.query(UserTenant).filter( + and_( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.tenant_id == tenant_id + ) + ).first() + + if not user_tenant: + raise HTTPException( + status_code=404, + detail="User-tenant relationship not found" + ) + + user_tenant.role = role + self.db.commit() + self.db.refresh(user_tenant) + + logger.info(f"Updated role for user {keycloak_user_id} in tenant {tenant_id} to {role}") + return user_tenant