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.