feat(portal): /api/me/capabilities + cross-app menu links + real role label
CI / scan (pull_request) Successful in 4m4s
CI / commits (pull_request) Successful in 4m4s
CI / check (pull_request) Successful in 4m59s
CI / a11y (pull_request) Successful in 1m31s
CI / perf (pull_request) Successful in 5m25s

PR 3 of 3 — final piece of the user-menu / profile / cross-app
chantier.

BFF
  * New `MeModule` ships `GET /api/me/capabilities`, a curated view
    derived from the active user-portal session. Returns
    `{ canAccessAdmin: boolean }` today; the shape is deliberately
    binary so the SPA cannot reconstruct the raw `roles` claim. 401
    on missing session, consistent posture with `/auth/me`.
  * ADR-0009 amended: new "Curated public view" section codifies
    the "no raw roles on `/auth/me`, capabilities is the release
    valve" stance; the routes table grows a row for the endpoint.

portal-shell
  * New `CapabilitiesService` (app-local) calls the BFF on auth-state
    flip and exposes `canAccessAdmin` as a signal. Anonymous
    sessions short-circuit to the all-false default without firing
    a request.
  * Header's `userMenuItems` is now a `computed` — the "Open Portal
    Admin" entry appears in the dropdown only when
    `canAccessAdmin()` is true, pointing at
    `environment.adminAppUrl`.
  * Sidebar role widget reads the auth + capabilities signals to
    render one of three labels ("Anonymous" / "User" /
    "Administrator") in place of the hardcoded "Anonymous".

portal-admin
  * Symmetric: unconditional "Open Portal Shell" entry in the admin
    user menu, pointing at `environment.shellAppUrl`. Anyone who
    can reach portal-admin can reach portal-shell, so no
    capabilities check needed.

i18n
  * `header.userMenu.openAdmin`, `sidebar.role.{administrator,user}`,
    `sidebar.role.aria` reshaped to take the role as a placeholder.
This commit is contained in:
Julien Gautier
2026-05-15 16:05:50 +02:00
parent 1160771061
commit 04138b435a
16 changed files with 520 additions and 52 deletions
@@ -120,15 +120,18 @@ In **local development** (HTTP `localhost`), browsers reject the `__Host-` prefi
**Routes.** All authentication endpoints live under the `/auth` prefix on the BFF:
| Method | Path | Purpose |
| ------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET` | `/auth/login` | starts the flow; accepts an optional `returnTo` query parameter validated against an in-app path allowlist; redirects the browser to Entra's `authorize` endpoint with `state`, `nonce`, and PKCE parameters |
| `GET` | `/auth/callback` | receives `code` + `state` from Entra; verifies state, exchanges via MSAL Node, validates id_token, creates the session, redirects to `returnTo` (or `/`) |
| `POST` | `/auth/logout` | requires `X-CSRF-Token`; invalidates the BFF session; returns 200 with the Entra `end_session_endpoint` URL in JSON; the SPA navigates the browser to that URL |
| `GET` | `/auth/me` | returns the current user's id, audience, and a curated subset of claims for the SPA to display (no tokens) |
| Method | Path | Purpose |
| ------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET` | `/auth/login` | starts the flow; accepts an optional `returnTo` query parameter validated against an in-app path allowlist; redirects the browser to Entra's `authorize` endpoint with `state`, `nonce`, and PKCE parameters |
| `GET` | `/auth/callback` | receives `code` + `state` from Entra; verifies state, exchanges via MSAL Node, validates id_token, creates the session, redirects to `returnTo` (or `/`) |
| `POST` | `/auth/logout` | requires `X-CSRF-Token`; invalidates the BFF session; returns 200 with the Entra `end_session_endpoint` URL in JSON; the SPA navigates the browser to that URL |
| `GET` | `/auth/me` | returns the current user's id, audience, and a curated subset of claims for the SPA to display (no tokens, no raw `roles` claim — see "curated public view" below) |
| `GET` | `/me/capabilities` | curated capabilities view derived from the session (currently `{ canAccessAdmin: boolean }`); drives binary SPA gates without exposing the raw `roles` claim — see "curated public view" below |
**Authorization layer.** A NestJS `AuthGuard` checks for a valid session and rejects with 401 otherwise. A `@CurrentUser()` decorator extracts `{ id, audience, claims }` from the request scope. Every controller (other than `/auth/*` itself, `/health`, etc.) is protected by `AuthGuard` by default — the framework default is "denied", explicit allow-listing is required.
**Curated public view.** The `/auth/me` payload exposes a deliberately narrow projection of the session: `oid`, `tid`, `username`, `displayName`. **The raw `roles` claim is _not_ part of `/auth/me`** — it stays server-side, consumed by the BFF's own guards (`@RequireAdmin`, downstream-API gates, etc.). The SPA derives binary UX hints from a dedicated companion endpoint, `GET /me/capabilities`, which returns a curated structure (today `{ canAccessAdmin: boolean }`; further keys land as conditional UI requires). The shape is intentional: the SPA can never reconstruct the raw role names from the curated view, so introducing additional internal-only roles (auditor, redactor, …) does not widen the SPA-side surface. The authoritative gate remains BFF-side via `@RequireAdmin` & co.; capabilities is UX-only. The admin-portal `/api/admin/auth/me` exposes `roles` explicitly because it _is_ the admin surface — see ADR-0020.
**Configuration.** All Entra-specific values come from environment variables — no tenant ID, client ID, or secret in source. The BFF refuses to start if any required variable is missing.
| Variable | Purpose |