feat(portal-bff): wire ADR-0013 audit pipeline to the auth lifecycle #120
Reference in New Issue
Block a user
Delete Branch "feat/portal-bff/audit-auth-lifecycle-events"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Wires the audit pipeline (ADR-0013) to the auth lifecycle. The foundation was already in place (Prisma
AuditEventmodel, Postgres roles + grants,AuditWriter.recordEventwithSET LOCAL ROLE audit_writer); this PR layers a typed event surface and emits the first four events on real code paths.What lands
AuditWriter:signIn,signInFailed,signOut,sessionExpired. Callers pass the raw Entraoid; 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— readsLOG_USER_ID_SALTonce at injection, exposeshash(userId)→ 16-hex-char digest used by bothaudit_events.actor_id_hash(ADR-0013) and the future Pinouser_id_hash(ADR-0012). Same salt + same input ⇒ same output ⇒ join key between the two streams.LOG_USER_ID_SALTenv var promoted from the "future vars" block in.env.exampleto the active section, with the same boot-time validator pattern asSESSION_SECRET/SESSION_ENCRYPTION_KEY: mandatory, base64url, ≥ 32 bytes decoded, placeholder rejected. Wired inmain.ts.AuditModuleis now@Global()and also providesHashUserIdService. 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 injectAuditWriterwithout re-importing AuditModule./auth/callbackhappy path →auth.sign_inaftersession.save()(blocking per ADR-0013 §"Blocking writes": a failed audit fails the sign-in)./auth/callbackfailure paths →auth.sign_in.failedwith a discriminatorfailureKind(entra-error,missing-code-or-state,no-pre-auth-cookie, or any of theAuthCodeFlowErrorkinds —state-mismatch,flow-expired,token-exchange-failed)./auth/logout(authenticated only) →auth.sign_outbeforesession.destroy()— once destroy runs we lose the actor id.auth.session.expiredwithreason: 'absolute'andageMsfor forensic granularity.Out of scope (next PRs)
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()/@RequireAdminguards.AUDIT_DATABASE_URLconnection pool withaudit_writer-only credentials — ADR-0013 marks it as the production hardening step, deferred behindSET LOCAL ROLEin v1.Notable choices
actorIdHashon the request CLS soAuditWriter.recordEventcan pick it up automatically. For the four call sites in this PR, every emission path already has the user object in hand, so we passactorIdHashexplicitly via the typed methods and skip the middleware. It can land later when more routes need it.audit.signInis 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.signInFailedskips the actor hash by default. Most failure paths reject before any claim is parsed (state mismatch, expired flow). The interface accepts an optionalactorfor 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 acrosscheck-log-user-id-salt,hash-user-id.service,audit.servicetyped-methods,auth.controller,absolute-timeout.middleware).pnpm nx lint portal-bff→ clean.pnpm nx build portal-bff→ clean.auth.module,session.module) now bootstrap their own@Global()stub providers forPrismaService+ClsServiceso AuditWriter's transitive resolution works without booting Prisma for real.select * from audit.events where event_type = 'auth.sign_in'returns one row withactor_id_hash,subject = 'session:…',payload.amrpopulated.auth.sign_outrow.SESSION_ABSOLUTE_TIMEOUT_SECONDS=5+ wait →auth.session.expiredrow withpayload.reason = 'absolute'andageMs > 5000.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).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.