feat(portal-bff): user directory upserted at sign-in #140
Reference in New Issue
Block a user
Delete Branch "feat/portal-bff-user-directory"
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
First PR of the portal-admin User-list chantier per ADR-0020 §"v1 scope — User list (read-only)". Ships the write side only:
public.userstable that holds the BFF's local cache of identities seen sign in to either portal-shell or portal-admin.UserDirectoryService.recordSignIn(user)upsert called fromSessionEstablisher.establishafter the blocking audit write.The read side (
GET /api/admin/users+ the admin viewer SPA screen) lands in two follow-up PRs of the same chantier.Schema
prisma/schema.prismagains aUsermodel in thepublicschema:oidtidaudience'workforce'|'customer'. Hardcoded toworkforcein v1 per ADR-0008's simplification; will read from session/claims when External ID activates.usernamedisplay_namefirst_seen_atlast_seen_ataudit.events.Indexes:
last_seen_at DESC— admin default sort.username— prefix filtering.Migration in
prisma/migrations/20260514192014_users_directory/.UserDirectoryServiceBest-effort write. Catches its own errors, logs a Pino warn (
user_directory.record_sign_in_failed), returnsundefined. The directory is a convenience for admin browsing, not a security boundary — a Postgres hiccup must not lock a user out of sign-in. ADR-0013's "no audit ⇒ no action" applies to the audit module only.SessionEstablisherwiringThe directory call lands right after the existing audit emission:
Two invariants the tests pin:
audit.signInthrows,userDirectory.recordSignInis NOT called. The directory never holds a row for a sign-in the audit log doesn't carry.Module wiring
UsersModuleis declared@Global()soSessionEstablisher(which lives inAuthModule) injectsUserDirectoryServicewithout forcingAuthModuleto importUsersModule. The directory is a true cross-cutting concern: one writer (the auth callback) and one future reader (the admin endpoint).Wired into
AppModulealongside the other v1 modules.auth.module.spec.tsupdated to also importUsersModulein its slice-of-graph compile (otherwise the test fails to resolve the newSessionEstablisherdep).Notes for the reviewer
firstSeenAtis intentionally absent from theupdatepayload. The Prisma upsert'supdateblock is precisely what changes on conflict; omitting the field leaves it untouched at the column level (Postgres-side default doesn't refire on UPDATE).public, not in a dedicatedidentityorcmsschema. ADR-0020 enumeratescms.*for editorial data andaudit.*for the audit ledger but doesn't require a separate schema for user-directory data. We can promote it to its own schema later if a role-isolation need emerges; the migration would be aALTER TABLE users SET SCHEMA ….audit.events.actor_id_hashis not stored onpublic.users. A future admin endpoint that joins sign-in counts fromaudit.eventscan compute the hash on-the-fly viaHashUserIdService— keeping the salted-hash invariant from ADR-0013 intact (the salt stays inside the audit module).Test plan
pnpm nx test portal-bff— 365 specs pass (was 358; +7: UserDirectoryService 4, SessionEstablisher integration 3).pnpm exec nx affected -t format:check lint test build --base=origin/main— clean (the pre-existing rate-limit warnings + one unused-eslint-disable from PR #137 are unrelated).migrate diffconfirms the model matches the migration SQL.public.userswith the rightoid/last_seen_at; sign in again, expect the same row'slast_seen_atto advance andfirst_seen_atto stay put.What's next
The chantier sequence:
GET /api/admin/users(paginated + filterable, gated by@RequireAdmin, emitsadmin.users.queryaudit)./usersscreen (table + filter form), promote the sidebar entry from "Soon" badge to live link.First PR of the portal-admin User-list chantier per ADR-0020 §"v1 scope — User list (read-only)". Ships the write side only: - a `public.users` table that holds the BFF's local cache of identities seen sign in to either portal-shell or portal-admin, - a `UserDirectoryService.recordSignIn(user)` upsert called from `SessionEstablisher.establish` AFTER the blocking audit write. The read side (`GET /api/admin/users` + the admin viewer SPA screen) lands in subsequent PRs of the chantier. Schema - `public.users` model in `schema.prisma`. Primary key on `oid` (Entra's stable per-user identifier inside the tenant — the natural choice; per-tenant uniqueness is sufficient for the v1 single-workforce-tenant design per ADR-0008). - Columns: `oid`, `tid`, `audience`, `username`, `display_name`, `first_seen_at` (default NOW, never overwritten), `last_seen_at` (default NOW, updated on every upsert). - Indexes: `last_seen_at DESC` for the admin "most recently active" default sort, plain `username` for prefix filtering. - Migration in `prisma/migrations/20260514192014_users_directory/`. UserDirectoryService - `recordSignIn(entry)` issues `prisma.user.upsert({ where: { oid }, create: {...}, update: {...} })`. Create sets `first_seen_at` + `last_seen_at` via the DEFAULT clauses; update touches `last_seen_at` + every mutable identity field but NOT `first_seen_at`. - Best-effort: catches its own errors, logs a Pino warn (`user_directory.record_sign_in_failed`) and returns `undefined`. The directory is a convenience for admin browsing, not a security boundary — a Postgres hiccup must not lock a user out of sign-in. ADR-0013's "no audit ⇒ no action" applies to the audit module only. SessionEstablisher wiring - Calls `userDirectory.recordSignIn` AFTER `audit.signIn` so an audit failure short-circuits the directory write (audit-first invariant per ADR-0013). The await ensures the upsert completes before the response is sent — an admin who just signed in sees themselves on the user list immediately, without racing the response. - v1 hardcodes `audience: 'workforce'` per ADR-0008's single- workforce-tenant simplification. Will read the real audience from the session/claims when External ID activates. Module wiring - `UsersModule` declared `@Global()` so `SessionEstablisher` (which lives in `AuthModule`) injects `UserDirectoryService` without re-routing the module graph through an import. The directory is a true cross-cutting concern: one writer (auth callback), one future reader (admin endpoint). - Wired into `AppModule` alongside the other v1 modules. Tests: +7 specs (UserDirectoryService 4, SessionEstablisher integration 3 — directory called with the right payload, called AFTER audit, NOT called when audit fails, audience pinned to workforce). Pre-existing test updates: `auth.module.spec.ts` now imports UsersModule (slice-of-graph compile would otherwise fail to resolve SessionEstablisher's new dep); `auth.controller.spec.ts` passes a noop directory mock to the SessionEstablisher constructor — kept noop because that spec's behavioural surface is about the controller path, not the directory.