mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
212 lines
6.5 KiB
TypeScript
212 lines
6.5 KiB
TypeScript
/**
|
|
* 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";
|
|
import { upsertSetting } from "../lib/settings-helpers.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 ----------------------------------------------------------
|
|
|
|
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 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: "",
|
|
};
|
|
}
|