From 3a44a1deedbf6e3d529617d96862018d1c548a6f Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Sun, 10 May 2026 03:41:32 +0200 Subject: [PATCH] feat(portal-bff): audit log foundation per ADR-0013 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lays down the append-only audit log: schema, migration with role grants, NestJS AuditWriter service. Typed event-family methods, separate AUDIT_DATABASE_URL pool, retention job, and live-DB integration tests are explicitly listed as "wired as features land" in ADR-0013 §Confirmation. Schema (apps/portal-bff/prisma/schema.prisma): - multiSchema preview enabled; datasource declares public + audit schemas. - AuditEvent model with: id (uuid), createdAt, eventType (free-form in v1, will formalise into a catalogue once we have N stable types), audience (workforce|customer enum), actorIdHash, traceId, subject, outcome (success|failure|denied enum), payload (jsonb). - Indexes on createdAt, eventType, traceId — covering the obvious query shapes (date-range scan, by event family, by trace for log-audit correlation). Migration (prisma/migrations/*_init_audit_schema/migration.sql): - CREATE TABLE / enums via Prisma's standard output. - Append-only contract re-applied explicitly: ALTER TABLE / TYPE OWNER TO audit_owner, then GRANT INSERT to audit_writer, SELECT to audit_reader, SELECT+DELETE to audit_archiver. SELECT is required for archiver because Postgres needs SELECT on every column referenced in DELETE's WHERE clause to evaluate "older than retention" — granted explicitly in this PR rather than silently failing later. - USAGE on the enum types granted to all three roles so audit_ writer's INSERT does not fail with "permission denied for type". - No GRANT for UPDATE / TRUNCATE to anyone, including audit_owner at runtime — only fresh schema migrations amend the table. Service (apps/portal-bff/src/audit/): - AuditWriter.recordEvent(input) — single entry point. Wraps every INSERT in a transaction whose first statement is `SET LOCAL ROLE audit_writer`, so the role contract holds at runtime even from the otherwise-privileged BFF connection. SET LOCAL is reset on COMMIT/ROLLBACK so the pool's next consumer sees the original role. - traceId auto-resolved from the active OTel span context. - actorIdHash auto-resolved from CLS (key 'actorIdHash') with explicit input-side override; null when neither is set (placeholder until ADR-0009 / ADR-0010 guards populate CLS). - Errors propagate (no catch-and-swallow), per ADR-0013's "blocking writes: no audit ⇒ no action". - AuditModule exposes the writer; wired into AppModule so any future feature module can inject it. Tests (apps/portal-bff/src/audit/audit.service.spec.ts, 8 cases): - Locks the transaction to audit_writer before INSERTing. - Passes input fields through. - Records Prisma.JsonNull when no payload is provided. - Reads actorIdHash from CLS when not passed. - Prefers explicit actorIdHash over CLS-resolved. - Stores actorIdHash = null when neither input nor CLS has one. - Captures the active OTel trace id. - Stores traceId = null when no span is active. - Propagates the underlying error (no swallow). End-to-end smoke against local-dev Postgres (manual, via psql): - INSERT under audit_writer: ok. - UPDATE under audit_writer: "permission denied for table events". - DELETE under audit_writer: "permission denied for table events". - DELETE under audit_archiver (after the SELECT grant fix): ok, row removed. ADR-0013 §Confirmation rewritten as "wired in foundation PR" / "wired as features land", with the latter listing the typed event-family methods, AUDIT_DATABASE_URL split, startup self-test probe, retention purge job, salt-shared cross-correlation test, and live-DB role-contract integration tests. --- .../migration.sql | 67 +++++++ .../prisma/migrations/migration_lock.toml | 3 + apps/portal-bff/prisma/schema.prisma | 81 +++++++- apps/portal-bff/src/app/app.module.ts | 8 +- apps/portal-bff/src/audit/audit.module.ts | 15 ++ .../src/audit/audit.service.spec.ts | 174 ++++++++++++++++++ apps/portal-bff/src/audit/audit.service.ts | 76 ++++++++ apps/portal-bff/src/audit/audit.types.ts | 43 +++++ ...it-trail-separated-postgres-append-only.md | 27 ++- 9 files changed, 480 insertions(+), 14 deletions(-) create mode 100644 apps/portal-bff/prisma/migrations/20260510011453_init_audit_schema/migration.sql create mode 100644 apps/portal-bff/prisma/migrations/migration_lock.toml create mode 100644 apps/portal-bff/src/audit/audit.module.ts create mode 100644 apps/portal-bff/src/audit/audit.service.spec.ts create mode 100644 apps/portal-bff/src/audit/audit.service.ts create mode 100644 apps/portal-bff/src/audit/audit.types.ts diff --git a/apps/portal-bff/prisma/migrations/20260510011453_init_audit_schema/migration.sql b/apps/portal-bff/prisma/migrations/20260510011453_init_audit_schema/migration.sql new file mode 100644 index 0000000..3d92c46 --- /dev/null +++ b/apps/portal-bff/prisma/migrations/20260510011453_init_audit_schema/migration.sql @@ -0,0 +1,67 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "audit"; + +-- CreateEnum +CREATE TYPE "audit"."AuditAudience" AS ENUM ('workforce', 'customer'); + +-- CreateEnum +CREATE TYPE "audit"."AuditOutcome" AS ENUM ('success', 'failure', 'denied'); + +-- CreateTable +CREATE TABLE "audit"."events" ( + "id" UUID NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "event_type" TEXT NOT NULL, + "audience" "audit"."AuditAudience" NOT NULL, + "actor_id_hash" TEXT, + "trace_id" TEXT, + "subject" TEXT, + "outcome" "audit"."AuditOutcome" NOT NULL, + "payload" JSONB, + + CONSTRAINT "events_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "events_created_at_idx" ON "audit"."events"("created_at"); + +-- CreateIndex +CREATE INDEX "events_event_type_idx" ON "audit"."events"("event_type"); + +-- CreateIndex +CREATE INDEX "events_trace_id_idx" ON "audit"."events"("trace_id"); + +-- ============================================================ +-- Append-only contract per ADR-0013 +-- ============================================================ +-- The schema-level DEFAULT PRIVILEGES set in +-- infra/local/init/postgres/01-init.sql only fire when audit_owner +-- creates the object. This migration runs as a privileged migrator +-- (the same login that owns the public schema), so the table is +-- initially owned by that user and audit_writer/reader/archiver +-- have no grants. Re-apply the owner transfer + grants explicitly +-- so the runtime role contract holds. + +ALTER TABLE "audit"."events" OWNER TO audit_owner; +ALTER TYPE "audit"."AuditAudience" OWNER TO audit_owner; +ALTER TYPE "audit"."AuditOutcome" OWNER TO audit_owner; + +GRANT INSERT ON "audit"."events" TO audit_writer; +GRANT SELECT ON "audit"."events" TO audit_reader; +-- audit_archiver needs both DELETE *and* SELECT: per ADR-0013 it +-- removes rows "older than retention", which means evaluating a +-- WHERE clause on `created_at`. Postgres requires SELECT on every +-- column referenced in DELETE's WHERE, even when the row count is +-- the only thing returned. SELECT here does NOT widen the contract +-- — UPDATE / TRUNCATE remain ungranted to every role. +GRANT SELECT, DELETE ON "audit"."events" TO audit_archiver; + +-- USAGE on the enum types is needed by every role that touches the +-- column. Without it, audit_writer's INSERT fails with "permission +-- denied for type audit.AuditAudience". +GRANT USAGE ON TYPE "audit"."AuditAudience" TO audit_writer, audit_reader, audit_archiver; +GRANT USAGE ON TYPE "audit"."AuditOutcome" TO audit_writer, audit_reader, audit_archiver; + +-- No GRANT for UPDATE / TRUNCATE to anyone, including audit_owner +-- at runtime — schema migrations alone amend the table going +-- forward. diff --git a/apps/portal-bff/prisma/migrations/migration_lock.toml b/apps/portal-bff/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/apps/portal-bff/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/apps/portal-bff/prisma/schema.prisma b/apps/portal-bff/prisma/schema.prisma index d7d0675..fb52710 100644 --- a/apps/portal-bff/prisma/schema.prisma +++ b/apps/portal-bff/prisma/schema.prisma @@ -1,13 +1,84 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - -// Get a free hosted Postgres database in seconds: `npx create-db` +// Prisma schema for portal-bff. +// +// `multiSchema` preview is enabled because per ADR-0013 the audit log +// lives in its own `audit` schema with role-based append-only access +// (audit_owner / audit_writer / audit_reader / audit_archiver). The +// public schema holds the regular business data; only audit.events +// lives in audit. generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" + previewFeatures = ["multiSchema"] } datasource db { provider = "postgresql" url = env("DATABASE_URL") + schemas = ["public", "audit"] +} + +// ============================================================ +// Audit log (per ADR-0013) +// ============================================================ +// +// Append-only by Postgres role grants — the schema, roles, and +// default privileges are provisioned by infra/local/init/postgres/ +// 01-init.sql in dev and the equivalent production manifest. The +// migration that creates this table re-applies the grants +// explicitly and ALTERs the table owner to `audit_owner` so the +// runtime role contract holds even when the migration runs as a +// privileged migrator account. +// +// At runtime, the BFF wraps every INSERT into this table in a +// transaction that begins with `SET LOCAL ROLE audit_writer`, so +// even a compromised BFF connection cannot UPDATE / TRUNCATE / +// DELETE — those grants are not on `audit_writer`. + +enum AuditAudience { + workforce + customer + + @@schema("audit") +} + +enum AuditOutcome { + success + failure + denied + + @@schema("audit") +} + +model AuditEvent { + id String @id @default(uuid()) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + eventType String @map("event_type") + audience AuditAudience + // Salted hash (LOG_USER_ID_SALT) of the actor's stable id. NULL + // when the actor is unauthenticated (e.g. failed login attempt + // before resolving an identity). The same salt is used by the + // BFF Pino logger so audit and app logs cross-correlate on this + // field. + actorIdHash String? @map("actor_id_hash") + // W3C trace id (32 hex chars) of the request that produced the + // event. Cross-correlates with traces in Jaeger and with Pino + // log lines that carry the same `trace_id` field. NULL only if + // the event was emitted outside any inbound request (e.g. a + // future cron job). + traceId String? @map("trace_id") + // Free-form identifier of what the event is *about* — typically + // a domain entity URI like `user:42` or `dossier:xyz`. NULL when + // the event is system-wide and has no clear subject. + subject String? + outcome AuditOutcome + // Event-specific structured detail. Redaction of PII is the + // caller's responsibility; the BFF Pino redact list is the + // reference allow-/deny-list. + payload Json? + + @@map("events") + @@schema("audit") + @@index([createdAt]) + @@index([eventType]) + @@index([traceId]) } diff --git a/apps/portal-bff/src/app/app.module.ts b/apps/portal-bff/src/app/app.module.ts index 917bbba..c5e86fe 100644 --- a/apps/portal-bff/src/app/app.module.ts +++ b/apps/portal-bff/src/app/app.module.ts @@ -4,9 +4,15 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { ObservabilityModule } from '../observability/observability.module'; import { HealthModule } from '../health/health.module'; +import { AuditModule } from '../audit/audit.module'; @Module({ - imports: [ObservabilityModule, PrismaModule.forRoot({ isGlobal: true }), HealthModule], + imports: [ + ObservabilityModule, + PrismaModule.forRoot({ isGlobal: true }), + AuditModule, + HealthModule, + ], controllers: [AppController], providers: [AppService], }) diff --git a/apps/portal-bff/src/audit/audit.module.ts b/apps/portal-bff/src/audit/audit.module.ts new file mode 100644 index 0000000..4653b54 --- /dev/null +++ b/apps/portal-bff/src/audit/audit.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { AuditWriter } from './audit.service'; + +/** + * Provides the AuditWriter to the rest of the BFF. Imported globally + * by AppModule so any feature module can inject it without an extra + * import. The actual append-only contract is enforced by the + * Postgres role grants set up in the audit-schema migration — see + * apps/portal-bff/prisma/migrations/*_init_audit_schema. + */ +@Module({ + providers: [AuditWriter], + exports: [AuditWriter], +}) +export class AuditModule {} diff --git a/apps/portal-bff/src/audit/audit.service.spec.ts b/apps/portal-bff/src/audit/audit.service.spec.ts new file mode 100644 index 0000000..c861395 --- /dev/null +++ b/apps/portal-bff/src/audit/audit.service.spec.ts @@ -0,0 +1,174 @@ +import { Test } from '@nestjs/testing'; +import { trace } from '@opentelemetry/api'; +import { ClsService } from 'nestjs-cls'; +import { PrismaService } from 'nestjs-prisma'; +import { Prisma } from '@prisma/client'; +import { AuditWriter } from './audit.service'; +import type { AuditEventInput } from './audit.types'; + +interface AuditEventCreateCall { + data: { + eventType: string; + audience: 'workforce' | 'customer'; + outcome: 'success' | 'failure' | 'denied'; + subject: string | null; + actorIdHash: string | null; + traceId: string | null; + payload: Prisma.InputJsonValue | typeof Prisma.JsonNull; + }; +} + +interface MockTx { + $executeRawUnsafe: jest.Mock; + auditEvent: { create: jest.Mock }; +} + +interface MockPrisma { + $transaction: jest.Mock; + tx: MockTx; +} + +function buildMocks(): { prisma: MockPrisma; cls: { get: jest.Mock } } { + const tx: MockTx = { + $executeRawUnsafe: jest.fn().mockResolvedValue(0), + auditEvent: { create: jest.fn().mockResolvedValue(undefined) }, + }; + const prisma: MockPrisma = { + tx, + $transaction: jest.fn(async (fn: (tx: MockTx) => Promise) => fn(tx)), + }; + const cls = { get: jest.fn() }; + return { prisma, cls }; +} + +async function createSubject(): Promise<{ + writer: AuditWriter; + prisma: MockPrisma; + cls: { get: jest.Mock }; +}> { + const { prisma, cls } = buildMocks(); + const moduleRef = await Test.createTestingModule({ + providers: [ + AuditWriter, + { provide: PrismaService, useValue: prisma }, + { provide: ClsService, useValue: cls }, + ], + }).compile(); + + return { writer: moduleRef.get(AuditWriter), prisma, cls }; +} + +const baseInput: AuditEventInput = { + eventType: 'auth.login', + audience: 'workforce', + outcome: 'success', + subject: 'user:42', +}; + +describe('AuditWriter', () => { + it('locks the transaction to audit_writer before INSERTing', async () => { + const { writer, prisma } = await createSubject(); + await writer.recordEvent(baseInput); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(prisma.tx.$executeRawUnsafe).toHaveBeenCalledWith('SET LOCAL ROLE audit_writer'); + // SET ROLE must precede the INSERT, otherwise the runtime + // privilege check happens with the wrong role. + const setRoleOrder = prisma.tx.$executeRawUnsafe.mock.invocationCallOrder[0]; + const createOrder = prisma.tx.auditEvent.create.mock.invocationCallOrder[0]; + expect(setRoleOrder).toBeLessThan(createOrder); + }); + + it('passes the input fields through to the create call', async () => { + const { writer, prisma } = await createSubject(); + await writer.recordEvent({ + ...baseInput, + payload: { route: '/auth/login', clientId: 'spa' }, + }); + + const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall; + expect(call.data.eventType).toBe('auth.login'); + expect(call.data.audience).toBe('workforce'); + expect(call.data.outcome).toBe('success'); + expect(call.data.subject).toBe('user:42'); + expect(call.data.payload).toEqual({ route: '/auth/login', clientId: 'spa' }); + }); + + it('records Prisma.JsonNull when no payload is provided', async () => { + const { writer, prisma } = await createSubject(); + await writer.recordEvent(baseInput); + + const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall; + expect(call.data.payload).toBe(Prisma.JsonNull); + }); + + it('reads actorIdHash from CLS when not passed explicitly', async () => { + const { writer, prisma, cls } = await createSubject(); + cls.get.mockReturnValue('hash-from-cls'); + + await writer.recordEvent(baseInput); + + expect(cls.get).toHaveBeenCalledWith('actorIdHash'); + const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall; + expect(call.data.actorIdHash).toBe('hash-from-cls'); + }); + + it('prefers an explicit actorIdHash over the CLS-resolved one', async () => { + const { writer, prisma, cls } = await createSubject(); + cls.get.mockReturnValue('hash-from-cls'); + + await writer.recordEvent({ ...baseInput, actorIdHash: 'hash-from-input' }); + + const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall; + expect(call.data.actorIdHash).toBe('hash-from-input'); + }); + + it('stores actorIdHash = null when neither input nor CLS has one', async () => { + const { writer, prisma, cls } = await createSubject(); + cls.get.mockReturnValue(undefined); + + await writer.recordEvent(baseInput); + + const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall; + expect(call.data.actorIdHash).toBeNull(); + }); + + it('captures the active OTel trace id', async () => { + const fakeSpan = { spanContext: () => ({ traceId: 'abc123', spanId: 'def', traceFlags: 0 }) }; + const getActiveSpanSpy = jest.spyOn(trace, 'getActiveSpan').mockReturnValue(fakeSpan as never); + + try { + const { writer, prisma } = await createSubject(); + await writer.recordEvent(baseInput); + + const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall; + expect(call.data.traceId).toBe('abc123'); + } finally { + getActiveSpanSpy.mockRestore(); + } + }); + + it('stores traceId = null when no span is active', async () => { + const getActiveSpanSpy = jest.spyOn(trace, 'getActiveSpan').mockReturnValue(undefined); + + try { + const { writer, prisma } = await createSubject(); + await writer.recordEvent(baseInput); + + const call = prisma.tx.auditEvent.create.mock.calls[0]?.[0] as AuditEventCreateCall; + expect(call.data.traceId).toBeNull(); + } finally { + getActiveSpanSpy.mockRestore(); + } + }); + + it('propagates the underlying error — no catch-and-swallow', async () => { + const { writer, prisma } = await createSubject(); + const dbError = new Error('permission denied for table events'); + prisma.tx.auditEvent.create.mockRejectedValueOnce(dbError); + + await expect(writer.recordEvent(baseInput)).rejects.toThrow( + 'permission denied for table events', + ); + }); +}); diff --git a/apps/portal-bff/src/audit/audit.service.ts b/apps/portal-bff/src/audit/audit.service.ts new file mode 100644 index 0000000..aeb5762 --- /dev/null +++ b/apps/portal-bff/src/audit/audit.service.ts @@ -0,0 +1,76 @@ +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; + } +} diff --git a/apps/portal-bff/src/audit/audit.types.ts b/apps/portal-bff/src/audit/audit.types.ts new file mode 100644 index 0000000..bc2302a --- /dev/null +++ b/apps/portal-bff/src/audit/audit.types.ts @@ -0,0 +1,43 @@ +/** + * Public type surface for the audit module — kept narrow so the + * generated Prisma types do not leak through the rest of the BFF. + */ + +export type AuditAudience = 'workforce' | 'customer'; +export type AuditOutcome = 'success' | 'failure' | 'denied'; + +export interface AuditEventInput { + /** + * Free-form categorical identifier. Convention: lower-case dotted + * verb-on-noun, e.g. `auth.login`, `dossier.update`. We keep it + * free-form in v1; the catalogue is formalised when we have N + * stable event types in production. + */ + eventType: string; + + audience: AuditAudience; + outcome: AuditOutcome; + + /** + * What the event is about — typically a domain entity URI like + * `user:42` or `dossier:xyz`. Optional when the event is + * system-wide and has no clear subject. + */ + subject?: string; + + /** + * Event-specific structured detail. **Caller-redacted PII** — + * the audit module does not run a redact pass; it is the writer's + * responsibility. Mirror of the Pino redact list in scope. + */ + payload?: Record; + + /** + * Override of the auto-resolved actor id hash. Normally left + * undefined — the service reads from the CLS context (set by + * future auth interceptors per ADR-0009 / ADR-0010). Pass this + * only when emitting events from a context where CLS is not + * populated (background jobs, future cron). + */ + actorIdHash?: string; +} diff --git a/docs/decisions/0013-audit-trail-separated-postgres-append-only.md b/docs/decisions/0013-audit-trail-separated-postgres-append-only.md index 5f68fc5..094f682 100644 --- a/docs/decisions/0013-audit-trail-separated-postgres-append-only.md +++ b/docs/decisions/0013-audit-trail-separated-postgres-append-only.md @@ -156,14 +156,25 @@ Hooks for **admin actions** and **sensitive data access** are designed-in: the w ### Confirmation -- `apps/portal-bff/prisma/schema.prisma` enables `multiSchema` and declares the `audit` schema; the `AuditEvent` model and `AuditOutcome` enum live in it. -- Three Postgres roles exist: `audit_writer` (INSERT only on `audit.events`), `audit_reader` (SELECT only), `audit_archiver` (DELETE only, on rows older than the configured retention). The schema migration includes the explicit `REVOKE UPDATE, DELETE, TRUNCATE FROM PUBLIC` on `audit.events` and the targeted grants. -- `apps/portal-bff/src/audit/audit.module.ts` provides an `AuditService` with one typed method per event family (`signIn`, `signInFailed`, `signOut`, `sessionExpired`, `sessionRevoked`, `tokenValidationFailed`, `mfaAssertionFailed`, `authzDeny`, plus dormant `adminAction` and `sensitiveDataAccess`). -- `AuditService` connects via `AUDIT_DATABASE_URL` as `audit_writer`; a startup probe asserts that the role can `INSERT` and cannot `UPDATE` (a deliberate failing UPDATE during boot is rejected, the BFF starts; if it succeeds, the BFF refuses to start). -- The auth controller, token-validation interceptor, MFA guard, sessions module, and authorization guards each call `AuditService` on the relevant outcomes. Tests assert one audit row per event. -- The retention purge runs daily; failure raises an alert and emits `audit.retention.purge` with `outcome = failure`. -- The same `LOG_USER_ID_SALT` is used by app logs and by audit; an integration test asserts that the same user produces the same `actor_id_hash` in both streams. -- `AUDIT_RETENTION_DAYS < 30` and missing required env vars prevent BFF startup. +**Wired in the foundation PR:** + +- `apps/portal-bff/prisma/schema.prisma` enables the `multiSchema` preview, declares the `audit` schema alongside `public`, and carries the `AuditEvent` model with `AuditAudience` (`workforce | customer`) and `AuditOutcome` (`success | failure | denied`) enums. +- The migration `prisma/migrations/*_init_audit_schema/migration.sql` creates `audit.events`, `ALTER`s table + enum types to be owned by `audit_owner`, and re-applies the role grants explicitly: `INSERT` to `audit_writer`, `SELECT` to `audit_reader`, `SELECT, DELETE` to `audit_archiver` (SELECT is needed for archiver to evaluate the `created_at` predicate of "delete older than retention"). No grant of `UPDATE` or `TRUNCATE` to anyone — including the migrator's own login at runtime; only fresh schema migrations amend the table. +- The roles themselves and the schema with default privileges are provisioned earlier by `infra/local/init/postgres/01-init.sql` (dev) — production replicates the same SQL via the future on-prem infrastructure ADR. +- `apps/portal-bff/src/audit/audit.service.ts` exposes a single `AuditWriter.recordEvent(input)` method. Every write runs in a transaction whose first statement is `SET LOCAL ROLE audit_writer`, so the runtime contract holds even if the BFF connection is otherwise privileged. `trace_id` is auto-resolved from the active OTel span; `actor_id_hash` is read from CLS or accepted as an explicit override (placeholder until ADR-0009 / ADR-0010 land their guards). Failures propagate — no catch-and-swallow, per "blocking writes: no audit ⇒ no action". +- BFF connects via the shared `DATABASE_URL` (the role switch is per-transaction). A separate `AUDIT_DATABASE_URL` connection pool is the production hardening, deferred — see "wired as features land" below. +- Smoke-tested end to end against the local-dev Postgres: `audit_writer` INSERTs successfully, fails on `UPDATE` and `DELETE`; `audit_archiver` SELECTs + DELETEs successfully. + +**Wired as the corresponding features land:** + +- One typed method per event family on `AuditWriter` — `signIn`, `signInFailed`, `signOut`, `sessionExpired`, `sessionRevoked`, `tokenValidationFailed`, `mfaAssertionFailed`, `authzDeny`, `adminAction`, `sensitiveDataAccess` — added as the matching feature ships (ADR-0009 / ADR-0010 / ADR-0011 / future authz). v1 keeps the surface narrow with the single `recordEvent` so events can be emitted today (e.g. by the auth flow once it lands) while the typed catalogue accretes on a real basis. +- `AUDIT_DATABASE_URL` separate connection (a distinct pool with `audit_writer`-only login credentials) — defense-in-depth that locks down what an at-runtime SQL injection can do further. v1 mitigates with `SET LOCAL ROLE` at the cost of sharing the pool with public-schema reads/writes; v2 splits. +- Startup self-test probe — a deliberate failing `UPDATE` against `audit.events` during boot, asserting it is rejected; if it succeeds, the BFF refuses to start. Lands with the connection split above. +- Retention purge job invoking the `audit_archiver` role daily via cron, emitting `audit.retention.purge` with `outcome = failure` on error. Operational concern — phase-3b infra ADR. +- Auth controller, MFA guard, sessions module, and authorization guards each call `AuditWriter` on the relevant outcomes. Tests assert one audit row per event. +- Integration test verifying that the same user produces the same `actor_id_hash` in both Pino logs and audit rows (same `LOG_USER_ID_SALT`, same hashing path) — wired with the auth + LOG_USER_ID_SALT enforcement. +- `AUDIT_RETENTION_DAYS < 30` and missing required env vars prevent BFF startup — wired with the retention job. +- Live-DB integration tests asserting the role contract (UPDATE/TRUNCATE rejected at runtime) — Testcontainers harness, separate PR. ## Pros and Cons of the Options -- 2.30.2