main
2 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7d91da691b |
feat(auth): @RequirePrivilege/@RequireRole/@RequireScope guards (ADR-0025) (#208)
## Summary Phase 2 of [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md)'s implementation phasing (§"More Information"). The three new route decorators land — `@RequirePrivilege`, `@RequireRole`, `@RequireScope` — alongside the migration of the legacy `@RequireAdmin` to read from `principal.privileges`. Each guard consumes the session-resident `Principal` built by [#206](#206), evaluates its requirement, and either passes or emits a 403 + audit row. The drift-CI gate (phase 3) is **not** in this PR — it lands next, once the catalogue is being referenced from real route handlers. ## What lands ### Shared lib (`libs/shared/auth`) | File | Role | | --- | --- | | `src/lib/principal-matchers.ts` | Pure functions: `principalHasAnyPrivilege` / `principalHasAnyRole` / `principalCoversResource`. The matchers live in the shared lib so the SPA can render UI predicates with the same logic the BFF enforces server-side. | | Same file | `ScopableResource` type — optional FINESS / delegation / region / siege parentage. Callers populate the subset their route resource exposes; the matcher iterates and returns true on the first scope that covers a present field. | | `src/lib/principal-matchers.spec.ts` | 24 unit tests covering OR-composition, the empty-requirement degenerate case (treated as "no constraint" — pass), every scope kind, and the resource-side parentage chain. | ### BFF guards + decorators (`apps/portal-bff/src/auth`) | File | Role | | --- | --- | | `require-privilege.{decorator,guard}.ts` | `@RequirePrivilege('Portal.Admin', ...)` — type-locked to the closed `Privilege` catalogue; multiple values OR-combine. | | `require-role.{decorator,guard}.ts` | `@RequireRole('rh', ...)` — same shape, against `FunctionalRole`. | | `require-scope.{decorator,guard}.ts` | `@RequireScope(req => extractResource(req))` — extractor can be async (Prisma lookup pattern); returning `null` is treated as denial with empty `required[]`. | | `principal-extractor.ts` | Single read of `Principal` off the session, with a legacy-session bridge for principals minted before [#206](#206) landed — filters `user.roles` for `Portal.*` values and synthesises an `unrestricted` scope. After the 12 h absolute-TTL window post-deploy, the bridge becomes dead code. | | `principal-extractor.spec.ts` | 7 tests covering both code paths. | | `require-{privilege,role,scope}.guard.spec.ts` | Unit tests for each guard: 401 on anonymous, 403 + audit on denial, pass on match, generic `forbidden` response body (no role/privilege/resource hint leaks). | | `auth-guards.persona-matrix.spec.ts` | Integration tests: each of the 19 test-tenant personas walked through 5 representative privilege guards + 6 representative role guards. The matrix proves the contracts hold against the real provisioning, not just synthesised principals. | ### Audit module | File | Change | | --- | --- | | `audit.types.ts` | New `AuthorizationDeniedInput` discriminated by `kind: 'privilege' \| 'role' \| 'scope'`. Carries `required[]` and `held[]` arrays so an auditor can pivot on "tried admin while logged in as RH" patterns without joining anything. | | `audit.service.ts` | New `authorizationDenied()` method emitting the `auth.authorization_denied` event type. Distinct from the existing `admin.access_denied` so admin-surface signals stay clean. | | `audit.service.spec.ts` | 3 new tests covering the three `kind` values. | ### Legacy guard migration | File | Change | | --- | --- | | `admin/admin-role.guard.ts` | Reads `principal.privileges` instead of `user.roles`. Public `@RequireAdmin()` API unchanged; `admin.access_denied` event type unchanged. The audit row's `rolesHeld` field now carries `Portal.*` values rather than the raw legacy `roles` claim. | | `admin/admin-role.guard.spec.ts` | Rewritten to exercise `session.principal`-shaped sessions, with a dedicated legacy-bridge sub-suite that asserts pre-ADR-0025 sessions still work. | | `me/me.controller.ts` | `capabilities().canAccessAdmin` reads `principal.privileges` via the same extractor. | | `me/me.controller.spec.ts` | Rewritten against the principal shape. | ### AuthModule wiring | File | Change | | --- | --- | | `auth/auth.module.ts` | Three new guards registered as providers and re-exported. | ## Notes for the reviewer - **Composition semantics.** Within a single decorator, values OR-combine (`@RequireRole('rh', 'comptable')` matches anyone with either). Stacking decorators AND-combines at the Nest level — `@RequireRole('rh') @RequirePrivilege('Portal.Admin')` requires both, because each guard runs separately and a single denial stops the request. The empty-requirement case is rejected at the type level by the tuple `[Privilege, ...Privilege[]]` so a route can not opt out of authorization by passing zero values. - **Why `auth.authorization_denied` and not `admin.access_denied` for the new guards.** ADR-0025 §347 originally said "writes an `admin.access_denied` row", but reusing the `admin.*` namespace would have meant the future `@RequireRole('rh')` on a non-admin HR route ships an event whose type implies an admin surface. The new event type lives in the `auth.*` namespace where the guards live; a `kind` discriminator on the payload tells privilege / role / scope denials apart. The legacy `admin.access_denied` event continues unchanged for `AdminRoleGuard`. - **The legacy-session bridge is short-lived.** After the 12 h absolute-TTL window from this PR's deploy, every session in Redis has a `principal` field and the bridge's `else` branch becomes unreachable. The bridge keeps `@RequireAdmin` + `MeController` functional during that window without needing a forced re-auth campaign. - **Why a `ScopableResource` shape rather than a `Resource | Resource | ...` union per kind.** Routes protect heterogeneous resource types (`Etablissement`, `Dossier`, `Person`), each exposing a different subset of the parentage chain. The optional-field shape lets each route populate what its resource carries and lets the matcher iterate over the principal's scopes once. The cost is that a route author who forgets to populate `delegationCode` on an `Etablissement` resource locks delegation-scoped users out — a typing exercise that the next round of work (concrete consumers) will surface naturally. - **Why `principalCoversResource` deny on doubt.** When a route's extractor returns a resource that lacks the parentage `delegationCode` field, a `delegation:33` scope no longer matches — deny is the safe direction. An over-restrictive deny shows up in the audit log (operator can fix the extractor); an over-permissive pass would silently leak data. The non-transitivity of the parentage chain is an intentional contract. - **Why migrate `MeController` in the same PR.** The `canAccessAdmin` flag reads the same axis (`Portal.Admin` privilege). Leaving it on the legacy `user.roles` shape while everything else moves to `principal.privileges` would create internal inconsistency; a future reviewer would rightfully ask "why?". The migration is three lines plus a spec rewrite. - **The drift-CI gate is the next PR.** ADR-0025 §"More Information" step 3. ESLint custom rule (or a `pnpm run` script) grepping every `@RequirePrivilege('...')` / `@RequireRole('...')` / scope-kind literal in the codebase and asserting each one exists in the catalogue. Now there is something to grep for (the decorators and their tests reference catalogue values), so the gate is well-scoped to land standalone. - **No real consumers of `@RequireScope` yet.** The scope guard ships with a fully exercised contract (extractor signature, async support, audit shape, generic-forbidden body) but no live route. The first consumer surface lands with the `Person` + `User` schema (proposed ADR-0026) and the resource-loading routes that follow. ## Test plan - [x] `pnpm nx affected -t lint test build --base=main` — 3 projects green - `portal-bff`: 741 tests pass (was 497 in #206 — +244 new: matchers/guards/persona-matrix/audit/extractor). - `shared-auth`: 41 tests pass (was 17 in #206 — +24 new matcher tests). - `portal-bff-e2e`: lint green. - [x] `pnpm nx format:check` — clean after `pnpm nx format:write`. - [ ] **Review focus** — the 19-persona matrix (each persona walked through 5 privilege guards + 6 role guards); the legacy-session bridge in `principal-extractor.ts` and its tests; the `admin.access_denied` audit payload now carries `Portal.*` values rather than legacy `roles` (intentional, called out above); the `auth.authorization_denied` event-type rationale. ## What's next Per ADR-0025 §"More Information" phasing: 1. ✅ #206 — types + Principal builder + group-to-role mapping skeleton. 2. ✅ **This PR** — the three new decorators + guards, legacy `@RequireAdmin` migration. 3. **Next PR** — drift CI gate. ESLint custom rule (or `pnpm run` script) that asserts every `@RequireX('...')` literal in code is in the closed catalogue. 4. **Depends on ADR-0026** — `user_scopes` Prisma table + seed + `PrismaScopeResolver` replacing `StubScopeResolver`, then the first concrete consumers of `@RequireScope`. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #208 |
||
|
|
ee51efb688 |
feat(portal): /api/me/capabilities + cross-app menu links + real role label (#151)
## 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 |