feat(portal-bff): audit log foundation per ADR-0013 (#76)
CI / check (push) Successful in 1m52s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m7s
CI / a11y (push) Successful in 52s
CI / perf (push) Successful in 2m17s

## 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.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #76
This commit was merged in pull request #76.
This commit is contained in:
2026-05-10 03:44:01 +02:00
parent e2dd2e4dd8
commit 02ac44e498
9 changed files with 480 additions and 14 deletions
@@ -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.
@@ -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"