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 @@
"""
Módulo de Tenants
"""
from .routes import router
__all__ = ["router"]

View File

@@ -2,6 +2,7 @@
DTOs (Data Transfer Objects) para módulo de tenants
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
"""
from pydantic import BaseModel, Field, EmailStr
from typing import Optional
from datetime import datetime
@@ -10,21 +11,35 @@ from enum import Enum
class TenantTypeDTO(str, Enum):
"""Tipo de tenant"""
SHARED = "shared"
DEDICATED = "dedicated"
class TenantCreateDTO(BaseModel):
"""DTO para crear un nuevo tenant"""
name: str = Field(..., min_length=3, max_length=255, description="Nombre del tenant")
slug: str = Field(..., min_length=3, max_length=100, description="Identificador único del tenant")
keycloak_realm: str = Field(..., min_length=3, max_length=255, description="Nombre del realm en Keycloak")
type: TenantTypeDTO = Field(default=TenantTypeDTO.SHARED, description="Tipo de tenant")
contact_name: Optional[str] = Field(None, max_length=255, description="Nombre de contacto")
name: str = Field(
..., min_length=3, max_length=255, description="Nombre del tenant"
)
slug: str = Field(
..., min_length=3, max_length=100, description="Identificador único del tenant"
)
keycloak_realm: str = Field(
..., min_length=3, max_length=255, description="Nombre del realm en Keycloak"
)
type: TenantTypeDTO = Field(
default=TenantTypeDTO.SHARED, description="Tipo de tenant"
)
contact_name: Optional[str] = Field(
None, max_length=255, description="Nombre de contacto"
)
contact_email: Optional[EmailStr] = Field(None, description="Email de contacto")
contact_phone: Optional[str] = Field(None, max_length=50, description="Teléfono de contacto")
contact_phone: Optional[str] = Field(
None, max_length=50, description="Teléfono de contacto"
)
class Config:
json_schema_extra = {
"example": {
@@ -34,30 +49,32 @@ class TenantCreateDTO(BaseModel):
"type": "shared",
"contact_name": "Juan Pérez",
"contact_email": "juan.perez@empresa-abc.com",
"contact_phone": "+52 55 1234 5678"
"contact_phone": "+52 55 1234 5678",
}
}
class TenantUpdateDTO(BaseModel):
"""DTO para actualizar un tenant"""
name: Optional[str] = Field(None, min_length=3, max_length=255)
contact_name: Optional[str] = Field(None, max_length=255)
contact_email: Optional[EmailStr] = None
contact_phone: Optional[str] = Field(None, max_length=50)
is_active: Optional[bool] = None
class Config:
json_schema_extra = {
"example": {
"name": "Empresa ABC S.A. de C.V. - Actualizado",
"contact_email": "nuevo@empresa-abc.com"
"contact_email": "nuevo@empresa-abc.com",
}
}
class TenantResponseDTO(BaseModel):
"""DTO para respuesta de tenant"""
id: int
name: str
slug: str
@@ -69,7 +86,7 @@ class TenantResponseDTO(BaseModel):
is_active: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
json_schema_extra = {
@@ -84,13 +101,14 @@ class TenantResponseDTO(BaseModel):
"contact_phone": "+52 55 1234 5678",
"is_active": True,
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-15T10:30:00Z"
"updated_at": "2025-01-15T10:30:00Z",
}
}
class TenantListResponseDTO(BaseModel):
"""DTO para lista de tenants"""
tenants: list[TenantResponseDTO]
total: int
page: int

View File

@@ -1,6 +1,7 @@
"""
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, relationship
@@ -15,6 +16,7 @@ if TYPE_CHECKING:
class TenantType(enum.Enum):
"""Tipo de tenant según tamaño y necesidades"""
SHARED = "shared" # BD compartida
DEDICATED = "dedicated" # BD dedicada
@@ -24,37 +26,44 @@ class Tenant(Base):
Modelo de Tenant - Cliente/Organización en el sistema
Cada tenant puede tener BD compartida o dedicada
"""
__tablename__ = "tenants"
__table_args__ = {"schema": "a76"}
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False, index=True)
slug = Column(String(100), unique=True, nullable=False, index=True)
# Tipo de tenant (compartido o dedicado)
type = Column(SQLEnum(TenantType), default=TenantType.SHARED, nullable=False)
# Keycloak realm asociado
keycloak_realm = Column(String(255), nullable=False)
# Configuración de BD dedicada (JSON string o NULL si usa BD compartida)
db_config = Column(Text, nullable=True) # JSON: {host, port, name, user, password}
# Información de contacto
contact_name = Column(String(255))
contact_email = Column(String(255))
contact_phone = Column(String(50))
# Estado
is_active = Column(Boolean, default=True, nullable=False)
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=func.now(), onupdate=func.now())
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=func.now()
)
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")
user_relations: Mapped[List["UserTenant"]] = relationship(
"UserTenant", back_populates="tenant"
)
def __repr__(self):
return f"<Tenant(id={self.id}, name={self.name}, type={self.type.value})>"

View File

@@ -1,13 +1,19 @@
"""
Endpoints API para gestión de tenants
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import List
from core.database import get_core_db
from core.security import get_current_user, has_role
from .dto import TenantCreateDTO, TenantUpdateDTO, TenantResponseDTO, TenantListResponseDTO
from .dto import (
TenantCreateDTO,
TenantUpdateDTO,
TenantResponseDTO,
TenantListResponseDTO,
)
from .service import TenantService
router = APIRouter(prefix="/tenants")
@@ -17,11 +23,11 @@ router = APIRouter(prefix="/tenants")
async def create_tenant(
tenant_data: TenantCreateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin"))
current_user: dict = Depends(has_role("admin")),
):
"""
Crea un nuevo tenant en el sistema
Requiere rol: admin
"""
service = TenantService(db)
@@ -34,29 +40,27 @@ async def list_tenants(
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
active_only: bool = Query(False, description="Solo tenants activos"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin"))
current_user: dict = Depends(has_role("admin")),
):
"""
Lista todos los tenants
Requiere rol: admin
"""
service = TenantService(db)
skip = (page - 1) * page_size
tenants = service.list_tenants(skip=skip, limit=page_size, active_only=active_only)
# Contar total
from .models import Tenant
query = db.query(Tenant)
if active_only:
query = query.filter(Tenant.is_active == True)
total = query.count()
return TenantListResponseDTO(
tenants=tenants,
total=total,
page=page,
page_size=page_size
tenants=tenants, total=total, page=page, page_size=page_size
)
@@ -64,7 +68,7 @@ async def list_tenants(
async def get_tenant(
tenant_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
current_user: dict = Depends(get_current_user),
):
"""
Obtiene información de un tenant por ID
@@ -81,11 +85,11 @@ async def update_tenant(
tenant_id: int,
tenant_data: TenantUpdateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin"))
current_user: dict = Depends(has_role("admin")),
):
"""
Actualiza un tenant
Requiere rol: admin
"""
service = TenantService(db)
@@ -99,11 +103,11 @@ async def update_tenant(
async def delete_tenant(
tenant_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin"))
current_user: dict = Depends(has_role("admin")),
):
"""
Elimina (desactiva) un tenant
Requiere rol: admin
"""
service = TenantService(db)
@@ -116,7 +120,7 @@ async def delete_tenant(
async def get_tenant_by_slug(
slug: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user)
current_user: dict = Depends(get_current_user),
):
"""
Obtiene un tenant por su slug

View File

@@ -1,6 +1,7 @@
"""
Capa de servicio para lógica de negocio de tenants
"""
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
@@ -16,29 +17,34 @@ logger = logging.getLogger(__name__)
class TenantService:
"""Servicio para gestión de tenants"""
def __init__(self, db: Session):
self.db = db
def create_tenant(self, tenant_data: TenantCreateDTO) -> TenantResponseDTO:
"""
Crea un nuevo tenant en el sistema
Args:
tenant_data: Datos del tenant a crear
Returns:
TenantResponseDTO con información del tenant creado
Raises:
HTTPException: Si el slug o realm ya existen
"""
try:
# Verificar que no exista el slug
existing = self.db.query(Tenant).filter(Tenant.slug == tenant_data.slug).first()
existing = (
self.db.query(Tenant).filter(Tenant.slug == tenant_data.slug).first()
)
if existing:
raise HTTPException(status_code=400, detail=f"Tenant with slug '{tenant_data.slug}' already exists")
raise HTTPException(
status_code=400,
detail=f"Tenant with slug '{tenant_data.slug}' already exists",
)
# Crear tenant
db_tenant = Tenant(
name=tenant_data.name,
@@ -48,35 +54,37 @@ class TenantService:
contact_name=tenant_data.contact_name,
contact_email=tenant_data.contact_email,
contact_phone=tenant_data.contact_phone,
is_active=True
is_active=True,
)
self.db.add(db_tenant)
self.db.commit()
self.db.refresh(db_tenant)
logger.info(f"Tenant created: {db_tenant.id} - {db_tenant.name}")
return TenantResponseDTO.model_validate(db_tenant)
except IntegrityError as e:
self.db.rollback()
logger.error(f"IntegrityError creating tenant: {str(e)}")
raise HTTPException(status_code=400, detail="Tenant with this slug or realm already exists")
raise HTTPException(
status_code=400, detail="Tenant with this slug or realm already exists"
)
except HTTPException:
raise
except Exception as e:
self.db.rollback()
logger.error(f"Error creating tenant: {str(e)}")
raise HTTPException(status_code=500, detail="Error creating tenant")
def get_tenant(self, tenant_id: int) -> Optional[TenantResponseDTO]:
"""
Obtiene un tenant por ID
Args:
tenant_id: ID del tenant
Returns:
TenantResponseDTO o None si no existe
"""
@@ -84,54 +92,58 @@ class TenantService:
if not tenant:
return None
return TenantResponseDTO.model_validate(tenant)
def get_tenant_by_slug(self, slug: str) -> Optional[TenantResponseDTO]:
"""Obtiene un tenant por slug"""
tenant = self.db.query(Tenant).filter(Tenant.slug == slug).first()
if not tenant:
return None
return TenantResponseDTO.model_validate(tenant)
def list_tenants(self, skip: int = 0, limit: int = 100, active_only: bool = False) -> List[TenantResponseDTO]:
def list_tenants(
self, skip: int = 0, limit: int = 100, active_only: bool = False
) -> List[TenantResponseDTO]:
"""
Lista todos los tenants
Args:
skip: Número de registros a omitir
limit: Número máximo de registros a retornar
active_only: Si True, solo retorna tenants activos
Returns:
Lista de TenantResponseDTO
"""
query = self.db.query(Tenant)
if active_only:
query = query.filter(Tenant.is_active == True)
tenants = query.offset(skip).limit(limit).all()
return [TenantResponseDTO.model_validate(t) for t in tenants]
def update_tenant(self, tenant_id: int, tenant_data: TenantUpdateDTO) -> Optional[TenantResponseDTO]:
def update_tenant(
self, tenant_id: int, tenant_data: TenantUpdateDTO
) -> Optional[TenantResponseDTO]:
"""
Actualiza un tenant
Args:
tenant_id: ID del tenant a actualizar
tenant_data: Datos a actualizar
Returns:
TenantResponseDTO actualizado o None si no existe
"""
tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
return None
# Actualizar solo campos proporcionados
update_data = tenant_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(tenant, field, value)
try:
self.db.commit()
self.db.refresh(tenant)
@@ -141,24 +153,24 @@ class TenantService:
self.db.rollback()
logger.error(f"Error updating tenant {tenant_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error updating tenant")
def delete_tenant(self, tenant_id: int) -> bool:
"""
Elimina (desactiva) un tenant
Args:
tenant_id: ID del tenant a eliminar
Returns:
True si se eliminó, False si no existe
"""
tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
return False
# Soft delete: marcar como inactivo
tenant.is_active = False
try:
self.db.commit()
logger.info(f"Tenant deleted (soft): {tenant_id}")
@@ -167,25 +179,27 @@ class TenantService:
self.db.rollback()
logger.error(f"Error deleting tenant {tenant_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error deleting tenant")
def upgrade_to_dedicated(self, tenant_id: int, db_config: dict) -> Optional[TenantResponseDTO]:
def upgrade_to_dedicated(
self, tenant_id: int, db_config: dict
) -> Optional[TenantResponseDTO]:
"""
Actualiza un tenant de BD compartida a BD dedicada
Args:
tenant_id: ID del tenant
db_config: Configuración de BD dedicada
Returns:
TenantResponseDTO actualizado
"""
tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
return None
tenant.type = TenantType.DEDICATED
tenant.db_config = json.dumps(db_config)
try:
self.db.commit()
self.db.refresh(tenant)