Files
apf_portal/apps/portal-bff/.env.example
T
julien 12136f7a8a
CI / commits (push) Has been skipped
CI / check (push) Failing after 20s
CI / a11y (push) Failing after 20s
CI / perf (push) Failing after 44s
CI / scan (push) Failing after 55s
Docs site / build (push) Failing after 29s
feat(auth): authorization catalogues + Principal builder skeleton (ADR-0025) (#206)
## Summary

First implementation PR after [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md) was accepted in #205. Lands the closed-set catalogues + the OIDC-callback hook that composes the session-resident `Principal`. No new guards yet — those come in the next PR per the ADR's `§More Information` phasing.

## What lands

**New shared library: `libs/shared/auth/`** (`scope:shared`, `type:shared`, framework-agnostic, vitest)

| File | Role |
| --- | --- |
| `src/lib/authorization.types.ts` | Closed catalogues: `PRIVILEGES` (4), `FUNCTIONAL_ROLES` (24), `SCOPE_KINDS` (6). `Principal`, `Scope` types. `isPrivilege` / `isFunctionalRole` / `isScopeKind` type-guards for the drift gate. |
| `src/lib/entra-group-to-role.ts` | `parseEntraGroupMap` (validates GUID + slug closedness + dedup) + `EntraGroupToRoleResolver` (translates the Entra `groups` claim into ordered `apf-role-*` slugs, reports unknown GUIDs via callback). |
| `src/lib/*.spec.ts` | 17 unit tests against the catalogue counts + resolver contracts. |

**BFF wiring** (`apps/portal-bff/src/auth/` + `src/config/`)

| File | Role |
| --- | --- |
| `auth.service.ts` | Extracts the Entra `groups` claim. `AuthenticatedUser` grows `groups: readonly string[]`. |
| `principal-builder.ts` | Composes the three axes at sign-in. Filters `roles` claim through `isPrivilege` (drops + WARNs on unknown), resolves `groups` claim through the `EntraGroupToRoleResolver` (drops + WARNs on unknown). |
| `scope-resolver.ts` | `ScopeResolver` abstract class + `StubScopeResolver` returning `[{ kind: 'unrestricted' }]` (per ADR-0025 §331 — replaced by a Prisma implementation when ADR-0026's `user_scopes` table lands). |
| `session-establisher.service.ts` | Calls `PrincipalBuilder.build()` once, persists the result as `req.session.principal`. The legacy `req.session.user` shape is untouched (audit + AI bridge keep working). |
| `session.types.ts` | Adds optional `principal?: Principal` field on the express-session augmentation. |
| `entra-group-to-role.token.ts` | DI token for the lazily-loaded resolver. |
| `config/load-entra-group-map.ts` | Reads the gitignored `infra/<env>-tenant.entra.json` at boot. Missing path → empty resolver + WARN. Malformed file → hard fail (alternative would be silently dropping a role). |

**Tests against the 19 test-tenant personas** (`principal-builder.spec.ts`)

Every persona provisioned in `apfrd.onmicrosoft.com` on 2026-05-20 is exercised end-to-end: `admin`, `directeur-bordeaux`, `directeur-complexe`, `rh-aquitaine`, `rh-siege`, `collaborateur-simple`, `tresorier-bordeaux`, `dpo`, `it`, `benevole-aquitaine`, `chef-equipe-bordeaux`, `chef-service-bordeaux`, `directeur-territorial-aquitaine`, `juriste-siege`, `rssi`, `communication-siege`, `elu-ca-national`, `president-cd-aquitaine`, `secretaire-cd-aquitaine`. A coverage assertion confirms the personas cover all 4 privileges + 23 of 24 functional roles (the `partenaire` gap is intentional per ADR-0025).

**Infra + docs**

| File | Change |
| --- | --- |
| `infra/test-tenant.entra.example.json` | Template GUID → slug map (24 entries, full v1 catalogue). Real file (`*-tenant.entra.json`) gitignored. |
| `infra/README.md` | New row + "Entra group map" section documenting the load semantics + provisioning workflow. |
| `apps/portal-bff/.env.example` | New `ENTRA_GROUP_MAP_PATH` block. |
| `.gitignore` | `infra/*-tenant.entra.json` (except `.example.json`). |
| `tsconfig.base.json` | `shared-auth` path alias. |
| `notes/entra-groups-claim-activation.md` | Operator runbook for the Entra admin-centre step (Token configuration → Add groups claim → Security groups → Group ID). |

**ADR amendment**

| File | Change |
| --- | --- |
| `docs/decisions/0025-authorization-model-privileges-roles-scopes.md` | Path references updated `libs/feature/auth/...` → `libs/shared/auth/...` (4 occurrences), and the `ENTRA_GROUP_TO_ROLE` static-record example replaced by the actual `EntraGroupToRoleResolver` + JSON shape. |

## Notes for the reviewer

- **Why `libs/shared/auth/` and not `libs/feature/auth/` (as the ADR initially said).** The existing `libs/feature/auth/` carries `tags: ['scope:portal-shell', 'type:feature']`, which the Nx `enforce-module-boundaries` rule in `eslint.config.mjs:36-46` blocks the BFF from importing. The catalogue is needed by both sides (BFF builds the `Principal`, SPA will gate UI conditionally later), so a `scope:shared` lib is the correct home. The ADR is patched in the same PR so the document and the code agree.

- **Why drop unknown privileges with a WARN instead of preserving them.** `Portal.GhostRole` in the `roles` claim is either a leftover from a different app registration or a v1+1 experiment that hasn't been added to the catalogue yet. Both paths should not silently grant access — drop with a WARN so an operator notices, and the drift CI gate (next PR) catches the source-side equivalent before merge.

- **Why the principal builder uses a `ScopeResolver` seam now.** Per ADR-0025 §331 the v1 implementation returns `unrestricted` for everyone; the real version queries the `user_scopes` Prisma table that lands with ADR-0026. The seam is here so the next PR replaces only the implementation, not the call-site in `PrincipalBuilder` or its 24 tests. Per-persona stub scope data was deliberately not embedded in this PR — no guard consumes scopes yet, so it would be write-only documentation that drifts from the eventual Prisma seed.

- **Why `user.id` and `user.personId` placeholder to `entraOid`.** Same logic: the real UUIDs land with the `Person` + `User` Prisma schema (proposed ADR-0026). Until then, populating both fields with `entraOid` lets consumers key on `principal.user.id` today and get a real value later without branching on availability. The seam is one place (in the builder), not 24 guards.

- **Why session.types.ts carries both `user` and `principal`.** Additive instead of replacement: the audit module + the AI-bridge controller key on `user.oid` / `user.tid` directly, and migrating them in the same PR would balloon the diff for zero behavioural benefit. New consumers (`@RequirePrivilege` / `@RequireRole` / `@RequireScope` decorators, next PR) reach for `principal`.

- **Map file fails soft on absence, hard on malformation.** Path unset OR file missing → empty resolver + WARN. Sign-in still succeeds, every user gets `roles: []`. This is deliberately fail-soft so a fresh dev environment isn't blocked by an Entra-side dependency. **Bad JSON / unknown slug / duplicate GUID → throws at boot.** The alternative would silently mis-resolve a role.

- **Why `boot-time` validation only (no per-request)** for the map file. Catalogue drift is detected once at boot; per-request validation would re-read the file from disk on every sign-in. The trade-off is that a hot-edit to the file requires a BFF restart — acceptable because the file changes once per tenant lifecycle, not per session.

- **Entra `groups` claim must be activated** on the BFF's app registration before this code does anything useful. The runbook in `notes/entra-groups-claim-activation.md` walks through the steps; if the claim isn't activated, every user signs in with `principal.roles = []` and a future warn for every group GUID that would have arrived (none, in that case).

- **No drift gate yet.** ADR-0025's §"Confirmation" §346 calls for an ESLint custom rule (or `pnpm run` script) that greps every `@RequireRole('...')` literal and asserts membership in the catalogue. That's the third PR in the phasing — there's no `@RequireRole` consumer in the tree yet, so the gate would have nothing to assert against today.

## Test plan

- [x] `pnpm nx affected -t lint test build --base=main` — 13 projects green
  - 497 BFF tests (was 465 — added 4 new `groups`-claim tests in `auth.service.spec.ts`, 1 new test in `session-establisher.service.spec.ts`, 6 new tests in `load-entra-group-map.spec.ts`, 24 new tests in `principal-builder.spec.ts`)
  - 17 `shared-auth` tests (catalogue counts + resolver contracts)
  - 1 `shared-util` test (unchanged)
  - full lint + full build
- [x] `pnpm nx format:check` — clean after `pnpm nx format:write`
- [x] Manual: `tsc --build libs/shared/auth/tsconfig.lib.json` emits `.d.ts` files at the path `portal-bff`'s webpack expects.
- [ ] **Review focus** — the closed catalogues (`PRIVILEGES`, `FUNCTIONAL_ROLES`, `SCOPE_KINDS`) verbatim match ADR-0025; the 19 personas verbatim match `notes/test-tenant-role-assignments.md`; the `Principal` shape matches the ADR §"Principal shape" (modulo the `user.id` / `personId` placeholder); fail-soft on missing map file, hard on malformed.
- [ ] **After merge — operator step** : activate the Entra `groups` claim per `notes/entra-groups-claim-activation.md` and drop the real GUIDs into `infra/test-tenant.entra.json` (gitignored). Then a sign-in with `admin@apfrd.onmicrosoft.com` should land `principal.privileges = ['Portal.Admin']` and `principal.roles = ['collaborateur', 'rh']` in Redis.

## What's next

Per ADR-0025 §"More Information" phasing:

1.  **This PR** — types + Principal builder + group-to-role mapping skeleton.
2. **Next PR** — `@RequirePrivilege` + `@RequireRole` + `@RequireScope` decorators + guard integration tests against the 19 personas.
3. **PR after that** — drift CI gate (ESLint custom rule asserting every `@RequireX('...')` literal is in the catalogue).
4. **Depends on ADR-0026** — `user_scopes` Prisma table + seed + `PrismaScopeResolver` replacing `StubScopeResolver`.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #206
2026-05-23 21:09:13 +02:00

234 lines
12 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# BFF environment template
# Copy to .env (which is gitignored) and fill in actual values for local development.
# Production values are managed by the platform's secret manager (see future infrastructure ADR).
# Postgres connection (per ADR-0006)
# Local dev default: dockerised Postgres on port 5432, schema 'public'.
# Username / password / db must match infra/local/.env (POSTGRES_USER /
# POSTGRES_PASSWORD / POSTGRES_DB) — those are the source of truth,
# this is the BFF view of the same connection.
#
# IMPORTANT — URL encoding. The password is part of the URL userinfo
# segment, so any of these characters must be URL-encoded:
# @ → %40 # → %23 : → %3A / → %2F ? → %3F
# % → %25 & → %26 = → %3D + → %2B ; → %3B
# i.e. if your POSTGRES_PASSWORD is "p@ss#1", DATABASE_URL must read
# "postgresql://portal:p%40ss%231@localhost:5432/portal_dev?schema=public"
# The BFF aborts at boot with a clear error if it detects an unencoded
# special character (see apps/portal-bff/src/config/check-database-url.ts).
DATABASE_URL="postgresql://portal:portal_dev_change_me@localhost:5432/portal_dev?schema=public"
# Observability (per ADR-0012)
# All OTEL_* keys are honoured by the OpenTelemetry SDK directly — see
# apps/portal-bff/src/observability/tracing.ts for the bootstrap.
# Pino log level: 'info' in prod, 'debug' in dev (default if unset).
LOG_LEVEL=debug
OTEL_SERVICE_NAME=portal-bff
OTEL_SERVICE_VERSION=dev
# Default endpoint targets the Collector provisioned in
# infra/local/dev.compose.yml. The /v1/traces suffix is required by
# the HTTP/Protobuf transport.
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
# v1 samples 100 % at the app; tail sampling is delegated to the
# Collector (per ADR-0012). Override only for spike investigations.
OTEL_TRACES_SAMPLER=always_on
# Identity / Entra ID app registration (per ADR-0008 / ADR-0009)
# Values come from the project's Entra application registration in the
# Azure Admin Center → App registrations → APF Portal. The four
# *_INSTANCE_URL / *_TENANT_ID / *_CLIENT_ID / *_CLIENT_SECRET keys
# are mandatory; the BFF refuses to boot without them (see
# apps/portal-bff/src/config/check-entra-config.ts).
#
# ENTRA_INSTANCE_URL is the Microsoft login endpoint — usually
# https://login.microsoftonline.com/. The authority used by MSAL is
# `${ENTRA_INSTANCE_URL}${ENTRA_TENANT_ID}` for single-tenant flows,
# or `${ENTRA_INSTANCE_URL}organizations` / `common` for multi-tenant
# (per ADR-0008's dual-audience design). v1 uses the tenant-scoped
# authority; the multi-tenant switch lands when External ID activation
# is needed.
#
# ENTRA_CLIENT_SECRET is the high-value secret of this set. Never
# commit a real value. Production manages it via the deploy platform's
# secret manager (future infrastructure ADR).
ENTRA_INSTANCE_URL=https://login.microsoftonline.com/
ENTRA_TENANT_ID=00000000-0000-0000-0000-000000000000
ENTRA_CLIENT_ID=00000000-0000-0000-0000-000000000000
ENTRA_CLIENT_SECRET=replace_with_real_value
# Redirect URIs registered in Entra alongside the same client id.
# User portal — `/api/auth/callback` is the OIDC return URL; the
# post-logout URL is where Entra sends the browser after RP-initiated
# logout (typically the SPA landing page).
ENTRA_REDIRECT_URI=http://localhost:3000/api/auth/callback
ENTRA_POST_LOGOUT_REDIRECT_URI=http://localhost:4200/
# Admin portal — distinct callback per ADR-0020 §"Sessions — distinct
# from `portal-shell`" so Entra routes the response to the matching
# session. Both `ENTRA_REDIRECT_URI` and `ENTRA_ADMIN_REDIRECT_URI`
# must be registered in the same Entra app registration's
# "Redirect URIs" list. Distinct post-logout URL routes admin
# sign-outs to the admin SPA's landing page (port 4300 in dev — see
# apps/portal-admin/project.json `serve.options.port`).
ENTRA_ADMIN_REDIRECT_URI=http://localhost:3000/api/admin/auth/callback
ENTRA_ADMIN_POST_LOGOUT_REDIRECT_URI=http://localhost:4300/
# Authorization model (per ADR-0025). Points at the JSON file that
# maps tenant-private Entra security-group GUIDs to the closed
# catalogue of `apf-role-*` slugs. The BFF loads it at boot through
# `EntraGroupToRoleResolver` (libs/shared/auth). Unset means the
# resolver runs empty — sign-in still succeeds but every user gets
# zero functional roles (no `apf-role-*` UI). A WARN is logged at
# boot so an operator can spot the missing config. See
# `infra/test-tenant.entra.example.json` for the schema; copy to
# `infra/test-tenant.entra.json` (gitignored) and fill in the real
# GUIDs from the Entra admin centre.
ENTRA_GROUP_MAP_PATH=infra/test-tenant.entra.json
# Cookie signing secret (per ADR-0009 §"Cookies"). Used to sign the
# transient pre-auth cookie that carries the OIDC `state` + PKCE
# verifier between the /auth/login redirect and the /auth/callback
# round-trip, and (once ADR-0010 ships) the session cookie's
# integrity layer. Mandatory at boot — the BFF aborts if missing or
# obviously weak (less than 32 base64-decoded bytes ≈ 256 bits of
# entropy). Generate a fresh value per environment:
#
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
SESSION_SECRET=replace_with_32_random_bytes_base64url
# Redis connection (per ADR-0010). The BFF uses `ioredis` for session
# storage (today: just the connection; the express-session +
# connect-redis middleware lands in the next PR).
#
# REDIS_URL — full URL form including auth. Must match `infra/local/.env`
# (REDIS_PASSWORD + REDIS_PORT) when running against the local Compose
# stack. Production wiring uses Sentinel (REDIS_SENTINEL_HOSTS +
# REDIS_SENTINEL_NAME — future-vars block below) and TLS; the current
# variable supports the dev single-instance shape only.
REDIS_URL=redis://default:redis_dev_change_me@localhost:6379/0
# Session payload encryption (per ADR-0010 §"At-rest encryption").
# AES-256-GCM key for encrypting the session JSON that connect-redis
# writes to Redis, so a Redis dump never carries raw user identities
# / future tokens / claims in plaintext. **Distinct** from
# SESSION_SECRET, which only signs the cookie's session-id — never
# reuse one for the other. Mandatory at boot.
#
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
SESSION_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url
# OBO downstream-token cache encryption (per ADR-0014 §"Token cache
# (for OBO)"). AES-256-GCM key for encrypting Entra-issued downstream
# tokens cached in Redis under `obo:{actor_id_hash}:{resource}`.
# **Dedicated key** — must differ from SESSION_ENCRYPTION_KEY so a
# leak of one does not cascade into the other. The boot validator
# refuses an identical value. Mandatory at boot.
#
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
OBO_CACHE_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url
# BFF JWKS signing material (per ADR-0014 §"Service strategy"). The
# BFF mints short-lived `X-User-Assertion` JWTs to propagate user
# identity to non-Entra downstreams; downstreams verify the signature
# against `/.well-known/jwks.json`. Both values are mandatory at boot.
#
# Generate an RSA private key:
# mkdir -p apps/portal-bff/.secrets && \
# openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \
# -out apps/portal-bff/.secrets/jwks.pem
#
# RSA-2048 is the minimum the validator accepts; 3072 is the
# default recommendation. EC P-256 / P-384 also accepted.
BFF_JWKS_PRIVATE_KEY_PATH=apps/portal-bff/.secrets/jwks.pem
# Stable key id published in the JWKS + emitted in the JWT `kid`
# header. URL-safe charset only ([A-Za-z0-9_-], 4128 chars). Bump
# this when rotating to a new key.
BFF_JWKS_KID=bff-2026-05
# Session timeouts (per ADR-0010). Both optional with sensible
# defaults; override only when staging / prod policy diverges.
# SESSION_IDLE_TIMEOUT_SECONDS — sliding window. Each request
# extends the cookie's `expires` by this many seconds.
# Default 1800 (30 min).
# SESSION_ABSOLUTE_TIMEOUT_SECONDS — hard ceiling. Session is
# destroyed regardless of activity at this age.
# Default 43200 (12 h).
# 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
# CORS allowlist (per ADR-0009 §"CORS"). Comma-separated list of
# origins (scheme://host[:port]) allowed to call the BFF with
# credentials. The BFF refuses to start without this — silently
# defaulting to localhost is the classic "works in dev, breaks in
# prod" trap.
#
# Local dev: portal-shell on :4200 and portal-admin on :4300 — both
# call the BFF with credentials. Source of truth for the ports:
# `apps/<app>/project.json` `serve.options.port`.
CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:4300
# Rate limiting (per ADR-0015 §"DoS mitigation"). Both optional
# with conservative defaults; override per environment when traffic
# patterns demand it. The BFF keys buckets by session id when the
# request is authenticated, by remote IP otherwise — rotating
# sessions doesn't dodge the limit.
# RATE_LIMIT_PER_MINUTE — general bucket. Default 120 (~ 2 r/s).
# RATE_LIMIT_AUTH_PER_MINUTE — stricter bucket on /auth/login and
# /auth/callback. Default 10/min, to
# slow brute-force / replay loops
# without inconveniencing legit users.
# RATE_LIMIT_PER_MINUTE=120
# RATE_LIMIT_AUTH_PER_MINUTE=10
# Future env vars introduced by upcoming phases / ADRs:
#
# Auth flow (ADR-0009) — additional keys wired as the routes land:
# ENTRA_CLIENT_CERT_PATH (alternative to ENTRA_CLIENT_SECRET)
# ENTRA_ACCEPTED_TENANT_IDS (CSV; restricts which tenants can sign in
# in the multi-tenant phase — empty means
# "only ENTRA_TENANT_ID is accepted")
#
# Sessions (ADR-0010) — additional keys wired as the layers land:
# REDIS_SENTINEL_HOSTS (CSV `host:port,host:port,…`; prod HA)
# REDIS_SENTINEL_NAME (master name in Sentinel; prod HA)
# REDIS_TLS ('true' in prod)
#
# MFA (ADR-0011):
# MFA_FRESHNESS_SECONDS (default 600)
#
# Audit trail (ADR-0013):
# AUDIT_DATABASE_URL (separate creds, role 'audit_writer')
# AUDIT_ARCHIVER_DATABASE_URL (role 'audit_archiver', for the retention purge job)
# AUDIT_RETENTION_DAYS (default 365)
#
# Downstream API access (ADR-0014):
# OBO_CACHE_ENCRYPTION_KEY — wired
# BFF_JWKS_PRIVATE_KEY_PATH — wired
# BFF_JWKS_KID — wired
# <SERVICE>_API_BASE_URL (per integrated downstream — lands with the first integration)
# <SERVICE>_TIMEOUT_MS (optional, defaults to 5000 — lands with the first integration)
# AI service relay (ADR-0024) — the BFF dials apf-ai-service over
# native gRPC HTTP/2 and bridges chat streams to SSE for the SPA.
# apf-ai-service runs from its own repo (../apf-ai-service); use
# that repo's docker-compose.yml to bring it up locally, then point
# AI_SERVICE_GRPC_ENDPOINT here at the host-published port. No
# JWT/auth on the wire in v1 — the Principal travels in the proto
# body (per ADR-0024 §"Sub-decision 4 — POC unsigned principal").
AI_SERVICE_GRPC_ENDPOINT=localhost:8080
AI_SERVICE_CLIENT_ID=apf-portal-dev
# Set to 'true' in preprod/prod (h2 + TLS via the edge proxy);
# 'false' in dev for h2c against the local apf-ai-service.
AI_SERVICE_GRPC_TLS=false