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
Owner

Summary

Lays down the append-only audit log per ADR-0013: schema declaration, first migration with role grants, NestJS AuditWriter service. Typed event-family methods, the separate AUDIT_DATABASE_URL pool, the retention job, and the live-DB integration tests are explicitly listed as "wired as features land" in the ADR's confirmation block — they ship when the matching feature ADRs do.

What lands

Prisma schema (apps/portal-bff/prisma/schema.prisma):

  • multiSchema preview enabled; datasource declares public + audit schemas.
  • AuditEvent model: id (uuid), createdAt, eventType (free-form in v1), audience enum (workforce | customer), actorIdHash, traceId, subject, outcome enum (success | failure | denied), payload (jsonb).
  • Indexes on createdAt, eventType, traceId — covering the three obvious query shapes.

Migration (prisma/migrations/*_init_audit_schema/migration.sql):

  • Standard Prisma CREATE TABLE / enums output, then the append-only contract re-applied explicitly:
    • ALTER TABLE/TYPE OWNER TO audit_owner.
    • GRANT INSERT to audit_writer, SELECT to audit_reader, SELECT, DELETE to audit_archiver (SELECT is needed to evaluate the created_at predicate of "delete older than retention" — Postgres requires SELECT on every column referenced in DELETE's WHERE).
    • GRANT USAGE on the enum types to all three roles (without it audit_writer.INSERT fails 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.
  • traceId auto-resolved from the active OTel span (so audit row joins with traces and Pino logs on the same trace_id).
  • 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".

Tests — 8 unit tests on AuditWriter (mocked Prisma + CLS): role-locking ordering, input pass-through, Prisma.JsonNull for missing payload, CLS-vs-input precedence on actorIdHash, OTel trace capture, error propagation.

End-to-end verification (manual, against local-dev Postgres)

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: ok, row removed (after the SELECT-grant fix)

ADR-0013 §Confirmation rewritten

Two-block split: "wired in foundation PR" lists what landed here; "wired as features land" lists the typed event-family methods, AUDIT_DATABASE_URL connection split, startup self-test probe, retention purge job, salt-shared cross-correlation test, and live-DB role-contract integration tests — each anchored to the feature ADR that triggers it.

Recovery for anyone with a pre-existing local-dev DB

If your local-dev Postgres already had the audit migration applied before the SELECT-grant fix, the archiver's DELETE will fail. Two options:

  1. Apply the missing grant directly:
    psql "$DATABASE_URL" -c "GRANT SELECT ON audit.events TO audit_archiver;"
    
  2. Or wipe the volume and re-migrate cleanly:
    ./infra/local/dev.sh down -v
    ./infra/local/dev.sh up
    pnpm --filter @apf-portal/source exec prisma migrate deploy   # or `cd apps/portal-bff && pnpm exec prisma migrate deploy`
    

Fresh DBs land with the corrected migration directly.

Out of scope (separate PRs)

  • Typed event-family methods (signIn, signInFailed, …) — added per matching feature ADR.
  • AUDIT_DATABASE_URL separate connection pool — defense-in-depth, when production needs it.
  • Startup self-test probe (deliberate failing UPDATE asserting rejection) — lands with the connection split.
  • Retention purge job (audit_archiver daily cron) — phase-3b infra.
  • Live-DB integration tests asserting the role contract — Testcontainers-style harness, separate PR.

Test plan

  • CI green on this PR.
  • prisma migrate deploy succeeds on a fresh DB (the recovery instructions cover the SELECT-grant gap for already-migrated dev DBs).
  • psql -c "\dp audit.events" shows the expected privilege matrix: audit_owner=arwdDxtm/audit_owner, audit_writer=a/audit_owner, audit_reader=r/audit_owner, audit_archiver=rd/audit_owner.
  • BFF boots; calling AuditWriter.recordEvent from a controller (manual smoke once a real flow lands) writes to audit.events with the expected trace_id matching the request's Jaeger span.
## Summary Lays down the append-only audit log per ADR-0013: schema declaration, first migration with role grants, NestJS `AuditWriter` service. Typed event-family methods, the separate `AUDIT_DATABASE_URL` pool, the retention job, and the live-DB integration tests are explicitly listed as "wired as features land" in the ADR's confirmation block — they ship when the matching feature ADRs do. ## What lands **Prisma schema** ([`apps/portal-bff/prisma/schema.prisma`](apps/portal-bff/prisma/schema.prisma)): - `multiSchema` preview enabled; datasource declares `public` + `audit` schemas. - `AuditEvent` model: `id` (uuid), `createdAt`, `eventType` (free-form in v1), `audience` enum (`workforce | customer`), `actorIdHash`, `traceId`, `subject`, `outcome` enum (`success | failure | denied`), `payload` (jsonb). - Indexes on `createdAt`, `eventType`, `traceId` — covering the three obvious query shapes. **Migration** ([`prisma/migrations/*_init_audit_schema/migration.sql`](apps/portal-bff/prisma/migrations/20260510011453_init_audit_schema/migration.sql)): - Standard Prisma `CREATE TABLE` / enums output, then the **append-only contract** re-applied explicitly: - `ALTER TABLE/TYPE OWNER TO audit_owner`. - `GRANT INSERT` to `audit_writer`, `SELECT` to `audit_reader`, **`SELECT, DELETE`** to `audit_archiver` (SELECT is needed to evaluate the `created_at` predicate of "delete older than retention" — Postgres requires SELECT on every column referenced in DELETE's WHERE). - `GRANT USAGE` on the enum types to all three roles (without it `audit_writer.INSERT` fails 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/`](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. - `traceId` auto-resolved from the active OTel span (so audit row joins with traces and Pino logs on the same `trace_id`). - `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". **Tests** — 8 unit tests on `AuditWriter` (mocked Prisma + CLS): role-locking ordering, input pass-through, `Prisma.JsonNull` for missing payload, CLS-vs-input precedence on `actorIdHash`, OTel trace capture, error propagation. ## End-to-end verification (manual, against local-dev Postgres) ``` 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: ok, row removed (after the SELECT-grant fix) ``` ## ADR-0013 §Confirmation rewritten Two-block split: "wired in foundation PR" lists what landed here; "wired as features land" lists the typed event-family methods, AUDIT_DATABASE_URL connection split, startup self-test probe, retention purge job, salt-shared cross-correlation test, and live-DB role-contract integration tests — each anchored to the feature ADR that triggers it. ## Recovery for anyone with a pre-existing local-dev DB If your local-dev Postgres already had the audit migration applied **before** the SELECT-grant fix, the archiver's DELETE will fail. Two options: 1. Apply the missing grant directly: ```bash psql "$DATABASE_URL" -c "GRANT SELECT ON audit.events TO audit_archiver;" ``` 2. Or wipe the volume and re-migrate cleanly: ```bash ./infra/local/dev.sh down -v ./infra/local/dev.sh up pnpm --filter @apf-portal/source exec prisma migrate deploy # or `cd apps/portal-bff && pnpm exec prisma migrate deploy` ``` Fresh DBs land with the corrected migration directly. ## Out of scope (separate PRs) - Typed event-family methods (`signIn`, `signInFailed`, …) — added per matching feature ADR. - `AUDIT_DATABASE_URL` separate connection pool — defense-in-depth, when production needs it. - Startup self-test probe (deliberate failing UPDATE asserting rejection) — lands with the connection split. - Retention purge job (`audit_archiver` daily cron) — phase-3b infra. - Live-DB integration tests asserting the role contract — Testcontainers-style harness, separate PR. ## Test plan - [ ] CI green on this PR. - [ ] `prisma migrate deploy` succeeds on a fresh DB (the recovery instructions cover the SELECT-grant gap for already-migrated dev DBs). - [ ] `psql -c "\dp audit.events"` shows the expected privilege matrix: `audit_owner=arwdDxtm/audit_owner`, `audit_writer=a/audit_owner`, `audit_reader=r/audit_owner`, `audit_archiver=rd/audit_owner`. - [ ] BFF boots; calling `AuditWriter.recordEvent` from a controller (manual smoke once a real flow lands) writes to `audit.events` with the expected `trace_id` matching the request's Jaeger span.
julien added 1 commit 2026-05-10 03:43:13 +02:00
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
3a44a1deed
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.
julien merged commit 02ac44e498 into main 2026-05-10 03:44:02 +02:00
julien deleted branch feat/portal-bff/audit-module 2026-05-10 03:44:05 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#76