Commit Graph

4 Commits

Author SHA1 Message Date
julien 940267e317 feat(portal-bff): wire ADR-0013 audit pipeline to the auth lifecycle (#120)
CI / check (push) Successful in 2m49s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m50s
CI / a11y (push) Successful in 1m56s
CI / perf (push) Successful in 3m29s
## Summary

Wires the audit pipeline (ADR-0013) to the auth lifecycle. The foundation was already in place (Prisma `AuditEvent` model, Postgres roles + grants, `AuditWriter.recordEvent` with `SET LOCAL ROLE audit_writer`); this PR layers a typed event surface and emits the first four events on real code paths.

### What lands

- **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 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`** — reads `LOG_USER_ID_SALT` once at injection, exposes `hash(userId)` → 16-hex-char digest used by both `audit_events.actor_id_hash` (ADR-0013) and the future Pino `user_id_hash` (ADR-0012). Same salt + same input ⇒ same output ⇒ join key between the two streams.
- **`LOG_USER_ID_SALT` env var** promoted from the "future vars" block in `.env.example` to the active section, with the same boot-time validator pattern as `SESSION_SECRET` / `SESSION_ENCRYPTION_KEY`: mandatory, base64url, ≥ 32 bytes decoded, placeholder rejected. Wired in `main.ts`.
- **`AuditModule` is now `@Global()`** and also provides `HashUserIdService`. 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 inject `AuditWriter` without re-importing AuditModule.
- **Emission points**:
  - `/auth/callback` happy path → `auth.sign_in` after `session.save()` (blocking per ADR-0013 §"Blocking writes": a failed audit fails the sign-in).
  - `/auth/callback` failure paths → `auth.sign_in.failed` with a discriminator `failureKind` (`entra-error`, `missing-code-or-state`, `no-pre-auth-cookie`, or any of the `AuthCodeFlowError` kinds — `state-mismatch`, `flow-expired`, `token-exchange-failed`).
  - `/auth/logout` (authenticated only) → `auth.sign_out` before `session.destroy()` — once destroy runs we lose the actor id.
  - Absolute-timeout middleware → `auth.session.expired` with `reason: 'absolute'` and `ageMs` for forensic granularity.

### Out of scope (next PRs)

- The other four v1 events from ADR-0013's catalogue (`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()` / `@RequireAdmin` guards.
- Idle-timeout expiry is intentionally silent — Redis lets the key disappear with no BFF observation point. Per ADR-0010.
- Separate `AUDIT_DATABASE_URL` connection pool with `audit_writer`-only credentials — ADR-0013 marks it as the production hardening step, deferred behind `SET LOCAL ROLE` in v1.
- Retention purge job + startup self-test probe — deferred to the on-prem infrastructure ADR per ADR-0013.

### Notable choices

- **No CLS-populating middleware.** ADR-0013 anticipates an interceptor that puts `actorIdHash` on the request CLS so `AuditWriter.recordEvent` can pick it up automatically. For the four call sites in this PR, every emission path already has the user object in hand, so we pass `actorIdHash` explicitly via the typed methods and skip the middleware. It can land later when more routes need it.
- **Blocking on the happy path = strict ADR posture.** `audit.signIn` is 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.
- **`signInFailed` skips the actor hash by default.** Most failure paths reject before any claim is parsed (state mismatch, expired flow). The interface accepts an optional `actor` for the rare identity-after-rejection case (future MFA assertion failure, etc.).

### Test plan

- [x] `pnpm nx test portal-bff` (clean env) → **142/142 pass** (was 123; +19 new specs across `check-log-user-id-salt`, `hash-user-id.service`, `audit.service` typed-methods, `auth.controller`, `absolute-timeout.middleware`).
- [x] `pnpm nx lint portal-bff` → clean.
- [x] `pnpm nx build portal-bff` → clean.
- [x] **CI clean-env repro** (lesson from #115/#116/#117): every env var unset → tests still 142/142. The two module specs that previously sat on the boundary (`auth.module`, `session.module`) now bootstrap their own `@Global()` stub providers for `PrismaService` + `ClsService` so AuditWriter's transitive resolution works without booting Prisma for real.
- [ ] Manual smoke against running BFF + Postgres:
  - [ ] Sign in → `select * from audit.events where event_type = 'auth.sign_in'` returns one row with `actor_id_hash`, `subject = 'session:…'`, `payload.amr` populated.
  - [ ] Sign out → matching `auth.sign_out` row.
  - [ ] Force `SESSION_ABSOLUTE_TIMEOUT_SECONDS=5` + wait → `auth.session.expired` row with `payload.reason = 'absolute'` and `ageMs > 5000`.
  - [ ] Manual `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).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #120
2026-05-13 14:21:42 +02:00
julien c427e5d4fe fix(portal-bff): set REDIS_URL + SESSION_* in auth.module.spec so ci:check passes on a clean runner (#116)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m23s
CI / check (push) Successful in 1m33s
CI / a11y (push) Successful in 45s
CI / perf (push) Successful in 2m58s
## Summary

CI red on `main` after #115. Failure was masked locally because `nx test` auto-loads `apps/portal-bff/.env` — the CI runner has no such file, so `process.env.REDIS_URL` is genuinely unset there and the test sees the real failure path.

Root cause: #115 made `AuthModule` import `SessionModule` so `AuthController` could inject `UserSessionIndexService`. `SessionModule` pulls in `RedisModule`, whose factory calls `assertRedisConfig()` and refuses to compile without `REDIS_URL`. The existing `auth.module.spec.ts` only set the `ENTRA_*` env vars — so as soon as the spec's `compile()` walks the new import graph, `assertRedisConfig` throws.

Fix is one file: add `REDIS_URL`, `SESSION_SECRET`, `SESSION_ENCRYPTION_KEY` to the spec's `VALID` env block and dispose the `ioredis` client in `afterEach` (the spec now compiles a full SessionModule, which opens a connection at module init). Same pattern as `session.module.spec.ts`.

## Verification

The reason the bug didn't surface locally was Nx's `.env` loading. To repro the CI condition locally:

```
env -u REDIS_URL -u SESSION_SECRET -u SESSION_ENCRYPTION_KEY -u DATABASE_URL \
    -u ENTRA_INSTANCE_URL -u ENTRA_TENANT_ID -u ENTRA_CLIENT_ID \
    -u ENTRA_CLIENT_SECRET -u ENTRA_REDIRECT_URI -u ENTRA_POST_LOGOUT_REDIRECT_URI \
    pnpm exec nx test portal-bff --skip-nx-cache
```

Before this PR (on main): `auth.module.spec.ts` fails with `REDIS_URL is not set` at `assertRedisConfig`. After: 123/123 pass under that same clean env.

## Test plan

- [x] `nx test portal-bff` with all BFF env vars `unset` → **123/123 pass** (the CI condition).
- [x] `nx lint portal-bff` → clean.
- [x] `nx build portal-bff` → clean.
- [x] Prettier-clean.
- [ ] CI re-run after merge → `ci:check` green.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #116
2026-05-12 23:58:55 +02:00
julien b7093d61de feat(portal-bff): msal confidential client provider in AuthModule (#104)
CI / check (push) Successful in 3m21s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m43s
CI / a11y (push) Successful in 1m56s
CI / perf (push) Successful in 3m34s
Second step of ADR-0009 wiring. AuthModule now exposes the
`@azure/msal-node` confidential client alongside the parsed Entra
config — the building block the upcoming OIDC routes inject to issue
the auth-code URL, exchange the callback code for tokens, and
acquire downstream tokens on behalf of the user.

What lands:

- `@azure/msal-node` added as a direct dependency (^5.2.1).
- `apps/portal-bff/src/auth/msal-client.token.ts` — `MSAL_CLIENT`
  string token + `ConfidentialClientApplication` type re-export.
  Mirrors the `ENTRA_CONFIG` token shape from PR #102.
- AuthModule grows a factory provider for `MSAL_CLIENT`:
  - Injects `ENTRA_CONFIG` + nestjs-pino `Logger`.
  - Builds a `ConfidentialClientApplication` with `clientId`,
    `authority`, `clientSecret` from the parsed config.
  - Wires `system.loggerOptions.loggerCallback` to forward MSAL's
    internal log lines into the Pino stream (per ADR-0012) — Error
    → logger.error, Warning → logger.warn, Verbose / Trace →
    logger.debug, Info → logger.log. PII logging is disabled by
    default so tokens / user identifiers never leak into our
    structured log records.
  - Sets MSAL's `logLevel` to Info — Pino's own threshold
    re-filters from there.
  - All MSAL log lines carry the `msal` Pino context for easy
    isolation in log queries.
- `AuthModule.exports` extended to include `MSAL_CLIENT`.

Verification:

- `nx run-many -t lint test build --projects=portal-bff` — green.
- 30/30 specs (was 29; +1 covering MSAL client construction).
- New spec imports `nestjs-pino`'s `LoggerModule.forRoot({ pinoHttp:
  { level: 'silent' } })` to provide the same Logger the production
  app supplies via `ObservabilityModule`, without flooding test
  stdout. Two tests assert the provider tree resolves correctly
  (ENTRA_CONFIG + MSAL_CLIENT) and one re-checks the missing-env
  failure mode still propagates through the new factory.

Construction is cheap — MSAL Node defers authority discovery to the
first auth call — so the client is built eagerly at module init.
The factory is injection-only; no MSAL methods get invoked yet.
Routes land in the next PR.

<!--
PR title format — becomes the squash-merge subject on main, validated by commitlint.

  <type>(<scope>): <short description>

Examples:
  feat(portal-shell): add user-preferences panel skeleton
  fix(portal-bff): correct env var bracket access
  docs(decisions): add ADR-0018 for security baseline
  chore(deps): bump @nx/* to 22.7.2

Imperative mood, lowercase, no trailing period, target ≤ 70 chars.
See docs/development.md §5 for the full convention (types, scopes).
-->

## Summary

## Motivation

## Implementation notes

## Verification

- [ ] `pnpm ci:check` green locally
- [ ] `pnpm ci:audit` green (or pre-existing drift acknowledged)
- [ ] Tested manually:
- [ ] Architecture diagram updated (if `docs/architecture.md` was affected)
- [ ] ADR amended or added (if a decision changed)

## Related

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #104
2026-05-12 10:34:23 +02:00
julien 58e3b65bd9 feat(portal-bff): entra config foundation — boot validator + auth module (#102)
CI / check (push) Successful in 2m15s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m1s
CI / a11y (push) Successful in 1m15s
CI / perf (push) Successful in 3m47s
## Summary

First step of ADR-0009 wiring on the BFF: capture the Entra app-registration env vars in the boot pipeline so subsequent PRs can plug `@azure/msal-node` onto a typed, already-validated config without re-reading `process.env`. **No MSAL client, no OIDC routes, no session integration yet** — those land in follow-up PRs.

## What lands

- **[`.env.example`](apps/portal-bff/.env.example)** promotes the Entra block from its previous "future-vars" comment stub to an active section. Six keys:
  - `ENTRA_INSTANCE_URL` — the Microsoft login endpoint (e.g. `https://login.microsoftonline.com/`).
  - `ENTRA_TENANT_ID`, `ENTRA_CLIENT_ID`, `ENTRA_CLIENT_SECRET` — the values from the Entra app-registration UI.
  - `ENTRA_REDIRECT_URI`, `ENTRA_POST_LOGOUT_REDIRECT_URI` — consumed by the OIDC routes in a follow-up PR.

  Multi-tenant `ENTRA_ACCEPTED_TENANT_IDS` stays in the future-vars comment until External ID activation (ADR-0008 phase 2).

- **[`apps/portal-bff/src/config/check-entra-config.ts`](apps/portal-bff/src/config/check-entra-config.ts)** — boot-time validator mirroring `check-database-url.ts`. Verifies every required key is present, the instance URL is `https://` and ends with `/`, tenant + client IDs are UUIDs, none of them are the literal placeholder values from `.env.example`, and the two redirect URIs parse as URLs. Returns a typed `EntraConfig` object with a pre-computed `authority` field (`${instanceUrl}${tenantId}`) so the future MSAL factory does not re-derive it.

- **[`auth.module.ts`](apps/portal-bff/src/auth/auth.module.ts)** — `AuthModule` whose v1 surface is one provider: the parsed `EntraConfig` keyed by the `ENTRA_CONFIG` injection token. Factory delegates to `assertEntraConfig()`. Non-global on purpose — consumers state intent by importing the module.

- **Bootstrap wiring** — `main.ts` calls `assertEntraConfig()` alongside `assertDatabaseUrl()` so misconfiguration fails fast at boot rather than mid-request (per ADR-0018 §"BFF env-var loading"). `AppModule` imports `AuthModule`.

## Naming choice

Chose `ENTRA_*` rather than `AZURE_AD_*` to align with the ADR text (Microsoft Entra ID, post-2023 rebrand). The values you copy from the Entra app-registration UI go into `apps/portal-bff/.env` (git-ignored).

## Decisions worth flagging

- **Validator called twice** — once in `main.ts` (boot-time fail-fast) and once in the `AuthModule` factory (to obtain the value for DI). Both reads are idempotent and trivially cheap. The duplication is intentional: boot-time gives a clear, pre-NestFactory error; the factory call surfaces the typed value to consumers.
- **No `@azure/msal-node` dependency added yet** — introducing the dep without a consumer would be a smell. Lands in the next PR alongside the MSAL client factory.
- **Pre-computed `authority`** in the parsed config rather than letting each MSAL consumer concatenate `instanceUrl + tenantId`. One place to change if the multi-tenant authority (`/organizations`, `/common`) replaces the tenant-scoped one when External ID activates.

## Verification

- `nx run-many -t lint test build --projects=portal-bff` — green.
- **29 / 29 specs** (was 20; +9 from the new entra-config spec + auth.module spec).
- Boot smoke test (manual): with the placeholder values in `.env.example`, `nx serve portal-bff` aborts immediately with `ENTRA_CLIENT_ID is still the .env.example placeholder (…)`. With real values in a local `.env`, the BFF starts normally.

## Test plan

- [x] Lint + test + build green.
- [x] Validator unit-test covers happy path + every documented failure mode.
- [ ] Manual: drop the real Entra values you obtained into `apps/portal-bff/.env`, `nx serve portal-bff` boots clean.
- [ ] Manual: temporarily blank out one of the four `ENTRA_*` keys → BFF aborts at boot with a clear message naming the missing key.

## Next PRs on the auth track

1. Install `@azure/msal-node`, add the `MsalConfidentialClient` factory provider in `AuthModule`, expose it via DI.
2. First OIDC routes: `/api/auth/login` (PKCE-initiated redirect to Entra) + `/api/auth/callback` (token exchange + ID-token validation, audit-logged, no session persistence yet).
3. Session persistence per ADR-0010 (Redis + AES-GCM, `__Host-portal_session` cookie). Closes the auth loop.
4. RP-initiated logout, CSRF protection, route guards.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #102
2026-05-12 02:27:55 +02:00