chore: baseline plantilla-proyectos como base del CRM
This commit is contained in:
41
backend/core/__init__.py
Normal file
41
backend/core/__init__.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Core module - Configuración y utilidades centrales de la aplicación
|
||||
"""
|
||||
|
||||
from .config import settings
|
||||
from .database import (
|
||||
Base,
|
||||
get_async_core_db,
|
||||
get_core_db,
|
||||
get_tenant_db,
|
||||
init_async_db,
|
||||
init_db,
|
||||
scoped_async_core_db,
|
||||
scoped_core_db,
|
||||
set_rls_context,
|
||||
)
|
||||
from .security import (
|
||||
get_current_active_user,
|
||||
get_current_user,
|
||||
get_tenant_from_token,
|
||||
has_role,
|
||||
verify_token,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"settings",
|
||||
"Base",
|
||||
"get_core_db",
|
||||
"get_async_core_db",
|
||||
"get_tenant_db",
|
||||
"scoped_core_db",
|
||||
"scoped_async_core_db",
|
||||
"set_rls_context",
|
||||
"init_db",
|
||||
"init_async_db",
|
||||
"verify_token",
|
||||
"get_current_user",
|
||||
"get_current_active_user",
|
||||
"has_role",
|
||||
"get_tenant_from_token",
|
||||
]
|
||||
126
backend/core/celery_app.py
Normal file
126
backend/core/celery_app.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import os
|
||||
import logging
|
||||
from celery import Celery
|
||||
from celery.signals import task_postrun, task_prerun
|
||||
|
||||
from core.database import reset_rls_context_tokens, rls_company_var, rls_tenant_var
|
||||
from core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
valkey_url = settings.VALKEY_URL
|
||||
print(f"DEBUG: Celery Broker URL: {valkey_url}")
|
||||
logger.info(
|
||||
"Initializing Celery app app_version=%s environment=%s broker=%s",
|
||||
settings.APP_VERSION,
|
||||
settings.ENVIRONMENT,
|
||||
valkey_url,
|
||||
)
|
||||
|
||||
# Configurar broker y backend explícitamente en el constructor
|
||||
celery_app = Celery(
|
||||
"app_tasks",
|
||||
broker=valkey_url,
|
||||
backend=valkey_url,
|
||||
)
|
||||
celery_app.set_default()
|
||||
|
||||
|
||||
_RLS_TOKENS_ATTR = "_rls_context_tokens"
|
||||
|
||||
|
||||
def _coerce_int(value) -> int | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@task_prerun.connect
|
||||
def _set_rls_context_from_task(task_id=None, task=None, args=None, kwargs=None, **_):
|
||||
"""Fija las ContextVars de RLS para la ejecución de la tarea.
|
||||
|
||||
Las rutas propagan ``tenant_id`` / ``company_id`` vía Celery headers en
|
||||
:func:`track_and_dispatch`. Aquí los materializamos en ContextVars para
|
||||
que cualquier sesión que se abra durante la tarea (incluidos los helpers
|
||||
``scoped_core_db`` y llamadas directas a ``CoreSessionLocal()``) aplique
|
||||
``SET LOCAL`` automáticamente.
|
||||
"""
|
||||
headers = {}
|
||||
request = getattr(task, "request", None) if task is not None else None
|
||||
if request is not None:
|
||||
headers = getattr(request, "headers", None) or {}
|
||||
|
||||
tenant_id = _coerce_int(headers.get("rls_tenant_id"))
|
||||
company_id = _coerce_int(headers.get("rls_company_id"))
|
||||
if tenant_id is None:
|
||||
logger.warning(
|
||||
"Celery task_prerun missing rls_tenant_id task=%s task_id=%s headers=%s",
|
||||
getattr(task, "name", "<unknown>"),
|
||||
task_id,
|
||||
headers,
|
||||
)
|
||||
logger.info(
|
||||
"Celery task_prerun RLS context task=%s task_id=%s tenant_id=%s company_id=%s",
|
||||
getattr(task, "name", "<unknown>"),
|
||||
task_id,
|
||||
tenant_id,
|
||||
company_id,
|
||||
)
|
||||
|
||||
token_t = rls_tenant_var.set(tenant_id)
|
||||
token_c = rls_company_var.set(company_id)
|
||||
setattr(task, _RLS_TOKENS_ATTR, (token_t, token_c))
|
||||
|
||||
|
||||
@task_postrun.connect
|
||||
def _reset_rls_context_from_task(task_id=None, task=None, **_):
|
||||
"""Restaura las ContextVars al terminar la tarea (evita fuga entre tareas
|
||||
cuando un worker reutiliza el mismo hilo)."""
|
||||
tokens = getattr(task, _RLS_TOKENS_ATTR, None) if task is not None else None
|
||||
if tokens is None:
|
||||
return
|
||||
token_t, token_c = tokens
|
||||
logger.info(
|
||||
"Celery task_postrun clearing RLS context task=%s task_id=%s tenant_id=%s company_id=%s",
|
||||
getattr(task, "name", "<unknown>"),
|
||||
task_id,
|
||||
rls_tenant_var.get(),
|
||||
rls_company_var.get(),
|
||||
)
|
||||
reset_rls_context_tokens(token_t, token_c)
|
||||
delattr(task, _RLS_TOKENS_ATTR)
|
||||
|
||||
celery_app.conf.update(
|
||||
include=[
|
||||
"api.v1.modules.core.help_center.tasks",
|
||||
# Agrega aquí las tareas de tu proyecto:
|
||||
# "api.v1.modules.example.tasks",
|
||||
]
|
||||
)
|
||||
|
||||
# Configuraciones adicionales
|
||||
celery_app.conf.update(
|
||||
task_track_started=True,
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
timezone="America/Mexico_City",
|
||||
enable_utc=True,
|
||||
)
|
||||
|
||||
celery_app.conf.beat_schedule = {
|
||||
"sync-from-hub-every-minute": {
|
||||
"task": "sync_from_hub_task",
|
||||
"schedule": 60.0, # Run every 60 seconds
|
||||
},
|
||||
"cleanup-orphan-layout-imports-hourly": {
|
||||
"task": "cleanup_orphan_layout_imports",
|
||||
"schedule": 3600.0,
|
||||
},
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
celery_app.start()
|
||||
149
backend/core/config.py
Normal file
149
backend/core/config.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Configuración centralizada de la aplicación usando Pydantic Settings
|
||||
"""
|
||||
|
||||
from typing import List, Literal
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Configuración de la aplicación"""
|
||||
|
||||
# Application
|
||||
APP_NAME: str = "Mi Aplicación"
|
||||
# Sobreescribible con APP_VERSION (Dockerfile/Jenkins: build-arg + ENV) o entorno en runtime
|
||||
APP_VERSION: str = "dev-local"
|
||||
DEBUG: bool = True
|
||||
ENVIRONMENT: str = "development"
|
||||
|
||||
# Auth local para desarrollo (sin Keycloak/Hub)
|
||||
# Nunca activar en producción.
|
||||
DEV_LOCAL_AUTH: bool = False
|
||||
DEV_LOCAL_AUTH_EMAIL: str = "dev@local.test"
|
||||
DEV_LOCAL_AUTH_NAME: str = "Dev User"
|
||||
DEV_LOCAL_AUTH_TENANT_ID: int = 1
|
||||
DEV_LOCAL_AUTH_COMPANY_ID: int = 1
|
||||
|
||||
# Database - Core (Shared)
|
||||
CORE_DB_HOST: str = "postgres"
|
||||
CORE_DB_PORT: int = 5432
|
||||
CORE_DB_NAME: str = "app_core"
|
||||
CORE_DB_USER: str = "postgres"
|
||||
CORE_DB_PASSWORD: str = "postgres"
|
||||
|
||||
# Security
|
||||
SECRET_KEY: str = "change-this-secret-key-in-production"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
# Valkey / Redis
|
||||
VALKEY_URL: str = "redis://valkey:6379/0"
|
||||
PERMISSION_CACHE_ENABLED: bool = True
|
||||
PERMISSION_CACHE_TTL_SECONDS: int = 300
|
||||
|
||||
# Synchronization
|
||||
SYNC_SECRET_TOKEN: str = "change-this-sync-token-in-production"
|
||||
CENTRAL_SERVER_URL: str = "http://localhost:8000/api/v1/core/help-center/sync/"
|
||||
SPOKE_URLS: str = "" # Comma separated list of Spoke URLs for Broadcast (Hub only)
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
||||
|
||||
# Hub de Aduanasoft — requerido siempre (SaaS y self-hosted)
|
||||
HUB_URL: str = "http://localhost:8001"
|
||||
# Base API del Hub/Workspace para endpoint /v1/auth/me (fuente de verdad de perfil)
|
||||
HUB_API_BASE_URL: str = ""
|
||||
HUB_PROFILE_SYNC_TIMEOUT_MS: int = 3000
|
||||
# Cuenta de servicio Hub — usada para operaciones admin (ej. sync de nombre a Keycloak)
|
||||
HUB_ADMIN_EMAIL: str = ""
|
||||
HUB_ADMIN_PASSWORD: str = ""
|
||||
|
||||
# URL pública del frontend — usada en links de email (invitaciones, etc.)
|
||||
APP_PUBLIC_URL: str = "http://localhost:3000"
|
||||
|
||||
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", "HUB_API_BASE_URL", mode="before")
|
||||
@classmethod
|
||||
def strip_quotes(cls, v: str) -> str:
|
||||
if v and isinstance(v, str):
|
||||
v = v.strip().strip('"').strip("'")
|
||||
# Evitar que solo espacios en .env se conviertan en "/" (rompe httpx: falta protocolo).
|
||||
if not v:
|
||||
return ""
|
||||
if not v.endswith("/"):
|
||||
v += "/"
|
||||
return v
|
||||
return v
|
||||
|
||||
# External APIs
|
||||
SITAR_API_URL: str = "api.sitar.aduanasoft.com:880"
|
||||
COVE_API_URL: str = "https://api.vu.aduanasoft.com"
|
||||
COVE_API_VERIFY_SSL: bool = False
|
||||
COVE_FIEL_HASH_KEY: str = ""
|
||||
COVE_FIEL_HASH_IV: str = ""
|
||||
SITAR_API_USER: str = ""
|
||||
SITAR_API_PASSWORD: str = ""
|
||||
# SMTP Email Configuration
|
||||
SMTP_HOST: str = "smtp.gmail.com"
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
SMTP_FROM_NAME: str = "Mi Aplicación"
|
||||
SMTP_USE_TLS: bool = True
|
||||
|
||||
# CSV imports (layouts_csv): redis = base64 en Valkey; minio = S3 + referencia en Redis
|
||||
CSV_IMPORT_STORAGE: Literal["redis", "minio"] = "minio"
|
||||
S3_ENDPOINT_URL: str = "http://minio:9000"
|
||||
S3_ACCESS_KEY: str = ""
|
||||
S3_SECRET_KEY: str = ""
|
||||
S3_BUCKET: str = "app"
|
||||
S3_REGION: str = "us-east-1"
|
||||
S3_USE_SSL: bool = False
|
||||
# Logos, certificados, help (si no quieres MinIO aquí, pon false Y CSV_IMPORT_STORAGE=redis)
|
||||
S3_FILE_STORAGE: bool = True
|
||||
S3_PRESIGNED_EXPIRES_SECONDS: int = 3600
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=[".env", "../.env"],
|
||||
case_sensitive=True,
|
||||
extra="ignore",
|
||||
env_file_encoding="utf-8",
|
||||
)
|
||||
|
||||
@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"""
|
||||
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
|
||||
|
||||
@property
|
||||
def use_s3_object_storage(self) -> bool:
|
||||
"""
|
||||
Usar MinIO para logos, certificados y Help (mismo bucket que CSV).
|
||||
True si los imports CSV ya usan MinIO o si S3_FILE_STORAGE está activo.
|
||||
"""
|
||||
return self.CSV_IMPORT_STORAGE == "minio" or self.S3_FILE_STORAGE
|
||||
|
||||
@property
|
||||
def hub_api_base_url(self) -> str:
|
||||
"""
|
||||
Base URL para endpoints /v1 del Workspace/Hub.
|
||||
Si HUB_API_BASE_URL no está definido, deriva de HUB_URL + /api.
|
||||
"""
|
||||
custom = (self.HUB_API_BASE_URL or "").strip().rstrip("/")
|
||||
if custom:
|
||||
return custom
|
||||
return f"{self.HUB_URL.rstrip('/')}/api"
|
||||
|
||||
|
||||
# Instancia global de configuración
|
||||
settings = Settings()
|
||||
10
backend/core/context.py
Normal file
10
backend/core/context.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from contextvars import ContextVar
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
_user_context: ContextVar[Optional[Dict[str, Any]]] = ContextVar("user_context", default=None)
|
||||
|
||||
def get_user_context() -> Optional[Dict[str, Any]]:
|
||||
return _user_context.get()
|
||||
|
||||
def set_user_context(user: Dict[str, Any]) -> None:
|
||||
_user_context.set(user)
|
||||
278
backend/core/database.py
Normal file
278
backend/core/database.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
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
|
||||
|
||||
Row-Level Security (RLS):
|
||||
Para respetar el aislamiento por tenant/company definido en PostgreSQL,
|
||||
cada sesión fija las GUCs ``app.tenant_id`` y ``app.company_id`` vía
|
||||
``SET LOCAL`` al inicio de cada transacción. El listener
|
||||
``after_begin`` aplica el contexto guardado en ``Session.info``.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import AsyncGenerator, Dict, Generator, Optional
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import create_engine, event, text
|
||||
from sqlalchemy.exc import ProgrammingError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import Session, declarative_base, sessionmaker
|
||||
|
||||
from .config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
core_engine = create_engine(
|
||||
settings.core_database_url,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
echo=False,
|
||||
)
|
||||
|
||||
CoreSessionLocal = sessionmaker(
|
||||
autocommit=False, autoflush=False, bind=core_engine)
|
||||
|
||||
async_core_engine = create_async_engine(
|
||||
settings.async_core_database_url,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
echo=settings.DEBUG,
|
||||
)
|
||||
|
||||
AsyncCoreSessionLocal = async_sessionmaker(
|
||||
async_core_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
_tenant_engines: Dict[str, any] = {}
|
||||
|
||||
|
||||
RLS_TENANT_KEY = "rls_tenant_id"
|
||||
RLS_COMPANY_KEY = "rls_company_id"
|
||||
|
||||
rls_tenant_var: ContextVar[Optional[int]] = ContextVar("rls_tenant_id", default=None)
|
||||
rls_company_var: ContextVar[Optional[int]] = ContextVar("rls_company_id", default=None)
|
||||
|
||||
|
||||
def _apply_rls_context(connection, tenant_id: Optional[int], company_id: Optional[int]) -> None:
|
||||
"""Ejecuta ``SET LOCAL`` en la transacción activa para fijar el contexto RLS."""
|
||||
tenant_value = "" if tenant_id is None else str(int(tenant_id))
|
||||
company_value = "" if company_id is None else str(int(company_id))
|
||||
connection.execute(
|
||||
text(
|
||||
"SELECT set_config('app.tenant_id', :t, true), "
|
||||
"set_config('app.company_id', :c, true)"
|
||||
),
|
||||
{"t": tenant_value, "c": company_value},
|
||||
)
|
||||
|
||||
|
||||
def _resolve_context(session) -> tuple[Optional[int], Optional[int]]:
|
||||
"""Selecciona tenant_id/company_id desde ``session.info`` y, si faltan,
|
||||
desde las ContextVars (usadas por tareas Celery vía task_prerun)."""
|
||||
tenant_id = session.info.get(RLS_TENANT_KEY)
|
||||
company_id = session.info.get(RLS_COMPANY_KEY)
|
||||
if tenant_id is None:
|
||||
tenant_id = rls_tenant_var.get()
|
||||
if company_id is None:
|
||||
company_id = rls_company_var.get()
|
||||
return tenant_id, company_id
|
||||
|
||||
|
||||
@event.listens_for(Session, "after_begin")
|
||||
def _after_begin(session: Session, transaction, connection) -> None: # type: ignore[no-untyped-def]
|
||||
"""Aplica ``SET LOCAL`` en cada transacción nueva.
|
||||
|
||||
Cubre sesiones síncronas y asíncronas porque ``AsyncSession`` envuelve
|
||||
internamente una ``Session`` que hereda de esta clase.
|
||||
"""
|
||||
tenant_id, company_id = _resolve_context(session)
|
||||
if tenant_id is None and company_id is None:
|
||||
return
|
||||
_apply_rls_context(connection, tenant_id, company_id)
|
||||
|
||||
|
||||
def set_rls_context(
|
||||
session: Session,
|
||||
tenant_id: Optional[int] = None,
|
||||
company_id: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Guarda el contexto RLS en la sesión y, si hay transacción abierta, lo aplica.
|
||||
|
||||
Útil para endpoints que validan acceso a una compañía específica después de
|
||||
crear la sesión (por ejemplo rutas que reciben ``company_id`` en el path).
|
||||
"""
|
||||
session.info[RLS_TENANT_KEY] = tenant_id
|
||||
session.info[RLS_COMPANY_KEY] = company_id
|
||||
if session.in_transaction():
|
||||
_apply_rls_context(session.connection(), tenant_id, company_id)
|
||||
|
||||
|
||||
def reset_rls_context_tokens(token_t, token_c) -> None:
|
||||
"""Restaura ContextVars de RLS de forma segura entre hilos/tareas asyncio.
|
||||
|
||||
``ContextVar.reset`` exige que el token se cree y restaure en el mismo contexto
|
||||
lógico; en rutas FastAPI async + dependencias síncronas con ``yield`` (thread
|
||||
pool) el ``finally`` puede ejecutarse en otro contexto y lanzar ``ValueError``
|
||||
(mensaje: "was created in a different Context"). En ese caso degradamos a
|
||||
``set(None)``, igual que ``task_postrun`` en ``core/celery_app.py``.
|
||||
"""
|
||||
try:
|
||||
rls_tenant_var.reset(token_t)
|
||||
rls_company_var.reset(token_c)
|
||||
except (ValueError, RuntimeError):
|
||||
rls_tenant_var.set(None)
|
||||
rls_company_var.set(None)
|
||||
|
||||
|
||||
def _extract_rls_context(request: Optional[Request]) -> tuple[Optional[int], Optional[int]]:
|
||||
"""Recupera ``tenant_id`` / ``company_id`` del estado del request (o de cookies)."""
|
||||
if request is None:
|
||||
return None, None
|
||||
tenant_id = getattr(request.state, "tenant_id", None)
|
||||
company_id = getattr(request.state, "company_id", None)
|
||||
if company_id is None:
|
||||
cookie_value = request.cookies.get("active_company_id")
|
||||
if cookie_value:
|
||||
try:
|
||||
company_id = int(cookie_value)
|
||||
except (TypeError, ValueError):
|
||||
company_id = None
|
||||
return tenant_id, company_id
|
||||
|
||||
|
||||
def get_core_db(request: Request = None) -> Generator[Session, None, None]:
|
||||
"""Dependency para obtener sesión síncrona con contexto RLS.
|
||||
|
||||
FastAPI inyecta ``Request`` automáticamente; los llamadores existentes que
|
||||
escriben ``db: Session = Depends(get_core_db)`` siguen funcionando sin
|
||||
cambios porque ``Request`` se resuelve en la capa de dependencia.
|
||||
|
||||
No se escriben las ContextVars de RLS aquí: las dependencias síncronas con
|
||||
``yield`` se ejecutan vía ``contextmanager_in_threadpool`` (hilo worker) y
|
||||
mezclar ``ContextVar.set`` / ``reset`` entre ese hilo y el bucle asyncio
|
||||
provoca ``ValueError: ... was created in a different Context``. El aislamiento
|
||||
RLS se aplica con ``session.info`` (véase ``after_begin`` y audit listeners).
|
||||
"""
|
||||
tenant_id, company_id = _extract_rls_context(request)
|
||||
db = CoreSessionLocal()
|
||||
db.info[RLS_TENANT_KEY] = tenant_id
|
||||
db.info[RLS_COMPANY_KEY] = company_id
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def get_async_core_db(request: Request = None) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Dependency async para obtener sesión con contexto RLS."""
|
||||
tenant_id, company_id = _extract_rls_context(request)
|
||||
prev_tenant = rls_tenant_var.get()
|
||||
prev_company = rls_company_var.get()
|
||||
rls_tenant_var.set(tenant_id)
|
||||
rls_company_var.set(company_id)
|
||||
try:
|
||||
async with AsyncCoreSessionLocal() as session:
|
||||
session.info[RLS_TENANT_KEY] = tenant_id
|
||||
session.info[RLS_COMPANY_KEY] = company_id
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
finally:
|
||||
rls_tenant_var.set(prev_tenant)
|
||||
rls_company_var.set(prev_company)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def scoped_core_db(
|
||||
tenant_id: Optional[int] = None,
|
||||
company_id: Optional[int] = None,
|
||||
) -> Generator[Session, None, None]:
|
||||
"""Abre una sesión síncrona con contexto RLS explícito.
|
||||
|
||||
Pensado para tareas Celery, comandos de mantenimiento o cualquier camino
|
||||
fuera del ciclo de request HTTP. El contexto se aplica con ``SET LOCAL``
|
||||
en cada transacción.
|
||||
"""
|
||||
db = CoreSessionLocal()
|
||||
db.info[RLS_TENANT_KEY] = tenant_id
|
||||
db.info[RLS_COMPANY_KEY] = company_id
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def scoped_async_core_db(
|
||||
tenant_id: Optional[int] = None,
|
||||
company_id: Optional[int] = None,
|
||||
) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Variante async de :func:`scoped_core_db`."""
|
||||
async with AsyncCoreSessionLocal() as session:
|
||||
session.info[RLS_TENANT_KEY] = tenant_id
|
||||
session.info[RLS_COMPANY_KEY] = company_id
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
def get_tenant_engine(tenant_id: int, db_config: dict):
|
||||
"""Obtiene o crea un engine para un tenant con BD dedicada."""
|
||||
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
|
||||
)
|
||||
return _tenant_engines[tenant_id]
|
||||
|
||||
|
||||
@contextmanager
|
||||
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 y aplica RLS con
|
||||
el ``tenant_id`` recibido. Si el tenant tiene BD dedicada, el aislamiento
|
||||
es físico y no se fija contexto RLS (no hay columna ``tenant_id``).
|
||||
"""
|
||||
if db_config is None:
|
||||
db = CoreSessionLocal()
|
||||
db.info[RLS_TENANT_KEY] = tenant_id
|
||||
else:
|
||||
engine = get_tenant_engine(tenant_id, db_config)
|
||||
SessionLocal = sessionmaker(
|
||||
autocommit=False, autoflush=False, bind=engine)
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Inicializa las tablas de la base de datos core."""
|
||||
try:
|
||||
Base.metadata.create_all(bind=core_engine, checkfirst=True)
|
||||
except ProgrammingError as e:
|
||||
if "already exists" in str(e):
|
||||
logger.warning(
|
||||
f"Algunas tablas ya existen en la base de datos: {e}")
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
async def init_async_db():
|
||||
"""Inicializa las tablas de la base de datos core (async)."""
|
||||
async with async_core_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
118
backend/core/email.py
Normal file
118
backend/core/email.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Email service for sending reports via SMTP.
|
||||
"""
|
||||
import aiosmtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.base import MIMEBase
|
||||
from email import encoders
|
||||
from typing import List
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""Service for sending emails with attachments."""
|
||||
|
||||
@staticmethod
|
||||
async def send_report_email(
|
||||
recipient_email: str,
|
||||
subject: str,
|
||||
body_text: str,
|
||||
csv_content: str,
|
||||
filename: str
|
||||
) -> bool:
|
||||
"""
|
||||
Send a report email with CSV attachment.
|
||||
|
||||
Args:
|
||||
recipient_email: Email address of recipient
|
||||
subject: Email subject line
|
||||
body_text: Plain text email body
|
||||
csv_content: CSV file content as string
|
||||
filename: Name for the CSV attachment
|
||||
|
||||
Returns:
|
||||
bool: True if email sent successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Create message
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_USER}>"
|
||||
msg['To'] = recipient_email
|
||||
msg['Subject'] = subject
|
||||
|
||||
# Email body
|
||||
html_body = f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #2563eb; border-bottom: 2px solid #2563eb; padding-bottom: 10px;">
|
||||
Reporte de Facturas
|
||||
</h2>
|
||||
<p>{body_text}</p>
|
||||
<p style="margin-top: 20px;">
|
||||
El reporte se encuentra adjunto en formato CSV.
|
||||
</p>
|
||||
<hr style="margin: 30px 0; border: none; border-top: 1px solid #e5e7eb;">
|
||||
<p style="font-size: 12px; color: #6b7280;">
|
||||
Este es un correo generado automáticamente. Por favor no responder.
|
||||
</p>
|
||||
<p style="font-size: 12px; color: #6b7280;">
|
||||
Generado el {datetime.now().strftime('%d/%m/%Y a las %H:%M')}
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
msg.attach(MIMEText(html_body, 'html'))
|
||||
|
||||
# CSV attachment with UTF-8 BOM for Excel compatibility
|
||||
attachment = MIMEBase('text', 'csv')
|
||||
csv_bytes = b'\xef\xbb\xbf' + csv_content.encode('utf-8')
|
||||
attachment.set_payload(csv_bytes)
|
||||
encoders.encode_base64(attachment)
|
||||
attachment.add_header(
|
||||
'Content-Disposition',
|
||||
f'attachment; filename="{filename}"'
|
||||
)
|
||||
msg.attach(attachment)
|
||||
|
||||
# Create SSL context that ignores certificate errors
|
||||
import ssl
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
# Send email
|
||||
if settings.SMTP_PORT == 465:
|
||||
# Port 465 uses implicit SSL
|
||||
async with aiosmtplib.SMTP(
|
||||
hostname=settings.SMTP_HOST,
|
||||
port=settings.SMTP_PORT,
|
||||
use_tls=True, # Implicit SSL
|
||||
tls_context=context
|
||||
) as smtp:
|
||||
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
await smtp.send_message(msg)
|
||||
else:
|
||||
# Port 587 uses STARTTLS
|
||||
async with aiosmtplib.SMTP(
|
||||
hostname=settings.SMTP_HOST,
|
||||
port=settings.SMTP_PORT,
|
||||
tls_context=context
|
||||
) as smtp:
|
||||
await smtp.starttls(tls_context=context)
|
||||
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
await smtp.send_message(msg)
|
||||
|
||||
logger.info(f"Email sent successfully to {recipient_email}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email to {recipient_email}: {str(e)}")
|
||||
return False
|
||||
357
backend/core/error_handlers.py
Normal file
357
backend/core/error_handlers.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
Manejadores globales de excepciones para FastAPI
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import Request, status, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
|
||||
from .config import settings
|
||||
from .exceptions import BaseAPIException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _cors_headers(request: Request) -> Dict[str, str]:
|
||||
"""CORS headers for error responses so browser does not block on 4xx/5xx."""
|
||||
origin = request.headers.get("origin")
|
||||
if not origin or origin not in settings.cors_origins_list:
|
||||
return {}
|
||||
return {
|
||||
"Access-Control-Allow-Origin": origin,
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
}
|
||||
|
||||
|
||||
async def base_exception_handler(
|
||||
request: Request,
|
||||
exc: BaseAPIException,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Manejador para todas las excepciones personalizadas de la API
|
||||
"""
|
||||
logger.warning(
|
||||
f"API Exception: {exc.error_code} - {exc.message}",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"method": request.method,
|
||||
"status_code": exc.status_code,
|
||||
},
|
||||
)
|
||||
|
||||
# Log detailed errors if they exist
|
||||
if hasattr(exc, "errors") and exc.errors:
|
||||
logger.warning(f"Validation errors details: {exc.errors}")
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=jsonable_encoder(exc.to_dict()),
|
||||
)
|
||||
for k, v in _cors_headers(request).items():
|
||||
response.headers[k] = v
|
||||
return response
|
||||
|
||||
|
||||
# Mapa de campos técnicos a nombres legibles en español
|
||||
_FIELD_LABELS: Dict[str, str] = {
|
||||
"broker_key": "Clave del Agente",
|
||||
"license": "Patente",
|
||||
"tax_id": "RFC",
|
||||
"personal_id": "CURP",
|
||||
"email": "Correo Electrónico",
|
||||
"phone": "Teléfono",
|
||||
"fax": "Fax",
|
||||
"contact": "Nombre de Contacto",
|
||||
"name": "Nombre / Razón Social",
|
||||
"address": "Dirección",
|
||||
"postal_code": "Código Postal",
|
||||
"city": "Ciudad",
|
||||
"state": "Estado",
|
||||
"country": "País",
|
||||
# Partes (A76)
|
||||
"unit_cost": "Costo Unitario",
|
||||
"unit_weight": "Peso Unitario",
|
||||
"sector": "Sector",
|
||||
"fraction_type": "Tipo de tarifa",
|
||||
}
|
||||
|
||||
_FIELD_PATTERN_MESSAGES: Dict[str, str] = {
|
||||
"broker_key": "La Clave del Agente solo puede contener letras y números (máx. 5 caracteres).",
|
||||
"license": "La Patente debe ser un número entre 1 y 9999 (no puede ser 0 ni contener letras).",
|
||||
"tax_id": "El RFC no tiene el formato correcto. Ejemplo válido: XAXX010101000.",
|
||||
"personal_id": "La CURP no tiene el formato correcto. Debe tener 18 caracteres alfanuméricos.",
|
||||
"email": "El correo electrónico no tiene un formato válido. Ejemplo: usuario@dominio.com.",
|
||||
"phone": "El teléfono solo puede contener dígitos, espacios y los símbolos: +, -, (, ).",
|
||||
"contact": "El nombre de contacto contiene caracteres no permitidos. Use solo letras, números y puntuación básica.",
|
||||
# Partes (A76)
|
||||
"sector": "El Sector solo puede contener números, máximo 8 dígitos (sin espacios ni caracteres especiales).",
|
||||
}
|
||||
|
||||
|
||||
def _friendly_message(field_key: str, error_type: str) -> str:
|
||||
"""Devuelve un mensaje de error legible en español según el campo y tipo de error."""
|
||||
if error_type in ("greater_than_equal",):
|
||||
if field_key in ("unit_cost", "unit_weight"):
|
||||
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' no puede ser negativo."
|
||||
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' debe ser mayor o igual a 0."
|
||||
if error_type in ("string_pattern_mismatch", "value_error"):
|
||||
return _FIELD_PATTERN_MESSAGES.get(
|
||||
field_key,
|
||||
f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' contiene un valor con formato inválido.",
|
||||
)
|
||||
if error_type in ("decimal_parsing", "decimal_type", "float_parsing", "float_type", "int_parsing", "int_type"):
|
||||
return (
|
||||
f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' debe ser numérico. "
|
||||
"Si no aplica, déjelo vacío."
|
||||
)
|
||||
if error_type in ("literal_error",):
|
||||
if field_key == "fraction_type":
|
||||
return "El campo 'Tipo de tarifa' es inválido. Seleccione una opción predefinida."
|
||||
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' contiene una opción inválida."
|
||||
if error_type == "string_too_long":
|
||||
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' excede la longitud máxima permitida."
|
||||
if error_type == "string_too_short":
|
||||
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' es demasiado corto."
|
||||
if error_type in ("missing", "value_error.missing"):
|
||||
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' es obligatorio."
|
||||
return f"El campo '{_FIELD_LABELS.get(field_key, field_key)}' contiene un valor inválido."
|
||||
|
||||
|
||||
async def validation_exception_handler(
|
||||
request: Request,
|
||||
exc: RequestValidationError,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Manejador para errores de validación de Pydantic/FastAPI.
|
||||
Devuelve mensajes legibles en español.
|
||||
"""
|
||||
errors = []
|
||||
for error in exc.errors():
|
||||
loc_parts = [str(loc) for loc in error["loc"] if loc != "body"]
|
||||
field = ".".join(loc_parts)
|
||||
field_key = loc_parts[-1] if loc_parts else ""
|
||||
|
||||
errors.append(
|
||||
{
|
||||
"field": field,
|
||||
"message": _friendly_message(field_key, error["type"]),
|
||||
"type": error["type"],
|
||||
}
|
||||
)
|
||||
|
||||
print(f"DEBUG REQUEST VALIDATION ERRORS: {errors}")
|
||||
logger.warning(
|
||||
f"Validation Error en {request.url.path}",
|
||||
extra={"errors": errors},
|
||||
)
|
||||
|
||||
summary = (
|
||||
errors[0]["message"]
|
||||
if len(errors) == 1
|
||||
else f"Hay {len(errors)} errores de validación: " + " | ".join(e["message"] for e in errors)
|
||||
)
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
content={
|
||||
"error": "VALIDATION_ERROR",
|
||||
"message": summary,
|
||||
"status_code": status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
"errors": errors,
|
||||
},
|
||||
)
|
||||
for k, v in _cors_headers(request).items():
|
||||
response.headers[k] = v
|
||||
return response
|
||||
|
||||
|
||||
import traceback
|
||||
|
||||
async def inner_validation_exception_handler(
|
||||
request: Request,
|
||||
exc: ValidationError,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Manejador para errores de validación de Pydantic lanzados internamente (como en tenant_crud_routes).
|
||||
"""
|
||||
traceback.print_exc()
|
||||
errors = []
|
||||
for error in exc.errors():
|
||||
loc_parts = [str(loc) for loc in error["loc"] if loc != "body"]
|
||||
field = ".".join(loc_parts)
|
||||
field_key = loc_parts[-1] if loc_parts else ""
|
||||
|
||||
errors.append(
|
||||
{
|
||||
"field": field,
|
||||
"message": _friendly_message(field_key, error["type"]),
|
||||
"type": error["type"],
|
||||
}
|
||||
)
|
||||
|
||||
print(f"DEBUG VALIDATION ERRORS: {errors}")
|
||||
logger.warning(
|
||||
f"Inner Validation Error en {request.url.path}",
|
||||
extra={"errors": errors},
|
||||
)
|
||||
|
||||
summary = (
|
||||
errors[0]["message"]
|
||||
if len(errors) == 1
|
||||
else f"Hay {len(errors)} errores de validación: " + " | ".join(e["message"] for e in errors)
|
||||
)
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
content={
|
||||
"error": "VALIDATION_ERROR",
|
||||
"message": summary,
|
||||
"status_code": status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
"errors": errors,
|
||||
},
|
||||
)
|
||||
for k, v in _cors_headers(request).items():
|
||||
response.headers[k] = v
|
||||
return response
|
||||
|
||||
|
||||
async def integrity_error_handler(
|
||||
request: Request,
|
||||
exc: IntegrityError,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Manejador para errores de integridad de la base de datos
|
||||
"""
|
||||
logger.error(
|
||||
f"Database Integrity Error: {str(exc.orig)}",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"method": request.method,
|
||||
},
|
||||
)
|
||||
|
||||
orig_msg = str(exc.orig).lower()
|
||||
|
||||
# Check for unique/duplicate key violations (English and Spanish)
|
||||
if any(kw in orig_msg for kw in ["unique constraint", "duplicate key", "duplicada", "unicidad", "ya existe"]):
|
||||
error_message = "El registro ya existe. Verifica los campos únicos (Año, Aduana, Patente, Número, etc.)."
|
||||
# Check for foreign key violations (English and Spanish)
|
||||
elif any(kw in orig_msg for kw in ["foreign key", "foránea", "referencia"]):
|
||||
error_message = "Referencia inválida a otro registro. Verifica las categorías y catálogos seleccionados."
|
||||
# Check for not null violations (English and Spanish)
|
||||
elif any(kw in orig_msg for kw in ["not null", "no nulo", "valor nulo"]):
|
||||
error_message = "Falta un campo requerido. Asegúrate de llenar todos los datos obligatorios."
|
||||
else:
|
||||
error_message = "Error de integridad en la base de datos"
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
content={
|
||||
"error": "DATABASE_INTEGRITY_ERROR",
|
||||
"message": error_message,
|
||||
"status_code": status.HTTP_409_CONFLICT,
|
||||
},
|
||||
)
|
||||
for k, v in _cors_headers(request).items():
|
||||
response.headers[k] = v
|
||||
return response
|
||||
|
||||
|
||||
async def sqlalchemy_error_handler(
|
||||
request: Request,
|
||||
exc: SQLAlchemyError,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Manejador para errores generales de SQLAlchemy
|
||||
"""
|
||||
logger.error(
|
||||
f"Database Error: {str(exc)}",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"method": request.method,
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"error": "DATABASE_ERROR",
|
||||
"message": f"Error en la operación de base de datos: {str(exc)}",
|
||||
"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
},
|
||||
)
|
||||
for k, v in _cors_headers(request).items():
|
||||
response.headers[k] = v
|
||||
return response
|
||||
|
||||
|
||||
async def http_exception_handler(
|
||||
request: Request,
|
||||
exc: HTTPException,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Manejador para HTTPException de FastAPI
|
||||
"""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"error": "HTTP_ERROR",
|
||||
"message": exc.detail,
|
||||
"status_code": exc.status_code,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def general_exception_handler(
|
||||
request: Request,
|
||||
exc: Exception,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Manejador para excepciones no capturadas
|
||||
"""
|
||||
logger.error(
|
||||
f"Unhandled Exception: {str(exc)}",
|
||||
extra={
|
||||
"path": request.url.path,
|
||||
"method": request.method,
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"error": "INTERNAL_SERVER_ERROR",
|
||||
"message": f"Error interno del servidor: {str(exc)}",
|
||||
"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
},
|
||||
)
|
||||
for k, v in _cors_headers(request).items():
|
||||
response.headers[k] = v
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def register_exception_handlers(app) -> None:
|
||||
"""
|
||||
Registra todos los manejadores de excepciones en la aplicación FastAPI
|
||||
|
||||
Args:
|
||||
app: Instancia de FastAPI
|
||||
"""
|
||||
app.add_exception_handler(BaseAPIException, base_exception_handler)
|
||||
app.add_exception_handler(HTTPException, http_exception_handler)
|
||||
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
||||
app.add_exception_handler(ValidationError, inner_validation_exception_handler)
|
||||
app.add_exception_handler(IntegrityError, integrity_error_handler)
|
||||
app.add_exception_handler(SQLAlchemyError, sqlalchemy_error_handler)
|
||||
app.add_exception_handler(Exception, general_exception_handler)
|
||||
|
||||
300
backend/core/exceptions.py
Normal file
300
backend/core/exceptions.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
Sistema centralizado de excepciones personalizadas
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict, Any
|
||||
from fastapi import status
|
||||
|
||||
|
||||
class BaseAPIException(Exception):
|
||||
"""Excepción base para todas las excepciones de la API"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
errors: Optional[List[Dict[str, Any]]] = None,
|
||||
error_code: Optional[str] = None,
|
||||
):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.errors = errors or []
|
||||
self.error_code = error_code or self.__class__.__name__
|
||||
super().__init__(self.message)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convierte la excepción a un diccionario para respuesta JSON"""
|
||||
response = {
|
||||
"error": self.error_code,
|
||||
"message": self.message,
|
||||
"status_code": self.status_code,
|
||||
}
|
||||
if self.errors:
|
||||
response["errors"] = self.errors
|
||||
return response
|
||||
|
||||
|
||||
class ValidationException(BaseAPIException):
|
||||
"""Excepción para errores de validación"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Error de validación",
|
||||
errors: Optional[List[Dict[str, Any]]] = None,
|
||||
):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
errors=errors,
|
||||
error_code="VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
|
||||
class DuplicateResourceException(BaseAPIException):
|
||||
"""Excepción cuando se intenta crear un recurso duplicado"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource: str,
|
||||
identifier: str,
|
||||
message: Optional[str] = None,
|
||||
):
|
||||
self.resource = resource
|
||||
self.identifier = identifier
|
||||
final_message = (
|
||||
message or f"{resource} con identificador '{identifier}' ya existe"
|
||||
)
|
||||
super().__init__(
|
||||
message=final_message,
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
error_code="DUPLICATE_RESOURCE",
|
||||
)
|
||||
|
||||
|
||||
class ResourceNotFoundException(BaseAPIException):
|
||||
"""Excepción cuando no se encuentra un recurso"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource: str,
|
||||
identifier: str,
|
||||
message: Optional[str] = None,
|
||||
):
|
||||
self.resource = resource
|
||||
self.identifier = identifier
|
||||
final_message = (
|
||||
message or f"{resource} con identificador '{identifier}' no encontrado"
|
||||
)
|
||||
super().__init__(
|
||||
message=final_message,
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
error_code="RESOURCE_NOT_FOUND",
|
||||
)
|
||||
|
||||
|
||||
class UnauthorizedException(BaseAPIException):
|
||||
"""Excepción para errores de autenticación"""
|
||||
|
||||
def __init__(self, message: str = "No autorizado"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
error_code="UNAUTHORIZED",
|
||||
)
|
||||
|
||||
|
||||
class ForbiddenException(BaseAPIException):
|
||||
"""Excepción para errores de permisos"""
|
||||
|
||||
def __init__(self, message: str = "Acceso prohibido"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
error_code="FORBIDDEN",
|
||||
)
|
||||
|
||||
|
||||
class BusinessRuleException(BaseAPIException):
|
||||
"""Excepción para errores de reglas de negocio"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
errors: Optional[List[Dict[str, Any]]] = None,
|
||||
):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
errors=errors,
|
||||
error_code="BUSINESS_RULE_ERROR",
|
||||
)
|
||||
|
||||
|
||||
class DatabaseException(BaseAPIException):
|
||||
"""Excepción para errores de base de datos"""
|
||||
|
||||
def __init__(self, message: str = "Error en la base de datos"):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
error_code="DATABASE_ERROR",
|
||||
)
|
||||
|
||||
|
||||
class ErrorCollector:
|
||||
"""
|
||||
Colector de errores para acumular múltiples errores de validación
|
||||
antes de lanzar una excepción
|
||||
|
||||
Uso:
|
||||
collector = ErrorCollector()
|
||||
|
||||
if not valid_email:
|
||||
collector.add_error("email", "Email inválido", "INVALID_EMAIL")
|
||||
|
||||
if not valid_phone:
|
||||
collector.add_error("phone", "Teléfono inválido", "INVALID_PHONE")
|
||||
|
||||
collector.raise_if_errors() # Lanza ValidationException si hay errores
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._errors: List[Dict[str, Any]] = []
|
||||
|
||||
def add_error(
|
||||
self,
|
||||
field: str,
|
||||
message: str,
|
||||
solution: Optional[List[str]],
|
||||
code: Optional[str] = None,
|
||||
value: Optional[Any] = None,
|
||||
) -> "ErrorCollector":
|
||||
"""
|
||||
Agrega un error al colector
|
||||
|
||||
Args:
|
||||
field: Campo donde ocurrió el error (ej: "invoice_number", "email")
|
||||
message: Mensaje descriptivo del error
|
||||
code: Código opcional del error (ej: "REQUIRED", "INVALID_FORMAT")
|
||||
value: Valor que causó el error (opcional)
|
||||
|
||||
Returns:
|
||||
Self para permitir encadenamiento
|
||||
"""
|
||||
error = {
|
||||
"field": field,
|
||||
"message": message,
|
||||
}
|
||||
if solution:
|
||||
error["solution"] = solution
|
||||
if code:
|
||||
error["code"] = code
|
||||
if value is not None:
|
||||
error["value"] = value
|
||||
|
||||
self._errors.append(error)
|
||||
return self
|
||||
|
||||
def add_field_error(
|
||||
self,
|
||||
field: str,
|
||||
message: str,
|
||||
code: str = "INVALID",
|
||||
) -> "ErrorCollector":
|
||||
"""Atajo para agregar error de campo"""
|
||||
return self.add_error(field, message, solution=None, code=code)
|
||||
|
||||
def add_required_error(self, field: str) -> "ErrorCollector":
|
||||
"""Atajo para agregar error de campo requerido"""
|
||||
return self.add_error(
|
||||
field, f"El campo '{field}' es requerido", solution=None, code="REQUIRED"
|
||||
)
|
||||
|
||||
def add_duplicate_error(
|
||||
self,
|
||||
field: str,
|
||||
value: Any,
|
||||
message: Optional[str] = None,
|
||||
) -> "ErrorCollector":
|
||||
"""Atajo para agregar error de duplicado"""
|
||||
final_message = (
|
||||
message or f"El valor '{value}' ya existe para el campo '{field}'"
|
||||
)
|
||||
return self.add_error(
|
||||
field, final_message, solution=None, code="DUPLICATE", value=value
|
||||
)
|
||||
|
||||
def add_invalid_format_error(
|
||||
self,
|
||||
field: str,
|
||||
expected_format: str,
|
||||
) -> "ErrorCollector":
|
||||
"""Atajo para agregar error de formato inválido"""
|
||||
return self.add_error(
|
||||
field,
|
||||
f"Formato inválido. Se esperaba: {expected_format}",
|
||||
solution=None,
|
||||
code="INVALID_FORMAT",
|
||||
)
|
||||
|
||||
def add_range_error(
|
||||
self,
|
||||
field: str,
|
||||
min_value: Optional[Any] = None,
|
||||
max_value: Optional[Any] = None,
|
||||
) -> "ErrorCollector":
|
||||
"""Atajo para agregar error de rango"""
|
||||
if min_value is not None and max_value is not None:
|
||||
message = f"El valor debe estar entre {min_value} y {max_value}"
|
||||
elif min_value is not None:
|
||||
message = f"El valor debe ser mayor o igual a {min_value}"
|
||||
elif max_value is not None:
|
||||
message = f"El valor debe ser menor o igual a {max_value}"
|
||||
else:
|
||||
message = "Valor fuera de rango"
|
||||
|
||||
return self.add_error(field, message, solution=None, code="OUT_OF_RANGE")
|
||||
|
||||
def has_errors(self) -> bool:
|
||||
"""Verifica si hay errores acumulados"""
|
||||
return len(self._errors) > 0
|
||||
|
||||
def get_errors(self) -> List[Dict[str, Any]]:
|
||||
"""Obtiene la lista de errores"""
|
||||
return self._errors.copy()
|
||||
|
||||
def get_error_count(self) -> int:
|
||||
"""Obtiene el número de errores"""
|
||||
return len(self._errors)
|
||||
|
||||
def clear(self) -> "ErrorCollector":
|
||||
"""Limpia todos los errores"""
|
||||
self._errors.clear()
|
||||
return self
|
||||
|
||||
def raise_if_errors(
|
||||
self,
|
||||
message: str = "Se encontraron errores de validación",
|
||||
) -> None:
|
||||
"""
|
||||
Lanza ValidationException si hay errores acumulados
|
||||
|
||||
Args:
|
||||
message: Mensaje principal de la excepción
|
||||
|
||||
Raises:
|
||||
ValidationException: Si hay errores acumulados
|
||||
"""
|
||||
if self.has_errors():
|
||||
raise ValidationException(message=message, errors=self._errors)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""Permite usar el colector en contextos booleanos"""
|
||||
return self.has_errors()
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Permite usar len() en el colector"""
|
||||
return self.get_error_count()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"ErrorCollector(errors={self.get_error_count()})"
|
||||
332
backend/core/middleware.py
Normal file
332
backend/core/middleware.py
Normal file
@@ -0,0 +1,332 @@
|
||||
import logging
|
||||
import time
|
||||
import httpx
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Optional
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from .config import settings
|
||||
from .security import get_tenant_from_token, verify_token, get_active_system
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_text(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return str(value).strip().lower()
|
||||
|
||||
|
||||
def _is_token_issue_message(*values: str | None) -> bool:
|
||||
text = " ".join(_normalize_text(v) for v in values if v)
|
||||
if not text:
|
||||
return False
|
||||
|
||||
token_markers = ["token", "jwt", "bearer", "access"]
|
||||
invalid_markers = [
|
||||
"invalido", "inválido", "invalid", "not valid", "malformed", "signature", "unauthorized"
|
||||
]
|
||||
expired_markers = ["expirado", "expirada", "expired", "has expired", "caducado", "vencido"]
|
||||
|
||||
has_token_context = any(marker in text for marker in token_markers)
|
||||
has_invalid_marker = any(marker in text for marker in invalid_markers)
|
||||
has_expired_marker = any(marker in text for marker in expired_markers)
|
||||
|
||||
return (has_expired_marker and has_token_context) or (has_token_context and has_invalid_marker)
|
||||
|
||||
|
||||
def _extract_company_id(request: Request) -> Optional[int]:
|
||||
"""Obtiene ``company_id`` activa desde header ``X-Company-Id`` o cookie.
|
||||
|
||||
El frontend guarda la compañía activa en la cookie ``active_company_id``
|
||||
(ver ``frontend/src/lib/stores/company.svelte.ts``). El header es la
|
||||
ruta explícita para clientes no-browser.
|
||||
"""
|
||||
header_value = request.headers.get("X-Company-Id")
|
||||
raw = header_value or request.cookies.get("active_company_id")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class TenantMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware original para extraer tenant_id y user_info del token.
|
||||
"""
|
||||
async def dispatch(self, request: Request, call_next: Callable):
|
||||
doc_prefixes = ["/api/redoc", "/api/openapi.json"]
|
||||
public_prefixes = [
|
||||
"/api/v1/auth",
|
||||
"/api/v1/status",
|
||||
"/api/health",
|
||||
"/api/",
|
||||
"/uploads",
|
||||
"/api/v1/core/help-center",
|
||||
"/api/v1/core/users/avatar",
|
||||
]
|
||||
|
||||
path = request.url.path
|
||||
|
||||
if any(path == prefix or path.startswith(prefix + "/") for prefix in doc_prefixes):
|
||||
return await call_next(request)
|
||||
|
||||
if any(path == prefix or (prefix != "/" and path.startswith(prefix)) for prefix in public_prefixes):
|
||||
return await call_next(request)
|
||||
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "HTTP_ERROR",
|
||||
"message": "Missing or invalid authorization header",
|
||||
"status_code": 401,
|
||||
}
|
||||
)
|
||||
|
||||
token = auth_header.split(" ")[1]
|
||||
try:
|
||||
user_info = await verify_token(token)
|
||||
tenant_id = get_tenant_from_token(user_info)
|
||||
|
||||
request.state.tenant_id = tenant_id
|
||||
request.state.user_info = user_info
|
||||
request.state.company_id = _extract_company_id(request)
|
||||
request.state.active_system = get_active_system(request)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Tenant validation error: {str(e)}")
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "HTTP_ERROR",
|
||||
"message": "Invalid authentication",
|
||||
"status_code": 401,
|
||||
}
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class LicenseValidationMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware que valida la licencia contra el Hub de Aduanasoft.
|
||||
El Hub siempre es requerido — tanto en SaaS como en self-hosted.
|
||||
Fail-closed: si el Hub no responde o la licencia es inválida, se bloquea el acceso.
|
||||
"""
|
||||
async def dispatch(self, request: Request, call_next: Callable):
|
||||
# En modo local (DEV_LOCAL_AUTH) no hay Hub — saltar validación de licencia.
|
||||
if settings.DEV_LOCAL_AUTH:
|
||||
return await call_next(request)
|
||||
|
||||
exempt_paths = [
|
||||
"/api/docs", "/api/redoc", "/openapi.json",
|
||||
"/api/v1/auth", "/api/v1/status", "/api/health",
|
||||
"/api/v1/core/help-center",
|
||||
"/api/v1/core/users/avatar",
|
||||
]
|
||||
|
||||
is_exempt = any(
|
||||
request.url.path == path or (path != "/" and request.url.path.startswith(path))
|
||||
for path in exempt_paths
|
||||
)
|
||||
|
||||
if is_exempt:
|
||||
return await call_next(request)
|
||||
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or not auth_header.startswith("Bearer "):
|
||||
# Permitimos pasar para que TenantMiddleware maneje el 401
|
||||
return await call_next(request)
|
||||
|
||||
token = auth_header.split(" ")[1]
|
||||
|
||||
tenant_override = request.headers.get("X-Tenant-Override")
|
||||
if not tenant_override:
|
||||
# Fallback para flujos SSO cuando el override no viaja en header.
|
||||
tenant_override = request.cookies.get("sso_tenant_id") or request.cookies.get("sso_tenant_pub")
|
||||
|
||||
# TenantMiddleware (corre antes) ya resolvió el token y dejó tenant en user_info.
|
||||
# Sin esto, Swagger/curl sin cookies SSO llaman verify-license sin contexto y el Hub
|
||||
# puede devolver 401 aunque /auth/me con el mismo Bearer responda 200.
|
||||
if not tenant_override:
|
||||
user_info = getattr(request.state, "user_info", None)
|
||||
if isinstance(user_info, dict):
|
||||
tid = user_info.get("tenant_id")
|
||||
if tid is not None and str(tid).strip() != "":
|
||||
tenant_override = str(tid)
|
||||
|
||||
hub_headers = {"Authorization": f"Bearer {token}"}
|
||||
if tenant_override:
|
||||
hub_headers["X-Tenant-Override"] = str(tenant_override)
|
||||
logger.info("[license] tenant override propagated to Hub: %s", tenant_override)
|
||||
|
||||
# Solo la petición HTTP al Hub va en try: los errores de rutas (p. ej. ContextVar RLS)
|
||||
# deben propagarse y no etiquetarse como fallo de licencia.
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(
|
||||
f"{settings.HUB_URL}api/v1/auth/verify-license",
|
||||
headers=hub_headers
|
||||
)
|
||||
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
||||
logger.critical(f"❌ CRITICAL: Hub unreachable: {str(e)}")
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "HUB_OFFLINE",
|
||||
"message": "Servicio de licencias fuera de línea. Acceso denegado.",
|
||||
"status_code": 503,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Hub verify-license request failed: %s", e)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "VALIDATION_ERROR",
|
||||
"message": "Error interno al contactar el servicio de licencias.",
|
||||
"status_code": 500,
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 404:
|
||||
# Endpoint no existe en este Hub — dejar pasar
|
||||
return await call_next(request)
|
||||
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
data = response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Hub verify-license JSON parse failed: {str(e)}")
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "HUB_ERROR",
|
||||
"message": "Respuesta inválida del servidor de licencias.",
|
||||
"status_code": 503,
|
||||
}
|
||||
)
|
||||
|
||||
# Escenario 1: sin licencia asignada o licencia inactiva
|
||||
if not data.get("valid", False):
|
||||
message = data.get("message", "Sin licencia asignada para este tenant")
|
||||
detail = data.get("detail")
|
||||
reason = data.get("reason")
|
||||
# Si el Hub reporta token inválido/expirado, devolver 401 para que
|
||||
# el frontend dispare el auto-refresh (solo se activa con 401/403, no 402).
|
||||
if _is_token_issue_message(message, detail, reason):
|
||||
logger.warning(
|
||||
"[license] token expirado/invalido detectado por verify-license; devolviendo 401 para silent refresh | message=%s detail=%s reason=%s",
|
||||
message,
|
||||
detail,
|
||||
reason,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "TOKEN_EXPIRED",
|
||||
"message": message,
|
||||
"status_code": 401,
|
||||
}
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"[license] licencia invalida para tenant=%s | message=%s",
|
||||
data.get("tenant_slug"),
|
||||
message,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=402,
|
||||
content={
|
||||
"error": "LICENSE_ERROR",
|
||||
"message": message,
|
||||
"status_code": 402,
|
||||
}
|
||||
)
|
||||
|
||||
# Escenario 2: licencia vencida (verificación local de expires_at)
|
||||
expires_at_str = data.get("expires_at")
|
||||
if expires_at_str:
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00"))
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
if expires_at < datetime.now(timezone.utc):
|
||||
logger.warning(
|
||||
"[license] licencia expirada para tenant=%s | expires_at=%s",
|
||||
data.get("tenant_slug"),
|
||||
expires_at_str,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=402,
|
||||
content={
|
||||
"error": "LICENSE_EXPIRED",
|
||||
"message": f"La licencia venció el {expires_at.strftime('%d/%m/%Y')}. Renueva tu suscripción.",
|
||||
"status_code": 402,
|
||||
}
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass # Si no se puede parsear, dejamos pasar — el Hub es la fuente de verdad
|
||||
|
||||
request.state.license_info = data
|
||||
return await call_next(request)
|
||||
|
||||
if response.status_code == 401:
|
||||
logger.warning("[license] Hub verify-license devolvio 401 (token invalido/expirado)")
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "TOKEN_EXPIRED",
|
||||
"message": "Token inválido o expirado.",
|
||||
"status_code": 401,
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 403:
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"error": "FORBIDDEN",
|
||||
"message": "El Tenant no tiene permisos en el Hub central.",
|
||||
"status_code": 403,
|
||||
}
|
||||
)
|
||||
|
||||
logger.error(f"Hub error status: {response.status_code}")
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "HUB_ERROR",
|
||||
"message": "Error en el servidor de licencias.",
|
||||
"status_code": 503,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware original para logging de performance.
|
||||
"""
|
||||
async def dispatch(self, request: Request, call_next: Callable):
|
||||
start_time = time.time()
|
||||
excluded_paths = ["/api/docs", "/api/redoc", "/openapi.json", "/api/v1/status", "/api/health"]
|
||||
|
||||
if any(request.url.path == path or request.url.path.startswith(path + "/") for path in excluded_paths):
|
||||
return await call_next(request)
|
||||
|
||||
logger.info(f"Request: {request.method} {request.url.path}")
|
||||
response = await call_next(request)
|
||||
process_time = time.time() - start_time
|
||||
|
||||
logger.info(
|
||||
f"Response: {request.method} {request.url.path} "
|
||||
f"Status: {response.status_code} "
|
||||
f"Duration: {process_time:.3f}s"
|
||||
)
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
return response
|
||||
15
backend/core/paths.py
Normal file
15
backend/core/paths.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Rutas base y resolución de paths para layouts (importación CSV, temp, errors).
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
# Raíz del backend (directorio que contiene api/, core/, etc.)
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def layout_path(*parts: str) -> str:
|
||||
"""Construye una ruta absoluta bajo backend/layouts/."""
|
||||
p = BASE_DIR / "layouts"
|
||||
for part in parts:
|
||||
p = p / part
|
||||
return str(p)
|
||||
427
backend/core/s3_keys.py
Normal file
427
backend/core/s3_keys.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
Convención de claves S3/MinIO para objetos persistidos.
|
||||
|
||||
Todas las cargas que usen ``put_object_bytes`` deben obtener la clave mediante
|
||||
funciones de este módulo (no construir ``tenants/...`` a mano en las rutas HTTP).
|
||||
|
||||
Árbol canónico
|
||||
--------------
|
||||
|
||||
**Multi-tenant** (datos de clientes), siempre bajo ``tenants/{tenant_id}/``:
|
||||
|
||||
- ``tenants/{tid}/users/{keycloak_sub}/``
|
||||
Perfil de usuario (avatar). Ver ``tenant_user_prefix``, ``user_avatar_key``.
|
||||
|
||||
- ``tenants/{tid}/companies/{company_id}/``
|
||||
Recursos ligados a una empresa:
|
||||
|
||||
- ``.../doda/{doda_id}/report/doda_report.pdf`` — reporte DODA en PDF. ``doda_report_pdf_key``.
|
||||
- ``.../branding/{filename}`` — logo. ``company_logo_key``.
|
||||
- ``.../certificates/{tipo}_{timestamp}.{cer|key}`` — CER/KEY FIEL, CFDI, cancelación.
|
||||
``company_certificate_key``.
|
||||
- ``.../imports/csv/{job_type}/{job_id}.csv`` — CSV de layouts (import jobs).
|
||||
``csv_import_key`` (usado por ``storage_s3.s3_key_for_csv_import``).
|
||||
- ``.../customs_brokers/{broker_id}/certificates/`` — CER del VU (``.cer``).
|
||||
``customs_broker_vu_certificate_key``.
|
||||
- ``.../customs_brokers/{broker_id}/keys/`` — llave privada VU (``.key``).
|
||||
``customs_broker_vu_private_key_key``.
|
||||
- ``.../customs_brokers/{broker_id}/cove/`` — archivos COVE (xml, zip, etc.).
|
||||
``customs_broker_vu_cove_key``.
|
||||
|
||||
**Sistema global** (no por tenant):
|
||||
|
||||
- ``system/help/{carpeta opcional}/{archivo}`` — biblioteca de ayuda (imágenes, PDFs, vídeos).
|
||||
``help_asset_key``, ``global_system_prefix``. Lectura HTTP mapea a este prefijo.
|
||||
|
||||
**Legado / migración**:
|
||||
|
||||
- ``imports/csv/{job_type}/{job_id}.csv`` — sin tenant/company. Solo ``legacy_csv_import_key``
|
||||
(cleanup o compatibilidad).
|
||||
|
||||
Constantes públicas
|
||||
-------------------
|
||||
|
||||
``SYSTEM_HELP_PREFIX`` — prefijo literal ``system/help/`` para lecturas y utilidades
|
||||
que no pasan por ``help_asset_key``.
|
||||
"""
|
||||
import re
|
||||
from typing import Union
|
||||
|
||||
# Segmentos permitidos en claves (evita path traversal)
|
||||
_SAFE_SEGMENT = re.compile(r"^[a-zA-Z0-9._\-]+$")
|
||||
|
||||
# Prefijo fijo para objetos de Help Center (debe coincidir con help_asset_key / GET /files/)
|
||||
SYSTEM_HELP_PREFIX = "system/help/"
|
||||
|
||||
|
||||
def _segment(value: Union[int, str], label: str) -> str:
|
||||
s = str(value).strip()
|
||||
if not s or "/" in s or ".." in s:
|
||||
raise ValueError(f"invalid {label} segment")
|
||||
if not _SAFE_SEGMENT.match(s):
|
||||
raise ValueError(f"invalid {label} characters")
|
||||
return s
|
||||
|
||||
|
||||
def tenant_company_prefix(tenant_id: Union[int, str], company_id: int) -> str:
|
||||
"""Prefijo `tenants/{tid}/companies/{cid}/` (termina en /)."""
|
||||
tid = _segment(tenant_id, "tenant_id")
|
||||
cid = _segment(company_id, "company_id")
|
||||
return f"tenants/{tid}/companies/{cid}/"
|
||||
|
||||
|
||||
def tenant_user_prefix(tenant_id: Union[int, str], keycloak_user_id: str) -> str:
|
||||
"""Prefijo `tenants/{tid}/users/{keycloak_sub}/` (avatar de perfil, sin company)."""
|
||||
tid = _segment(tenant_id, "tenant_id")
|
||||
kid = _segment(keycloak_user_id, "keycloak_user_id")
|
||||
return f"tenants/{tid}/users/{kid}/"
|
||||
|
||||
|
||||
def user_avatar_key(
|
||||
tenant_id: Union[int, str],
|
||||
keycloak_user_id: str,
|
||||
ext: str,
|
||||
) -> str:
|
||||
ext = ext.lower() if ext.startswith(".") else f".{ext}"
|
||||
allowed = (".jpg", ".jpeg", ".png", ".gif", ".webp")
|
||||
if ext not in allowed:
|
||||
raise ValueError("invalid avatar extension")
|
||||
return f"{tenant_user_prefix(tenant_id, keycloak_user_id)}avatar{ext}"
|
||||
|
||||
|
||||
def public_user_avatar_api_path(tenant_id: int, keycloak_user_id: str) -> str:
|
||||
"""Ruta GET pública para servir la imagen (sin host)."""
|
||||
return f"/api/v1/core/users/avatar/{tenant_id}/{keycloak_user_id}"
|
||||
|
||||
|
||||
def global_system_prefix(subpath: str = "help") -> str:
|
||||
"""Prefijo bajo `system/` para contenido global (p. ej. help). Termina en /."""
|
||||
sub = subpath.strip().strip("/")
|
||||
if not sub:
|
||||
return SYSTEM_HELP_PREFIX
|
||||
parts = sub.split("/")
|
||||
for p in parts:
|
||||
_segment(p, "system_subpath")
|
||||
return f"system/{sub}/"
|
||||
|
||||
|
||||
def customs_broker_vu_prefix(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
broker_id: int,
|
||||
) -> str:
|
||||
"""
|
||||
Prefijo para el bloque VU del agente aduanal.
|
||||
|
||||
Forma: ``tenants/{tid}/companies/{cid}/customs_brokers/{broker_id}/``
|
||||
"""
|
||||
bid = _segment(str(broker_id), "broker_id")
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}customs_brokers/{bid}/"
|
||||
|
||||
|
||||
def customs_broker_vu_certificate_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
broker_id: int,
|
||||
timestamp: str,
|
||||
file_ext: str,
|
||||
) -> str:
|
||||
"""CER del VU bajo ``.../customs_brokers/{id}/certificates/vu_cer_{timestamp}.cer``."""
|
||||
ts = _segment(timestamp, "timestamp")
|
||||
ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}"
|
||||
if ext != ".cer":
|
||||
raise ValueError("VU certificate must be .cer")
|
||||
base = f"vu_cer_{ts}{ext}"
|
||||
return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}certificates/{base}"
|
||||
|
||||
|
||||
def customs_broker_vu_private_key_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
broker_id: int,
|
||||
timestamp: str,
|
||||
file_ext: str,
|
||||
) -> str:
|
||||
"""Llave privada del VU bajo ``.../customs_brokers/{id}/keys/vu_key_{timestamp}.key``."""
|
||||
ts = _segment(timestamp, "timestamp")
|
||||
ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}"
|
||||
if ext != ".key":
|
||||
raise ValueError("VU private key must be .key")
|
||||
base = f"vu_key_{ts}{ext}"
|
||||
return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}keys/{base}"
|
||||
|
||||
|
||||
def customs_broker_vu_cove_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
broker_id: int,
|
||||
timestamp: str,
|
||||
original_filename: str,
|
||||
) -> str:
|
||||
"""
|
||||
Archivos COVE bajo ``.../customs_brokers/{id}/cove/cove_{timestamp}_{filename}``.
|
||||
Extensiones típicas: .xml, .zip, .txt, .pdf, .json
|
||||
"""
|
||||
ts = _segment(timestamp, "timestamp")
|
||||
fn = safe_filename(original_filename)
|
||||
parts = fn.rsplit(".", 1)
|
||||
if len(parts) < 2:
|
||||
raise ValueError("COVE file must have an extension")
|
||||
ext = "." + parts[1].lower()
|
||||
allowed = (".xml", ".zip", ".txt", ".pdf", ".json")
|
||||
if ext not in allowed:
|
||||
raise ValueError(f"COVE extension not allowed: {ext}")
|
||||
base = f"cove_{ts}_{fn}"
|
||||
return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}cove/{base}"
|
||||
|
||||
|
||||
def customs_broker_vu_doda_certificate_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
broker_id: int,
|
||||
timestamp: str,
|
||||
file_ext: str,
|
||||
) -> str:
|
||||
"""CER DODA bajo ``.../customs_brokers/{id}/doda/certificates/doda_cer_{timestamp}.cer``."""
|
||||
ts = _segment(timestamp, "timestamp")
|
||||
ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}"
|
||||
if ext != ".cer":
|
||||
raise ValueError("DODA certificate must be .cer")
|
||||
base = f"doda_cer_{ts}{ext}"
|
||||
return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}doda/certificates/{base}"
|
||||
|
||||
|
||||
def customs_broker_vu_doda_private_key_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
broker_id: int,
|
||||
timestamp: str,
|
||||
file_ext: str,
|
||||
) -> str:
|
||||
"""Llave DODA bajo ``.../customs_brokers/{id}/doda/keys/doda_key_{timestamp}.key``."""
|
||||
ts = _segment(timestamp, "timestamp")
|
||||
ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}"
|
||||
if ext != ".key":
|
||||
raise ValueError("DODA private key must be .key")
|
||||
base = f"doda_key_{ts}{ext}"
|
||||
return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}doda/keys/{base}"
|
||||
|
||||
|
||||
def customs_broker_vu_doda_cove_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
broker_id: int,
|
||||
timestamp: str,
|
||||
original_filename: str,
|
||||
) -> str:
|
||||
"""
|
||||
Archivos DODA XML bajo ``.../customs_brokers/{id}/doda/cove/doda_cove_{timestamp}_{filename}``.
|
||||
Extensiones permitidas: .xml, .zip, .txt, .pdf, .json
|
||||
"""
|
||||
ts = _segment(timestamp, "timestamp")
|
||||
fn = safe_filename(original_filename)
|
||||
parts = fn.rsplit(".", 1)
|
||||
if len(parts) < 2:
|
||||
raise ValueError("DODA file must have an extension")
|
||||
ext = "." + parts[1].lower()
|
||||
allowed = (".xml", ".zip", ".txt", ".pdf", ".json")
|
||||
if ext not in allowed:
|
||||
raise ValueError(f"DODA extension not allowed: {ext}")
|
||||
base = f"doda_cove_{ts}_{fn}"
|
||||
return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}doda/cove/{base}"
|
||||
|
||||
|
||||
def job_type_segment(job_type: str) -> str:
|
||||
if job_type == "" or job_type == "invoice":
|
||||
return "invoice"
|
||||
return job_type
|
||||
|
||||
|
||||
def csv_import_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
job_type: str,
|
||||
job_id: str,
|
||||
) -> str:
|
||||
_segment(job_id, "job_id")
|
||||
return (
|
||||
f"{tenant_company_prefix(tenant_id, company_id)}"
|
||||
f"imports/csv/{job_type_segment(job_type)}/{job_id}.csv"
|
||||
)
|
||||
|
||||
|
||||
def legacy_csv_import_key(job_type: str, job_id: str) -> str:
|
||||
"""Clave antigua sin tenant/company (solo migración / cleanup)."""
|
||||
_segment(job_id, "job_id")
|
||||
return f"imports/csv/{job_type_segment(job_type)}/{job_id}.csv"
|
||||
|
||||
|
||||
def safe_filename(filename: str) -> str:
|
||||
"""Nombre de archivo final sin separadores."""
|
||||
base = filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
if not base or ".." in base:
|
||||
raise ValueError("invalid filename")
|
||||
return base
|
||||
|
||||
|
||||
def company_logo_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
filename: str,
|
||||
) -> str:
|
||||
fn = safe_filename(filename)
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}branding/{fn}"
|
||||
|
||||
|
||||
def doda_report_pdf_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
doda_id: int,
|
||||
) -> str:
|
||||
"""
|
||||
Reporte DODA en PDF bajo ``.../doda/{doda_id}/report/doda_report.pdf`` (clave estable).
|
||||
"""
|
||||
did = _segment(doda_id, "doda_id")
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}doda/{did}/report/doda_report.pdf"
|
||||
|
||||
|
||||
def company_certificate_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
certificate_type: str,
|
||||
timestamp: str,
|
||||
file_ext: str,
|
||||
) -> str:
|
||||
ct = _segment(certificate_type.replace(".", "_"), "certificate_type")
|
||||
ts = _segment(timestamp, "timestamp")
|
||||
ext = file_ext.lower() if file_ext.startswith(".") else f".{file_ext}"
|
||||
if ext not in (".cer", ".key"):
|
||||
raise ValueError("certificate file must be .cer or .key")
|
||||
base = f"{ct}_{ts}{ext}"
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}certificates/{base}"
|
||||
|
||||
|
||||
def expediente_archivo_document_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
expediente_id: int,
|
||||
timestamp: str,
|
||||
original_filename: str,
|
||||
) -> str:
|
||||
"""
|
||||
Archivo del expediente bajo ``.../expediente_archivos/{id}/documents/expediente_{timestamp}_{filename}``.
|
||||
Extensiones permitidas: .pdf, .xml, .png, .jpg, .jpeg, .json, .txt, .zip
|
||||
"""
|
||||
ts = _segment(timestamp, "timestamp")
|
||||
eid = _segment(expediente_id, "expediente_id")
|
||||
fn = safe_filename(original_filename)
|
||||
parts = fn.rsplit(".", 1)
|
||||
if len(parts) < 2:
|
||||
raise ValueError("expediente file must have an extension")
|
||||
ext = "." + parts[1].lower()
|
||||
allowed = (".pdf", ".xml", ".png", ".jpg", ".jpeg", ".json", ".txt", ".zip")
|
||||
if ext not in allowed:
|
||||
raise ValueError(f"expediente file extension not allowed: {ext}")
|
||||
base = f"expediente_{ts}_{fn}"
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}expediente_archivos/{eid}/documents/{base}"
|
||||
|
||||
|
||||
def expediente_archivo_artifact_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
expediente_id: int,
|
||||
artifact_type: str,
|
||||
timestamp: str,
|
||||
) -> str:
|
||||
"""
|
||||
Artefacto de digitalización bajo ``.../expediente_archivos/{id}/artifacts/{type}_{timestamp}.{ext}``.
|
||||
artifact_type: acuse | envio_xml | respuesta_xml | consulta_envio_xml | consulta_respuesta_xml
|
||||
"""
|
||||
ts = _segment(timestamp, "timestamp")
|
||||
eid = _segment(expediente_id, "expediente_id")
|
||||
at = _segment(artifact_type, "artifact_type")
|
||||
ext = ".pdf" if artifact_type == "acuse" else ".xml"
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}expediente_archivos/{eid}/artifacts/{at}_{ts}{ext}"
|
||||
|
||||
|
||||
def cove_xml_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
invoice_id: int,
|
||||
) -> str:
|
||||
"""
|
||||
XML de COVE devuelto por Ventanilla Única, bajo
|
||||
``.../invoices/{invoice_id}/cove/cove.xml`` (clave estable por factura).
|
||||
"""
|
||||
iid = _segment(invoice_id, "invoice_id")
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}invoices/{iid}/cove/cove.xml"
|
||||
|
||||
|
||||
def cove_acuse_pdf_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
invoice_id: int,
|
||||
) -> str:
|
||||
"""
|
||||
PDF de Acuse de COVE bajo
|
||||
``.../invoices/{invoice_id}/cove/acuse_cove.pdf`` (clave estable por factura).
|
||||
"""
|
||||
iid = _segment(invoice_id, "invoice_id")
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}invoices/{iid}/cove/acuse_cove.pdf"
|
||||
|
||||
|
||||
def help_asset_key(folder: str, new_filename: str) -> str:
|
||||
"""
|
||||
folder: '', 'pdfs', 'videos', 'assets' relativo a system/help/
|
||||
"""
|
||||
folder = folder.strip().strip("/")
|
||||
fn = safe_filename(new_filename)
|
||||
if folder:
|
||||
for p in folder.split("/"):
|
||||
_segment(p, "help_folder")
|
||||
return f"{global_system_prefix('help')}{folder}/{fn}"
|
||||
return f"{global_system_prefix('help')}{fn}"
|
||||
|
||||
|
||||
def help_s3_key_to_public_relative_path(key: str) -> str:
|
||||
"""Parte tras `system/help/` para el path del endpoint público."""
|
||||
if not key.startswith(SYSTEM_HELP_PREFIX):
|
||||
raise ValueError("key is not under system/help/")
|
||||
return key[len(SYSTEM_HELP_PREFIX) :]
|
||||
|
||||
|
||||
def help_public_api_path(relative_under_help: str) -> str:
|
||||
"""URL de lectura pública bajo el router help-center (sin host)."""
|
||||
rel = relative_under_help.lstrip("/")
|
||||
return f"/api/v1/core/help-center/files/{rel}"
|
||||
|
||||
|
||||
def signature_photo_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
signature_id: int,
|
||||
timestamp: str,
|
||||
file_ext: str,
|
||||
) -> str:
|
||||
"""
|
||||
Foto de firma bajo ``.../signatures/{signature_id}/photo_{timestamp}.{ext}``.
|
||||
Extensiones permitidas: .jpg, .jpeg, .png, .gif, .webp
|
||||
"""
|
||||
sid = _segment(signature_id, "signature_id")
|
||||
ts = _segment(timestamp, "timestamp")
|
||||
ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}"
|
||||
allowed = (".jpg", ".jpeg", ".png", ".gif", ".webp")
|
||||
if ext not in allowed:
|
||||
raise ValueError(f"signature photo extension not allowed: {ext}")
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}signatures/{sid}/photo_{ts}{ext}"
|
||||
|
||||
|
||||
def system_help_object_key(relative_path: str) -> str:
|
||||
"""
|
||||
Clave S3 completa bajo ``system/help/`` para un path relativo (p. ej. GET /files/...).
|
||||
``relative_path`` no debe empezar por / ni contener '..'.
|
||||
"""
|
||||
rel = relative_path.strip().lstrip("/")
|
||||
if ".." in rel or not rel:
|
||||
raise ValueError("invalid help object path")
|
||||
return f"{SYSTEM_HELP_PREFIX}{rel}"
|
||||
737
backend/core/security.py
Normal file
737
backend/core/security.py
Normal file
@@ -0,0 +1,737 @@
|
||||
"""
|
||||
Utilidades de seguridad y autenticación con Keycloak
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, Set
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, Security
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
import httpx
|
||||
from cachetools import TTLCache
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from .config import settings
|
||||
from .database import get_core_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache para tokens verificados (1 minuto de TTL, máximo 1000 tokens)
|
||||
token_cache = TTLCache(maxsize=1000, ttl=60)
|
||||
|
||||
# IDs de tenants ya sincronizados en este proceso (evita consultas repetidas)
|
||||
_synced_tenant_ids: Set[int] = set()
|
||||
|
||||
# Alias Hub tenant_id -> tenant_id local cuando existe drift histórico de IDs
|
||||
# (mismo slug, diferente id).
|
||||
_tenant_id_aliases: Dict[int, int] = {}
|
||||
# Inverso: id local core.tenants -> id tenant en Hub (JWT / client_tenants) para llamadas al Hub.
|
||||
_tenant_id_hub_by_local: Dict[int, int] = {}
|
||||
|
||||
# Security scheme
|
||||
security = HTTPBearer()
|
||||
|
||||
def get_active_system(request: Request) -> Optional[str]:
|
||||
"""Sistema activo: header ``X-Active-System`` o cookie ``active_system``."""
|
||||
return request.headers.get("x-active-system") or request.cookies.get("active_system") or None
|
||||
|
||||
|
||||
async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Verifica un token JWT llamando al Hub central.
|
||||
Si DEV_LOCAL_AUTH=True y el token es HS256 local, lo verifica sin Hub.
|
||||
"""
|
||||
cache_key = (token, tenant_id_override)
|
||||
if cache_key in token_cache:
|
||||
return token_cache[cache_key]
|
||||
|
||||
# Shortcut para tokens de desarrollo local
|
||||
if settings.DEV_LOCAL_AUTH:
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
if header.get("alg") == "HS256":
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
if payload.get("dev_local"):
|
||||
token_cache[cache_key] = payload
|
||||
return payload
|
||||
except JWTError as e:
|
||||
raise HTTPException(status_code=401, detail=f"Dev token inválido: {e}")
|
||||
|
||||
try:
|
||||
headers: Dict[str, str] = {"Authorization": f"Bearer {token}"}
|
||||
if tenant_id_override:
|
||||
headers["X-Tenant-Override"] = tenant_id_override
|
||||
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(
|
||||
f"{settings.HUB_URL}api/v1/auth/me",
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
user_info = response.json()
|
||||
token_cache[cache_key] = user_info
|
||||
return user_info
|
||||
|
||||
logger.error(f"Hub token verification failed with status {response.status_code}")
|
||||
raise HTTPException(status_code=401, detail="Could not validate credentials")
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Hub unreachable or error during token verification: {str(e)}")
|
||||
raise HTTPException(status_code=503, detail="Authentication service unavailable")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during token verification: {str(e)}")
|
||||
raise HTTPException(status_code=401, detail="Authentication error")
|
||||
|
||||
|
||||
def _ensure_user_tenant_for_company(
|
||||
db: Session, keycloak_user_id: str, tenant_id: int, company_id: int
|
||||
) -> None:
|
||||
"""Garantiza fila core.user_tenants (usuario ↔ compañía ↔ tenant)."""
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
|
||||
existing = (
|
||||
db.query(UserTenant)
|
||||
.filter(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
UserTenant.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
if not existing.is_active:
|
||||
existing.is_active = True
|
||||
db.commit()
|
||||
return
|
||||
db.add(
|
||||
UserTenant(
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _ensure_company_exists(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
tenant_name: str,
|
||||
hub_user: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
STUB — implementa este método con el modelo de compañía de tu proyecto.
|
||||
|
||||
Debe garantizar que exista al menos una empresa para el tenant y que el
|
||||
usuario del token (hub_user["sub"]) tenga un registro en user_tenants.
|
||||
"""
|
||||
logger.debug(
|
||||
"_ensure_company_exists: no implementado en la plantilla (tenant_id=%s)", tenant_id
|
||||
)
|
||||
|
||||
|
||||
def _repair_user_company_link_if_needed(
|
||||
db: Session,
|
||||
tenant_id_effective: int,
|
||||
hub_user: Optional[Dict[str, Any]],
|
||||
) -> None:
|
||||
"""STUB — implementa con el modelo de compañía de tu proyecto."""
|
||||
if not hub_user or not hub_user.get("sub"):
|
||||
return
|
||||
from api.v1.modules.core.permissions.service import PermissionService
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
|
||||
# Sin modelo de compañía en la plantilla, no hay empresa que verificar.
|
||||
# Implementa esta función cuando definas tu tabla de compañías.
|
||||
|
||||
|
||||
def _ensure_tenant_synced(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
tenant_slug: str,
|
||||
hub_user: Optional[Dict[str, Any]] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Garantiza que el tenant del Hub exista en core.tenants local.
|
||||
Se ejecuta una sola vez por tenant_id por ciclo de vida del proceso.
|
||||
El Hub es la fuente de verdad — este método solo sincroniza en una dirección.
|
||||
"""
|
||||
if tenant_id in _synced_tenant_ids:
|
||||
effective = int(_tenant_id_aliases.get(tenant_id, tenant_id))
|
||||
_repair_user_company_link_if_needed(db, effective, hub_user)
|
||||
return effective
|
||||
|
||||
try:
|
||||
# Importación local para evitar imports circulares
|
||||
from api.v1.modules.core.tenants.models import Tenant, TenantType
|
||||
|
||||
name = " ".join(word.capitalize() for word in tenant_slug.replace("-", " ").split())
|
||||
|
||||
existing = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
if existing:
|
||||
# Update name/slug/keycloak_realm if they differ (Hub is source of truth)
|
||||
if existing.slug != tenant_slug or existing.name != name or existing.keycloak_realm != tenant_slug:
|
||||
existing.slug = tenant_slug
|
||||
existing.name = name
|
||||
existing.keycloak_realm = tenant_slug
|
||||
db.commit()
|
||||
logger.info(f"Tenant id={tenant_id} actualizado: slug='{tenant_slug}'")
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
# Garantizar empresa aunque el tenant ya existiera
|
||||
_ensure_company_exists(db, tenant_id, name, hub_user)
|
||||
return tenant_id
|
||||
|
||||
# Crear el tenant local con los datos disponibles del token.
|
||||
# El Hub siempre crea el realm de Keycloak con el mismo nombre que el slug.
|
||||
tenant = Tenant(
|
||||
id=tenant_id,
|
||||
name=name,
|
||||
slug=tenant_slug,
|
||||
type=TenantType.SHARED,
|
||||
keycloak_realm=tenant_slug,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(tenant)
|
||||
db.commit()
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
logger.info(f"Tenant '{tenant_slug}' (id={tenant_id}) sincronizado desde Hub a core.tenants")
|
||||
# Crear la empresa correspondiente al tenant recién sincronizado
|
||||
_ensure_company_exists(db, tenant_id, name, hub_user)
|
||||
return tenant_id
|
||||
|
||||
except IntegrityError:
|
||||
# Puede ser concurrencia o colisión de slug (id diferente, mismo slug)
|
||||
db.rollback()
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
# Si el slug ya existe con diferente id, el tenant real del Hub no está registrado aún.
|
||||
# Logueamos el conflicto para depuración; el sistema continuará con tenant_id vacío.
|
||||
stale = db.query(Tenant).filter(Tenant.slug == tenant_slug).first()
|
||||
if stale and stale.id != tenant_id:
|
||||
logger.error(
|
||||
f"Conflicto de tenant: JWT dice id={tenant_id} slug='{tenant_slug}', "
|
||||
f"pero core.tenants tiene id={stale.id} mismo slug. "
|
||||
f"Elimine el registro obsoleto con: "
|
||||
f"DELETE FROM core.tenants WHERE id={stale.id};"
|
||||
)
|
||||
# Auto-heal en runtime: mapear temporalmente al tenant local existente por slug
|
||||
# para evitar dejar al usuario sin compañías y evitar este conflicto en cada request.
|
||||
_tenant_id_aliases[tenant_id] = int(stale.id)
|
||||
_tenant_id_hub_by_local[int(stale.id)] = int(tenant_id)
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
_ensure_company_exists(db, int(stale.id), stale.name or tenant_slug, hub_user)
|
||||
return int(stale.id)
|
||||
else:
|
||||
_synced_tenant_ids.add(tenant_id)
|
||||
return tenant_id
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.warning(f"No se pudo sincronizar tenant {tenant_id} ({tenant_slug}): {e}")
|
||||
return _tenant_id_aliases.get(tenant_id, tenant_id)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Security(security),
|
||||
db: Session = Depends(get_core_db),
|
||||
request: Request = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Dependency para obtener el usuario actual desde el token JWT.
|
||||
Auto-sincroniza el tenant en core.tenants si fue creado en el Hub
|
||||
pero aún no existe en la BD local.
|
||||
|
||||
Uso en FastAPI:
|
||||
current_user: dict = Depends(get_current_user)
|
||||
"""
|
||||
token = credentials.credentials
|
||||
|
||||
# Leer tenant override del header X-Tenant-Override (pasado por el SvelteKit server
|
||||
# desde la cookie sso_tenant_id, flujo SSO relay multi-tenant)
|
||||
tenant_override = request.headers.get('X-Tenant-Override') if request else None
|
||||
|
||||
logger.info(f"[get_current_user] X-Tenant-Override={tenant_override!r}")
|
||||
|
||||
user_info = await verify_token(token, tenant_id_override=tenant_override)
|
||||
# Copia local para poder normalizar tenant_id sin mutar el objeto cacheado
|
||||
user_info = dict(user_info)
|
||||
|
||||
# Modo local: el token ya trae todo. Saltar sincronización con el Hub.
|
||||
if settings.DEV_LOCAL_AUTH and user_info.get("dev_local"):
|
||||
return user_info
|
||||
|
||||
# Sincronizar tenant desde Hub a BD local (solo la primera vez por tenant)
|
||||
tenant_id = user_info.get("tenant_id")
|
||||
tenant_slug = user_info.get("tenant_slug")
|
||||
if tenant_id and tenant_slug:
|
||||
effective_tenant_id = _ensure_tenant_synced(
|
||||
db, int(tenant_id), str(tenant_slug), hub_user=user_info
|
||||
)
|
||||
if effective_tenant_id != int(tenant_id):
|
||||
logger.warning(
|
||||
f"[get_current_user] tenant_id ajustado por alias: hub={tenant_id} local={effective_tenant_id} slug={tenant_slug}"
|
||||
)
|
||||
user_info["tenant_id"] = effective_tenant_id
|
||||
|
||||
# Rehidratación de sesión: sincronización no bloqueante de avatar/perfil
|
||||
# con cache corto para evitar llamadas excesivas al Hub.
|
||||
try:
|
||||
from core.workspace_profile_sync import sync_workspace_profile_for_user
|
||||
|
||||
await sync_workspace_profile_for_user(
|
||||
db,
|
||||
access_token=token,
|
||||
keycloak_user_id=user_info.get("sub"),
|
||||
tenant_id=user_info.get("tenant_id"),
|
||||
workspace_profile=user_info,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("workspace_profile_sync_failed_on_get_current_user: %s", exc)
|
||||
|
||||
return user_info
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Dependency para obtener usuario activo (puede incluir validaciones adicionales)
|
||||
"""
|
||||
# Aquí se pueden agregar validaciones adicionales
|
||||
# Por ejemplo, verificar si el usuario está activo en la BD
|
||||
return current_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),
|
||||
) -> Dict[str, Any]:
|
||||
user_roles = collect_user_role_names(current_user)
|
||||
|
||||
if required_role not in user_roles:
|
||||
logger.warning(
|
||||
"Role denied. Required: %s. User has: %s",
|
||||
required_role,
|
||||
sorted(user_roles),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
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")
|
||||
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 resolve_hub_tenant_id_for_api(
|
||||
local_tenant_id: Optional[int], x_tenant_override: Optional[str]
|
||||
) -> int:
|
||||
"""
|
||||
ID de tenant en Hub (client_tenants) para llamadas a la API del Hub.
|
||||
|
||||
Prioriza X-Tenant-Override (cookie SSO). Si hubo drift id Hub↔local,
|
||||
usa el mapeo inverso registrado en _ensure_tenant_synced.
|
||||
"""
|
||||
if x_tenant_override and str(x_tenant_override).strip().isdigit():
|
||||
return int(str(x_tenant_override).strip())
|
||||
if local_tenant_id is None:
|
||||
return 0
|
||||
lid = int(local_tenant_id)
|
||||
return int(_tenant_id_hub_by_local.get(lid, lid))
|
||||
|
||||
|
||||
def resolve_effective_tenant_id_from_user(current_user: Dict[str, Any]) -> Optional[int]:
|
||||
"""
|
||||
tenant_id efectivo del usuario: claims del token vía get_tenant_from_token,
|
||||
luego fallback a ``tenant_id`` plano del Hub (puede venir como lista).
|
||||
|
||||
Contrato Hub: no es obligatorio que todo usuario tenga ``tenant_id`` en /auth/me;
|
||||
el acceso por compañía puede basarse solo en RBAC local (ver ``user_has_app_company_membership``).
|
||||
"""
|
||||
tid = get_tenant_from_token(current_user)
|
||||
if tid is not None:
|
||||
return int(tid)
|
||||
raw = current_user.get("tenant_id")
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, list) and raw:
|
||||
raw = raw[0]
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def is_hub_admin(current_user: Dict[str, Any]) -> bool:
|
||||
"""True si el Hub atestigua que el usuario es hub_admin (super-admin global)."""
|
||||
return bool(current_user.get("is_hub_admin"))
|
||||
|
||||
|
||||
def _has_local_super_admin_role(
|
||||
db: "Session", user_id: Optional[str], company_id: Optional[int]
|
||||
) -> bool:
|
||||
"""
|
||||
True si el usuario tiene el rol local ``super_admin`` activo en la compañía.
|
||||
|
||||
Sustituye al antiguo bypass por rol ``admin`` del realm Keycloak para
|
||||
autorización: la fuente de verdad es la BD local (``core.user_company_roles``
|
||||
+ ``core.company_roles``), no claims del JWT. La promoción automática de
|
||||
admins de Keycloak a ``super_admin`` local sigue ocurriendo en el endpoint
|
||||
``/permissions/me`` (bootstrap), por lo que un admin del realm que entre
|
||||
al sistema sigue obteniendo el bypass sin coordinación manual.
|
||||
"""
|
||||
if not user_id or not company_id:
|
||||
return False
|
||||
try:
|
||||
from api.v1.modules.core.permissions.models import (
|
||||
CompanyRole,
|
||||
UserCompanyRole,
|
||||
)
|
||||
|
||||
return (
|
||||
db.query(UserCompanyRole)
|
||||
.join(CompanyRole, CompanyRole.id == UserCompanyRole.company_role_id)
|
||||
.filter(
|
||||
UserCompanyRole.user_id == user_id,
|
||||
UserCompanyRole.company_id == company_id,
|
||||
UserCompanyRole.is_active == True,
|
||||
CompanyRole.code == "super_admin",
|
||||
CompanyRole.is_active == True,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
except Exception as exc:
|
||||
# Un fallo de BD aquí no debe escalar a acceso silenciosamente:
|
||||
# se loguea y se trata como "no es super_admin" (deniega bypass).
|
||||
logger.warning(
|
||||
"has_local_super_admin_role_failed",
|
||||
extra={
|
||||
"op": "has_local_super_admin_role",
|
||||
"user_id": user_id,
|
||||
"company_id": company_id,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def resolve_tenant_id_required(
|
||||
current_user: Dict[str, Any],
|
||||
db: Optional["Session"] = None,
|
||||
company_id: Optional[int] = None,
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Retorna el tenant_id efectivo o lanza 400.
|
||||
Hub admin sin tenant_id en token: resuelve desde la empresa si company_id está disponible,
|
||||
o retorna None como sentinel de acceso global (sin filtro de tenant).
|
||||
"""
|
||||
tid = get_tenant_from_token(current_user)
|
||||
if tid is not None:
|
||||
return int(tid)
|
||||
raw = current_user.get("tenant_id")
|
||||
if isinstance(raw, list) and raw:
|
||||
raw = raw[0]
|
||||
if raw is not None:
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="Invalid tenant ID in token")
|
||||
|
||||
if is_hub_admin(current_user):
|
||||
# Sin modelo de compañía en la plantilla → acceso global sin filtro de tenant.
|
||||
# Implementa la consulta a tu tabla de compañías si necesitas resolución exacta.
|
||||
return None
|
||||
|
||||
raise HTTPException(status_code=400, detail="Tenant ID not found in user data")
|
||||
|
||||
|
||||
def user_has_app_company_membership(
|
||||
db: Session, user_id: str, company_id: int
|
||||
) -> bool:
|
||||
"""
|
||||
True si el usuario tiene fila activa en RBAC de la app o en core.user_tenants
|
||||
para esa compañía (independiente del tenant en el JWT).
|
||||
"""
|
||||
if not user_id:
|
||||
return False
|
||||
try:
|
||||
from api.v1.modules.core.permissions.models import (
|
||||
UserCompanyPermission,
|
||||
UserCompanyRole,
|
||||
)
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
|
||||
if (
|
||||
db.query(UserCompanyRole)
|
||||
.filter(
|
||||
UserCompanyRole.user_id == user_id,
|
||||
UserCompanyRole.company_id == company_id,
|
||||
UserCompanyRole.is_active == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
):
|
||||
return True
|
||||
if (
|
||||
db.query(UserCompanyPermission)
|
||||
.filter(
|
||||
UserCompanyPermission.user_id == user_id,
|
||||
UserCompanyPermission.company_id == company_id,
|
||||
UserCompanyPermission.is_active == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
):
|
||||
return True
|
||||
if (
|
||||
db.query(UserTenant)
|
||||
.filter(
|
||||
UserTenant.keycloak_user_id == user_id,
|
||||
UserTenant.company_id == company_id,
|
||||
UserTenant.is_active == True, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
):
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Error checking app company membership: %s", e)
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def collect_company_ids_from_app_membership(
|
||||
db: Session, user_id: str
|
||||
) -> Set[int]:
|
||||
"""IDs de compañía donde el usuario tiene rol, permiso directo o user_tenants."""
|
||||
ids: Set[int] = set()
|
||||
if not user_id:
|
||||
return ids
|
||||
try:
|
||||
from api.v1.modules.core.permissions.models import (
|
||||
UserCompanyPermission,
|
||||
UserCompanyRole,
|
||||
)
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
|
||||
for (cid,) in (
|
||||
db.query(UserCompanyRole.company_id)
|
||||
.filter(
|
||||
UserCompanyRole.user_id == user_id,
|
||||
UserCompanyRole.is_active == True, # noqa: E712
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
):
|
||||
ids.add(int(cid))
|
||||
for (cid,) in (
|
||||
db.query(UserCompanyPermission.company_id)
|
||||
.filter(
|
||||
UserCompanyPermission.user_id == user_id,
|
||||
UserCompanyPermission.is_active == True, # noqa: E712
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
):
|
||||
ids.add(int(cid))
|
||||
for (cid,) in (
|
||||
db.query(UserTenant.company_id)
|
||||
.filter(
|
||||
UserTenant.keycloak_user_id == user_id,
|
||||
UserTenant.is_active == True, # noqa: E712
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
):
|
||||
ids.add(int(cid))
|
||||
except Exception as e:
|
||||
logger.error("Error collecting company ids from membership: %s", e)
|
||||
return ids
|
||||
|
||||
|
||||
def collect_user_role_names(current_user: Dict[str, Any]) -> Set[str]:
|
||||
"""
|
||||
Roles del usuario: primero la lista ``roles`` del Hub (GET /api/v1/auth/me
|
||||
vía verify_token). Si no hay lista no vacía, se unen realm_access y
|
||||
resource_access del JWT Keycloak clásico.
|
||||
"""
|
||||
names: Set[str] = set()
|
||||
hub_roles = current_user.get("roles")
|
||||
if isinstance(hub_roles, list):
|
||||
names.update(str(r) for r in hub_roles if r is not None)
|
||||
|
||||
if names:
|
||||
return names
|
||||
|
||||
realm = current_user.get("realm_access")
|
||||
if isinstance(realm, dict):
|
||||
names.update(str(r) for r in (realm.get("roles") or []) if r is not None)
|
||||
for client in (current_user.get("resource_access") or {}).values():
|
||||
if isinstance(client, dict):
|
||||
names.update(str(r) for r in (client.get("roles") or []) if r is not None)
|
||||
return names
|
||||
|
||||
|
||||
def validate_company_access(
|
||||
db: Session, company_id: int, current_user: Dict[str, Any]
|
||||
) -> bool:
|
||||
"""
|
||||
Valida acceso a la compañía: (1) tenant del token/Hub alineado con la empresa, o
|
||||
(2) membership en la app (RBAC / user_tenants) para ese ``company_id``.
|
||||
|
||||
El contrato con el Hub puede no incluir ``tenant_id`` para todos los usuarios;
|
||||
en ese caso el acceso se basa en asignaciones en PostgreSQL.
|
||||
"""
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
if user_id and user_has_app_company_membership(db, str(user_id), company_id):
|
||||
return True
|
||||
|
||||
tenant_id = resolve_effective_tenant_id_from_user(current_user)
|
||||
if not tenant_id:
|
||||
return False
|
||||
|
||||
# Sin modelo de compañía en la plantilla → delega solo en user_has_app_company_membership.
|
||||
# Implementa la consulta a tu tabla de compañías para validación estricta.
|
||||
return True
|
||||
|
||||
|
||||
def validate_access_to_resource(
|
||||
db: Session,
|
||||
company_id: int,
|
||||
current_user: Dict[str, Any],
|
||||
required_permissions: Optional[list[str]] = None,
|
||||
require_all: bool = True,
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Valida que el usuario tenga acceso a un recurso específico basado en company_id
|
||||
y regresa el tenant_id. Opcionalmente verifica permisos.
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos
|
||||
company_id: company_id asociado al recurso
|
||||
current_user: Información del usuario actual desde el token
|
||||
required_permissions: Lista opcional de permisos requeridos. Si es None, no verifica permisos.
|
||||
require_all: Si True, requiere TODOS los permisos. Si False, requiere AL MENOS UNO.
|
||||
|
||||
Returns:
|
||||
tenant_id si el usuario tiene acceso
|
||||
|
||||
Raises:
|
||||
HTTPException: Si no hay tenant_id, no tiene acceso o no tiene los permisos requeridos
|
||||
"""
|
||||
|
||||
tenant_id = resolve_effective_tenant_id_from_user(current_user)
|
||||
|
||||
# Bypass de checks de permisos: hub_admin (atestado por el Hub en /auth/me)
|
||||
# o rol local "super_admin" en la compañía (fuente de verdad: BD de a76).
|
||||
# Se reemplazó el antiguo "admin" in realm_access.roles para que la
|
||||
# autorización deje de depender de claims del JWT.
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
is_global_admin = is_hub_admin(current_user) or _has_local_super_admin_role(
|
||||
db, user_id, company_id
|
||||
)
|
||||
|
||||
# 🚪 EXCEPCIÓN ESPECIAL: Si es el endpoint /me, permitimos el paso para el Bootstrap
|
||||
# Detectamos si no se requieren permisos (típico de /me)
|
||||
is_me_endpoint = required_permissions is None
|
||||
|
||||
if not is_global_admin and not is_me_endpoint:
|
||||
if not validate_company_access(db, company_id, current_user):
|
||||
raise HTTPException(status_code=403, detail="Access denied to this company")
|
||||
|
||||
# Sin modelo de compañía en la plantilla no se puede resolver tenant_id desde company.
|
||||
# Implementa esta lógica cuando definas tu tabla de compañías.
|
||||
|
||||
# Si aún no hay tenant_id y no es admin, error 400
|
||||
if not tenant_id and not is_global_admin and not is_me_endpoint:
|
||||
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
|
||||
|
||||
# Verificar permisos locales
|
||||
if required_permissions:
|
||||
if is_global_admin:
|
||||
# hub_admin / super_admin local: siempre debe tener tenant_id resuelto
|
||||
# cuando se exigen permisos; retornar 1 silenciosamente sería acceso
|
||||
# al tenant equivocado.
|
||||
if tenant_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No se pudo resolver el tenant_id para la empresa especificada",
|
||||
)
|
||||
return int(tenant_id)
|
||||
|
||||
from api.v1.modules.core.permissions.service import PermissionService
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
permission_service = PermissionService(db)
|
||||
|
||||
has_access = False
|
||||
if require_all:
|
||||
has_access = permission_service.has_all_permissions(user_id, company_id, required_permissions)
|
||||
else:
|
||||
has_access = permission_service.has_any_permission(user_id, company_id, required_permissions)
|
||||
|
||||
# 🛡️ MEJORA DEV: Auto-bootstrap si falla el acceso en desarrollo
|
||||
if not has_access and settings.ENVIRONMENT == "development":
|
||||
try:
|
||||
# Si el usuario no tiene roles asignados, intentamos el bootstrap
|
||||
# bootstrap_super_admin solo asigna el rol si no tiene ninguno (o es admin)
|
||||
permission_service.bootstrap_super_admin(user_id, company_id)
|
||||
# Re-validar
|
||||
if require_all:
|
||||
has_access = permission_service.has_all_permissions(user_id, company_id, required_permissions)
|
||||
else:
|
||||
has_access = permission_service.has_any_permission(user_id, company_id, required_permissions)
|
||||
|
||||
if has_access:
|
||||
logger.info(
|
||||
"Auto-bootstrap exitoso para user_id=%s company_id=%s", user_id, company_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Error en auto-bootstrap de seguridad user_id=%s company_id=%s: %s",
|
||||
user_id, company_id, e,
|
||||
)
|
||||
|
||||
if not has_access:
|
||||
raise HTTPException(status_code=403, detail="Permission denied")
|
||||
|
||||
# Nunca sustituir tenant_id=None/0 silenciosamente — un valor inválido aquí
|
||||
# significaría acceso al tenant equivocado. Si llegamos aquí sin tenant_id
|
||||
# válido para un usuario no-admin, es un estado inconsistente que debe fallar.
|
||||
if not isinstance(tenant_id, int) or tenant_id <= 0:
|
||||
if not is_global_admin:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No se pudo determinar el tenant_id para la empresa especificada",
|
||||
)
|
||||
return tenant_id # puede ser None solo para hub_admin sin required_permissions (acceso global)
|
||||
226
backend/core/storage_s3.py
Normal file
226
backend/core/storage_s3.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Cliente S3 (MinIO): bucket, objetos genéricos, presign, imports CSV.
|
||||
|
||||
Las claves de objeto deben generarse con ``core.s3_keys`` (p. ej. ``csv_import_key`` vía
|
||||
``s3_key_for_csv_import``); no construir prefijos ``tenants/...`` aquí.
|
||||
"""
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from core.config import settings
|
||||
from core.s3_keys import csv_import_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _client():
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.S3_ENDPOINT_URL,
|
||||
aws_access_key_id=settings.S3_ACCESS_KEY,
|
||||
aws_secret_access_key=settings.S3_SECRET_KEY,
|
||||
region_name=settings.S3_REGION,
|
||||
use_ssl=settings.S3_USE_SSL,
|
||||
config=Config(
|
||||
signature_version="s3v4",
|
||||
s3={"addressing_style": "path"},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def should_ensure_s3_bucket() -> bool:
|
||||
return settings.use_s3_object_storage
|
||||
|
||||
|
||||
def ensure_s3_bucket() -> None:
|
||||
"""Crea el bucket si no existe (idempotente)."""
|
||||
if not should_ensure_s3_bucket():
|
||||
return
|
||||
bucket = settings.S3_BUCKET
|
||||
client = _client()
|
||||
try:
|
||||
client.head_bucket(Bucket=bucket)
|
||||
logger.info("S3 bucket %s exists", bucket)
|
||||
return
|
||||
except ClientError as e:
|
||||
code = e.response.get("Error", {}).get("Code", "")
|
||||
if code not in ("404", "NoSuchBucket", "403"):
|
||||
logger.warning("head_bucket %s: %s", bucket, e)
|
||||
try:
|
||||
if settings.S3_REGION == "us-east-1":
|
||||
client.create_bucket(Bucket=bucket)
|
||||
else:
|
||||
client.create_bucket(
|
||||
Bucket=bucket,
|
||||
CreateBucketConfiguration={"LocationConstraint": settings.S3_REGION},
|
||||
)
|
||||
logger.info("S3 bucket %s created", bucket)
|
||||
except ClientError as e:
|
||||
logger.error("create_bucket %s failed: %s", bucket, e)
|
||||
raise
|
||||
|
||||
|
||||
# Alias para código existente
|
||||
def ensure_csv_import_bucket() -> None:
|
||||
ensure_s3_bucket()
|
||||
|
||||
|
||||
def put_object_bytes(key: str, body: bytes, content_type: str = "application/octet-stream") -> None:
|
||||
_client().put_object(
|
||||
Bucket=settings.S3_BUCKET,
|
||||
Key=key,
|
||||
Body=body,
|
||||
ContentType=content_type,
|
||||
)
|
||||
|
||||
|
||||
def put_csv_object(key: str, body: bytes, content_type: str = "text/csv") -> None:
|
||||
put_object_bytes(key, body, content_type=content_type)
|
||||
|
||||
|
||||
def get_object_bytes(key: str) -> bytes:
|
||||
resp = _client().get_object(Bucket=settings.S3_BUCKET, Key=key)
|
||||
return resp["Body"].read()
|
||||
|
||||
|
||||
def delete_object_if_exists(key: str) -> None:
|
||||
try:
|
||||
_client().delete_object(Bucket=settings.S3_BUCKET, Key=key)
|
||||
except ClientError as e:
|
||||
logger.warning("delete_object %s: %s", key, e)
|
||||
|
||||
|
||||
def delete_objects_with_prefix(prefix: str, batch_size: int = 1000) -> None:
|
||||
"""
|
||||
Elimina en cascada todos los objetos cuyo Key empieza con `prefix`.
|
||||
|
||||
Pensado para limpiar recursos ligados a una entidad (por ejemplo,
|
||||
todos los objetos de una compañía bajo `tenants/{tid}/companies/{cid}/`).
|
||||
"""
|
||||
if not settings.use_s3_object_storage:
|
||||
return
|
||||
|
||||
client = _client()
|
||||
continuation_token: Optional[str] = None
|
||||
|
||||
while True:
|
||||
params: Dict[str, Any] = {
|
||||
"Bucket": settings.S3_BUCKET,
|
||||
"Prefix": prefix,
|
||||
"MaxKeys": max(1, min(int(batch_size), 1000)),
|
||||
}
|
||||
if continuation_token:
|
||||
params["ContinuationToken"] = continuation_token
|
||||
|
||||
try:
|
||||
resp = client.list_objects_v2(**params)
|
||||
except ClientError as e:
|
||||
logger.warning("list_objects_v2 for prefix %s failed: %s", prefix, e)
|
||||
break
|
||||
|
||||
contents = resp.get("Contents") or []
|
||||
if not contents:
|
||||
break
|
||||
|
||||
to_delete = [{"Key": obj.get("Key")} for obj in contents if obj.get("Key")]
|
||||
if to_delete:
|
||||
try:
|
||||
client.delete_objects(
|
||||
Bucket=settings.S3_BUCKET,
|
||||
Delete={"Objects": to_delete, "Quiet": True},
|
||||
)
|
||||
except ClientError as e:
|
||||
logger.warning(
|
||||
"delete_objects_with_prefix %s (batch_size=%s) failed: %s",
|
||||
prefix,
|
||||
len(to_delete),
|
||||
e,
|
||||
)
|
||||
|
||||
if not resp.get("IsTruncated"):
|
||||
break
|
||||
|
||||
continuation_token = resp.get("NextContinuationToken")
|
||||
|
||||
|
||||
def object_exists(key: str) -> bool:
|
||||
try:
|
||||
_client().head_object(Bucket=settings.S3_BUCKET, Key=key)
|
||||
return True
|
||||
except ClientError:
|
||||
return False
|
||||
|
||||
|
||||
def presigned_get_url(key: str, expires_in: Optional[int] = None) -> str:
|
||||
sec = expires_in if expires_in is not None else settings.S3_PRESIGNED_EXPIRES_SECONDS
|
||||
return _client().generate_presigned_url(
|
||||
"get_object",
|
||||
Params={"Bucket": settings.S3_BUCKET, "Key": key},
|
||||
ExpiresIn=sec,
|
||||
)
|
||||
|
||||
|
||||
def list_objects_tree(
|
||||
prefix: str,
|
||||
delimiter: str = "/",
|
||||
max_keys: int = 100,
|
||||
continuation_token: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Lista objetos/prefijos como árbol virtual.
|
||||
|
||||
Retorna:
|
||||
- ``prefixes``: subcarpetas (CommonPrefixes)
|
||||
- ``objects``: objetos directos bajo ``prefix``
|
||||
- ``next_continuation_token`` y ``is_truncated`` para paginación
|
||||
"""
|
||||
params: Dict[str, Any] = {
|
||||
"Bucket": settings.S3_BUCKET,
|
||||
"Prefix": prefix,
|
||||
"Delimiter": delimiter,
|
||||
"MaxKeys": max(1, min(int(max_keys), 500)),
|
||||
}
|
||||
if continuation_token:
|
||||
params["ContinuationToken"] = continuation_token
|
||||
|
||||
resp = _client().list_objects_v2(**params)
|
||||
common_prefixes: List[str] = [
|
||||
p.get("Prefix", "") for p in (resp.get("CommonPrefixes") or []) if p.get("Prefix")
|
||||
]
|
||||
objects: List[Dict[str, Any]] = []
|
||||
for obj in resp.get("Contents") or []:
|
||||
key = obj.get("Key")
|
||||
if not key:
|
||||
continue
|
||||
if key == prefix:
|
||||
# Marcador de carpeta (objeto vacío con mismo nombre del prefijo).
|
||||
continue
|
||||
objects.append(
|
||||
{
|
||||
"key": key,
|
||||
"size": int(obj.get("Size", 0) or 0),
|
||||
"last_modified": obj.get("LastModified"),
|
||||
"etag": obj.get("ETag"),
|
||||
"storage_class": obj.get("StorageClass"),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"prefixes": common_prefixes,
|
||||
"objects": objects,
|
||||
"next_continuation_token": resp.get("NextContinuationToken"),
|
||||
"is_truncated": bool(resp.get("IsTruncated")),
|
||||
}
|
||||
|
||||
|
||||
def s3_key_for_csv_import(
|
||||
tenant_id,
|
||||
company_id: int,
|
||||
job_type: str,
|
||||
job_id: str,
|
||||
) -> str:
|
||||
return csv_import_key(tenant_id, company_id, job_type, job_id)
|
||||
80
backend/core/workspace_profile_client.py
Normal file
80
backend/core/workspace_profile_client.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkspaceProfileClient:
|
||||
"""Cliente para consultar perfil del usuario en Workspace Hub (/v1/auth/me)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
timeout_ms: Optional[int] = None,
|
||||
retries: int = 2,
|
||||
transport: Optional[httpx.BaseTransport] = None,
|
||||
):
|
||||
self.base_url = (base_url or settings.hub_api_base_url).rstrip("/")
|
||||
self.timeout_s = max(0.1, float(timeout_ms or settings.HUB_PROFILE_SYNC_TIMEOUT_MS) / 1000.0)
|
||||
self.retries = max(0, int(retries))
|
||||
self.transport = transport
|
||||
|
||||
async def get_me(self, access_token: str) -> dict[str, Any]:
|
||||
if not access_token:
|
||||
raise ValueError("access_token is required")
|
||||
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
url = f"{self.base_url}/v1/auth/me"
|
||||
|
||||
last_error: Optional[Exception] = None
|
||||
for attempt in range(self.retries + 1):
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=self.timeout_s,
|
||||
transport=self.transport,
|
||||
) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
|
||||
if response.status_code == 200:
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Invalid workspace profile payload")
|
||||
return payload
|
||||
|
||||
if response.status_code in (401, 403, 404):
|
||||
# Errores de autenticación/autorización o endpoint no disponible:
|
||||
# no vale la pena reintentar.
|
||||
raise httpx.HTTPStatusError(
|
||||
f"Workspace profile request failed with status {response.status_code}",
|
||||
request=response.request,
|
||||
response=response,
|
||||
)
|
||||
|
||||
# Reintentar solo para errores transitorios 5xx.
|
||||
if response.status_code >= 500 and attempt < self.retries:
|
||||
await asyncio.sleep(0.15 * (attempt + 1))
|
||||
continue
|
||||
|
||||
raise httpx.HTTPStatusError(
|
||||
f"Workspace profile request failed with status {response.status_code}",
|
||||
request=response.request,
|
||||
response=response,
|
||||
)
|
||||
|
||||
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
||||
last_error = exc
|
||||
if attempt >= self.retries:
|
||||
break
|
||||
await asyncio.sleep(0.15 * (attempt + 1))
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
break
|
||||
|
||||
if last_error:
|
||||
raise last_error
|
||||
raise RuntimeError("Workspace profile request failed")
|
||||
114
backend/core/workspace_profile_sync.py
Normal file
114
backend/core/workspace_profile_sync.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
from core.workspace_profile_client import WorkspaceProfileClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SYNC_TTL_SECONDS = 300
|
||||
|
||||
|
||||
def _is_valid_http_url(url: Optional[str]) -> bool:
|
||||
if not url or not isinstance(url, str):
|
||||
return False
|
||||
parsed = urlparse(url.strip())
|
||||
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
||||
|
||||
|
||||
def _is_fresh(ts: Optional[datetime], ttl_seconds: int = SYNC_TTL_SECONDS) -> bool:
|
||||
if not ts:
|
||||
return False
|
||||
now = datetime.now(timezone.utc)
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
return ts >= (now - timedelta(seconds=ttl_seconds))
|
||||
|
||||
|
||||
async def sync_workspace_profile_for_user(
|
||||
db: Session,
|
||||
*,
|
||||
access_token: Optional[str],
|
||||
keycloak_user_id: Optional[str],
|
||||
tenant_id: Optional[int] = None,
|
||||
company_id: Optional[int] = None,
|
||||
workspace_profile: Optional[dict[str, Any]] = None,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Sincroniza sub/avatar_url desde Workspace hacia core.user_tenants.
|
||||
Nunca lanza excepción para no bloquear login ni requests autenticados.
|
||||
"""
|
||||
if not access_token or not keycloak_user_id:
|
||||
return
|
||||
|
||||
try:
|
||||
query = db.query(UserTenant).filter(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(UserTenant.tenant_id == int(tenant_id))
|
||||
if company_id is not None:
|
||||
query = query.filter(UserTenant.company_id == int(company_id))
|
||||
|
||||
target = query.first()
|
||||
if not target:
|
||||
return
|
||||
|
||||
if not force and _is_fresh(target.workspace_profile_synced_at):
|
||||
return
|
||||
|
||||
payload = workspace_profile
|
||||
if payload is None:
|
||||
client = WorkspaceProfileClient()
|
||||
payload = await client.get_me(access_token)
|
||||
|
||||
workspace_sub = payload.get("sub")
|
||||
if not workspace_sub:
|
||||
logger.warning(
|
||||
"workspace_profile_sync_warning",
|
||||
extra={
|
||||
"event": "workspace_profile_sync_warning",
|
||||
"reason": "missing_sub",
|
||||
"keycloak_user_id": keycloak_user_id,
|
||||
"tenant_id": target.tenant_id,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
avatar_url = payload.get("avatar_url")
|
||||
sanitized_avatar = avatar_url.strip() if isinstance(avatar_url, str) else None
|
||||
if sanitized_avatar and not _is_valid_http_url(sanitized_avatar):
|
||||
logger.warning(
|
||||
"workspace_profile_sync_warning",
|
||||
extra={
|
||||
"event": "workspace_profile_sync_warning",
|
||||
"reason": "invalid_avatar_url",
|
||||
"keycloak_user_id": keycloak_user_id,
|
||||
"tenant_id": target.tenant_id,
|
||||
},
|
||||
)
|
||||
sanitized_avatar = None
|
||||
|
||||
target.workspace_user_id = str(workspace_sub)
|
||||
target.workspace_avatar_url = sanitized_avatar
|
||||
target.workspace_profile_synced_at = datetime.now(timezone.utc)
|
||||
db.add(target)
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
logger.warning(
|
||||
"workspace_profile_sync_failed",
|
||||
extra={
|
||||
"event": "workspace_profile_sync_failed",
|
||||
"error": str(exc),
|
||||
"keycloak_user_id": keycloak_user_id,
|
||||
"tenant_id": tenant_id,
|
||||
"company_id": company_id,
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user