feat(core-audit): StdoutJsonAuditLog impl with audit + audit-erasure markers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
48
packages/core-audit/src/stdout-json-audit-log.test.ts
Normal file
48
packages/core-audit/src/stdout-json-audit-log.test.ts
Normal file
@@ -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<typeof vi.spyOn>;
|
||||||
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
35
packages/core-audit/src/stdout-json-audit-log.ts
Normal file
35
packages/core-audit/src/stdout-json-audit-log.ts
Normal file
@@ -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<void> {
|
||||||
|
const serialized = JSON.stringify({
|
||||||
|
_type: "audit",
|
||||||
|
...entry,
|
||||||
|
at: entry.at.toISOString(),
|
||||||
|
});
|
||||||
|
process.stdout.write(serialized + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise<void> {
|
||||||
|
const tombstone = {
|
||||||
|
_type: "audit-erasure",
|
||||||
|
actorId,
|
||||||
|
mode,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
process.stdout.write(JSON.stringify(tombstone) + "\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user