Files
apf_portal/docs/decisions/0020-portal-admin-app.md
T
Julien Gautier ad369d872a
CI / scan (pull_request) Successful in 2m47s
CI / commits (pull_request) Successful in 2m47s
CI / check (pull_request) Successful in 2m56s
CI / a11y (pull_request) Successful in 2m40s
CI / perf (pull_request) Successful in 5m51s
fix(portal-bff): align admin entra role name with Portal.Admin
The AdminRoleGuard was matching on `admin`, but the Entra app
registration declares the role with value `Portal.Admin`. Sessions
were established with `roles: []`, so every authenticated user got
a 403 on `/api/admin/audit` and `/api/admin/users`.

Single source of truth: the `ADMIN_ROLE` constant in
admin-role.guard.ts. The spec already imports the constant, so the
behaviour rolls through unchanged.

Doc-comment touches in admin-role.guard.ts, admin.controller.ts and
audit.service.ts keep the inline references accurate; ADR-0020,
docs/architecture.md and CLAUDE.md updated to the new name.
2026-05-15 10:29:51 +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