Compare commits

..

4 Commits

Author SHA1 Message Date
b9a0c55719 fix(observability): RequestLoggingMiddleware debe envolver a Tenant/License
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).
2026-07-21 16:32:27 -05:00
beb2f7bdf0 fix(auth): corregir orden de middlewares — Tenant debe correr antes que License
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.
2026-07-21 16:29:15 -05:00
695af2f0b1 perf(dashboard): cachear my-tenants/my-apps del Hub por sesión estable
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).
2026-07-21 16:13:02 -05:00
a1bbb6b1b2 perf(auth): extender TTL de token_cache y cachear licencia válida por tenant
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).
2026-07-21 16:13:02 -05:00
7 changed files with 177 additions and 22 deletions

View File

@@ -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:

View File

@@ -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()

View File

@@ -60,12 +60,17 @@ async def on_startup():
# Agregar middlewares personalizados
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)
app.add_middleware(TenantMiddleware)
app.add_middleware(LicenseValidationMiddleware)
# 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(

View File

@@ -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();
});
});

View File

@@ -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<string, { data: DashboardShellData; expiresAt: number }>();
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 });
}

View File

@@ -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<string, unknown> | 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;
}

View File

@@ -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 {