From 683dab691f6453133704d24bc4768f29e9321491 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 11 May 2026 16:14:47 +0200 Subject: [PATCH] feat(core-audit): bindAudit binder with sink selection + prod salt validation Co-Authored-By: Claude Sonnet 4.6 --- packages/core-audit/src/di/bind-audit.test.ts | 52 ++++++++++++++++ packages/core-audit/src/di/bind-audit.ts | 62 +++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 packages/core-audit/src/di/bind-audit.test.ts create mode 100644 packages/core-audit/src/di/bind-audit.ts diff --git a/packages/core-audit/src/di/bind-audit.test.ts b/packages/core-audit/src/di/bind-audit.test.ts new file mode 100644 index 0000000..620939d --- /dev/null +++ b/packages/core-audit/src/di/bind-audit.test.ts @@ -0,0 +1,52 @@ +import "reflect-metadata"; +import { describe, it, expect } from "vitest"; +import { Container } from "inversify"; +import { bindAudit } from "./bind-audit"; +import { AUDIT_SYMBOLS } from "./symbols"; +import { NoopAuditLog } from "../noop-audit-log"; +import { StdoutJsonAuditLog } from "../stdout-json-audit-log"; +import { PayloadAuditLog } from "../payload-audit-log"; +import { MultiSinkAuditLog } from "../multi-sink-audit-log"; +import type { IAuditLog } from "../audit-log.interface"; + +describe("bindAudit", () => { + it("defaults to MultiSinkAuditLog([payload, stdout]) when payloadConfig is provided", () => { + const container = new Container(); + bindAudit(container, { payloadConfig: {} as never }); + const auditLog = container.get(AUDIT_SYMBOLS.IAuditLog); + expect(auditLog).toBeInstanceOf(MultiSinkAuditLog); + }); + + it("returns StdoutJsonAuditLog alone when payloadConfig omitted + default sinks", () => { + const container = new Container(); + bindAudit(container, {}); + const auditLog = container.get(AUDIT_SYMBOLS.IAuditLog); + expect(auditLog).toBeInstanceOf(StdoutJsonAuditLog); + }); + + it("returns NoopAuditLog when sinks=[]", () => { + const container = new Container(); + bindAudit(container, { sinks: [] }); + const auditLog = container.get(AUDIT_SYMBOLS.IAuditLog); + expect(auditLog).toBeInstanceOf(NoopAuditLog); + }); + + it("returns PayloadAuditLog when sinks=['payload'] only", () => { + const container = new Container(); + bindAudit(container, { payloadConfig: {} as never, sinks: ["payload"] }); + const auditLog = container.get(AUDIT_SYMBOLS.IAuditLog); + expect(auditLog).toBeInstanceOf(PayloadAuditLog); + }); + + it("validates AUDIT_PSEUDONYM_SALT in production", () => { + const oldEnv = process.env.NODE_ENV; + const oldSalt = process.env.AUDIT_PSEUDONYM_SALT; + process.env.NODE_ENV = "production"; + delete process.env.AUDIT_PSEUDONYM_SALT; + expect(() => bindAudit(new Container(), { sinks: ["stdout"] })).toThrow( + /AUDIT_PSEUDONYM_SALT/, + ); + process.env.NODE_ENV = oldEnv; + if (oldSalt) process.env.AUDIT_PSEUDONYM_SALT = oldSalt; + }); +}); diff --git a/packages/core-audit/src/di/bind-audit.ts b/packages/core-audit/src/di/bind-audit.ts new file mode 100644 index 0000000..4d3ddb8 --- /dev/null +++ b/packages/core-audit/src/di/bind-audit.ts @@ -0,0 +1,62 @@ +import "reflect-metadata"; +import type { Container } from "inversify"; +import { getPayload, type SanitizedConfig } from "payload"; +import { NoopAuditLog } from "../noop-audit-log"; +import { PayloadAuditLog } from "../payload-audit-log"; +import { StdoutJsonAuditLog } from "../stdout-json-audit-log"; +import { MultiSinkAuditLog } from "../multi-sink-audit-log"; +import type { IAuditLog } from "../audit-log.interface"; +import { AUDIT_SYMBOLS } from "./symbols"; + +export type BindAuditOpts = { + /** Payload config; required if "payload" is in sinks. */ + payloadConfig?: SanitizedConfig; + /** Sink selection. Default ["payload", "stdout"]. */ + sinks?: ("payload" | "stdout")[]; +}; + +/** + * Binds an `IAuditLog` impl to the container under `AUDIT_SYMBOLS.IAuditLog`. + * + * Default sink set: ["payload", "stdout"] — Payload local cache + structured + * JSON to stdout (operator wires a log shipper to the centralized aggregator). + * + * In production, AUDIT_PSEUDONYM_SALT env var MUST be set. Boot fails fast + * if not — better to refuse to start than to ship audit data with a dev-fallback + * salt that an attacker could reverse. + * + * Note: Phase 4 wraps the returned auditLog in TraceIdEnrichingAuditLog + * for OTel correlation. Phase 2 returns the inner sink/fan-out directly. + */ +export function bindAudit( + container: Container, + opts: BindAuditOpts = {}, +): { auditLog: IAuditLog } { + if (process.env.NODE_ENV === "production" && !process.env.AUDIT_PSEUDONYM_SALT) { + throw new Error( + "AUDIT_PSEUDONYM_SALT environment variable is required in production. " + + "Generate via `openssl rand -hex 32` and store in your secrets manager.", + ); + } + + const sinkList = opts.sinks ?? ["payload", "stdout"]; + const sinks: IAuditLog[] = []; + if (sinkList.includes("payload") && opts.payloadConfig) { + sinks.push(new PayloadAuditLog(opts.payloadConfig, getPayload)); + } + if (sinkList.includes("stdout")) { + sinks.push(new StdoutJsonAuditLog()); + } + + const auditLog: IAuditLog = + sinks.length > 1 ? new MultiSinkAuditLog(sinks) + : sinks.length === 1 ? sinks[0]! + : new NoopAuditLog(); + + if (container.isBound(AUDIT_SYMBOLS.IAuditLog)) { + container.unbind(AUDIT_SYMBOLS.IAuditLog); + } + container.bind(AUDIT_SYMBOLS.IAuditLog).toConstantValue(auditLog); + + return { auditLog }; +}