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 @@
"""
Core module - Configuración y utilidades centrales de la aplicación
"""
from .config import settings
from .database import (
Base,
@@ -8,14 +9,14 @@ from .database import (
get_async_core_db,
get_tenant_db,
init_db,
init_async_db
init_async_db,
)
from .security import (
verify_token,
get_current_user,
get_current_active_user,
has_role,
get_tenant_from_token
get_tenant_from_token,
)
__all__ = [

View File

@@ -1,26 +1,27 @@
"""
Configuración centralizada de la aplicación usando Pydantic Settings
"""
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import List
class Settings(BaseSettings):
"""Configuración de la aplicación"""
# Application
APP_NAME: str = "Anexo76"
APP_VERSION: str = "1.0.0"
DEBUG: bool = True
ENVIRONMENT: str = "development"
# Database - Core (Shared)
CORE_DB_HOST: str = "postgres-a76"
CORE_DB_PORT: int = 5432
CORE_DB_NAME: str = "anexo76_core"
CORE_DB_USER: str = "postgres"
CORE_DB_PASSWORD: str = "postgres"
# Keycloak
KEYCLOAK_SERVER_URL: str = "http://localhost:8080"
KEYCLOAK_REALM: str = "master"
@@ -28,34 +29,32 @@ class Settings(BaseSettings):
KEYCLOAK_CLIENT_SECRET: str = ""
KEYCLOAK_ADMIN_USERNAME: str = "admin"
KEYCLOAK_ADMIN_PASSWORD: str = "admin"
# Security
SECRET_KEY: str = "change-this-secret-key-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# CORS
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
# License
LICENSE_CHECK_ENABLED: bool = True
model_config = SettingsConfigDict(
env_file=".env",
case_sensitive=True,
extra="ignore"
env_file=".env", case_sensitive=True, extra="ignore"
)
@property
def core_database_url(self) -> str:
"""URL de conexión a la base de datos core"""
return f"postgresql://{self.CORE_DB_USER}:{self.CORE_DB_PASSWORD}@{self.CORE_DB_HOST}:{self.CORE_DB_PORT}/{self.CORE_DB_NAME}"
@property
def async_core_database_url(self) -> str:
"""URL de conexión asíncrona a la base de datos core"""
return f"postgresql+asyncpg://{self.CORE_DB_USER}:{self.CORE_DB_PASSWORD}@{self.CORE_DB_HOST}:{self.CORE_DB_PORT}/{self.CORE_DB_NAME}"
@property
def cors_origins_list(self) -> List[str]:
"""Lista de orígenes CORS permitidos"""

View File

@@ -3,6 +3,7 @@ Configuración de base de datos con soporte multi-tenant
- Base de datos compartida (core_db) para tenants pequeños/medianos
- Bases de datos dedicadas para clientes enterprise
"""
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base
from sqlalchemy.orm import sessionmaker, Session
@@ -20,14 +21,10 @@ core_engine = create_engine(
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
echo=settings.DEBUG
echo=settings.DEBUG,
)
CoreSessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=core_engine
)
CoreSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=core_engine)
# Engine asíncrono para operaciones async
async_core_engine = create_async_engine(
@@ -35,13 +32,11 @@ async_core_engine = create_async_engine(
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
echo=settings.DEBUG
echo=settings.DEBUG,
)
AsyncCoreSessionLocal = async_sessionmaker(
async_core_engine,
class_=AsyncSession,
expire_on_commit=False
async_core_engine, class_=AsyncSession, expire_on_commit=False
)
# Cache de engines para tenants con BD dedicada
@@ -74,33 +69,32 @@ async def get_async_core_db() -> AsyncGenerator[AsyncSession, None]:
def get_tenant_engine(tenant_id: int, db_config: dict):
"""
Obtiene o crea un engine para un tenant con BD dedicada
Args:
tenant_id: ID del tenant
db_config: Configuración de BD {host, port, name, user, password}
Returns:
Engine de SQLAlchemy para el tenant
"""
if tenant_id not in _tenant_engines:
db_url = f"postgresql://{db_config['user']}:{db_config['password']}@{db_config['host']}:{db_config['port']}/{db_config['name']}"
_tenant_engines[tenant_id] = create_engine(
db_url,
pool_pre_ping=True,
pool_size=5,
max_overflow=10
db_url, pool_pre_ping=True, pool_size=5, max_overflow=10
)
return _tenant_engines[tenant_id]
@contextmanager
def get_tenant_db(tenant_id: int, db_config: Optional[dict] = None) -> Generator[Session, None, None]:
def get_tenant_db(
tenant_id: int, db_config: Optional[dict] = None
) -> Generator[Session, None, None]:
"""
Context manager para obtener sesión de BD de un tenant específico
Si db_config es None, usa la BD core (compartida)
Si db_config está presente, usa la BD dedicada del tenant
Uso:
with get_tenant_db(tenant_id, config) as db:
# operaciones con db
@@ -113,7 +107,7 @@ def get_tenant_db(tenant_id: int, db_config: Optional[dict] = None) -> Generator
engine = get_tenant_engine(tenant_id, db_config)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = SessionLocal()
try:
yield db
finally:

View File

@@ -4,6 +4,7 @@ Middleware personalizado para Anexo76
- Gestión de multi-tenancy
- Logging de requests
"""
from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware
from typing import Callable
@@ -22,58 +23,59 @@ class TenantMiddleware(BaseHTTPMiddleware):
"""
Middleware para identificar y validar el tenant en cada request
"""
async def dispatch(self, request: Request, call_next: Callable):
# Rutas públicas que no requieren tenant
# Permitir acceso sin autenticación a rutas de documentación y salud
doc_prefixes = [
"/api/redoc",
"/api/openapi.json"
]
public_prefixes = [
"/api/v1/auth",
"/api/v1/status",
"/api/health",
"/api/"
]
doc_prefixes = ["/api/redoc", "/api/openapi.json"]
public_prefixes = ["/api/v1/auth", "/api/v1/status", "/api/health", "/api/"]
path = request.url.path
# Permitir cualquier subruta de docs/redoc/openapi.json (por ejemplo, /api/docs, /api/docs/, /api/docs/oauth2-redirect)
if any(path == prefix or path.startswith(prefix + "/") for prefix in doc_prefixes):
if any(
path == prefix or path.startswith(prefix + "/") for prefix in doc_prefixes
):
return await call_next(request)
# Permitir rutas públicas exactas o con prefijo
if any(path == prefix or (prefix != "/" and path.startswith(prefix)) for prefix in public_prefixes):
if any(
path == prefix or (prefix != "/" and path.startswith(prefix))
for prefix in public_prefixes
):
return await call_next(request)
# Extraer token y obtener tenant
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or invalid authorization header")
raise HTTPException(
status_code=401, detail="Missing or invalid authorization header"
)
token = auth_header.split(" ")[1]
try:
user_info = verify_token(token)
tenant_id = get_tenant_from_token(user_info)
# ⚠️ NOTA: tenant_id puede ser None para usuarios SSO que aún no tienen tenant asignado
# En ese caso, el endpoint específico deberá manejarlo
if not tenant_id:
logger.warning(f"⚠️ Token sin tenant_id para usuario: {user_info.get('sub', 'unknown')}")
logger.warning(
f"⚠️ Token sin tenant_id para usuario: {user_info.get('sub', 'unknown')}"
)
# No lanzamos error aquí, dejamos que el endpoint decida qué hacer
# Agregar tenant_id al state del request (puede ser None)
request.state.tenant_id = tenant_id
request.state.user_info = user_info
except HTTPException:
# Re-lanzar HTTPException directamente
raise
except Exception as e:
logger.error(f"❌ Tenant validation error: {str(e)}")
raise HTTPException(status_code=401, detail="Invalid authentication")
response = await call_next(request)
return response
@@ -82,58 +84,60 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
"""
Middleware para validar la licencia del tenant antes de procesar requests
"""
async def dispatch(self, request: Request, call_next: Callable):
if not settings.LICENSE_CHECK_ENABLED:
return await call_next(request)
# Rutas que no requieren validación de licencia
exempt_paths = [
"/api/docs",
"/api/docs",
"/api/redoc",
"/openapi.json",
"/api/v1/auth",
"/api/v1/auth",
"/openapi.json",
"/api/v1/auth",
"/api/v1/auth",
"/api/v1/status",
"/api/v1/status",
"/api/health",
"/api/"
"/api/",
]
# Verificar si la ruta está exenta (comparación exacta o prefijo)
is_exempt = False
for path in exempt_paths:
if request.url.path == path or (path != "/" and request.url.path.startswith(path)):
if request.url.path == path or (
path != "/" and request.url.path.startswith(path)
):
is_exempt = True
break
if is_exempt:
return await call_next(request)
# Obtener tenant_id del request state (debe ser seteado por TenantMiddleware)
tenant_id = getattr(request.state, "tenant_id", None)
if not tenant_id:
return await call_next(request) # Dejamos que TenantMiddleware maneje esto
# Validar licencia
db = CoreSessionLocal()
try:
# Importar aquí para evitar imports circulares
from api.v1.modules.a76.licenses.service import LicenseService
license_service = LicenseService(db)
license_info = license_service.validate_license(tenant_id)
if not license_info["is_valid"]:
raise HTTPException(
status_code=402,
detail=f"License validation failed: {license_info['reason']}"
detail=f"License validation failed: {license_info['reason']}",
)
# Agregar info de licencia al request state
request.state.license_info = license_info
except HTTPException:
raise
except Exception as e:
@@ -141,7 +145,7 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
raise HTTPException(status_code=500, detail="License validation error")
finally:
db.close()
response = await call_next(request)
return response
@@ -150,15 +154,15 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
"""
Middleware para logging de requests
"""
async def dispatch(self, request: Request, call_next: Callable):
start_time = time.time()
# Log request
logger.info(f"Request: {request.method} {request.url.path}")
response = await call_next(request)
# Log response
process_time = time.time() - start_time
logger.info(
@@ -166,8 +170,8 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
f"Status: {response.status_code} "
f"Duration: {process_time:.3f}s"
)
# Agregar header con tiempo de procesamiento
response.headers["X-Process-Time"] = str(process_time)
return response

View File

@@ -1,11 +1,13 @@
"""
Utilidades de seguridad y autenticación con Keycloak
"""
from fastapi import HTTPException, Security, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from keycloak import KeycloakOpenID
from jose import jwt, JWTError
from typing import Optional, Dict, Any
from sqlalchemy.orm import Session
from .config import settings
import logging
from sqlalchemy.orm import Session
@@ -19,7 +21,7 @@ keycloak_openid = KeycloakOpenID(
server_url=settings.KEYCLOAK_SERVER_URL,
client_id=settings.KEYCLOAK_CLIENT_ID,
realm_name=settings.KEYCLOAK_REALM,
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
)
# Security scheme
@@ -29,13 +31,13 @@ security = HTTPBearer()
def verify_token(token: str) -> Dict[str, Any]:
"""
Verifica y decodifica un token JWT de Keycloak
Args:
token: Token JWT
Returns:
Payload del token decodificado
Raises:
HTTPException: Si el token es inválido
"""
@@ -46,45 +48,30 @@ def verify_token(token: str) -> Dict[str, Any]:
+ keycloak_openid.public_key()
+ "\n-----END PUBLIC KEY-----"
)
# Decodificar y verificar token
options = {
"verify_signature": True,
"verify_aud": False,
"verify_exp": True
}
options = {"verify_signature": True, "verify_aud": False, "verify_exp": True}
decoded_token = jwt.decode(
token,
KEYCLOAK_PUBLIC_KEY,
algorithms=["RS256"],
options=options
token, KEYCLOAK_PUBLIC_KEY, algorithms=["RS256"], options=options
)
return decoded_token
except JWTError as e:
logger.error(f"Token verification failed: {str(e)}")
raise HTTPException(
status_code=401,
detail="Could not validate credentials"
)
raise HTTPException(status_code=401, detail="Could not validate credentials")
except Exception as e:
logger.error(f"Unexpected error during token verification: {str(e)}")
raise HTTPException(
status_code=401,
detail="Authentication error"
)
raise HTTPException(status_code=401, detail="Authentication error")
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Security(security),
db: Session = Depends(get_core_db)
) -> Dict[str, Any]:
"""
Dependency para obtener el usuario actual desde el token JWT
Enriquecido con tenant_id y company_id desde la tabla user_tenant
Uso en FastAPI:
current_user: dict = Depends(get_current_user)
"""
@@ -109,7 +96,7 @@ async def get_current_user(
async def get_current_active_user(
current_user: Dict[str, Any] = Depends(get_current_user)
current_user: Dict[str, Any] = Depends(get_current_user),
) -> Dict[str, Any]:
"""
Dependency para obtener usuario activo (puede incluir validaciones adicionales)
@@ -122,121 +109,127 @@ async def get_current_active_user(
def has_role(required_role: str):
"""
Decorator/Dependency para verificar roles de usuario
Uso:
@router.get("/admin")
async def admin_endpoint(user = Depends(has_role("admin"))):
...
"""
async def role_checker(
current_user: Dict[str, Any] = Depends(get_current_user)
current_user: Dict[str, Any] = Depends(get_current_user),
) -> Dict[str, Any]:
user_roles = current_user.get("realm_access", {}).get("roles", [])
if required_role not in user_roles:
raise HTTPException(
status_code=403,
detail=f"User does not have required role: {required_role}"
detail=f"User does not have required role: {required_role}",
)
return current_user
return role_checker
def get_tenant_from_token(user_info: Dict[str, Any]) -> Optional[int]:
"""
Extrae el tenant_id del token JWT
El tenant_id puede estar en diferentes lugares según configuración de Keycloak:
- En claims personalizados
- En el realm
- En atributos del usuario
"""
# Intentar obtener de claims personalizados
tenant_id = user_info.get("tenant_id")
tenant_id = user_info.get("tenant_id")
if not tenant_id:
# Intentar obtener de atributos
tenant_id = user_info.get("attributes", {}).get("tenant_id")
if tenant_id:
return int(tenant_id)
return None
def validate_company_access(
company_id: int,
current_user: Dict[str, Any]
) -> bool:
def validate_company_access(db: Session, company_id: int, current_user: Dict[str, Any]) -> bool:
"""
Valida que el usuario tenga acceso a la compañía solicitada
Args:
company_id: ID de la compañía a la que se quiere acceder
current_user: Información del usuario actual desde el token
Returns:
True si el usuario tiene acceso, False en caso contrario
Nota:
Por ahora solo verifica que el tenant_id del usuario coincida con el company_id.
Se puede extender para validar permisos específicos por compañía.
Verifica que la compañía pertenezca al tenant del usuario consultando la BD.
"""
tenant_id = get_tenant_from_token(current_user)
tenant_id = get_tenant_from_token(current_user)
# Si no hay tenant_id en el token, denegar acceso
if not tenant_id:
return False
# Validar que el company_id pertenezca al tenant del usuario
# Por ahora asumimos que company_id == tenant_id
# Esto se puede modificar si hay una tabla de relación tenant-company
return tenant_id == company_id
def validate_access_to_resource(
company_id: int,
current_user: dict = Depends(get_current_user)
) -> bool:
# Consultar si la compañía pertenece al tenant
try:
from api.v1.modules.a76.company.models import Company
company = db.query(Company).filter(
Company.id == company_id,
Company.tenant_id == tenant_id
).first()
return company is not None
finally:
db.close()
def validate_access_to_resource(db: Session, company_id: int, current_user: Dict[str, Any]) -> int:
"""
Valida que el usuario tenga acceso a un recurso específico basado en company_id
y regresa el tenant_id
Args:
company_id: company_id asociado al recurso
current_user: Información del usuario actual desde el token
Returns:
True si el usuario tiene acceso, False en caso contrario
tenant_id si el usuario tiene acceso
Raises:
HTTPException: Si no hay tenant_id o no tiene acceso
"""
tenant_id = get_tenant_from_token(current_user)
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
if not validate_company_access(company_id, current_user):
if not validate_company_access(db, company_id, current_user):
raise HTTPException(status_code=403, detail="Access denied to this company")
# Validar que el tenant_id del usuario coincida con el del recurso
return tenant_id
class KeycloakClient:
"""Cliente para interactuar con Keycloak Admin API"""
def __init__(self):
self.openid = keycloak_openid
def create_user(self, email: str, password: str, tenant_id: int, **kwargs):
"""Crea un usuario en Keycloak"""
# Implementar lógica para crear usuario usando keycloak admin
pass
def assign_role(self, user_id: str, role: str):
"""Asigna un rol a un usuario"""
pass
def create_tenant_realm(self, tenant_name: str):
"""Crea un realm para un nuevo tenant"""
pass