diff --git a/lib/admin/__tests__/audit-log.test.ts b/lib/admin/__tests__/audit-log.test.ts new file mode 100644 index 000000000..4cf7f8b24 --- /dev/null +++ b/lib/admin/__tests__/audit-log.test.ts @@ -0,0 +1,106 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +// The admin audit log: today's audit.log in the state dir on one instance, +// one log for every replica with a shared store (#1066). + +const log = vi.hoisted(() => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() })); +vi.mock('@/lib/logger', () => ({ logger: log })); + +import { auditLog, readAuditLog } from '../audit'; +import { getStore, setStoreForTests } from '@/lib/shared-store'; +import { resetLocalStoreForTests } from '@/lib/shared-store/local'; +import { separateFakeStore } from './shared-store-fake'; + +let dir: string; +const statePath = (file: string) => path.join(dir, 'state', file); +/** The shared log as another replica reads it. */ +const sharedLines = () => getStore().log('audit', { + local: { path: () => statePath('audit.log'), maxBytes: 10 * 1024 * 1024, rotations: 3 }, + maxEntries: 50_000, +}).readAll(); + +beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'bw-audit-')); + vi.stubEnv('ADMIN_STATE_DIR', path.join(dir, 'state')); + resetLocalStoreForTests(); + log.error.mockClear(); +}); + +afterEach(async () => { + setStoreForTests(null); + resetLocalStoreForTests(); + vi.unstubAllEnvs(); + await rm(dir, { recursive: true, force: true }); +}); + +describe('audit log on one instance', () => { + it('appends one JSON line per entry to audit.log and reads them newest first', async () => { + await auditLog('admin.login', {}, '203.0.113.1'); + await auditLog('config.update', { changes: [{ key: 'sessionSecret', old: 'a', new: 'b' }] }, '203.0.113.2'); + const lines = (await readFile(statePath('audit.log'), 'utf-8')).split('\n'); + expect(lines).toHaveLength(3); + expect(lines[2]).toBe(''); + expect(JSON.parse(lines[0])).toMatchObject({ action: 'admin.login', detail: {}, ip: '203.0.113.1' }); + expect(Object.keys(JSON.parse(lines[0]))).toEqual(['ts', 'action', 'detail', 'ip']); + expect(JSON.parse(lines[1]).detail.changes[0]).toEqual({ key: 'sessionSecret', old: '[redacted]', new: '[redacted]' }); + + const { entries, total } = await readAuditLog(); + expect(total).toBe(2); + expect(entries.map((e) => e.action)).toEqual(['config.update', 'admin.login']); + }); + + it('rotates audit.log at 10 MB and keeps three older files', async () => { + await mkdir(path.join(dir, 'state'), { recursive: true }); + await writeFile(statePath('audit.log'), `${'x'.repeat(10 * 1024 * 1024)}\n`); + await writeFile(statePath('audit.log.1'), 'one\n'); + await writeFile(statePath('audit.log.2'), 'two\n'); + await writeFile(statePath('audit.log.3'), 'three\n'); + await auditLog('admin.login', {}, '203.0.113.1'); + + expect(existsSync(statePath('audit.log'))).toBe(false); + expect((await readFile(statePath('audit.log.1'), 'utf-8')).endsWith('"ip":"203.0.113.1"}\n')).toBe(true); + expect(await readFile(statePath('audit.log.2'), 'utf-8')).toBe('one\n'); + expect(await readFile(statePath('audit.log.3'), 'utf-8')).toBe('two\n'); + expect(await readAuditLog()).toEqual({ entries: [], total: 0 }); + + await auditLog('admin.logout', {}, '203.0.113.1'); + expect((await readAuditLog()).entries.map((e) => e.action)).toEqual(['admin.logout']); + }); +}); + +describe('audit log with a shared store', () => { + let store: ReturnType; + beforeEach(() => { + store = separateFakeStore(path.join(dir, 'store')); + setStoreForTests(store); + }); + + it('keeps every entry of replicas writing at once, whole', async () => { + const replicas = await Promise.all([0, 1, 2].map(async () => { + vi.resetModules(); + return (await import('../audit')).auditLog; + })); + await Promise.all(Array.from({ length: 60 }, (_, i) => replicas[i % 3]('admin.login', { n: i, note: 'line\nbreak' }, `198.51.100.${i}`))); + + const lines = await sharedLines(); + expect(lines).toHaveLength(60); + const numbers = lines.map((line) => JSON.parse(line).detail.n).sort((a, b) => a - b); + expect(numbers).toEqual(Array.from({ length: 60 }, (_, i) => i)); + expect((await readAuditLog(1, 100)).total).toBe(60); + // Nothing in this replica's state dir. + expect(existsSync(statePath('audit.log'))).toBe(false); + }); + + it('puts the entry in the server log when the store is down', async () => { + store.down = true; + await expect(auditLog('admin.login_failed', {}, '203.0.113.9')).resolves.toBeUndefined(); + expect(log.error).toHaveBeenCalledWith('Failed to write audit log', expect.objectContaining({ + entry: expect.objectContaining({ action: 'admin.login_failed', ip: '203.0.113.9' }), + })); + }); +}); diff --git a/lib/admin/audit.ts b/lib/admin/audit.ts index 8bc3b324e..0c4e2b6ed 100644 --- a/lib/admin/audit.ts +++ b/lib/admin/audit.ts @@ -1,6 +1,5 @@ -import { appendFile, stat, rename, readFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; import { logger } from '@/lib/logger'; +import { getStore, isSharedMode, type AppendLog, type LogOptions } from '@/lib/shared-store'; import { ensureStateDir, getStatePath } from './paths'; import { SENSITIVE_CONFIG_KEYS, type AuditEntry } from './types'; @@ -60,8 +59,19 @@ const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB const MAX_ROTATIONS = 3; const AUDIT_LOG_FILE = 'audit.log'; -function getAuditLogPath(): string { - return getStatePath(AUDIT_LOG_FILE); +/** + * The audit log (#1066). Local mode: /audit.log, rotated at + * 10 MB to .1 … .3, of which only the current file is read, as before. + * Shared mode: one log for every replica, keeping the newest 50 000 + * entries (about what one 10 MB file holds). + */ +export const AUDIT_LOG_OPTIONS: LogOptions = { + local: { path: () => getStatePath(AUDIT_LOG_FILE), maxBytes: MAX_LOG_SIZE, rotations: MAX_ROTATIONS }, + maxEntries: 50_000, +}; + +function auditStore(): AppendLog { + return getStore().log('audit', AUDIT_LOG_OPTIONS); } /** @@ -69,7 +79,8 @@ function getAuditLogPath(): string { * so it remains writable when the config dir is mounted read-only. */ export async function auditLog(action: string, detail: Record, ip: string): Promise { - await ensureStateDir(); + const shared = isSharedMode(); + if (!shared) await ensureStateDir(); const entry: AuditEntry = { ts: new Date().toISOString(), @@ -78,30 +89,16 @@ export async function auditLog(action: string, detail: Record, ip, }; - const logPath = getAuditLogPath(); try { - await appendFile(logPath, JSON.stringify(entry) + '\n', 'utf-8'); - await rotateIfNeeded(logPath); + // One line per entry; the store rotates the local file (audit.log.3 → + // deleted, .2 → .3, .1 → .2, audit.log → .1) once it passes 10 MB. + await auditStore().append(JSON.stringify(entry)); } catch (error) { - logger.error('Failed to write audit log', { error: error instanceof Error ? error.message : 'Unknown error' }); - } -} - -async function rotateIfNeeded(logPath: string): Promise { - try { - const stats = await stat(logPath); - if (stats.size < MAX_LOG_SIZE) return; - - // Rotate: audit.log.3 → deleted, audit.log.2 → .3, audit.log.1 → .2, audit.log → .1 - for (let i = MAX_ROTATIONS; i >= 1; i--) { - const from = i === 1 ? logPath : `${logPath}.${i - 1}`; - const to = `${logPath}.${i}`; - if (existsSync(from)) { - try { await rename(from, to); } catch { /* target may exist on overwrite */ } - } - } - } catch { - // stat failed, probably file doesn't exist yet + logger.error('Failed to write audit log', { + error: error instanceof Error ? error.message : 'Unknown error', + // With a shared store that is down, the entry is kept in the server log. + ...(shared ? { entry } : {}), + }); } } @@ -109,10 +106,8 @@ async function rotateIfNeeded(logPath: string): Promise { * Read audit log entries, newest first. Supports pagination. */ export async function readAuditLog(page: number = 1, limit: number = 50, actionFilter?: string): Promise<{ entries: AuditEntry[]; total: number }> { - const logPath = getAuditLogPath(); try { - const content = await readFile(logPath, 'utf-8'); - const lines = content.trim().split('\n').filter(Boolean); + const lines = await auditStore().readAll(); let entries: AuditEntry[] = lines.map(line => { try { return JSON.parse(line); } catch { return null; }