feat(portal-bff): audit log foundation per ADR-0013 #76

Merged
julien merged 1 commits from feat/portal-bff/audit-module into main 2026-05-10 03:44:02 +02:00

1 Commits

Author SHA1 Message Date
Julien Gautier 3a44a1deed 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.
2026-05-10 03:41:32 +02:00