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 import Request, Response
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from cachetools import TTLCache
from .config import settings from .config import settings
from .security import get_tenant_from_token, verify_token, get_active_system from .security import get_tenant_from_token, verify_token, get_active_system
logger = logging.getLogger(__name__) 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: def _normalize_text(value: str | None) -> str:
if not value: if not value:
@@ -160,6 +168,11 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
if tid is not None and str(tid).strip() != "": if tid is not None and str(tid).strip() != "":
tenant_override = str(tid) 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}"} hub_headers = {"Authorization": f"Bearer {token}"}
if tenant_override: if tenant_override:
hub_headers["X-Tenant-Override"] = str(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 pass # Si no se puede parsear, dejamos pasar — el Hub es la fuente de verdad
request.state.license_info = data request.state.license_info = data
if tenant_override:
_license_ok_cache[tenant_override] = True
return await call_next(request) return await call_next(request)
if response.status_code == 401: if response.status_code == 401:

View File

@@ -18,8 +18,10 @@ from .database import get_core_db
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Cache para tokens verificados (1 minuto de TTL, máximo 1000 tokens) # Cache para tokens verificados (5 minutos de TTL, máximo 1000 tokens).
token_cache = TTLCache(maxsize=1000, ttl=60) # 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) # IDs de tenants ya sincronizados en este proceso (evita consultas repetidas)
_synced_tenant_ids: Set[int] = set() _synced_tenant_ids: Set[int] = set()

View File

@@ -60,12 +60,17 @@ async def on_startup():
# Agregar middlewares personalizados # 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: if settings.DEBUG:
app.add_middleware(RequestLoggingMiddleware) 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 # 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 # y cubra todas las respuestas, incluyendo las de los middlewares internos
app.add_middleware( 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 { redirectToWorkspaceLogin } from '$lib/server/workspace-auth';
import { resolveActiveCompanyId } from '$lib/server/system-gate'; import { resolveActiveCompanyId } from '$lib/server/system-gate';
import { fetchMyApps } from '$lib/server/workspace-apps'; 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'; const DEV_LOCAL_AUTH = (env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true';
@@ -30,6 +32,15 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
let myApps: { apps: unknown[]; routing: unknown } = { apps: [], routing: null }; let myApps: { apps: unknown[]; routing: unknown } = { apps: [], routing: null };
if (!DEV_LOCAL_AUTH) { if (!DEV_LOCAL_AUTH) {
// 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;
if (cachedShell) {
userTenants = cachedShell.userTenants;
myApps = cachedShell.myApps;
} else {
try { try {
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, ''); const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
const tenantOverride = cookies.get('sso_tenant_id'); const tenantOverride = cookies.get('sso_tenant_id');
@@ -48,6 +59,11 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
const freshAccessToken = getAuthTokens(cookies).accessToken ?? accessToken; const freshAccessToken = getAuthTokens(cookies).accessToken ?? accessToken;
myApps = await fetchMyApps(freshAccessToken, fetch, cookies.get('sso_tenant_id')); myApps = await fetchMyApps(freshAccessToken, fetch, cookies.get('sso_tenant_id'));
if (sessionKey) {
setDashboardShellCache(sessionKey, { userTenants, myApps });
}
}
} }
return { return {