feat: Implement multi-tenancy support in middleware and security layers

- Enhanced TenantMiddleware to validate tenant information from JWT tokens.
- Added LicenseValidationMiddleware to check tenant licenses before processing requests.
- Updated security utilities to extract tenant information from tokens and validate company access.
- Introduced CompanyStore to manage active company state and handle company switching in the frontend.
- Modified API routes to include company_id in requests for better resource management.
- Improved logging and error handling throughout the middleware and API layers.
- Updated frontend components to reflect changes in company management and selection.
- Added new API route for fetching user's companies with proper authentication handling.
This commit is contained in:
2025-11-11 14:00:56 -06:00
parent e1eb6bbd01
commit 52b8fcd434
242 changed files with 7067 additions and 3274 deletions

View File

@@ -1,6 +1,7 @@
"""
DTOs para gestión de relaciones usuario-tenant
"""
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
@@ -8,6 +9,7 @@ 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")
@@ -15,6 +17,7 @@ class AddUserToTenantRequestDTO(BaseModel):
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")
@@ -22,6 +25,7 @@ class RemoveUserFromTenantRequestDTO(BaseModel):
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")
@@ -29,6 +33,7 @@ class UpdateUserRoleRequestDTO(BaseModel):
class UserTenantResponseDTO(BaseModel):
"""Response con información de relación usuario-tenant"""
id: int
keycloak_user_id: str
tenant_id: int
@@ -36,24 +41,26 @@ class UserTenantResponseDTO(BaseModel):
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

@@ -1,7 +1,15 @@
"""
Modelo de relación entre usuarios (Keycloak) y tenants
"""
from sqlalchemy import Integer, String, DateTime, Boolean, UniqueConstraint, ForeignKeyConstraint, ForeignKey
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
@@ -11,42 +19,51 @@ 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"}
ForeignKeyConstraint(["company_id"], ["a76.company.id"]),
ForeignKeyConstraint(["tenant_id"], ["a76.tenants.id"]),
UniqueConstraint(
"keycloak_user_id", "tenant_id", "company_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)
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)
# ID de la empresa asociada
company_id: Mapped[int] = mapped_column(Integer, ForeignKey("a76.companies.id"), nullable=False, index=True)
company_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())
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

@@ -1,6 +1,7 @@
"""
Rutas para gestión de relaciones usuario-tenant
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
@@ -14,31 +15,26 @@ from .dto import (
UpdateUserRoleRequestDTO,
UserTenantResponseDTO,
UserTenantsResponseDTO,
TenantBasicInfoDTO
TenantBasicInfoDTO,
)
router = APIRouter(
prefix="/user-tenants",
tags=["User-Tenant Relations"]
)
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)
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
keycloak_user_id=data.keycloak_user_id, tenant_id=data.tenant_id, role=data.role
)
return result
@@ -47,18 +43,18 @@ def add_user_to_tenant(
def remove_user_from_tenant(
data: RemoveUserFromTenantRequestDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
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
soft_delete=data.soft_delete,
)
return {"message": "User removed from tenant successfully"}
@@ -67,18 +63,16 @@ def remove_user_from_tenant(
def update_user_role(
data: UpdateUserRoleRequestDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
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
keycloak_user_id=data.keycloak_user_id, tenant_id=data.tenant_id, role=data.role
)
return result
@@ -87,27 +81,26 @@ def update_user_role(
def get_user_tenants(
keycloak_user_id: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
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"
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]
tenants=[TenantBasicInfoDTO.model_validate(t) for t in tenants],
)
@@ -115,11 +108,11 @@ def get_user_tenants(
def get_tenant_users(
tenant_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
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)
@@ -132,16 +125,16 @@ def check_user_access(
keycloak_user_id: str,
tenant_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
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
"has_access": has_access,
}

View File

@@ -1,6 +1,7 @@
"""
Servicio para gestionar relaciones entre usuarios y tenants
"""
from sqlalchemy.orm import Session
from sqlalchemy import and_
from typing import List, Optional
@@ -15,24 +16,21 @@ 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
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
"""
@@ -40,15 +38,19 @@ class UserTenantService:
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
existing = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.tenant_id == tenant_id,
)
)
).first()
.first()
)
if existing:
# Si existe pero está inactiva, reactivarla
if not existing.is_active:
@@ -56,59 +58,60 @@ class UserTenantService:
existing.role = role
self.db.commit()
self.db.refresh(existing)
logger.info(f"Reactivated user {keycloak_user_id} in tenant {tenant_id}")
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"
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
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
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
user_tenant = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.tenant_id == tenant_id,
)
)
).first()
.first()
)
if not user_tenant:
raise HTTPException(
status_code=404,
detail="User-tenant relationship not found"
status_code=404, detail="User-tenant relationship not found"
)
if soft_delete:
user_tenant.is_active = False
self.db.commit()
@@ -117,112 +120,118 @@ class UserTenantService:
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
user_tenants = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.is_active == True,
)
)
).all()
.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()
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
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:
.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
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()
.first()
)
return user_tenant is not None
def update_user_role_in_tenant(
self,
keycloak_user_id: str,
tenant_id: int,
role: str
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
user_tenant = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.tenant_id == tenant_id,
)
)
).first()
.first()
)
if not user_tenant:
raise HTTPException(
status_code=404,
detail="User-tenant relationship not found"
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}")
logger.info(
f"Updated role for user {keycloak_user_id} in tenant {tenant_id} to {role}"
)
return user_tenant