feat: Implement user-tenant relationship management with CRUD operations and access control

This commit is contained in:
2025-11-07 14:38:48 -06:00
parent ef3924a41d
commit 2fe6a7c8ff
7 changed files with 572 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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