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

19 KiB

status, date, decision-makers, tags
status date decision-makers tags
accepted 2026-05-11 R&D Lead
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 (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 viewerGET /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