Audit log připisuje akce cizímu uživateli — module-global „current user"
import { Aside } from ‘@astrojs/starlight/components’;
Symptom
Sekce “Symptom”Detail dokladu ukazoval v historii „Vytvořeno — Uživatel A”, ale pole „Vytvořil” (FK creatorId v DB) říkalo Uživatel B. Rozpor se objevoval náhodně, hlavně u záznamů vytvářených automaticky (cron, webhook) — tam audit log ukazoval jakéhokoli uživatele, který se v aplikaci naposledy autorizoval.
Root cause
Sekce “Root cause”Aplikace měla automatický audit přes Prisma $extends hook (každý create/update/delete zapíše záznam do AuditLog). Actor se předával přes „request context” implementovaný jako module-level proměnná:
// audit-context.ts — CHYBAlet currentUser: AuditUser | null = null;export function setAuditUser(u: AuditUser) { currentUser = u; }export function getAuditUser() { return currentUser; }setAuditUser() volal RBAC guard (checkPermission) na začátku každého autorizovaného requestu. Jenže Node.js server obsluhuje souběžné requesty v jednom procesu — proměnná tedy drží posledního autorizovaného uživatele odkudkoli z aplikace, ne uživatele aktuálního requestu:
- Request uživatele A projde guardem →
currentUser = A - Než A dokončí DB zápis, přijde request uživatele B →
currentUser = B - Prisma hook requestu A zapíše audit s actorem B
- Cron/webhook (bez session) nikdy
setAuditUsernevolá → zdědí „visícího” posledního uživatele a připíše mu systémovou akci
Fix
Sekce “Fix”Pozor na past: AsyncLocalStorage.enterWith() volaný uvnitř awaitované funkce se nepropaguje zpět do volajícího (ověřeno na Node 24 — continuation callera vzniká před enterWith). Bez možnosti obalit všechny handlery do als.run() je spolehlivé řešení použít request-scoped objekt frameworku jako klíč WeakMap. V Next.js App Router je headers() per request cachovaný (stabilní identita) a žije v Next vlastní AsyncLocalStorage, která se propaguje správně:
// audit-context.ts — OPRAVAimport { headers } from "next/headers";
const requestUsers = new WeakMap<object, AuditUser>();
function requestKey(): object | null { try { return headers(); } // stabilní per-request objekt catch { return null; } // mimo request scope (cron, build)}
export function setAuditUser(user: AuditUser): void { const key = requestKey(); if (key) requestUsers.set(key, user);}
export function getAuditUser(): AuditUser | null { const key = requestKey(); return key ? requestUsers.get(key) ?? null : null;}- Actor je vázaný na konkrétní request; souběžné requesty se nemíchají.
- Cron/webhook bez session nemá zapsaného actora → auto-audit se korektně přeskočí (místo připsání akce náhodnému člověku).
- WeakMap se uklidí GC spolu s request objektem — žádný leak.
Jak to najít
Sekce “Jak to najít”SQL cross-check auditu proti FK autorovi:
SELECT a."userName", u.name AS creator, e.numberFROM audit_log aJOIN entity e ON e.id = a.entity_idJOIN users u ON u.id = e.creator_idWHERE a.action = 'CREATE' AND a."userName" <> u.name;Podezřelé vzory: mismatche v čase cronů (přesné minuty), audit záznamy bez IP/details, actor bez oprávnění k dané entitě.
Kde jinde hrozí
Sekce “Kde jinde hrozí”- Jakýkoli „context” modul s
let current…na top-level (tenant, locale, feature flags per user) - Singleton logger s
setUser()(Sentry scope má protowithScope) - Express/Fastify middleware ukládající uživatele do globálu místo
req