chore: baseline plantilla-proyectos como base del CRM

This commit is contained in:
Aduanasoft
2026-07-14 09:03:52 -06:00
commit c3d0eedc8d
469 changed files with 69739 additions and 0 deletions

41
backend/core/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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)

View 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")

View 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,
},
)