diff --git a/modules/audit-logger/audit.ts b/modules/audit-logger/audit.ts new file mode 100644 index 0000000..9ed2f6d --- /dev/null +++ b/modules/audit-logger/audit.ts @@ -0,0 +1,27 @@ +// The audit handler — appends one line per event to an append-only audit log. It is the whole of +// the module's code: an audit logger is not a privileged component, only a module that listens to +// everything and writes it down (novox/hq ADR 0046). + +import { appendFile, mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { Event } from "@novox/mesh-sdk/events"; + +/** Where the trail is written. A directory the host applies; one file per node. */ +export function auditLogPath(env: NodeJS.ProcessEnv = process.env): string { + return env.AUDIT_LOG ?? "/var/lib/audit-logger/audit.log"; +} + +/** Append an event to the trail as one JSON line, keeping the metadata an audit needs first. */ +export async function record(event: Event, path: string): Promise { + const line = + JSON.stringify({ + id: event.type + "@" + event.at, // a stable-ish key until x-event-id headers land (ADR 0047) + type: event.type, + source: event.source, + node: event.node, + at: event.at, + body: event.body, + }) + "\n"; + await mkdir(dirname(path), { recursive: true }).catch(() => {}); + await appendFile(path, line, { mode: 0o600 }); +} diff --git a/modules/audit-logger/index.ts b/modules/audit-logger/index.ts new file mode 100644 index 0000000..aa5eaf5 --- /dev/null +++ b/modules/audit-logger/index.ts @@ -0,0 +1,20 @@ +// The audit-logger's entrypoint. The per-node module runtime imports this once the broker is +// bound; the on("#") subscription is the whole handshake — it consumes every event on the mesh +// (module.*, mesh.*, node.*) and writes each to the trail. + +import { on } from "@novox/mesh-sdk/events"; +import { auditLogPath, record } from "./audit.js"; + +const path = auditLogPath(); + +await on("#", async (event) => { + try { + await record(event, path); + } catch (err) { + // A failure to write the audit trail is worth a loud line, never a swallowed one — but it must + // not throw back into the broker and wedge the subscription. + console.error(`[audit-logger] could not record ${event.type}: ${err}`); + } +}); + +console.log(`[audit-logger] recording all mesh events to ${path}`); diff --git a/modules/audit-logger/module.json b/modules/audit-logger/module.json new file mode 100644 index 0000000..72bf5b9 --- /dev/null +++ b/modules/audit-logger/module.json @@ -0,0 +1,15 @@ +{ + "module": "audit-logger", + "version": "1", + "consumes": [ + "#" + ], + "resources": [ + { + "id": "log", + "type": "directory", + "path": "/var/lib/audit-logger", + "mode": "0700" + } + ] +} diff --git a/modules/audit-logger/package.json b/modules/audit-logger/package.json new file mode 100644 index 0000000..14b04b1 --- /dev/null +++ b/modules/audit-logger/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-audit-logger", + "version": "0.1.0", + "description": "audit-logger — records every event on the mesh (module, mesh and node) to an append-only trail.", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/audit-logger/test/audit.test.ts b/modules/audit-logger/test/audit.test.ts new file mode 100644 index 0000000..2762934 --- /dev/null +++ b/modules/audit-logger/test/audit.test.ts @@ -0,0 +1,58 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { useBroker } from "@novox/mesh-sdk/messaging"; +import { emit, on } from "@novox/mesh-sdk/events"; +import { record } from "../audit.ts"; + +test("audit-logger records every event to the trail as one line each", async () => { + const broker = memBroker(); + useBroker(() => broker); + const dir = await mkdtemp(join(tmpdir(), "audit-")); + const path = join(dir, "audit.log"); + + // The audit-logger's whole behaviour: consume everything, record it. + await on("#", async (event) => record(event, path)); + + process.env.MESH_MODULE = "umami"; + process.env.MESH_NODE = "anchor"; + await emit("module.umami.site.created", { domain: "my-app" }); + await emit("node.anchor.joined", { role: "worker" }); // a node event, not a module one + + const lines = (await readFile(path, "utf8")).trim().split("\n").map((l) => JSON.parse(l)); + assert.equal(lines.length, 2); + assert.deepEqual(lines.map((l) => l.type), ["module.umami.site.created", "node.anchor.joined"]); + assert.equal(lines[0].source, "umami"); + assert.equal(lines[0].node, "anchor"); + assert.equal(lines[0].body.domain, "my-app"); + + delete process.env.MESH_MODULE; + delete process.env.MESH_NODE; +}); + +function memBroker() { + const subs: { pattern: string; handler: (env: { key: string; node: string; body: unknown }) => Promise }[] = []; + return { + async request() { + throw new Error("unused"); + }, + async handle() { + return () => {}; + }, + async publish(env: { key: string; node: string; body: T }): Promise { + for (const s of subs) if (match(s.pattern, env.key)) await s.handler(env); + }, + async subscribe(pattern: string, handler: (env: { key: string; node: string; body: T }) => Promise): Promise<() => void> { + subs.push({ pattern, handler: handler as (env: { key: string; node: string; body: unknown }) => Promise }); + return () => {}; + }, + async close() {}, + }; +} + +function match(pattern: string, key: string): boolean { + return pattern === "#" || pattern === key; +} diff --git a/modules/audit-logger/tsconfig.json b/modules/audit-logger/tsconfig.json new file mode 100644 index 0000000..871c26d --- /dev/null +++ b/modules/audit-logger/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["audit.ts", "index.ts"] +}