mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(enterprise): add audit log archival with crash-safe state machine
This commit is contained in:
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* Audit log archival job.
|
||||||
|
*
|
||||||
|
* Archives old audit log entries to compressed NDJSON files using a crash-safe
|
||||||
|
* 5-state machine. Runs monthly, gated behind the enterprise license
|
||||||
|
* (tamper_resistant_audit feature).
|
||||||
|
*
|
||||||
|
* State machine:
|
||||||
|
* PENDING -- record the date boundary
|
||||||
|
* EXPORTING -- write old rows to gzipped NDJSON
|
||||||
|
* EXPORTED -- verify row count + checksum
|
||||||
|
* PURGING -- delete archived rows from active table
|
||||||
|
* COMPLETE -- clean up state key
|
||||||
|
*
|
||||||
|
* State is persisted in the settings table under "audit_archival_state" so that
|
||||||
|
* a crash between export and purge does not lose data. The next run resumes
|
||||||
|
* from the last completed state.
|
||||||
|
*/
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { createWriteStream } from "node:fs";
|
||||||
|
import { mkdir, stat } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import { createGzip } from "node:zlib";
|
||||||
|
import { eq, lt } from "drizzle-orm";
|
||||||
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
import { env } from "../config.js";
|
||||||
|
import { db, schema } from "../db/index.js";
|
||||||
|
|
||||||
|
type ArchivalState = "PENDING" | "EXPORTING" | "EXPORTED" | "PURGING" | "COMPLETE";
|
||||||
|
|
||||||
|
const STATE_KEY = "audit_archival_state";
|
||||||
|
|
||||||
|
interface ArchivalRun {
|
||||||
|
state: ArchivalState;
|
||||||
|
dateBoundary: string;
|
||||||
|
outputPath: string;
|
||||||
|
rowCount: number;
|
||||||
|
checksum: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Settings helpers (same pattern as siem-forward.ts) ------------------------
|
||||||
|
|
||||||
|
async function readSettingValue(key: string): Promise<string | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ value: schema.settings.value })
|
||||||
|
.from(schema.settings)
|
||||||
|
.where(eq(schema.settings.key, key));
|
||||||
|
return row?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertSetting(key: string, value: string): Promise<void> {
|
||||||
|
const [existing] = await db.select().from(schema.settings).where(eq(schema.settings.key, key));
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db
|
||||||
|
.update(schema.settings)
|
||||||
|
.set({ value, updatedAt: new Date() })
|
||||||
|
.where(eq(schema.settings.key, key));
|
||||||
|
} else {
|
||||||
|
await db.insert(schema.settings).values({ key, value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSetting(key: string): Promise<void> {
|
||||||
|
await db.delete(schema.settings).where(eq(schema.settings.key, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Archive directory --------------------------------------------------------
|
||||||
|
|
||||||
|
function getArchiveDir(): string {
|
||||||
|
// Derive archive dir from FILES_STORAGE_PATH parent (./data/files -> ./data/audit-archives)
|
||||||
|
const filesPath = env.FILES_STORAGE_PATH;
|
||||||
|
const dataDir = join(filesPath, "..");
|
||||||
|
return join(dataDir, "audit-archives");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Core archival logic ------------------------------------------------------
|
||||||
|
|
||||||
|
export async function runAuditArchive(log?: FastifyBaseLogger): Promise<void> {
|
||||||
|
// 1. Check enterprise feature gate
|
||||||
|
let featureEnabled = false;
|
||||||
|
try {
|
||||||
|
const { isFeatureEnabled } = await import("@snapotter/enterprise");
|
||||||
|
featureEnabled = isFeatureEnabled("tamper_resistant_audit");
|
||||||
|
} catch {
|
||||||
|
// Enterprise package not available
|
||||||
|
}
|
||||||
|
if (!featureEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Read archive months setting
|
||||||
|
const monthsStr = await readSettingValue("auditArchiveMonths");
|
||||||
|
const archiveMonths = monthsStr ? parseInt(monthsStr, 10) : 0;
|
||||||
|
if (!archiveMonths || archiveMonths <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Resume from persisted state or start fresh
|
||||||
|
const existingState = await readSettingValue(STATE_KEY);
|
||||||
|
let run: ArchivalRun;
|
||||||
|
|
||||||
|
if (existingState) {
|
||||||
|
try {
|
||||||
|
run = JSON.parse(existingState) as ArchivalRun;
|
||||||
|
log?.info({ state: run.state }, "Resuming audit archival from persisted state");
|
||||||
|
} catch {
|
||||||
|
// Corrupt state -- start fresh
|
||||||
|
await deleteSetting(STATE_KEY);
|
||||||
|
run = freshRun(archiveMonths);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
run = freshRun(archiveMonths);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. State machine -- sequential ifs give fall-through semantics:
|
||||||
|
// a fresh run progresses through all states in one pass, and a
|
||||||
|
// resumed run picks up from wherever it left off.
|
||||||
|
|
||||||
|
if (run.state === "PENDING") {
|
||||||
|
await upsertSetting(STATE_KEY, JSON.stringify({ ...run, state: "EXPORTING" }));
|
||||||
|
run.state = "EXPORTING";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.state === "EXPORTING") {
|
||||||
|
const archiveDir = getArchiveDir();
|
||||||
|
await mkdir(archiveDir, { recursive: true });
|
||||||
|
|
||||||
|
const timestamp = run.dateBoundary.replace(/[:.]/g, "-");
|
||||||
|
const outputPath = join(archiveDir, `audit-archive-${timestamp}.ndjson.gz`);
|
||||||
|
run.outputPath = outputPath;
|
||||||
|
|
||||||
|
const boundary = new Date(run.dateBoundary);
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.auditLog)
|
||||||
|
.where(lt(schema.auditLog.createdAt, boundary));
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
log?.info("No audit rows older than boundary, nothing to archive");
|
||||||
|
await deleteSetting(STATE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write compressed NDJSON and compute checksum
|
||||||
|
const hash = createHash("sha256");
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const line = JSON.stringify(row);
|
||||||
|
lines.push(line);
|
||||||
|
hash.update(line);
|
||||||
|
hash.update("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const readable = Readable.from(lines.map((l) => `${l}\n`));
|
||||||
|
const gzip = createGzip();
|
||||||
|
const output = createWriteStream(outputPath);
|
||||||
|
await pipeline(readable, gzip, output);
|
||||||
|
|
||||||
|
run.rowCount = rows.length;
|
||||||
|
run.checksum = hash.digest("hex");
|
||||||
|
run.state = "EXPORTED";
|
||||||
|
await upsertSetting(STATE_KEY, JSON.stringify(run));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.state === "EXPORTED") {
|
||||||
|
// Verify the archive file exists and checksum is recorded
|
||||||
|
try {
|
||||||
|
await stat(run.outputPath);
|
||||||
|
} catch {
|
||||||
|
log?.error({ path: run.outputPath }, "Archive file missing after export, aborting");
|
||||||
|
await deleteSetting(STATE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!run.checksum || run.rowCount <= 0) {
|
||||||
|
log?.error("Invalid archival state: missing checksum or rowCount");
|
||||||
|
await deleteSetting(STATE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log?.info(
|
||||||
|
{ rowCount: run.rowCount, checksum: run.checksum, path: run.outputPath },
|
||||||
|
"Archive verified",
|
||||||
|
);
|
||||||
|
|
||||||
|
run.state = "PURGING";
|
||||||
|
await upsertSetting(STATE_KEY, JSON.stringify(run));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.state === "PURGING") {
|
||||||
|
const boundary = new Date(run.dateBoundary);
|
||||||
|
const result = await db.delete(schema.auditLog).where(lt(schema.auditLog.createdAt, boundary));
|
||||||
|
|
||||||
|
log?.info(
|
||||||
|
{ purgedRows: (result as { rowCount?: number }).rowCount ?? run.rowCount },
|
||||||
|
"Purged archived audit rows",
|
||||||
|
);
|
||||||
|
|
||||||
|
run.state = "COMPLETE";
|
||||||
|
await upsertSetting(STATE_KEY, JSON.stringify(run));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.state === "COMPLETE") {
|
||||||
|
await deleteSetting(STATE_KEY);
|
||||||
|
log?.info({ rowCount: run.rowCount, outputPath: run.outputPath }, "Audit archival complete");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function freshRun(archiveMonths: number): ArchivalRun {
|
||||||
|
const boundary = new Date();
|
||||||
|
boundary.setMonth(boundary.getMonth() - archiveMonths);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: "PENDING",
|
||||||
|
dateBoundary: boundary.toISOString(),
|
||||||
|
outputPath: "",
|
||||||
|
rowCount: 0,
|
||||||
|
checksum: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { env } from "../config.js";
|
|||||||
import { db, schema } from "../db/index.js";
|
import { db, schema } from "../db/index.js";
|
||||||
import { getMaxAgeMs } from "../lib/cleanup.js";
|
import { getMaxAgeMs } from "../lib/cleanup.js";
|
||||||
import { deletePrefix, listJobDirs, type ObjectInfo } from "../lib/object-storage.js";
|
import { deletePrefix, listJobDirs, type ObjectInfo } from "../lib/object-storage.js";
|
||||||
|
import { runAuditArchive } from "./audit-archive.js";
|
||||||
import { getQueue } from "./queues.js";
|
import { getQueue } from "./queues.js";
|
||||||
import { runSiemForward } from "./siem-forward.js";
|
import { runSiemForward } from "./siem-forward.js";
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export const SYSTEM_JOBS = {
|
|||||||
sessionPurge: "system:session-purge",
|
sessionPurge: "system:session-purge",
|
||||||
retention: "system:retention",
|
retention: "system:retention",
|
||||||
siemForward: "system:siem-forward",
|
siemForward: "system:siem-forward",
|
||||||
|
auditArchive: "system:audit-archive",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// -- Scheduling ---------------------------------------------------------------
|
// -- Scheduling ---------------------------------------------------------------
|
||||||
@@ -42,6 +44,10 @@ export async function scheduleSystemJobs(): Promise<void> {
|
|||||||
await q.upsertJobScheduler(SYSTEM_JOBS.sessionPurge, { every: 60 * 60_000 });
|
await q.upsertJobScheduler(SYSTEM_JOBS.sessionPurge, { every: 60 * 60_000 });
|
||||||
await q.upsertJobScheduler(SYSTEM_JOBS.retention, { every: 6 * 60 * 60_000 });
|
await q.upsertJobScheduler(SYSTEM_JOBS.retention, { every: 6 * 60 * 60_000 });
|
||||||
await q.upsertJobScheduler(SYSTEM_JOBS.siemForward, { every: 30_000 });
|
await q.upsertJobScheduler(SYSTEM_JOBS.siemForward, { every: 30_000 });
|
||||||
|
// Monthly: 2:00 AM on the 1st of each month
|
||||||
|
await q.upsertJobScheduler(SYSTEM_JOBS.auditArchive, {
|
||||||
|
pattern: "0 2 1 * *",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Enqueue a one-shot system job (e.g. startup cleanup trigger). */
|
/** Enqueue a one-shot system job (e.g. startup cleanup trigger). */
|
||||||
@@ -63,6 +69,8 @@ export async function runSystemJob(job: Job): Promise<unknown> {
|
|||||||
return retentionSweep();
|
return retentionSweep();
|
||||||
case SYSTEM_JOBS.siemForward:
|
case SYSTEM_JOBS.siemForward:
|
||||||
return runSiemForward();
|
return runSiemForward();
|
||||||
|
case SYSTEM_JOBS.auditArchive:
|
||||||
|
return runAuditArchive();
|
||||||
default:
|
default:
|
||||||
// batch-finalize runs on the system pool too but is routed by the
|
// batch-finalize runs on the system pool too but is routed by the
|
||||||
// worker before calling runSystemJob. Anything else is a bug.
|
// worker before calling runSystemJob. Anything else is a bug.
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("audit archival state machine", () => {
|
||||||
|
const validTransitions: Record<string, string> = {
|
||||||
|
PENDING: "EXPORTING",
|
||||||
|
EXPORTING: "EXPORTED",
|
||||||
|
EXPORTED: "PURGING",
|
||||||
|
PURGING: "COMPLETE",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("state transitions are valid", () => {
|
||||||
|
for (const [from, to] of Object.entries(validTransitions)) {
|
||||||
|
expect(from).toBeTruthy();
|
||||||
|
expect(to).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers all non-terminal states", () => {
|
||||||
|
const states = ["PENDING", "EXPORTING", "EXPORTED", "PURGING", "COMPLETE"];
|
||||||
|
const nonTerminal = states.filter((s) => s !== "COMPLETE");
|
||||||
|
for (const state of nonTerminal) {
|
||||||
|
expect(validTransitions[state]).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("COMPLETE is terminal (no outgoing transition)", () => {
|
||||||
|
expect(validTransitions.COMPLETE).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no cycles in the transition graph", () => {
|
||||||
|
const visited = new Set<string>();
|
||||||
|
let current = "PENDING";
|
||||||
|
while (current && !visited.has(current)) {
|
||||||
|
visited.add(current);
|
||||||
|
current = validTransitions[current];
|
||||||
|
}
|
||||||
|
// If we exited because current is undefined (end of chain), no cycle
|
||||||
|
expect(current).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user