---
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).
