Files
apf_portal/docs/decisions/0020-portal-admin-app.md
T
julien 2480d0dd6d
CI / check (push) Successful in 2m47s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m47s
CI / a11y (push) Successful in 1m30s
CI / perf (push) Successful in 3m57s
fix(portal-bff): align admin entra role name with Portal.Admin (#145)
## Summary

The [`AdminRoleGuard`](apps/portal-bff/src/admin/admin-role.guard.ts) was matching on the literal `'admin'`, but the Entra app registration declares the admin app role with `value: "Portal.Admin"`. End result: an authenticated user with the role assigned in Entra still landed with `roles: []` in their session (claim simply not present in the id token), and every request to `/api/admin/audit` and `/api/admin/users` returned a **403**.

Caught manually in the portal-admin SPA: login succeeded, sidebar links to "Audit log" / "User list" returned 403. The [`/api/admin/auth/me`](apps/portal-bff/src/admin/admin-auth.controller.ts) self-test confirmed the missing claim was the cause.

## What lands

### Constant value — single source of truth

[`apps/portal-bff/src/admin/admin-role.guard.ts:18`](apps/portal-bff/src/admin/admin-role.guard.ts#L18):

```diff
-export const ADMIN_ROLE = 'admin';
+export const ADMIN_ROLE = 'Portal.Admin';
```

[`admin-role.guard.spec.ts`](apps/portal-bff/src/admin/admin-role.guard.spec.ts) already imports `ADMIN_ROLE` from the source rather than hardcoding the literal, so the guard contract spec rolls through unchanged. The fixtures elsewhere ([`auth.service.spec.ts`](apps/portal-bff/src/auth/auth.service.spec.ts), [`admin.controller.spec.ts`](apps/portal-bff/src/admin/admin.controller.spec.ts), [`admin-auth.controller.spec.ts`](apps/portal-bff/src/admin/admin-auth.controller.spec.ts), [`require-mfa.guard.spec.ts`](apps/portal-bff/src/auth/require-mfa.guard.spec.ts)) keep `roles: ['admin']` as fixture data — those tests exercise the extraction / serialization pipeline, which is role-value-agnostic; touching them would be incidental cleanup with no behaviour signal.

### Doc-comment refresh

Inline references to the role name updated so future readers don't grep `'admin'` and find a phantom value:

- [`admin-role.guard.ts`](apps/portal-bff/src/admin/admin-role.guard.ts) — class doc-block (3 mentions).
- [`admin.controller.ts`](apps/portal-bff/src/admin/admin.controller.ts) — class doc-block + inline guard-contract comment.
- [`audit.service.ts`](apps/portal-bff/src/audit/audit.service.ts) — `adminAccessDenied` doc-block (2 mentions).

### Documentation

- [`docs/decisions/0020-portal-admin-app.md`](docs/decisions/0020-portal-admin-app.md) — 5 references to the role across §"How is admin access enforced", §"Auth — same Entra ID …", and the Consequences §.
- [`docs/architecture.md`](docs/architecture.md) — note next to the C4 container diagram describing the admin entry gate.
- [`CLAUDE.md`](CLAUDE.md) — "Admin application" project rule.

## Notes for the reviewer

- **Why `Portal.Admin` rather than `admin`?** Operator's call on the Entra side. The `<Application>.<Role>` namespace is the conventional Entra App Role pattern when the directory may host roles for multiple applications, and `admin` alone is ambiguous in a directory shared across products.
- **Why no migration / backfill?** The role value lives only in two places: Entra app-registration manifest (operator-managed) and the BFF constant (this PR). Existing Redis sessions captured `roles: []` (claim absent) — they'll naturally pick up the correct value on next sign-in. No persisted data references the old value.
- **No ADR.** ADR-0020 §"How is admin access enforced" already commits to "Entra ID role claim + BFF guard"; the literal role string is an implementation detail the ADR happened to spell. Updated the ADR's prose to the new value to keep the doc honest, but the decision is unchanged.

## Test plan

- [x] `pnpm nx test portal-bff` — **396 specs pass**, unchanged from `main`. The `AdminRoleGuard` contract spec (covers 401-on-no-session, 403-on-missing-role + audit emission, pass-through-on-role-present) imports `ADMIN_ROLE` and re-exercises with the new value.
- [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean.
- [ ] Manual verification — pending Entra-side App Role declaration with `value: "Portal.Admin"` + assignment to the test user. Once both exist: sign out + sign in on portal-admin, hit `/api/admin/auth/me` and confirm `roles: ["Portal.Admin"]`, then click "Audit log" + "User list" and confirm both render. An `admin.access_denied` row in `audit.events` is the negative-test signal (still emitted for any user without the role).

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #145
2026-05-15 10:33:54 +02:00

218 lines
19 KiB
Markdown

---
status: accepted
date: 2026-05-11
decision-makers: R&D Lead
tags: [frontend, backend, security, infrastructure, process]
---
# `portal-admin` — dedicated SPA for portal administration, sharing the existing BFF
## Context and Problem Statement
The portal needs a place for administrative tasks that are not part of the end-user product surface:
- **Static / editorial page management** — create, edit, publish localised pages (per ADR-0019, FR and EN content) outside the developer's deploy loop.
- **Menu management** — toggle which sidebar items are visible, control which roles each item is gated on (the `requiredPermissions` field already reserved in the `MenuItem` shape).
- **User list** (read-only) — see who has authenticated, when they last connected, which audience they came from.
- **Audit log viewer** — filter the `audit.events` table per [ADR-0013](0013-audit-trail-separated-postgres-append-only.md) (actor, action, date range), without giving auditors direct SQL access.
These features share three traits that distinguish them from end-user features:
1. **Audience is disjoint** — APF internal staff with an admin role, not the general user population. Bundle-size optimisations targeted at end users do not apply.
2. **Security context is elevated** — every admin action mutates content / configuration / observes audit data. Defense in depth pays back here.
3. **UX style is data-dense** — tables, filters, bulk actions, formularies. The end-user portal is task-oriented and visually lighter.
This ADR records **where the admin lives**, **what ships in v1**, and **what stays explicitly out of scope** (so the v1 effort is bounded).
## Decision Drivers
- **Defense in depth** — admin code, admin endpoints, and admin auth context should be isolatable. A compromise of the end-user SPA should not give an attacker the admin surface, and vice versa.
- **Operational restrictability** — the admin app should be deployable behind an IP allow-list, on a VPN, or behind a stricter Entra Conditional Access policy (ADR-0011), without affecting the end-user portal.
- **No infrastructure duplication** — the project is small. Two databases, two audit logs, two observability stacks would be bricolage.
- **Compatibility with the existing security stack** — Entra ID identity (ADR-0008), OIDC + PKCE auth flow (ADR-0009), Redis sessions (ADR-0010), audit append-only schema (ADR-0013). The admin should reuse these, not parallel them.
- **Performance bar** — the end-user portal has a 300 KB initial bundle budget (ADR-0017). Admin code lazy-loaded inside the same SPA risks regressions; a separate SPA removes the risk entirely.
- **Discoverability for auditors** — the admin URL is distinct from the user portal URL. An auditor inspecting deploy artefacts, DNS, or access logs sees the admin surface as a first-class concern.
## Considered Options
### Where does the admin app live
- **New Angular SPA `apps/portal-admin`, same BFF (`portal-bff`)** _(chosen)_. Adds one frontend app to the Nx workspace; the BFF gains an `/api/admin/*` route family guarded by RBAC. Distinct deploy URL.
- **`/admin/*` route prefix inside `portal-shell`**, lazy-loaded. Single SPA, shared libs are trivial. Bundle bloat is mitigated by lazy loading. Auth gating is via a route guard on the `/admin` parent.
- **Two SPAs AND two BFFs (`portal-admin-bff`)**. Hard isolation at every layer. Doubles the infrastructure: two Postgres connections to manage, two OBO token caches, two OpenTelemetry pipelines, two deploy targets.
- **Off-the-shelf admin tooling** (Retool / Forest Admin / SQLpad-style). Bypasses our security baseline (sessions, audit log, observability). Hard to keep within the project quality bar.
### How is admin access enforced
- **Entra ID role claim + BFF guard** _(chosen)_. The user's app role in Entra carries `Portal.Admin` (assignment managed in Entra Admin Center). The BFF reads the role claim on every request to `/api/admin/*`; missing role → 403. The SPA does not own authorisation; it just decides what to render based on the claim it sees through the session payload.
- **Static IP / VPN gate only**. Network-layer isolation but no identity-bound check. Not sufficient; if an internal user without admin role lands on the admin URL, they should not be able to act.
- **Distinct OIDC client / scope per app**. Two app registrations in Entra, one per audience. Symmetric but operationally heavier without clear payoff at our scale.
### Session sharing between `portal-shell` and `portal-admin`
- **Distinct sessions per app** _(chosen)_. Each app authenticates independently against the BFF; the cookie domain / path scoping (`__Host-` prefix, ADR-0010) keeps the sessions isolated. An admin signed in to `portal-admin` is not silently authenticated to `portal-shell` and vice versa. The signing in to admin requires a fresh auth event — which Entra Conditional Access can then enforce as MFA-fresh (ADR-0011's `@RequireMfa` decorator becomes the natural gate for the admin entry route).
- **Shared session across apps**. Would require domain-wide cookies and breaks the `__Host-` security guarantee.
### v1 scope — which features ship now
The user has selected all four candidate features. They are independent enough to land iteratively in their own PRs, all within `portal-admin`:
1. **Editorial pages CMS** — CRUD on a `cms_page` table, fields: `slug`, `locale`, `title`, `body` (markdown), `published_at`, `updated_by`, `version`. Localised per ADR-0019 conventions (one row per `(slug, locale)`).
2. **Menu management** — CRUD on a `menu_item` table that the BFF reads to compose the response for `GET /api/me/menu`. The shape mirrors the existing static `MenuItem` interface in `portal-shell` (label, icon, route, `requiredPermissions[]`, `displayOrder`, `enabled`).
3. **User list (read-only)**`GET /api/admin/users` returns a paginated, filterable view derived from `audit.events` (login events) joined with whatever the BFF caches per user (per-tenant Entra `oid` ↔ display name mapping). No write actions in v1; invitations stay in Entra Admin Center.
4. **Audit log viewer**`GET /api/admin/audit` with filters on `actor_id_hash`, `action`, time range, `target` resource. The query talks to the `audit_reader` Postgres role per ADR-0013. UI: filter form + virtualised table.
### v1 explicitly out of scope
- **B2B user invitations from the admin UI**. Stays in Entra Admin Center. Double-source-of-truth would be a recurring divergence risk; defer until APF Ops actually asks for a unified flow.
- **Feature flag toggles**. We do not yet have a feature-flag substrate; revisit once one is chosen via its own ADR.
- **Theme / branding customisation**. Locked in code via the brand palette in `styles.css`; no per-tenant theming in v1.
- **CMS preview UI** in the user-facing portal. The admin's "preview" is an iframe rendering the user-portal route with a `?previewToken=…` query param; rich live-preview features defer to v2.
- **Workflow / approval flows on CMS edits**. Single-stage save in v1 (the editor publishes directly). Multi-stage workflow (draft → reviewed → published) is its own ADR if APF Comms requests it.
## Decision Outcome
### Architecture — separate SPA, shared BFF
```
apps/
portal-shell/ ← end-user SPA (existing)
portal-bff/ ← shared NestJS BFF (existing)
↳ /api/... ← end-user routes
↳ /api/admin/... ← admin routes, RBAC-gated
portal-admin/ ← admin SPA (new)
```
- `portal-admin` is a separate Angular workspace app per the Nx `apps` preset. Same Angular major (latest LTS), same standalone + zoneless + Signals + CSR-only configuration as `portal-shell` (ADR-0004).
- Shared UI primitives that turn out to need promoting (the `Icon` façade, `LayoutStateService`, brand tokens, Tailwind dark-mode variant, button / form primitives) graduate to `libs/shared/ui` and `libs/shared/state` when the second consumer materialises — that is _now_. They stay app-local in `portal-shell` until then; the move is mechanical.
- The BFF gains `apps/portal-bff/src/admin/admin.module.ts`. Every admin controller is guarded by a Nest `@UseGuards(AdminRoleGuard)` that asserts the session's role claim contains `Portal.Admin`. The guard returns 403 (not 401) on missing role — the user _is_ authenticated, just not authorised. Audit log captures every 403 with the actor and the attempted route.
- `portal-admin` ships under a distinct origin / hostname (e.g. `admin.portal.apf.fr` or `portal.apf.fr/admin/` depending on the production hosting decision in the future infrastructure ADR). The exact URL is **not** locked here — it depends on TLS / reverse-proxy choices. Lock-in level: medium; either option lets us IP-restrict or VPN-gate the admin URL without affecting `portal-shell`.
### Auth — same Entra ID, same MSAL Node, `Portal.Admin` role claim, fresh-MFA at entry
- Identity & auth flow per ADR-0008 / ADR-0009 are reused as-is. `portal-admin` is **not** a new Entra app registration in v1 — it uses the same registration with an additional `Portal.Admin` app role assignable in Entra Admin Center.
- On every request to `/api/admin/*`, the BFF guard reads `session.user.roles` and requires `Portal.Admin`. Missing role → 403 with an audit log entry (`action: 'admin.access_denied'`).
- The admin SPA's entry route is decorated with `@RequireMfa({ freshness: 600 })` (ADR-0011) — even though the standard portal does not require fresh MFA in v1, the admin app always does. Concrete consequence: signing in to admin re-prompts MFA if the user's last MFA event is older than 10 minutes.
### Sessions — distinct from `portal-shell`
- The admin SPA uses its own `__Host-portal_admin_session` cookie scoped to the admin origin. The Redis payload (ADR-0010) is structured identically but lives under a separate key namespace (`session:admin:<id>`).
- Logging in to `portal-shell` does **not** sign the user in to `portal-admin` and vice versa. SSO is preserved (Entra Single Sign-On at the IdP level still kicks in, no second password prompt) but session establishment is per-app.
### Database — same Postgres, dedicated schemas for admin-managed data
- New schemas added to the existing Postgres instance (per ADR-0006):
- `cms.pages` — editorial content per locale.
- `cms.menu_items` — admin-managed menu structure.
- Migrations land in `apps/portal-bff/prisma/migrations/`, owned by the same Prisma schema. Two-step rollout per ADR-0006 for any change to existing tables; pure-additive for the new schemas.
- The admin module's Prisma client uses the **shared** `DATABASE_URL` pool (full CRUD). The audit log connection stays on `AUDIT_DATABASE_URL` (or the dev fallback per ADR-0018) — admin _writes_ to `audit.events` via the audit module, admin _reads_ via the `audit_reader` role, never directly via the admin schema.
### Observability — same OTel pipeline, distinct service name
- `portal-admin` registers as `service.name=portal-admin` in OTel resource attributes (ADR-0012). Logs, traces, and metrics are routed to the same collector, distinguishable by service name in dashboards. The admin SPA's user-interaction instrumentation is enabled by default (no end-user privacy concern; admin users are a small known set).
- Audit log captures every admin write action with `actor_id_hash`, `action` (e.g. `admin.cms_page.update`), `target` (e.g. `cms_page:<slug>:<locale>`), and `before` / `after` snapshots. Read actions (audit log viewing, user list browsing) are also captured at coarser granularity (`admin.audit.query`) to deter fishing expeditions.
### Performance budgets
- `portal-admin` inherits the ADR-0017 budgets with a relaxation acknowledged here: the admin audience tolerates a heavier initial bundle (table virtualisation, formularies, rich text editor). Concrete numbers:
- **Initial bundle**: ≤ **500 KB gzip** (vs 300 KB for `portal-shell`). Justified by audience + by tables / charts dependencies.
- **LCP / INP / CLS / TBT**: same Core Web Vitals targets as `portal-shell` ("Good" thresholds). Admin users still deserve a responsive UI.
- **Lighthouse**: Performance ≥ 85 on critical admin routes (vs ≥ 90 on `portal-shell`). Five-point allowance for richer interactions.
- Bundle budgets enforced in `apps/portal-admin/project.json` as `type: "error"` thresholds (same mechanism as `portal-shell`).
### Accessibility & visual identity
- `portal-admin` inherits the ADR-0016 baseline: WCAG 2.2 AA + targeted AAA, RGAA 4.1, same brand palette. The denser UI (tables, formularies) makes some AAA criteria harder (1.4.6 contrast on disabled rows, 2.4.9 link purpose in dense tables) — those are best-effort with documented exceptions, not blanket waivers.
- Dark mode (per the recent theme switcher chantier) is supported from day one; the admin shares `LayoutStateService` once it graduates to `libs/shared/state`.
### Confirmation
**Wired across a sequence of PRs**, each independent and reviewable on its own:
1. **App skeleton**: `nx g @nx/angular:app portal-admin` per the conventions of ADR-0003. Smoke route `/` + a stub `<app-root>` rendering a placeholder shell. CI gates apply.
2. **Shared libs graduation**: extract `Icon`, `LayoutStateService`, brand tokens, dark-mode SCSS helpers into `libs/shared/ui` + `libs/shared/state`. `portal-shell` consumes from there; `portal-admin` consumes the same.
3. **BFF admin module**: `AdminModule` with `AdminRoleGuard` + first endpoint (`GET /api/admin/me` returning the session for self-test). Audit log captures access decisions.
4. **Entry route + admin shell** in `portal-admin`: header + sidebar + footer pattern, branded slightly differently (admin badge / banner) so users instantly see they are in the admin UI.
5. **First functional module**: pick one of the four v1 features (suggested order: CMS pages → menu management → audit viewer → user list, by increasing dependency on other systems). One PR per module.
**CI**:
- `portal-admin` is added to the affected-projects matrix in CI (no change needed — `nx affected -t lint test build` picks it up automatically).
- `ci:perf` (ADR-0017) runs Lighthouse against `portal-admin`'s critical routes with the relaxed thresholds above.
### Consequences
- Good, because admin code is isolated from the end-user bundle — zero risk of accidentally shipping admin features to the public surface (e.g. an export button leaking through a feature flag misconfiguration).
- Good, because the admin URL is a first-class concern operationally — VPN, IP allow-list, stricter Conditional Access policy are all local changes.
- Good, because the existing security baseline (sessions, audit, OBO, observability) is reused — no duplicate code paths, no risk of "the admin has its own auth that drifted".
- Good, because the `Portal.Admin` Entra app role becomes the single source of authority. Granting / revoking admin is one Entra Admin Center operation, takes effect on next sign-in.
- Good, because the menu-management feature delivers the `requiredPermissions` story the `<app-sidebar>` already anticipates.
- Bad, because Nx now manages two SPAs — wider CI matrix, larger workspace. Bounded; Nx is designed for this.
- Bad, because shared UI primitives must graduate to `libs/shared/*` _now_ (a chunk of refactor) where they could have stayed app-local indefinitely otherwise. Acceptable; the move is mechanical and the libs are the right home anyway.
- Bad, because admin user roles are managed in Entra Admin Center (not in our UI) — operationally split. Acceptable for v1; the alternative (writing an in-app role manager) duplicates Entra functionality.
- Neutral, because the same Postgres instance hosts user data, CMS data, and audit data. Mitigated by schema-level isolation (`cms.*`, `audit.*`) and the audit-pool split (ADR-0018).
## Pros and Cons of the Options
### Where the admin app lives
#### Separate SPA, shared BFF (chosen)
- Good, because hard isolation of frontend surfaces — public bundle stays lean.
- Good, because admin URL can be restricted at the network layer independently.
- Good, because no doubled infrastructure on the BFF / DB / observability side.
- Bad, because Nx workspace grows; shared libs must graduate sooner.
#### `/admin/*` route in `portal-shell`, lazy-loaded
- Good, because single workspace app — simpler structure.
- Good, because shared design system is trivial — same SPA.
- Bad, because admin code ships in the same bundle origin; an XSS on the public app reaches admin code (defense-in-depth weaker).
- Bad, because admin URL cannot be IP-restricted without breaking the public app.
- Bad, because admin features end up subject to the public app's bundle budget (300 KB) — and admin tables / editors blow it.
#### Two SPAs + two BFFs
- Good, because maximal isolation at every layer.
- Bad, because doubled infra. The team is small; this is bricolage at our scale.
#### Off-the-shelf admin tooling
- Good, because zero implementation cost on day one.
- Bad, because escapes our security / observability / audit baseline. Re-onboarding it later would be expensive.
### Auth gating
#### Entra role claim + BFF guard (chosen)
- Good, because reuses the existing identity model.
- Good, because role grants / revocations are auditable in Entra.
- Bad, because role propagation latency — Entra role assignment is honoured on next sign-in. Acceptable.
#### Static IP / VPN only
- Bad, because no identity-bound check — an authenticated non-admin internal user on VPN gets in. Inadequate alone (acceptable as a defence-in-depth layer on top of the role check).
#### Distinct OIDC client per app
- Good, because cleanest separation of audiences in Entra.
- Bad, because two app registrations to manage, two sets of consent screens, two redirect URIs to keep aligned. Operational overhead without a clear payoff at our scale.
### Session sharing
#### Distinct sessions per app (chosen)
- Good, because compromise of one session does not leak into the other.
- Good, because `__Host-` cookie scoping (ADR-0010) is preserved.
- Good, because the admin entry point can demand fresh MFA without inconveniencing the end-user portal.
- Bad, because admin users sign in twice if they switch apps in the same browser session. Mitigated by Entra SSO — no password re-prompt, just a click-through.
#### Shared session across apps
- Good, because seamless cross-app navigation.
- Bad, because requires broader cookie domain — breaks `__Host-` guarantees.
## More Information
- Related ADRs: [ADR-0002](0002-adopt-nx-monorepo-apps-preset.md) (Nx apps preset), [ADR-0003](0003-workspace-and-app-naming-convention.md) (naming conventions), [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md) (frontend stack — inherited), [ADR-0005](0005-backend-stack-nestjs.md) (NestJS), [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (Entra workforce identity), [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) (auth flow), [ADR-0010](0010-session-management-redis.md) (sessions), [ADR-0011](0011-mfa-enforcement-entra-conditional-access.md) (MFA freshness), [ADR-0013](0013-audit-trail-separated-postgres-append-only.md) (audit), [ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) (a11y baseline — inherited), [ADR-0017](0017-performance-budgets-lighthouse-ci.md) (perf budgets — relaxed for admin), [ADR-0018](0018-environment-configuration-strategy.md) (env config — shared), [ADR-0019](0019-internationalisation-angular-localize.md) (i18n — CMS content uses the same locales but is BFF-served, not bundle-baked).