Add AuditLog table and Prisma audit extension
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user