Files
CRM_AGENTES_CARGA/backend/core/config.py
Ernesto Herrera 5d1c65f236 feat(auth): sesión local del CRM (patrón SIWEB) para eliminar el bucle de login
Desacopla la sesión de la app del access token de Keycloak (60s). Tras el SSO/
refresh, el backend emite un JWT de sesión local (HS256) con vida por inactividad
(SESSION_IDLE_MINUTES, cap SESSION_MAX_HOURS) y guarda los tokens KC en valkey.
La app usa esa sesión local como bearer (cookie access_token); el token KC vive en
kc_access_token solo para llamadas directas al Hub. Así el refresh KC contra el Hub
solo se intenta al expirar la sesión local, no cada ~60s → se elimina el bucle
causado por el bug "Token is not active" del relay.

Se RESPETA la revocación central de Keycloak: si el Hub rechaza el refresh, la
sesión termina (401). Sin re-emisión de fallback → sin bypass de revocación.

Todo detrás de SESSION_STORE_ENABLED (default False = comportamiento idéntico).

Backend: core/local_session.py, core/session_store.py (valkey), verify_token acepta
la sesión local, refresh la emite/actualiza; DTOs con session_token/session_id;
test unitario de local_session.
Frontend: access_token=sesión local, kc_access_token para el Hub; sso/refresh/
silent-refresh/switch-tenant y pantallas de workspace cableadas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:50:35 -06:00

163 lines
6.0 KiB
Python

"""
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
# Sesión local del CRM (patrón SIWEB) — desacopla la sesión de la app del
# token KC de 60s. Tras SSO/login se guardan los tokens KC en valkey y se emite
# una sesión local firmada (HS256) con vida por inactividad (idle) y cap
# absoluto. Así el refresh del token KC contra el Hub solo se intenta al expirar
# la sesión local (no cada ~60s), lo que elimina el bucle de login.
#
# SE RESPETA la revocación central de Keycloak: si el Hub rechaza el refresh, la
# sesión termina (no hay re-emisión local de fallback). Flag-gated para rollback:
# con SESSION_STORE_ENABLED=False el comportamiento no cambia.
SESSION_STORE_ENABLED: bool = False
SESSION_IDLE_MINUTES: int = 30
SESSION_MAX_HOURS: int = 10
# 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()