Compare commits
5 Commits
bdd089954b
...
67bcb6794b
| Author | SHA1 | Date | |
|---|---|---|---|
| 67bcb6794b | |||
| b9a0c55719 | |||
| beb2f7bdf0 | |||
| 695af2f0b1 | |||
| a1bbb6b1b2 |
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
49
frontend/src/lib/server/dashboard-shell-cache.test.ts
Normal file
49
frontend/src/lib/server/dashboard-shell-cache.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
33
frontend/src/lib/server/dashboard-shell-cache.ts
Normal file
33
frontend/src/lib/server/dashboard-shell-cache.ts
Normal 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 });
|
||||||
|
}
|
||||||
35
frontend/src/lib/server/jwt.ts
Normal file
35
frontend/src/lib/server/jwt.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user