Add AuditLog table and Prisma audit extension

This commit is contained in:
NeilClack
2026-09-10 15:10:11 -05:00
parent 7596937f30
commit d16965dab6
15 changed files with 2121 additions and 72 deletions
+23
View File
@@ -0,0 +1,23 @@
import { AsyncLocalStorage } from 'node:async_hooks';
/**
* The actor for the current request, used by the Prisma audit extension to
* attribute database mutations. Populated in `hooks.server.ts`.
*/
export type AuditContext = {
userId: string | null;
email: string | null;
roleId: number | null;
page: string;
ip: string | null;
};
const storage = new AsyncLocalStorage<AuditContext>();
export function runWithAuditContext<T>(context: AuditContext, fn: () => T): T {
return storage.run(context, fn);
}
export function getAuditContext(): AuditContext | undefined {
return storage.getStore();
}
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import { describeAction, entityIdFor, sanitizeAuditValue, shouldAudit } from './audit';
describe('shouldAudit', () => {
it('audits mutations', () => {
expect(shouldAudit('User', 'create')).toBe(true);
expect(shouldAudit('Profile', 'update')).toBe(true);
expect(shouldAudit('Proclamation', 'delete')).toBe(true);
expect(shouldAudit('LicenseKey', 'updateMany')).toBe(true);
});
it('ignores reads', () => {
expect(shouldAudit('User', 'findMany')).toBe(false);
expect(shouldAudit('User', 'findUnique')).toBe(false);
expect(shouldAudit('User', 'count')).toBe(false);
});
it('ignores the audit log and auth internals', () => {
for (const model of ['AuditLog', 'Session', 'Account', 'Verification']) {
expect(shouldAudit(model, 'create')).toBe(false);
}
});
});
describe('sanitizeAuditValue', () => {
it('redacts sensitive keys', () => {
const out = sanitizeAuditValue({
password: 'hunter2',
token: 'abc',
keyHash: 'deadbeef',
email: 'jane@example.com'
}) as Record<string, unknown>;
expect(out.password).toBe('[redacted]');
expect(out.token).toBe('[redacted]');
expect(out.keyHash).toBe('[redacted]');
expect(out.email).toBe('jane@example.com');
});
it('converts dates to ISO strings', () => {
const date = new Date('2026-01-01T00:00:00.000Z');
expect(sanitizeAuditValue({ at: date })).toEqual({ at: '2026-01-01T00:00:00.000Z' });
});
it('handles arrays and nullish values', () => {
expect(sanitizeAuditValue([1, null, 'a'])).toEqual([1, null, 'a']);
expect(sanitizeAuditValue(null)).toBeNull();
expect(sanitizeAuditValue(undefined)).toBeNull();
});
it('truncates very long strings', () => {
const long = 'a'.repeat(21000);
const out = sanitizeAuditValue(long) as string;
expect(out.length).toBe(20001);
expect(out.endsWith('…')).toBe(true);
});
});
describe('entityIdFor', () => {
it('prefers the result id', () => {
expect(entityIdFor({ where: { id: 'w' } }, { id: 'r' }, { id: 'b' })).toBe('r');
});
it('falls back to where, data, then before', () => {
expect(entityIdFor({ where: { id: 5 } }, null, null)).toBe('5');
expect(entityIdFor({ data: { id: 'd' } }, null, null)).toBe('d');
expect(entityIdFor({}, null, { id: 'b' })).toBe('b');
expect(entityIdFor({}, null, null)).toBeNull();
});
});
describe('describeAction', () => {
it('labels bulk operations', () => {
expect(describeAction('updateMany')).toBe('bulk update');
expect(describeAction('deleteMany')).toBe('bulk delete');
expect(describeAction('create')).toBe('create');
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* Pure helpers for the audit extension. Kept free of database imports so the
* extension in `$lib/db.ts` can use them without a cycle.
*/
/** Prisma operations that change data. Reads are never audited. */
const MUTATION_OPERATIONS = new Set([
'create',
'createMany',
'createManyAndReturn',
'update',
'updateMany',
'updateManyAndReturn',
'upsert',
'delete',
'deleteMany'
]);
/**
* Models we never audit:
* - `AuditLog` itself (would recurse and is not user data)
* - `Session`/`Account`/`Verification` are high-churn auth internals that hold
* tokens, password hashes and other secrets. Business mutations are audited.
*/
const EXCLUDED_MODELS = new Set(['AuditLog', 'Session', 'Account', 'Verification']);
export function shouldAudit(model: string, operation: string): boolean {
return !EXCLUDED_MODELS.has(model) && MUTATION_OPERATIONS.has(operation);
}
const SENSITIVE_KEY = /pass(word)?|secret|token|hash/i;
/**
* Make a value safe to persist as JSON: dates become ISO strings, sensitive
* keys are redacted, very long strings are truncated, and cycles/depth are
* bounded.
*/
export function sanitizeAuditValue(value: unknown, depth = 0): unknown {
if (value === undefined || value === null) return null;
if (depth > 8) return '[truncated]';
if (value instanceof Date) return value.toISOString();
if (Array.isArray(value)) return value.map((item) => sanitizeAuditValue(item, depth + 1));
if (typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
out[key] = SENSITIVE_KEY.test(key) ? '[redacted]' : sanitizeAuditValue(nested, depth + 1);
}
return out;
}
if (typeof value === 'string' && value.length > 20000) return `${value.slice(0, 20000)}…`;
return value;
}
/** Best-effort row id for the changed record. */
export function entityIdFor(args: unknown, result: unknown, before: unknown): string | null {
const where = (args as { where?: { id?: unknown } } | null)?.where;
const data = (args as { data?: { id?: unknown } } | null)?.data;
const resultId = (result as { id?: unknown } | null)?.id;
const first = Array.isArray(before) ? before[0] : before;
const beforeId = (first as { id?: unknown } | undefined)?.id;
const id = resultId ?? where?.id ?? data?.id ?? beforeId;
return id === undefined || id === null ? null : String(id);
}
/** Human label for an operation, e.g. `updateMany` → `bulk update`. */
export function describeAction(operation: string): string {
switch (operation) {
case 'create':
return 'create';
case 'update':
return 'update';
case 'delete':
return 'delete';
case 'upsert':
return 'upsert';
case 'createMany':
case 'createManyAndReturn':
return 'bulk create';
case 'updateMany':
case 'updateManyAndReturn':
return 'bulk update';
case 'deleteMany':
return 'bulk delete';
default:
return operation;
}
}