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

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.
This commit is contained in:
Julien Gautier
2026-05-13 14:08:30 +02:00
parent 4d4791a807
commit 60183d51ae
17 changed files with 682 additions and 41 deletions
+12 -4
View File
@@ -105,6 +105,18 @@ SESSION_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url
# SESSION_IDLE_TIMEOUT_SECONDS=1800
# SESSION_ABSOLUTE_TIMEOUT_SECONDS=43200
# Per-environment salt used to pseudonymise the user id before it
# lands in audit rows (per ADR-0013 §"Schema") and in Pino app log
# lines (per ADR-0012 §"User id hashing"). Same value must be used
# on both sides so audit and app logs join on `actor_id_hash`.
#
# Rotation invalidates the join key — old rows / log lines can no
# longer be correlated with the new hash. Treat as long-lived per
# environment. Mandatory at boot.
#
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
LOG_USER_ID_SALT=replace_with_32_random_bytes_base64url
# Future env vars introduced by upcoming phases / ADRs:
#
# Auth flow (ADR-0009) — additional keys wired as the routes land:
@@ -121,10 +133,6 @@ SESSION_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url
# MFA (ADR-0011):
# MFA_FRESHNESS_SECONDS (default 600)
#
# Observability — additional keys to be wired as features land:
# LOG_USER_ID_SALT (per-environment salt for hashing user_id
# in CLS context — needed once auth lands)
#
# Audit trail (ADR-0013):
# AUDIT_DATABASE_URL (separate creds, role 'audit_writer')
# AUDIT_ARCHIVER_DATABASE_URL (role 'audit_archiver', for the retention purge job)