From 03e3ef39cd667498e6a6509c62ec90f6097a266b Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 11 May 2026 16:12:18 +0200 Subject: [PATCH] feat(core-audit): StdoutJsonAuditLog impl with audit + audit-erasure markers Co-Authored-By: Claude Sonnet 4.6 --- .../src/stdout-json-audit-log.test.ts | 48 +++++++++++++++++++ .../core-audit/src/stdout-json-audit-log.ts | 35 ++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 packages/core-audit/src/stdout-json-audit-log.test.ts create mode 100644 packages/core-audit/src/stdout-json-audit-log.ts diff --git a/packages/core-audit/src/stdout-json-audit-log.test.ts b/packages/core-audit/src/stdout-json-audit-log.test.ts new file mode 100644 index 0000000..977138b --- /dev/null +++ b/packages/core-audit/src/stdout-json-audit-log.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { StdoutJsonAuditLog } from "./stdout-json-audit-log"; +import type { AuditEntry } from "@repo/core-shared/audit"; + +const sample: AuditEntry = { + actorId: "user_1", + actorType: "user", + actorRoles: ["admin"], + action: "CREATE", + resource: { type: "articles", id: "abc" }, + at: new Date("2026-05-11T10:00:00.000Z"), + scope: { feature: "blog", environment: "production", tenant: "default" }, + from: { ipTruncated: "10.0.0.0", userAgent: "Mozilla/5.0" }, + containsPii: false, + outcome: "success", +}; + +describe("StdoutJsonAuditLog", () => { + let writeSpy: ReturnType; + beforeEach(() => { + writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + }); + + it("record() writes one JSON line per entry to stdout", async () => { + const log = new StdoutJsonAuditLog(); + await log.record(sample); + expect(writeSpy).toHaveBeenCalledOnce(); + const written = writeSpy.mock.calls[0]![0] as string; + expect(written.endsWith("\n")).toBe(true); + const parsed = JSON.parse(written.trimEnd()); + expect(parsed._type).toBe("audit"); + expect(parsed.actorId).toBe("user_1"); + expect(parsed.action).toBe("CREATE"); + expect(parsed.at).toBe("2026-05-11T10:00:00.000Z"); // ISO 8601 serialization + }); + + it("eraseSubject() emits a tombstone with mode + actorId", async () => { + const log = new StdoutJsonAuditLog(); + await log.eraseSubject("user_1", "pseudonymize"); + expect(writeSpy).toHaveBeenCalledOnce(); + const written = writeSpy.mock.calls[0]![0] as string; + const parsed = JSON.parse(written.trimEnd()); + expect(parsed._type).toBe("audit-erasure"); + expect(parsed.actorId).toBe("user_1"); + expect(parsed.mode).toBe("pseudonymize"); + expect(typeof parsed.at).toBe("string"); // ISO 8601 timestamp + }); +}); diff --git a/packages/core-audit/src/stdout-json-audit-log.ts b/packages/core-audit/src/stdout-json-audit-log.ts new file mode 100644 index 0000000..9d46493 --- /dev/null +++ b/packages/core-audit/src/stdout-json-audit-log.ts @@ -0,0 +1,35 @@ +import type { AuditEntry } from "@repo/core-shared/audit"; +import type { IAuditLog } from "./audit-log.interface"; + +/** + * Writes one structured JSON line per audit entry to stdout. A log shipper + * (Vector, Fluent Bit) picks these up and forwards to the centralized + * aggregator (Grafana Cloud, Datadog, Loki EU, etc.). + * + * Lines include a `_type` discriminator so the shipper can route: + * "audit" → audit entry + * "audit-erasure" → GDPR erasure tombstone + * + * `eraseSubject` is best-effort: past stdout lines can't be retroactively + * removed. The tombstone informs the downstream aggregator to filter/delete. + */ +export class StdoutJsonAuditLog implements IAuditLog { + async record(entry: AuditEntry): Promise { + const serialized = JSON.stringify({ + _type: "audit", + ...entry, + at: entry.at.toISOString(), + }); + process.stdout.write(serialized + "\n"); + } + + async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise { + const tombstone = { + _type: "audit-erasure", + actorId, + mode, + at: new Date().toISOString(), + }; + process.stdout.write(JSON.stringify(tombstone) + "\n"); + } +}