feat(portal-bff): wire ADR-0013 audit pipeline to the auth lifecycle #120

Merged
julien merged 1 commits from feat/portal-bff/audit-auth-lifecycle-events into main 2026-05-13 14:21:43 +02:00
Owner

Summary

Wires the audit pipeline (ADR-0013) to the auth lifecycle. The foundation was already in place (Prisma AuditEvent model, Postgres roles + grants, AuditWriter.recordEvent with SET LOCAL ROLE audit_writer); this PR layers a typed event surface and emits the first four events on real code paths.

What lands

  • Typed methods on AuditWriter: signIn, signInFailed, signOut, sessionExpired. Callers pass the raw Entra oid; hashing happens inside the writer so the salt never leaves the audit module. ADR-0013 explicitly defers adding these typed methods "as the matching feature ships" — auth has shipped, so we add the four events tied to code paths that exist today.
  • HashUserIdService — reads LOG_USER_ID_SALT once at injection, exposes hash(userId) → 16-hex-char digest used by both audit_events.actor_id_hash (ADR-0013) and the future Pino user_id_hash (ADR-0012). Same salt + same input ⇒ same output ⇒ join key between the two streams.
  • LOG_USER_ID_SALT env var promoted from the "future vars" block in .env.example to the active section, with the same boot-time validator pattern as SESSION_SECRET / SESSION_ENCRYPTION_KEY: mandatory, base64url, ≥ 32 bytes decoded, placeholder rejected. Wired in main.ts.
  • AuditModule is now @Global() and also provides HashUserIdService. The previous in-line comment said "imported globally by AppModule" but the decorator was missing — without it, AuthController and the absolute-timeout middleware couldn't inject AuditWriter without re-importing AuditModule.
  • Emission points:
    • /auth/callback happy path → auth.sign_in after session.save() (blocking per ADR-0013 §"Blocking writes": a failed audit fails the sign-in).
    • /auth/callback failure paths → auth.sign_in.failed with a discriminator failureKind (entra-error, missing-code-or-state, no-pre-auth-cookie, or any of the AuthCodeFlowError kinds — state-mismatch, flow-expired, token-exchange-failed).
    • /auth/logout (authenticated only) → auth.sign_out before session.destroy() — once destroy runs we lose the actor id.
    • Absolute-timeout middleware → auth.session.expired with reason: 'absolute' and ageMs for forensic granularity.

Out of scope (next PRs)

  • The other four v1 events from ADR-0013's catalogue (auth.session.revoked, auth.token.validation.failed, auth.mfa.assertion.failed, authz.deny) — no triggering code path exists today. They land with the admin "logout everywhere" route, downstream API access (ADR-0014), and the eventual @RequireMfa() / @RequireAdmin guards.
  • Idle-timeout expiry is intentionally silent — Redis lets the key disappear with no BFF observation point. Per ADR-0010.
  • Separate AUDIT_DATABASE_URL connection pool with audit_writer-only credentials — ADR-0013 marks it as the production hardening step, deferred behind SET LOCAL ROLE in v1.
  • Retention purge job + startup self-test probe — deferred to the on-prem infrastructure ADR per ADR-0013.

Notable choices

  • No CLS-populating middleware. ADR-0013 anticipates an interceptor that puts actorIdHash on the request CLS so AuditWriter.recordEvent can pick it up automatically. For the four call sites in this PR, every emission path already has the user object in hand, so we pass actorIdHash explicitly via the typed methods and skip the middleware. It can land later when more routes need it.
  • Blocking on the happy path = strict ADR posture. audit.signIn is awaited before the 302; a Postgres outage makes the sign-in fail (5xx) rather than silently producing an un-audited session. That's "no audit ⇒ no action" applied to authentication itself. Matches ADR-0013 §"Blocking writes" verbatim.
  • signInFailed skips the actor hash by default. Most failure paths reject before any claim is parsed (state mismatch, expired flow). The interface accepts an optional actor for the rare identity-after-rejection case (future MFA assertion failure, etc.).

Test plan

  • pnpm nx test portal-bff (clean env) → 142/142 pass (was 123; +19 new specs across check-log-user-id-salt, hash-user-id.service, audit.service typed-methods, auth.controller, absolute-timeout.middleware).
  • pnpm nx lint portal-bff → clean.
  • pnpm nx build portal-bff → clean.
  • CI clean-env repro (lesson from #115/#116/#117): every env var unset → tests still 142/142. The two module specs that previously sat on the boundary (auth.module, session.module) now bootstrap their own @Global() stub providers for PrismaService + ClsService so AuditWriter's transitive resolution works without booting Prisma for real.
  • Manual smoke against running BFF + Postgres:
    • Sign in → select * from audit.events where event_type = 'auth.sign_in' returns one row with actor_id_hash, subject = 'session:…', payload.amr populated.
    • Sign out → matching auth.sign_out row.
    • Force SESSION_ABSOLUTE_TIMEOUT_SECONDS=5 + wait → auth.session.expired row with payload.reason = 'absolute' and ageMs > 5000.
    • Manual UPDATE audit.events SET event_type = 'x' WHERE id = ... as the BFF role → fails with "permission denied" (the role contract holds even when the migrator runs as a privileged login).
## Summary Wires the audit pipeline (ADR-0013) to the auth lifecycle. The foundation was already in place (Prisma `AuditEvent` model, Postgres roles + grants, `AuditWriter.recordEvent` with `SET LOCAL ROLE audit_writer`); this PR layers a typed event surface and emits the first four events on real code paths. ### What lands - **Typed methods on `AuditWriter`**: `signIn`, `signInFailed`, `signOut`, `sessionExpired`. Callers pass the raw Entra `oid`; hashing happens inside the writer so the salt never leaves the audit module. ADR-0013 explicitly defers adding these typed methods "as the matching feature ships" — auth has shipped, so we add the four events tied to code paths that exist today. - **`HashUserIdService`** — reads `LOG_USER_ID_SALT` once at injection, exposes `hash(userId)` → 16-hex-char digest used by both `audit_events.actor_id_hash` (ADR-0013) and the future Pino `user_id_hash` (ADR-0012). Same salt + same input ⇒ same output ⇒ join key between the two streams. - **`LOG_USER_ID_SALT` env var** promoted from the "future vars" block in `.env.example` to the active section, with the same boot-time validator pattern as `SESSION_SECRET` / `SESSION_ENCRYPTION_KEY`: mandatory, base64url, ≥ 32 bytes decoded, placeholder rejected. Wired in `main.ts`. - **`AuditModule` is now `@Global()`** and also provides `HashUserIdService`. The previous in-line comment said "imported globally by AppModule" but the decorator was missing — without it, AuthController and the absolute-timeout middleware couldn't inject `AuditWriter` without re-importing AuditModule. - **Emission points**: - `/auth/callback` happy path → `auth.sign_in` after `session.save()` (blocking per ADR-0013 §"Blocking writes": a failed audit fails the sign-in). - `/auth/callback` failure paths → `auth.sign_in.failed` with a discriminator `failureKind` (`entra-error`, `missing-code-or-state`, `no-pre-auth-cookie`, or any of the `AuthCodeFlowError` kinds — `state-mismatch`, `flow-expired`, `token-exchange-failed`). - `/auth/logout` (authenticated only) → `auth.sign_out` before `session.destroy()` — once destroy runs we lose the actor id. - Absolute-timeout middleware → `auth.session.expired` with `reason: 'absolute'` and `ageMs` for forensic granularity. ### Out of scope (next PRs) - The other four v1 events from ADR-0013's catalogue (`auth.session.revoked`, `auth.token.validation.failed`, `auth.mfa.assertion.failed`, `authz.deny`) — no triggering code path exists today. They land with the admin "logout everywhere" route, downstream API access (ADR-0014), and the eventual `@RequireMfa()` / `@RequireAdmin` guards. - Idle-timeout expiry is intentionally silent — Redis lets the key disappear with no BFF observation point. Per ADR-0010. - Separate `AUDIT_DATABASE_URL` connection pool with `audit_writer`-only credentials — ADR-0013 marks it as the production hardening step, deferred behind `SET LOCAL ROLE` in v1. - Retention purge job + startup self-test probe — deferred to the on-prem infrastructure ADR per ADR-0013. ### Notable choices - **No CLS-populating middleware.** ADR-0013 anticipates an interceptor that puts `actorIdHash` on the request CLS so `AuditWriter.recordEvent` can pick it up automatically. For the four call sites in this PR, every emission path already has the user object in hand, so we pass `actorIdHash` explicitly via the typed methods and skip the middleware. It can land later when more routes need it. - **Blocking on the happy path = strict ADR posture.** `audit.signIn` is awaited before the 302; a Postgres outage makes the sign-in fail (5xx) rather than silently producing an un-audited session. That's "no audit ⇒ no action" applied to authentication itself. Matches ADR-0013 §"Blocking writes" verbatim. - **`signInFailed` skips the actor hash by default.** Most failure paths reject before any claim is parsed (state mismatch, expired flow). The interface accepts an optional `actor` for the rare identity-after-rejection case (future MFA assertion failure, etc.). ### Test plan - [x] `pnpm nx test portal-bff` (clean env) → **142/142 pass** (was 123; +19 new specs across `check-log-user-id-salt`, `hash-user-id.service`, `audit.service` typed-methods, `auth.controller`, `absolute-timeout.middleware`). - [x] `pnpm nx lint portal-bff` → clean. - [x] `pnpm nx build portal-bff` → clean. - [x] **CI clean-env repro** (lesson from #115/#116/#117): every env var unset → tests still 142/142. The two module specs that previously sat on the boundary (`auth.module`, `session.module`) now bootstrap their own `@Global()` stub providers for `PrismaService` + `ClsService` so AuditWriter's transitive resolution works without booting Prisma for real. - [ ] Manual smoke against running BFF + Postgres: - [ ] Sign in → `select * from audit.events where event_type = 'auth.sign_in'` returns one row with `actor_id_hash`, `subject = 'session:…'`, `payload.amr` populated. - [ ] Sign out → matching `auth.sign_out` row. - [ ] Force `SESSION_ABSOLUTE_TIMEOUT_SECONDS=5` + wait → `auth.session.expired` row with `payload.reason = 'absolute'` and `ageMs > 5000`. - [ ] Manual `UPDATE audit.events SET event_type = 'x' WHERE id = ...` as the BFF role → fails with "permission denied" (the role contract holds even when the migrator runs as a privileged login).
julien added 1 commit 2026-05-13 14:17:50 +02:00
feat(portal-bff): wire ADR-0013 audit pipeline to the auth lifecycle
CI / commits (pull_request) Successful in 2m7s
CI / scan (pull_request) Successful in 2m20s
CI / check (pull_request) Successful in 2m28s
CI / a11y (pull_request) Successful in 1m31s
CI / perf (pull_request) Successful in 4m52s
60183d51ae
the audit foundations were already in place from earlier work (prisma
AuditEvent model, postgres roles + grants in
infra/local/init/postgres/01-init.sql, AuditWriter.recordEvent with
SET LOCAL ROLE audit_writer). this pr layers typed methods on top
and emits the first four events from real code paths.

typed methods on AuditWriter:
  signIn, signInFailed, signOut, sessionExpired. callers pass the
  raw entra oid; hashing happens inside the writer so the salt
  never leaves the audit module. adr-0013 explicitly defers adding
  typed methods "as the matching feature ships" — auth has shipped,
  so we add the four events tied to today's code paths. the other
  four catalogue entries (session.revoked, token.validation.failed,
  mfa.assertion.failed, authz.deny) wait for their triggering paths
  (admin "logout everywhere", obo, requiremfa guard, etc.).

HashUserIdService:
  reads LOG_USER_ID_SALT once at injection, exposes hash(userId) →
  16-hex-char digest. same salt + same input ⇒ same output across
  audit_events.actor_id_hash (adr-0013) and the future pino
  user_id_hash (adr-0012) — that stability is the join key
  investigators use to correlate the two streams.

LOG_USER_ID_SALT env var:
  promoted from .env.example's "future vars" block to the active
  section. mandatory at boot; new assertLogUserIdSalt() validator
  follows the same pattern as session-secret /
  session-encryption-key (base64url, ≥32 bytes decoded, placeholder
  rejected). wired in main.ts.

AuditModule is now @Global():
  the previous in-line comment claimed it was "imported globally by
  AppModule" but the decorator was missing — without it,
  AuthController and the absolute-timeout middleware couldn't
  inject AuditWriter without re-importing AuditModule. AuditModule
  now also provides HashUserIdService.

emission points:
  - /auth/callback success → audit.signIn after session.save()
    (blocking per adr-0013: a failed audit fails the sign-in, the
    user sees a 5xx, ops gets a pino line).
  - /auth/callback failure → audit.signInFailed with a
    discriminator failureKind. covers all six rejection paths:
    entra-error, missing-code-or-state, no-pre-auth-cookie, and the
    three AuthCodeFlowError kinds (state-mismatch, flow-expired,
    token-exchange-failed). the malformed-cookie branch collapses
    into no-pre-auth-cookie since the controller can't distinguish.
  - /auth/logout (authenticated only) → audit.signOut before
    session.destroy() — once destroy runs the actor id is gone.
    anonymous logout emits no audit row (would be noise).
  - absolute-timeout middleware → audit.sessionExpired with
    reason: 'absolute' and ageMs for forensic granularity. idle-ttl
    expiry stays silent (no bff observation point — redis drops the
    key on its own).

specs:
  - auth.module.spec + session.module.spec now bootstrap their own
    @Global() stub for PrismaService + ClsService so auditwriter's
    transitive resolution works in the slice-of-graph test without
    booting prisma. lesson learned the hard way on prs #115/#116:
    nx loads apps/portal-bff/.env locally and masks env-graph bugs
    that ci's clean shell surfaces. all 142 specs pass under
    env -u REDIS_URL -u SESSION_SECRET -u SESSION_ENCRYPTION_KEY
    -u LOG_USER_ID_SALT -u DATABASE_URL -u ENTRA_*.
  - 19 new specs across check-log-user-id-salt,
    hash-user-id.service, audit.service typed-methods,
    auth.controller, and absolute-timeout.middleware.

out of scope, landing in follow-ups:
  - the other four adr-0013 catalogue events.
  - separate audit_database_url pool with audit_writer-only creds
    (production hardening, deferred behind SET LOCAL ROLE in v1).
  - retention purge job + startup self-test probe (on-prem infra
    adr).
  - cls-populating middleware that puts actorIdHash on the request
    context — every emission path in this pr has the user in hand
    and passes it explicitly; the middleware can come when more
    routes need it.
julien merged commit 940267e317 into main 2026-05-13 14:21:43 +02:00
julien deleted branch feat/portal-bff/audit-auth-lifecycle-events 2026-05-13 14:21:44 +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#120