feat(portal-bff): audit log foundation per ADR-0013 (#76)
## 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:
@@ -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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user