feat(core-audit): bindAudit binder with sink selection + prod salt validation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 16:14:47 +02:00
parent 7f75f3d56a
commit 683dab691f
2 changed files with 114 additions and 0 deletions

View File

@@ -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<IAuditLog>(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<IAuditLog>(AUDIT_SYMBOLS.IAuditLog);
expect(auditLog).toBeInstanceOf(StdoutJsonAuditLog);
});
it("returns NoopAuditLog when sinks=[]", () => {
const container = new Container();
bindAudit(container, { sinks: [] });
const auditLog = container.get<IAuditLog>(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<IAuditLog>(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;
});
});

View File

@@ -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<IAuditLog>(AUDIT_SYMBOLS.IAuditLog).toConstantValue(auditLog);
return { auditLog };
}