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:
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Módulo de Tenants
|
||||
"""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})>"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user