feat(portal-bff): wire ADR-0013 audit pipeline to the auth lifecycle
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user