Commit Graph

3 Commits

Author SHA1 Message Date
julien ee51efb688 feat(portal): /api/me/capabilities + cross-app menu links + real role label (#151)
CI / scan (push) Successful in 2m13s
CI / commits (push) Has been skipped
CI / check (push) Successful in 3m48s
CI / a11y (push) Successful in 1m53s
CI / perf (push) Successful in 3m46s
## Summary

PR 3 of 3 — final piece of the user-menu / profile / cross-app chantier. Closes the loop with the BFF capabilities endpoint, symmetric cross-app entries in both user menus, and a real role label in place of the hardcoded "Anonymous" widget on the portal-shell sidebar.

| PR | Périmètre |
| --- | --- |
| PR 1  | Shared `UserMenu` dropdown + integration. |
| PR 2  | `/profile` pages on both apps. |
| **PR 3 (this one)** | `GET /api/me/capabilities` + real sidebar role label + cross-app menu entries. |

## What lands

### BFF — `GET /api/me/capabilities`

New [`MeModule`](apps/portal-bff/src/me/me.module.ts) wiring a single endpoint:

```ts
GET /api/me/capabilities → { canAccessAdmin: boolean }
```

Resolved against the user-portal session ([`portal_session`](apps/portal-bff/src/me/me.controller.ts) — the path-routed session middleware in `main.ts` already maps `/api/me/*` to that session). Returns 401 if no session is present, consistent with `/api/auth/me`. 5 specs cover the four state combinations + a regression-fence asserting the curated view never leaks the raw `roles` array.

### ADR-0009 amendment

[`docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md`](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md) — new **"Curated public view"** section codifies the design stance the user picked when we agreed the staging:

> 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 […]. The SPA derives binary UX hints from a dedicated companion endpoint […]. The shape is intentional: the SPA can never reconstruct the raw role names from the curated view, so introducing additional internal-only roles […] does not widen the SPA-side surface.

The routes table grows a row for `/me/capabilities`.

### Portal-shell — capabilities-driven UI

- **New service** [`CapabilitiesService`](apps/portal-shell/src/app/services/capabilities.service.ts) (app-local, not in `feature-auth` — the admin app has its own roles channel via `/api/admin/auth/me` and would never use this). Signals: `capabilities`, `canAccessAdmin`. Fires `GET /me/capabilities` reactively via an `effect` that watches `auth.currentUser()`. Anonymous sessions short-circuit to the all-false default without a fetch — the BFF would 401 anyway.
- **Header** ([`header.ts`](apps/portal-shell/src/app/components/header/header.ts)) — `userMenuItems` is now a `computed`. Profile + Settings stay unconditional; **"Open Portal Admin"** appears only when `canAccessAdmin()` flips true, with `href = environment.adminAppUrl`. Per ADR-0020 the two SPAs live on distinct origins, so this is a raw cross-origin anchor, not a routerLink.
- **Sidebar** ([`sidebar.ts`](apps/portal-shell/src/app/components/sidebar/sidebar.ts) + [`sidebar.html`](apps/portal-shell/src/app/components/sidebar/sidebar.html)) — the hardcoded `Role: Anonymous` widget is replaced by a derived computed:
  - `Anonymous` when no session.
  - `Administrator` when `canAccessAdmin()` is true.
  - `User` otherwise (signed-in, no admin).

  The aria-label gains a `role` placeholder so screen readers hear the live value.

### Portal-admin — symmetric cross-app entry

- **Header** ([`header.ts`](apps/portal-admin/src/app/components/header/header.ts)) — adds an unconditional `Open Portal Shell` row pointing at `environment.shellAppUrl`. Anyone able to reach portal-admin can reach portal-shell, so no capabilities check needed; admins always benefit from a one-click jump back to the end-user surface.

### Environments

`adminAppUrl` and `shellAppUrl` added to the respective [`environment.ts`](apps/portal-shell/src/environments/environment.ts) files (dev defaults: `:4300` for admin, `:4200` for shell). Per-env siblings (staging / prod) will override the host once they exist, per ADR-0018.

### i18n

| Key | EN source | FR target |
| --- | --- | --- |
| `header.userMenu.openAdmin` | Open Portal Admin | Ouvrir Administration APF Portal |
| `sidebar.role.administrator` | Administrator | Administrateur |
| `sidebar.role.user` | User | Utilisateur |
| `sidebar.role.aria` | reshaped with `{role}` placeholder | reshaped likewise |

Admin-side strings stay in English source per ADR-0020.

## Notes for the reviewer

- **Why `CapabilitiesService` in the app, not in `feature-auth`?** Only `portal-shell` will ever call `/api/me/capabilities` — the admin SPA hits `/api/admin/auth/me` which already returns `roles`. Putting the service in `feature-auth` would publish a tree-shakable `providedIn: 'root'` injectable that ships in both bundles. Keeping it app-local makes the boundary explicit.
- **Why a `computed` for `userMenuItems` rather than mutating an array in an `effect`?** Signals + computed = single source of truth. The shared `UserMenu` re-renders automatically when the items list changes (whenever capabilities flips). Less ceremony than maintaining a `WritableSignal<UserMenuItem[]>`.
- **Why the `flushPendingEffects` test helper?** Zoneless apps rely on the signals scheduler to dispatch `effect()` callbacks via micro-task scheduling. `fixture.detectChanges() + whenStable()` once is not enough: the chain is `meReq.flush()` → `_state.set()` → effect scheduled → effect fires → `http.get()` queued. The helper loops 4× to give the scheduler enough rounds to settle before `expectOne(CAPABILITIES_URL)` looks up the request.
- **Why no test for the dev URL values?** `environment.ts` is config that gets swapped at build time per ADR-0018; the values themselves are environmental. Asserting the dev value in a test would lock in a port (4200/4300) that's separately configured in `project.json`.

## Test plan

- [x] `pnpm nx test portal-bff` — **401 specs pass** (was 396, +5 for `MeController`).
- [x] `pnpm nx test portal-shell` — **40 specs pass** (was 35, +5: 3 sidebar role-label + 2 header admin-link).
- [x] `pnpm nx run-many -t lint test build --projects=portal-shell,portal-admin,portal-bff,shared-ui,shared-state,feature-auth` — 18/18 tasks green, including the i18n-strict `portal-shell:build:production`.
- [ ] Manual smoke (with a `Portal.Admin`-assigned account):
  - Sign in on portal-shell → sidebar reads `Role: Administrator`, user menu lists `Open Portal Admin` at the bottom (right above the Sign-out separator), clicking the link opens `localhost:4300`.
  - Sign in on portal-admin → user menu lists `Open Portal Shell`, clicking opens `localhost:4200`.
  - Sign in on portal-shell with a non-admin account → sidebar reads `Role: User`, `Open Portal Admin` is absent.
  - Sign out → sidebar reads `Role: Anonymous`, the menu collapses to its anonymous-state Sign-in button.

## What's next

Chantier closed. The user-menu shape is now stable; further entries (notifications inbox, theme override, locale switcher inside the menu rather than the footer) plug into the existing `items` API without re-shaping the component.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #151
2026-05-15 16:11:26 +02:00
julien 5bbe2304ff feat(portal-bff): helmet + env-driven CORS allowlist + double-submit CSRF (#122)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m10s
CI / check (push) Successful in 3m16s
CI / a11y (push) Successful in 1m6s
CI / perf (push) Successful in 4m16s
## Summary

Phase-2 security baseline that the `main.ts` placeholder note has been advertising since the auth/session work began. Three independent middlewares + their SPA counterparts, all mounted in a single PR because they only become meaningful together.

### Helmet on the BFF

`helmet()` with three overrides matching our specific shape:

- **HSTS only in production** — dev runs on plain HTTP, HSTS is just noise.
- **`crossOriginResourcePolicy: 'cross-origin'`** — the SPA on its own origin reads JSON from the BFF; the default `same-origin` would block it.
- **CSP disabled in non-production** — the BFF doesn't render HTML, so CSP on JSON responses is mostly inert, but Helmet's default CSP triggers noisy `connect-src` violations in browser devtools that we don't need.

Everything else is Helmet defaults: `X-Frame-Options=SAMEORIGIN`, `X-Content-Type-Options=nosniff`, `Referrer-Policy=no-referrer`, `X-Powered-By` removed, etc.

### CORS allowlist, env-driven

`CORS_ALLOWED_ORIGINS` env (comma-separated) is now **mandatory** at boot. The BFF refuses to start without it via `readCorsAllowlist()` — same boot-time validator family as `assertSessionSecret` etc. The previous hardcoded `http://localhost:4200` fallback is gone; getting CORS wrong silently is the kind of "works in dev, breaks in prod" trap the validator is specifically designed to catch. `X-CSRF-Token` is now in the allowed headers.

### Double-submit CSRF

- BFF mints a 256-bit `csrfToken` at session creation (`/auth/callback`), stored on `req.session.csrfToken` and mirrored to a JS-readable cookie (`__Host-portal_csrf` prod / `portal_csrf` dev). The cookie is the SPA's read-only view; the server-side session is the source of truth.
- `createCsrfMiddleware` (mounted after the session middleware in `main.ts`) compares the `X-CSRF-Token` header with `req.session.csrfToken` using `crypto.timingSafeEqual`. Skips:
  - safe methods (`GET / HEAD / OPTIONS`),
  - anonymous requests (no `req.session.user`),
  - `/api/auth/login` and `/api/auth/callback` (those mint the token themselves).
- Mismatch → `403 {"error":"csrf"}` with a structured Pino warn.
- SPA's `csrfInterceptor` reads the cookie via `document.cookie` and copies its value into `X-CSRF-Token` on every mutating BFF request. The header is omitted on `GET / HEAD / OPTIONS` (BFF skips them anyway) and on non-BFF origins.
- Logout and the absolute-timeout middleware both clear the CSRF cookie alongside the session cookie.

## Notable choices

**Session-bound double-submit, not pure cookie-vs-header.** A naive "compare cookie with header" check is defeated when an attacker can plant a cookie (subdomain takeover, etc.). Comparing the header to the server-side session-stored token instead means the attacker would also need to be the authenticated user — which is what CSRF defense is supposed to prevent in the first place.

**No CSRF for anonymous mutating routes (v1).** None exist today; we don't have an unauthenticated POST endpoint anywhere. Generating a CSRF token for anonymous sessions would conflict with `saveUninitialized: false` on express-session and add complexity we don't need yet. Anonymous public-form CSRF defenses (site-key, captcha) land if and when those routes ship.

**`SameSite=Lax`, not `Strict`, on the CSRF cookie.** Matches the session cookie's policy so the two travel together on the SPA→BFF cross-origin same-site fetch (different ports = different origin, same registrable domain). The double-submit pattern is what gives the protection; `SameSite=Lax` is a belt-and-braces layer.

**`csrfInterceptor` runs after `bffCredentialsInterceptor` and before `bffUnauthorizedInterceptor` in the chain.** Order: credentials first (set `withCredentials`), then CSRF (set the header), then unauthorized handling (catch 401s). Forward order, no surprises.

**`CORS_ALLOWED_ORIGINS` has no localhost fallback.** I considered keeping the fallback for ergonomics but it makes the BFF silently misconfigured if someone forgets the env. The error message points straight at the file to edit.

## Out of scope (next PRs)

- Rate limiting + structured error filter (still in the phase-2 to-do).
- CSP fine-tuning when we have actual HTML pages (portal-shell + portal-admin static serving).
- CSRF token rotation on idle-extension (today the token lives the session's lifetime; refreshing on each request would invalidate in-flight mutations).

## Test plan

- [x] `pnpm nx run-many -t test --projects=portal-bff,feature-auth,portal-shell` clean env → **177 + 28 + 34 = 239/239 pass** (was 144 + 19 + 34 = 197 before; +42 specs across CSRF middleware, CSRF cookie helpers, CORS allowlist parser, csrfInterceptor, and extended auth.controller / absolute-timeout coverage).
- [x] `pnpm nx run-many -t lint build --projects=portal-bff,feature-auth,portal-shell` → clean.
- [x] **CI clean-env repro** (lesson from prior PRs): every env var unset (including new `CORS_ALLOWED_ORIGINS`) → tests still pass. The BFF refuses to boot without `CORS_ALLOWED_ORIGINS`, which is the intended behaviour.
- [x] Prettier-clean.
- [ ] Manual smoke against running BFF:
  - [ ] Sign in → `__Host-portal_csrf` (prod) / `portal_csrf` (dev) cookie set, value matches `audit.events.payload->>actorIdHash`-style traceability via `req.session.csrfToken` in Redis.
  - [ ] Hit a future POST route from the SPA → request carries `X-CSRF-Token`, BFF accepts.
  - [ ] Forge a POST without the header (curl) → 403 `{"error":"csrf"}`.
  - [ ] Sign out → both cookies cleared.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #122
2026-05-13 20:50:44 +02:00
julien 8f125d2a90 feat(portal-shell): wire environment.ts per ADR-0018 (#90)
CI / check (push) Successful in 2m46s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m29s
CI / a11y (push) Successful in 1m14s
CI / perf (push) Successful in 3m21s
## Summary

First implementation step of ADR-0018. Create [`src/environments/environment.ts`](apps/portal-shell/src/environments/environment.ts) holding the two SPA per-environment values the ADR calls out — `bffApiBaseUrl` and `otlpEndpoint` — and replace the hard-coded URLs at the two SPA call sites that needed them.

## What changes

- **New `environment.ts`** with dev defaults (`http://localhost:3000/api` and `http://localhost:4318/v1/traces`). Header comment links to ADR-0018, documents the constraint that per-environment siblings must share the same shape, and notes that nothing here is a secret (the SPA bundle is public).
- **`observability/tracing.ts`** reads `environment.otlpEndpoint` for the exporter, and **derives** the `propagateTraceHeaderCorsUrls` regex from `environment.bffApiBaseUrl` — a future change to the BFF origin propagates `traceparent` to the right host automatically, no second edit needed.
- **`home-status.service.ts`** builds `/health` as `${environment.bffApiBaseUrl}/health`.

## What this PR explicitly does NOT do

- **No `environment.staging.ts` / `environment.prod.ts`** yet. The ADR says "ship later" for those, and the real prod / staging URLs are unknown until the infrastructure ADR lands. Dropping plausible-but-wrong URLs into the repo would be worse than waiting.
- **No `fileReplacements` configuration in `project.json`** — it depends on the per-environment files existing. Wired in the same PR that introduces them.
- **No BFF-side audit pool split** (`AUDIT_DATABASE_URL` validator, second Prisma client, boot-time UPDATE-rejection self-test). Also in the ADR's Confirmation list, but it touches `AuditModule` and deserves its own review. Separate PR.
- **No `SERVICE_VERSION` wiring** in `tracing.ts`. Still hard-coded to `'dev'`; the build-time version source (same one that will feed the footer's dev-only version badge) is its own small chantier.

## Test plan

- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (36 / 36 specs unchanged, no new tests needed).
- [x] Production build size unchanged (121 kB gzip initial — `environment.ts` is one literal object inlined by the bundler).
- [ ] Manual: `pnpm exec nx serve portal-shell` → home page loads, the health widget hits the BFF, Jaeger shows the SPA `document_load` + `fetch` + BFF child span trace.
- [ ] Manual: temporarily change `bffApiBaseUrl` to `http://localhost:9999/api` → the fetch fails (expected), and the `traceparent` propagation regex no longer matches `:3000` (verifiable in Network panel — header is absent on cross-origin requests).

---------

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