From cf1f0c8bc5891c35a3e57457a8936e4fdde0bb05 Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:36:38 -0500 Subject: [PATCH] refactor: isolate audit file storage --- .../service-filesystem-boundary.json | 8 +- server/src/services/audit-service.ts | 114 ++---------------- server/src/storage/audit-file-repository.ts | 108 +++++++++++++++++ 3 files changed, 118 insertions(+), 112 deletions(-) create mode 100644 server/src/storage/audit-file-repository.ts diff --git a/docs/architecture/service-filesystem-boundary.json b/docs/architecture/service-filesystem-boundary.json index 6def3964..f4f4a9df 100644 --- a/docs/architecture/service-filesystem-boundary.json +++ b/docs/architecture/service-filesystem-boundary.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "maximumEntries": 29, + "maximumEntries": 28, "entries": [ { "path": "server/src/services/attachment-service.ts", @@ -8,12 +8,6 @@ "owner": "#1188", "rationale": "Managed-content storage migration is tracked in issue #1188." }, - { - "path": "server/src/services/audit-service.ts", - "category": "authoritative-persistence", - "owner": "#1187", - "rationale": "Operational evidence storage migration is tracked in issue #1187." - }, { "path": "server/src/services/clawdbot-agent-service.ts", "category": "transient-process-io", diff --git a/server/src/services/audit-service.ts b/server/src/services/audit-service.ts index 7d98162d..9b6f1cb1 100644 --- a/server/src/services/audit-service.ts +++ b/server/src/services/audit-service.ts @@ -7,14 +7,11 @@ * Log files are stored as JSONL (one JSON object per line) with monthly rotation: * {dataDir}/audit/audit-{YYYY-MM}.log */ -import fs from 'fs/promises'; -import { createReadStream } from 'fs'; -import path from 'path'; import crypto from 'crypto'; -import readline from 'readline'; import { createLogger } from '../lib/logger.js'; import { SqliteDatabase } from '../storage/sqlite/database.js'; import { SqliteAuditRepository } from '../storage/sqlite/audit-policy-repositories.js'; +import { AuditFileRepository } from '../storage/audit-file-repository.js'; const log = createLogger('audit'); @@ -57,6 +54,7 @@ import { getAuditDir } from '../utils/paths.js'; const AUDIT_DIR = getAuditDir(); const SQLITE_AUDIT_LOG_PATH = 'sqlite://audit/current'; +const auditFileRepository = new AuditFileRepository(AUDIT_DIR); // --------------------------------------------------------------------------- // Internal State @@ -89,21 +87,19 @@ function logFilePath(date: Date = new Date()): string { return SQLITE_AUDIT_LOG_PATH; } - const yyyy = date.getFullYear(); - const mm = String(date.getMonth() + 1).padStart(2, '0'); - const month = `${yyyy}-${mm}`; + const month = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; // Cache to avoid path.join on every write if (month !== currentMonth) { currentMonth = month; - currentLogPath = path.join(AUDIT_DIR, `audit-${month}.log`); + currentLogPath = auditFileRepository.getMonthlyLogPath(date); } return currentLogPath; } /** Ensure the audit directory exists. */ async function ensureAuditDir(): Promise { - await fs.mkdir(AUDIT_DIR, { recursive: true }); + await auditFileRepository.ensureReady(); } function isSqliteAuditEnabled(): boolean { @@ -131,22 +127,7 @@ async function seedLastHash(filePath: string): Promise { return; } - try { - const content = await fs.readFile(filePath, 'utf8'); - const lines = content.trimEnd().split('\n').filter(Boolean); - if (lines.length > 0) { - lastHash = sha256(lines[lines.length - 1]); - } else { - lastHash = ''; - } - } catch (err: unknown) { - // File doesn't exist yet — first entry - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - lastHash = ''; - } else { - throw err; - } - } + lastHash = await auditFileRepository.getLastHash(filePath); } /** Track whether we've seeded for the current file. */ @@ -198,7 +179,7 @@ async function writeEntry(event: AuditEvent): Promise { if (isSqliteAuditEnabled()) { getAuditRepository().save(entry, line); } else { - await fs.appendFile(filePath, line + '\n', 'utf8'); + await auditFileRepository.append(filePath, line); } // Update the running hash @@ -216,71 +197,7 @@ export async function verifyAuditLog(filePath: string): Promise { return getAuditRepository().verify(); } - // Check if file exists - try { - await fs.access(filePath); - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return { valid: true, entries: 0 }; - } - throw err; - } - - return new Promise((resolve, reject) => { - const stream = createReadStream(filePath, { encoding: 'utf8' }); - const rl = readline.createInterface({ - input: stream, - crlfDelay: Infinity, - }); - - let prevHash = ''; - let lineIndex = 0; - let totalLines = 0; - let invalidResult: VerifyResult | null = null; - - rl.on('line', (line) => { - if (invalidResult) return; // Already found an error - - const trimmed = line.trim(); - if (!trimmed) { - lineIndex++; - return; - } - - totalLines++; - - let entry: AuditEntry; - try { - entry = JSON.parse(trimmed) as AuditEntry; - } catch { - invalidResult = { valid: false, entries: totalLines, firstBroken: lineIndex }; - rl.close(); - stream.destroy(); - return; - } - - if (entry.integrity !== prevHash) { - invalidResult = { valid: false, entries: totalLines, firstBroken: lineIndex }; - rl.close(); - stream.destroy(); - return; - } - - prevHash = sha256(trimmed); - lineIndex++; - }); - - rl.on('close', () => { - if (invalidResult) { - resolve(invalidResult); - } else { - resolve({ valid: true, entries: totalLines }); - } - }); - - rl.on('error', reject); - stream.on('error', reject); - }); + return auditFileRepository.verify(filePath); } /** @@ -293,20 +210,7 @@ export async function readRecentAuditEntries(limit = 100): Promise return getAuditRepository().readRecent(limit) as AuditEntry[]; } - let content: string; - try { - content = await fs.readFile(filePath, 'utf8'); - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return []; - } - throw err; - } - - const lines = content.trimEnd().split('\n').filter(Boolean); - // Take the last `limit` entries and reverse for newest-first - const slice = lines.slice(-limit).reverse(); - return slice.map((line) => JSON.parse(line) as AuditEntry); + return auditFileRepository.readRecent(filePath, limit); } /** diff --git a/server/src/storage/audit-file-repository.ts b/server/src/storage/audit-file-repository.ts new file mode 100644 index 00000000..5f524826 --- /dev/null +++ b/server/src/storage/audit-file-repository.ts @@ -0,0 +1,108 @@ +import crypto from 'node:crypto'; +import { appendFile, mkdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import readline from 'node:readline'; +import { createReadStream } from './fs-helpers.js'; + +export interface StoredAuditEntry { + timestamp: string; + action: string; + actor: string; + resource?: string; + details?: Record; + integrity: string; +} + +export interface AuditFileVerifyResult { + valid: boolean; + entries: number; + firstBroken?: number; +} + +function sha256(data: string): string { + return crypto.createHash('sha256').update(data, 'utf8').digest('hex'); +} + +export class AuditFileRepository { + constructor(private readonly directory: string) {} + + getMonthlyLogPath(date: Date): string { + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + return path.join(this.directory, `audit-${yyyy}-${mm}.log`); + } + + ensureReady(): Promise { + return mkdir(this.directory, { recursive: true }).then(() => undefined); + } + + async getLastHash(filePath: string): Promise { + try { + const content = await readFile(filePath, 'utf8'); + const lines = content.trimEnd().split('\n').filter(Boolean); + return lines.length > 0 ? sha256(lines[lines.length - 1]) : ''; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''; + throw error; + } + } + + append(filePath: string, line: string): Promise { + return appendFile(filePath, `${line}\n`, 'utf8'); + } + + async verify(filePath: string): Promise { + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const reader = readline.createInterface({ input: stream, crlfDelay: Infinity }); + let previousHash = ''; + let lineIndex = 0; + let totalEntries = 0; + + try { + for await (const line of reader) { + const trimmed = line.trim(); + if (!trimmed) { + lineIndex += 1; + continue; + } + + totalEntries += 1; + let entry: StoredAuditEntry; + try { + entry = JSON.parse(trimmed) as StoredAuditEntry; + } catch { + return { valid: false, entries: totalEntries, firstBroken: lineIndex }; + } + + if (entry.integrity !== previousHash) { + return { valid: false, entries: totalEntries, firstBroken: lineIndex }; + } + previousHash = sha256(trimmed); + lineIndex += 1; + } + return { valid: true, entries: totalEntries }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { valid: true, entries: 0 }; + throw error; + } finally { + reader.close(); + stream.destroy(); + } + } + + async readRecent(filePath: string, limit: number): Promise { + try { + const content = await readFile(filePath, 'utf8'); + return content + .trimEnd() + .split('\n') + .filter(Boolean) + .slice(-limit) + .reverse() + .map((line) => JSON.parse(line) as StoredAuditEntry); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + } +}