feat: Implement user-tenant relationship management with CRUD operations and access control
This commit is contained in:
59
backend/api/v1/modules/a76/user_tenant/dto.py
Normal file
59
backend/api/v1/modules/a76/user_tenant/dto.py
Normal 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]
|
||||
49
backend/api/v1/modules/a76/user_tenant/models.py
Normal file
49
backend/api/v1/modules/a76/user_tenant/models.py
Normal 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")
|
||||
147
backend/api/v1/modules/a76/user_tenant/routes.py
Normal file
147
backend/api/v1/modules/a76/user_tenant/routes.py
Normal 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
|
||||
}
|
||||
228
backend/api/v1/modules/a76/user_tenant/service.py
Normal file
228
backend/api/v1/modules/a76/user_tenant/service.py
Normal 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
|
||||
Reference in New Issue
Block a user