diff --git a/packages/core-audit/src/multi-sink-audit-log.test.ts b/packages/core-audit/src/multi-sink-audit-log.test.ts new file mode 100644 index 0000000..63c9961 --- /dev/null +++ b/packages/core-audit/src/multi-sink-audit-log.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from "vitest"; +import { MultiSinkAuditLog } from "./multi-sink-audit-log"; +import type { AuditEntry } from "@repo/core-shared/audit"; +import type { IAuditLog } from "./audit-log.interface"; + +const sample: AuditEntry = { + actorId: "user_1", + actorType: "user", + actorRoles: [], + action: "VIEW", + resource: { type: "articles" }, + at: new Date(), + scope: { feature: "blog", environment: "test", tenant: "default" }, + from: { ipTruncated: "10.0.0.0", userAgent: "test" }, + containsPii: false, + outcome: "success", +}; + +function makeRecorder(): IAuditLog & { records: AuditEntry[]; erasures: string[] } { + const records: AuditEntry[] = []; + const erasures: string[] = []; + return { + records, + erasures, + async record(e) { records.push(e); }, + async eraseSubject(actorId) { erasures.push(actorId); }, + }; +} + +describe("MultiSinkAuditLog", () => { + it("record() fans out to every sink", async () => { + const a = makeRecorder(); + const b = makeRecorder(); + const m = new MultiSinkAuditLog([a, b]); + await m.record(sample); + expect(a.records).toHaveLength(1); + expect(b.records).toHaveLength(1); + }); + + it("settle-all: one sink failing does not skip others", async () => { + const errSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const a: IAuditLog = { record: async () => { throw new Error("a-fail"); }, eraseSubject: async () => {} }; + const b = makeRecorder(); + const m = new MultiSinkAuditLog([a, b]); + + await m.record(sample); + + expect(b.records).toHaveLength(1); // b still received the entry + expect(errSpy).toHaveBeenCalledOnce(); + const written = errSpy.mock.calls[0]![0] as string; + const parsed = JSON.parse(written.trimEnd()); + expect(parsed._type).toBe("audit-sink-error"); + expect(parsed.error).toContain("a-fail"); + errSpy.mockRestore(); + }); + + it("eraseSubject() fans out to every sink", async () => { + const a = makeRecorder(); + const b = makeRecorder(); + const m = new MultiSinkAuditLog([a, b]); + await m.eraseSubject("user_1", "delete"); + expect(a.erasures).toEqual(["user_1"]); + expect(b.erasures).toEqual(["user_1"]); + }); +}); diff --git a/packages/core-audit/src/multi-sink-audit-log.ts b/packages/core-audit/src/multi-sink-audit-log.ts new file mode 100644 index 0000000..0be9b36 --- /dev/null +++ b/packages/core-audit/src/multi-sink-audit-log.ts @@ -0,0 +1,45 @@ +import type { AuditEntry } from "@repo/core-shared/audit"; +import type { IAuditLog } from "./audit-log.interface"; + +/** + * Fan-out wrapper. Delivers each entry to every inner sink with settle-all + * semantics — one failing sink doesn't drop the audit entry from others. + * + * Failures emit a structured `audit-sink-error` JSON line to stderr. + * Stderr (not via OTel/Sentry) avoids recursion: if Sentry is one of the + * sinks failing and we routed the error back through Sentry's reporter, + * we'd loop. Stderr is consumed by the same log shipper as audit entries + * themselves, so the operator sees the failure in their aggregator. + */ +export class MultiSinkAuditLog implements IAuditLog { + constructor(private readonly sinks: IAuditLog[]) {} + + async record(entry: AuditEntry): Promise { + const results = await Promise.allSettled(this.sinks.map((s) => s.record(entry))); + for (const r of results) { + if (r.status === "rejected") { + this.reportSinkError(r.reason); + } + } + } + + async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise { + const results = await Promise.allSettled( + this.sinks.map((s) => s.eraseSubject(actorId, mode)), + ); + for (const r of results) { + if (r.status === "rejected") { + this.reportSinkError(r.reason); + } + } + } + + private reportSinkError(reason: unknown): void { + const line = JSON.stringify({ + _type: "audit-sink-error", + error: String(reason), + at: new Date().toISOString(), + }); + process.stderr.write(line + "\n"); + } +}