mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
refactor: isolate audit file storage
This commit is contained in:
parent
94c3b569d5
commit
cf1f0c8bc5
3 changed files with 118 additions and 112 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
await fs.mkdir(AUDIT_DIR, { recursive: true });
|
||||
await auditFileRepository.ensureReady();
|
||||
}
|
||||
|
||||
function isSqliteAuditEnabled(): boolean {
|
||||
|
|
@ -131,22 +127,7 @@ async function seedLastHash(filePath: string): Promise<void> {
|
|||
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<void> {
|
|||
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<VerifyResult> {
|
|||
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<AuditEntry[]>
|
|||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
108
server/src/storage/audit-file-repository.ts
Normal file
108
server/src/storage/audit-file-repository.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<void> {
|
||||
return mkdir(this.directory, { recursive: true }).then(() => undefined);
|
||||
}
|
||||
|
||||
async getLastHash(filePath: string): Promise<string> {
|
||||
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<void> {
|
||||
return appendFile(filePath, `${line}\n`, 'utf8');
|
||||
}
|
||||
|
||||
async verify(filePath: string): Promise<AuditFileVerifyResult> {
|
||||
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<StoredAuditEntry[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue