feat(portal-bff): audit log foundation per ADR-0013
CI / commits (pull_request) Successful in 1m27s
CI / check (pull_request) Successful in 1m37s
CI / scan (pull_request) Successful in 1m37s
CI / a11y (pull_request) Successful in 1m42s
CI / perf (pull_request) Successful in 3m19s

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.
This commit is contained in:
Julien Gautier
2026-05-10 03:41:32 +02:00
parent e2dd2e4dd8
commit 3a44a1deed
9 changed files with 480 additions and 14 deletions
+76 -5
View File
@@ -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])
}