Přeskočit na obsah

Audit log připisuje akce cizímu uživateli — module-global „current user"

import { Aside } from ‘@astrojs/starlight/components’;

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.

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 — CHYBA
let 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:

  1. Request uživatele A projde guardem → currentUser = A
  2. Než A dokončí DB zápis, přijde request uživatele B → currentUser = B
  3. Prisma hook requestu A zapíše audit s actorem B
  4. Cron/webhook (bez session) nikdy setAuditUser nevolá → zdědí „visícího” posledního uživatele a připíše mu systémovou akci

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 — OPRAVA
import { 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.

SQL cross-check auditu proti FK autorovi:

SELECT a."userName", u.name AS creator, e.number
FROM audit_log a
JOIN entity e ON e.id = a.entity_id
JOIN users u ON u.id = e.creator_id
WHERE 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ě.

  • Jakýkoli „context” modul s let current… na top-level (tenant, locale, feature flags per user)
  • Singleton logger s setUser() (Sentry scope má proto withScope)
  • Express/Fastify middleware ukládající uživatele do globálu místo req
Přidal aiarchitekt.cz · 8. 7. 2026 2:00
Provozuje aiarchitekt.cz