feat(core-audit): MultiSinkAuditLog fan-out with settle-all + stderr fallback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
65
packages/core-audit/src/multi-sink-audit-log.test.ts
Normal file
65
packages/core-audit/src/multi-sink-audit-log.test.ts
Normal file
@@ -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"]);
|
||||
});
|
||||
});
|
||||
45
packages/core-audit/src/multi-sink-audit-log.ts
Normal file
45
packages/core-audit/src/multi-sink-audit-log.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user