Multi-tenant — module-level in-memory cache leakuje data mezi tenanty
import { Aside } from ‘@astrojs/starlight/components’;
Symptom
Sekce “Symptom”Po zavedení multi-tenant architektury (databáze per tenant, resolvace přes subdoménu) vracel endpoint pro tenant B data tenanta A — v našem případě branding aplikace, ale stejný vzor postihoval i fakturační údaje firmy, RBAC práva rolí a feature flagy. Projevilo se až E2E smoke testem se dvěma tenanty; unit testy i build byly zelené.
Root cause
Sekce “Root cause”Single-tenant aplikace léta bezpečně používala module-level in-memory cache s TTL — modul žije jednou v celém Node procesu, takže cache byla implicitně „per aplikace = per zákazník”:
// PŘED — bezpečné v single-tenant, kritický leak v multi-tenantlet cachedConfig: Config | null = null;let cacheExpiry = 0;
export async function getConfig(): Promise<Config> { if (cachedConfig && Date.now() < cacheExpiry) return cachedConfig; cachedConfig = await db.setting.findUnique(...); // dotaz do TENANT DB cacheExpiry = Date.now() + 5 * 60 * 1000; return cachedConfig;}V multi-tenant režimu obsluhuje tentýž proces požadavky všech tenantů.
První tenant cache naplní daty ze SVÉ databáze a dalších 5 minut je dostávají
všichni ostatní. Zvlášť zákeřné u RBAC: cache práv klíčovaná jen kódem role
("ADMIN") aplikovala oprávnění tenanta A u tenanta B, protože kódy rolí
napříč tenant databázemi kolidují.
Fix
Sekce “Fix”Centrální helper vracející klíč aktuálního tenanta (z request kontextu / AsyncLocalStorage; v single-tenant režimu konstantu), a všechny cache klíčovat přes něj:
// PO — cache klíčovaná tenantemconst configCache = new Map<string, { value: Config; expiry: number }>();
export async function getConfig(): Promise<Config> { const tk = tenantCacheKey(); // dbName tenanta | "default" v single-tenant const cached = configCache.get(tk); if (cached && Date.now() < cached.expiry) return cached.value; const value = await db.setting.findUnique(...); configCache.set(tk, { value, expiry: Date.now() + 5 * 60 * 1000 }); return value;}U kompozitních klíčů prefixovat: `${tenantCacheKey()}:${role}`.
Invalidační funkce nejjednodušeji cache.clear() (mění se zřídka).
Jak to najít
Sekce “Jak to najít”grep -rn 'let .*[Cc]ache\|const .*[Cc]ache.*=.*Map\|cacheExpiry\|CACHE_TTL' src --include='*.ts'Každý nález, který čte z tenant DB, je kandidát. Pozor i na cache třetích stran (session, tokeny externích API) — tam rozhoduje, zda jsou credentials per tenant, nebo per proces.
Poznámky
Sekce “Poznámky”- Fyzická izolace DB per tenant tuto třídu chyb NEřeší — leak vzniká v aplikační vrstvě nad ní.
- Unit testy to nechytí (běží single-tenant); nutný E2E test se dvěma tenanty a ROZDÍLNÝMI daty + assert, že tenant B nikdy nevidí hodnoty tenanta A.
- Helper držet v modulu, který testy nemockují (u nás oddělený od Prisma
klienta, protože
jest.mockcelého DB modulu by helper zabil).