From a1bbb6b1b2a88d96d43e13d015a9babaae5be765 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Tue, 21 Jul 2026 16:13:02 -0500 Subject: [PATCH 1/4] =?UTF-8?q?perf(auth):=20extender=20TTL=20de=20token?= =?UTF-8?q?=5Fcache=20y=20cachear=20licencia=20v=C3=A1lida=20por=20tenant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cada navegación al dashboard disparaba verify-license y auth/me contra el Hub sin ningún caché, sumando 1-6s por llamada del lado del Hub a cada request. - security.py: token_cache TTL 60s→300s (el access token vive más que el TTL anterior; cachear su verificación es seguro). - middleware.py: nuevo _license_ok_cache (TTLCache, 300s) en LicenseValidationMiddleware — SOLO cachea el camino "licencia válida" por tenant_override; inválida/expirada/error nunca se cachea y siempre revalida contra el Hub (fail-closed: el peor caso es que un tenant recién revocado siga pasando hasta 5 min más, nunca al revés). --- backend/core/middleware.py | 15 +++++++++++++++ backend/core/security.py | 6 ++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/backend/core/middleware.py b/backend/core/middleware.py index 89d70014..2219cc3e 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -6,12 +6,20 @@ from typing import Callable, Optional from fastapi import Request, Response from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware +from cachetools import TTLCache from .config import settings from .security import get_tenant_from_token, verify_token, get_active_system logger = logging.getLogger(__name__) +# Cache de licencia VÁLIDA por tenant (evita golpear verify-license del Hub en cada +# request). Solo el camino "válida" se cachea — inválida/expirada/error nunca se +# cachean y siempre revalidan contra el Hub (fail-closed: lo peor que puede pasar es +# que un tenant recién revocado siga pasando hasta 5 min más, nunca al revés). +_LICENSE_OK_TTL_SECONDS = 300 +_license_ok_cache: TTLCache = TTLCache(maxsize=1000, ttl=_LICENSE_OK_TTL_SECONDS) + def _normalize_text(value: str | None) -> str: if not value: @@ -160,6 +168,11 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): if tid is not None and str(tid).strip() != "": tenant_override = str(tid) + # Licencia ya confirmada válida hace poco para este tenant — no volver a + # golpear al Hub (ver definición de _license_ok_cache arriba). + if tenant_override and tenant_override in _license_ok_cache: + return await call_next(request) + hub_headers = {"Authorization": f"Bearer {token}"} if tenant_override: hub_headers["X-Tenant-Override"] = str(tenant_override) @@ -274,6 +287,8 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): pass # Si no se puede parsear, dejamos pasar — el Hub es la fuente de verdad request.state.license_info = data + if tenant_override: + _license_ok_cache[tenant_override] = True return await call_next(request) if response.status_code == 401: diff --git a/backend/core/security.py b/backend/core/security.py index cb188c99..3a22ddf8 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -18,8 +18,10 @@ 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) +# Cache para tokens verificados (5 minutos de TTL, máximo 1000 tokens). +# El access token vive más que este TTL, así que cachear su verificación es seguro +# y evita golpear /auth/me del Hub en cada request de navegación. +token_cache = TTLCache(maxsize=1000, ttl=300) # IDs de tenants ya sincronizados en este proceso (evita consultas repetidas) _synced_tenant_ids: Set[int] = set() From 695af2f0b1762423e05a0af68edcb1a922ce4cdc Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Tue, 21 Jul 2026 16:13:02 -0500 Subject: [PATCH 2/4] =?UTF-8?q?perf(dashboard):=20cachear=20my-tenants/my-?= =?UTF-8?q?apps=20del=20Hub=20por=20sesi=C3=B3n=20estable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit my-tenants y my-apps no tienen caché propia en el Hub — se repetían en cada +layout.server.ts load del dashboard, sin importar cuántas veces navegara el usuario en la misma sesión. - jwt.ts: getJwtSessionKey() extrae sid/sub del JWT — identificador estable que sobrevive al refresh del access token (rota cada ~60s). - dashboard-shell-cache.ts: caché en memoria (Map + TTL de 30s) para el bundle de tenants/apps, keyed por session key — NUNCA por el access token, que rotando cada ~60s haría que cada set()/get() usaran llaves distintas y la caché nunca acertara. - +layout.server.ts: usa el caché antes de golpear al Hub; lo llena tras el primer fetch exitoso de la sesión. - dashboard-shell-cache.test.ts: cubre hit/miss, expiración por TTL, y que sobrevive a la rotación del access token (llave estable). Verificado: 5/5 tests nuevos pasan; svelte-check da los mismos 38 errores/8 warnings preexistentes que main (0 nuevos); los 2 fallos de backend.test.ts son preexistentes (ENOTFOUND backend fuera de Docker, igual en main). --- .../lib/server/dashboard-shell-cache.test.ts | 49 ++++++++++++++++++ .../src/lib/server/dashboard-shell-cache.ts | 33 ++++++++++++ frontend/src/lib/server/jwt.ts | 35 +++++++++++++ .../src/routes/dashboard/+layout.server.ts | 50 ++++++++++++------- 4 files changed, 150 insertions(+), 17 deletions(-) create mode 100644 frontend/src/lib/server/dashboard-shell-cache.test.ts create mode 100644 frontend/src/lib/server/dashboard-shell-cache.ts create mode 100644 frontend/src/lib/server/jwt.ts diff --git a/frontend/src/lib/server/dashboard-shell-cache.test.ts b/frontend/src/lib/server/dashboard-shell-cache.test.ts new file mode 100644 index 00000000..87306e7a --- /dev/null +++ b/frontend/src/lib/server/dashboard-shell-cache.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + getDashboardShellCache, + setDashboardShellCache, + DASHBOARD_SHELL_CACHE_TTL_MS +} from './dashboard-shell-cache'; + +const SAMPLE_DATA = { + userTenants: [{ id: 1, name: 'Tenant A', slug: 'tenant-a' }], + myApps: { apps: [{ id: 'app-1' }], routing: 'default' } +}; + +describe('dashboard-shell-cache', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('devuelve null si no hay nada cacheado para esa llave', () => { + expect(getDashboardShellCache('sesion-inexistente')).toBeNull(); + }); + + it('devuelve lo cacheado dentro del TTL', () => { + setDashboardShellCache('sesion-1', SAMPLE_DATA); + expect(getDashboardShellCache('sesion-1')).toEqual(SAMPLE_DATA); + }); + + it('sigue acertando aunque el "access token" hubiera rotado — la llave es el session key, no el token', () => { + // Simula dos navegaciones con tokens de acceso distintos (rotación cada ~60s) + // pero el mismo session key estable (sid/sub) — como haría getJwtSessionKey(). + setDashboardShellCache('mismo-session-key', SAMPLE_DATA); + vi.advanceTimersByTime(5_000); + expect(getDashboardShellCache('mismo-session-key')).toEqual(SAMPLE_DATA); + }); + + it('expira después del TTL', () => { + setDashboardShellCache('sesion-2', SAMPLE_DATA); + vi.advanceTimersByTime(DASHBOARD_SHELL_CACHE_TTL_MS + 1); + expect(getDashboardShellCache('sesion-2')).toBeNull(); + }); + + it('no acierta entre llaves distintas (sesiones distintas)', () => { + setDashboardShellCache('sesion-usuario-a', SAMPLE_DATA); + expect(getDashboardShellCache('sesion-usuario-b')).toBeNull(); + }); +}); diff --git a/frontend/src/lib/server/dashboard-shell-cache.ts b/frontend/src/lib/server/dashboard-shell-cache.ts new file mode 100644 index 00000000..530e878e --- /dev/null +++ b/frontend/src/lib/server/dashboard-shell-cache.ts @@ -0,0 +1,33 @@ +/** + * Caché en memoria del "shell" del dashboard (tenants y apps del Workspace) para no + * golpear al Hub en cada navegación — esas dos llamadas no tienen caché propia en el + * Hub y antes se repetían en cada `+layout.server.ts` load. + * + * Llave = session key estable (ver jwt.ts), NUNCA el access token: el token rota cada + * ~60s, así que usarlo como llave produce 0% de aciertos entre navegaciones. + */ + +export const DASHBOARD_SHELL_CACHE_TTL_MS = 30_000; + +export interface DashboardShellData { + userTenants: { id: number; name: string; slug: string }[]; + myApps: { apps: unknown[]; routing: unknown }; +} + +const cache = new Map(); + +export function getDashboardShellCache(sessionKey: string): DashboardShellData | null { + const entry = cache.get(sessionKey); + if (!entry) return null; + + if (Date.now() > entry.expiresAt) { + cache.delete(sessionKey); + return null; + } + + return entry.data; +} + +export function setDashboardShellCache(sessionKey: string, data: DashboardShellData): void { + cache.set(sessionKey, { data, expiresAt: Date.now() + DASHBOARD_SHELL_CACHE_TTL_MS }); +} diff --git a/frontend/src/lib/server/jwt.ts b/frontend/src/lib/server/jwt.ts new file mode 100644 index 00000000..068d7565 --- /dev/null +++ b/frontend/src/lib/server/jwt.ts @@ -0,0 +1,35 @@ +/** + * Decodifica el payload de un JWT sin verificar firma (el backend ya lo valida contra + * el Hub) para extraer un identificador de sesión estable. + */ + +function decodeJwtPayload(token: string): Record | null { + try { + const payload = token.split('.')[1]; + if (!payload) return null; + return JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8')); + } catch { + return null; + } +} + +/** + * ID de sesión estable que sobrevive al refresh del access token: `sid` de Keycloak, + * o `sub` (usuario) como respaldo. + * + * El access token rota cada ~60s (ver token_cache en backend/core/security.py) — + * usar el propio token (o parte de él) como llave de caché hace que cada refresh + * invalide la caché sin motivo real, dejándola con 0% de aciertos. + */ +export function getJwtSessionKey(token: string): string | null { + const payload = decodeJwtPayload(token); + if (!payload) return null; + + const sid = payload['sid']; + if (typeof sid === 'string' && sid) return sid; + + const sub = payload['sub']; + if (typeof sub === 'string' && sub) return sub; + + return null; +} diff --git a/frontend/src/routes/dashboard/+layout.server.ts b/frontend/src/routes/dashboard/+layout.server.ts index a3f98a97..213aae78 100644 --- a/frontend/src/routes/dashboard/+layout.server.ts +++ b/frontend/src/routes/dashboard/+layout.server.ts @@ -9,6 +9,8 @@ import { import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth'; import { resolveActiveCompanyId } from '$lib/server/system-gate'; import { fetchMyApps } from '$lib/server/workspace-apps'; +import { getJwtSessionKey } from '$lib/server/jwt'; +import { getDashboardShellCache, setDashboardShellCache } from '$lib/server/dashboard-shell-cache'; const DEV_LOCAL_AUTH = (env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true'; @@ -30,24 +32,38 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { let myApps: { apps: unknown[]; routing: unknown } = { apps: [], routing: null }; if (!DEV_LOCAL_AUTH) { - try { - const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, ''); - const tenantOverride = cookies.get('sso_tenant_id'); - const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, { - headers: { - 'Authorization': `Bearer ${accessToken}`, - ...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}) - } - }); - if (tenantsRes.ok) { - userTenants = await tenantsRes.json(); - } - } catch { - // No bloquear el dashboard si falla la carga de tenants - } + // my-tenants y my-apps no tienen caché propia en el Hub — sin esto, cada + // navegación al dashboard las repetía (ver dashboard-shell-cache.ts). + const sessionKey = getJwtSessionKey(accessToken); + const cachedShell = sessionKey ? getDashboardShellCache(sessionKey) : null; - const freshAccessToken = getAuthTokens(cookies).accessToken ?? accessToken; - myApps = await fetchMyApps(freshAccessToken, fetch, cookies.get('sso_tenant_id')); + if (cachedShell) { + userTenants = cachedShell.userTenants; + myApps = cachedShell.myApps; + } else { + try { + const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, ''); + const tenantOverride = cookies.get('sso_tenant_id'); + const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + ...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}) + } + }); + if (tenantsRes.ok) { + userTenants = await tenantsRes.json(); + } + } catch { + // No bloquear el dashboard si falla la carga de tenants + } + + const freshAccessToken = getAuthTokens(cookies).accessToken ?? accessToken; + myApps = await fetchMyApps(freshAccessToken, fetch, cookies.get('sso_tenant_id')); + + if (sessionKey) { + setDashboardShellCache(sessionKey, { userTenants, myApps }); + } + } } return { From beb2f7bdf09f7dd9a06679ef66c8ecbd40871069 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Tue, 21 Jul 2026 16:29:15 -0500 Subject: [PATCH 3/4] =?UTF-8?q?fix(auth):=20corregir=20orden=20de=20middle?= =?UTF-8?q?wares=20=E2=80=94=20Tenant=20debe=20correr=20antes=20que=20Lice?= =?UTF-8?q?nse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug preexistente: LicenseValidationMiddleware se registraba después de TenantMiddleware, pero add_middleware() de Starlette invierte el orden de ejecución (el último registrado corre primero) — así que License corría ANTES que Tenant en la práctica, contradiciendo el comentario en middleware.py que asumía lo contrario ("TenantMiddleware corre antes... dejó tenant en user_info"). Efecto real: request.state.user_info nunca existía cuando License intentaba usarlo como fallback para tenant_override → el caché de licencia (agregado en a1bbb6b1) nunca podía activarse para requests sin X-Tenant-Override header/cookie explícito. Confirmado en vivo contra el Hub real (workspace.aduanasoft.com): verify-license se repetía en cada request pese al caché. Con el orden corregido, la segunda llamada dentro del TTL ya no golpea al Hub. --- backend/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/main.py b/backend/main.py index f4a7990e..39676db3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -63,8 +63,8 @@ async def on_startup(): if settings.DEBUG: app.add_middleware(RequestLoggingMiddleware) -app.add_middleware(TenantMiddleware) app.add_middleware(LicenseValidationMiddleware) +app.add_middleware(TenantMiddleware) # CORS debe ser el último en añadirse para que sea el más externo # y cubra todas las respuestas, incluyendo las de los middlewares internos From b9a0c5571903b41bee07c90ffcbc480ec2000e26 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Tue, 21 Jul 2026 16:32:27 -0500 Subject: [PATCH 4/4] fix(observability): RequestLoggingMiddleware debe envolver a Tenant/License MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Estaba registrado antes que Tenant/License, así que su cronómetro arrancaba DESPUÉS de que ambos ya habían corrido — el Duration: en los logs nunca incluyó el costo de verify-license/auth-me contra el Hub, dando la falsa impresión de que todo respondía en 2-5ms. Reordenado para que sea el más externo de los tres (CORS sigue siendo el más externo de todos). --- backend/main.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/backend/main.py b/backend/main.py index 39676db3..d85c713c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -60,12 +60,17 @@ async def on_startup(): # Agregar middlewares personalizados -if settings.DEBUG: - app.add_middleware(RequestLoggingMiddleware) - app.add_middleware(LicenseValidationMiddleware) app.add_middleware(TenantMiddleware) +# RequestLoggingMiddleware va después de Tenant/License (add_middleware() invierte +# el orden de ejecución: el último en registrarse corre primero) para que su cronómetro +# envuelva TODO, incluyendo las llamadas al Hub — si no, "Duration:" en los logs +# nunca reflejó el costo real de verify-license/auth-me, dando una falsa sensación +# de que todo respondía en 2-5ms. +if settings.DEBUG: + app.add_middleware(RequestLoggingMiddleware) + # CORS debe ser el último en añadirse para que sea el más externo # y cubra todas las respuestas, incluyendo las de los middlewares internos app.add_middleware(