diff --git a/packages/core-audit/package.json b/packages/core-audit/package.json index 0eaac61..013f356 100644 --- a/packages/core-audit/package.json +++ b/packages/core-audit/package.json @@ -25,9 +25,15 @@ "payload": "^3.0.0" }, "peerDependenciesMeta": { - "payload": { "optional": true } + "payload": { + "optional": true + } }, "devDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.55.0", + "@opentelemetry/context-async-hooks": "^1.28.0", + "@opentelemetry/sdk-trace-base": "^1.27.0", "@repo/core-eslint": "workspace:*", "@repo/core-testing": "workspace:*", "@repo/core-typescript": "workspace:*", diff --git a/packages/core-audit/src/trace-id-enriching-audit-log.test.ts b/packages/core-audit/src/trace-id-enriching-audit-log.test.ts new file mode 100644 index 0000000..59147d7 --- /dev/null +++ b/packages/core-audit/src/trace-id-enriching-audit-log.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { context, trace } from "@opentelemetry/api"; +import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; +import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"; +import { TraceIdEnrichingAuditLog } from "./trace-id-enriching-audit-log"; +import type { AuditEntry } from "@repo/core-shared/audit"; +import type { IAuditLog } from "./audit-log.interface"; + +// Register async context manager so startActiveSpan propagates context. +const ctxManager = new AsyncLocalStorageContextManager(); +ctxManager.enable(); +context.setGlobalContextManager(ctxManager); + +function setupProvider(): { exporter: InMemorySpanExporter; provider: BasicTracerProvider } { + const exporter = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(exporter)], + }); + trace.setGlobalTracerProvider(provider); + return { exporter, provider }; +} + +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 makeInner(): IAuditLog & { records: AuditEntry[] } { + const records: AuditEntry[] = []; + return { + records, + async record(e) { + records.push(e); + }, + eraseSubject: vi.fn(), + }; +} + +describe("TraceIdEnrichingAuditLog", () => { + let exporter: InMemorySpanExporter; + let provider: BasicTracerProvider; + + beforeEach(() => { + ({ exporter, provider } = setupProvider()); + }); + + afterEach(async () => { + await provider.shutdown(); + trace.disable(); + void exporter; + }); + + it("passes through when no active span", async () => { + const inner = makeInner(); + const wrapper = new TraceIdEnrichingAuditLog(inner); + await wrapper.record(sample); + expect(inner.records[0]!.correlationId).toBeUndefined(); + }); + + it("auto-populates correlationId from active span", async () => { + const inner = makeInner(); + const wrapper = new TraceIdEnrichingAuditLog(inner); + const tracer = trace.getTracer("test"); + await new Promise((resolve) => { + tracer.startActiveSpan("test", async (span) => { + await wrapper.record(sample); + const expected = span.spanContext().traceId; + expect(inner.records[0]!.correlationId).toBe(expected); + span.end(); + resolve(); + }); + }); + }); + + it("explicit correlationId wins over auto-populated", async () => { + const inner = makeInner(); + const wrapper = new TraceIdEnrichingAuditLog(inner); + const tracer = trace.getTracer("test"); + await new Promise((resolve) => { + tracer.startActiveSpan("test", async (span) => { + await wrapper.record({ ...sample, correlationId: "explicit-trace-id" }); + expect(inner.records[0]!.correlationId).toBe("explicit-trace-id"); + span.end(); + resolve(); + }); + }); + }); + + it("eraseSubject passes through unchanged", async () => { + const eraseSpy = vi.fn(); + const inner: IAuditLog = { record: vi.fn(), eraseSubject: eraseSpy }; + const wrapper = new TraceIdEnrichingAuditLog(inner); + await wrapper.eraseSubject("user_1", "delete"); + expect(eraseSpy).toHaveBeenCalledWith("user_1", "delete"); + }); +}); diff --git a/packages/core-audit/src/trace-id-enriching-audit-log.ts b/packages/core-audit/src/trace-id-enriching-audit-log.ts new file mode 100644 index 0000000..6829ba1 --- /dev/null +++ b/packages/core-audit/src/trace-id-enriching-audit-log.ts @@ -0,0 +1,30 @@ +import type { AuditEntry } from "@repo/core-shared/audit"; +import { currentTraceId } from "@repo/core-shared/instrumentation"; +import type { IAuditLog } from "./audit-log.interface"; + +/** + * Decorates any IAuditLog by auto-populating AuditEntry.correlationId from + * the active OTel span (when present and the caller didn't supply a value). + * Caller-supplied correlationId always wins — explicit > implicit. + * + * Applied at bind time by bindAudit so all sinks see entries with + * correlationId already set. Single source of truth for the OTel-audit bridge. + */ +export class TraceIdEnrichingAuditLog implements IAuditLog { + constructor(readonly inner: IAuditLog) {} + + async record(entry: AuditEntry): Promise { + if (entry.correlationId) { + return this.inner.record(entry); + } + const traceId = currentTraceId(); + if (!traceId) { + return this.inner.record(entry); + } + return this.inner.record({ ...entry, correlationId: traceId }); + } + + eraseSubject(actorId: string, mode: "pseudonymize" | "delete"): Promise { + return this.inner.eraseSubject(actorId, mode); + } +}