import { Injectable } from '@nestjs/common'; import { trace } from '@opentelemetry/api'; import { ClsService } from 'nestjs-cls'; import { PrismaService } from 'nestjs-prisma'; import { Prisma } from '@prisma/client'; import type { AuditEventInput } from './audit.types'; /** * AuditWriter — single entry point for ADR-0013 audit-log writes. * * Contract * -------- * - **Append-only at the database level.** Every write runs inside a * transaction whose first statement is `SET LOCAL ROLE * audit_writer`. That role only has `INSERT` on `audit.events` * (per the migration that created the table); `UPDATE`, `DELETE`, * `TRUNCATE` all fail at the Postgres level even if the BFF * connection is otherwise privileged. The role is reset * automatically at transaction end. * * - **Fail loud, never swallow.** Per ADR-0013 §"Blocking writes": * no audit ⇒ no action. Callers must propagate the rejection up * so the requested action does not proceed when its audit trail * cannot be written. The service throws the underlying Prisma * error unchanged; do not wrap it in a catch-and-log block. * * - **trace_id and actor_id_hash are auto-resolved.** trace_id is * read from the active OTel span context (so the audit row joins * with the BFF request span and the Pino log lines on the same * request). actor_id_hash is read from the CLS context populated * by future auth guards (ADR-0009 / ADR-0010); v1 stores `null` * when no actor is established. Callers can override either by * passing them on `AuditEventInput`. */ @Injectable() export class AuditWriter { constructor( private readonly prisma: PrismaService, private readonly cls: ClsService, ) {} async recordEvent(input: AuditEventInput): Promise { const traceId = trace.getActiveSpan()?.spanContext().traceId ?? null; const actorIdHash = input.actorIdHash ?? this.cls.get('actorIdHash') ?? null; await this.prisma.$transaction(async (tx) => { // Lock the connection to audit_writer for the duration of this // transaction. SET LOCAL is reset at COMMIT/ROLLBACK so the // pool's next consumer sees the original role. await tx.$executeRawUnsafe(`SET LOCAL ROLE audit_writer`); await tx.auditEvent.create({ data: { eventType: input.eventType, audience: input.audience, outcome: input.outcome, subject: input.subject ?? null, actorIdHash, traceId, payload: this.toJsonInput(input.payload), }, }); }); } // Prisma's `Json` field accepts `Prisma.JsonNull` (null literal in // SQL JSONB) or a serialisable value; explicit `undefined` skips // the column. Map `payload` accordingly. private toJsonInput( payload: AuditEventInput['payload'], ): Prisma.InputJsonValue | typeof Prisma.JsonNull { if (payload === undefined) return Prisma.JsonNull; return payload as Prisma.InputJsonValue; } }