161e2ecb1c
ADR-0019 picks `@angular/localize` in build-time mode with two locales (`fr` default, `en` source). URLs are always prefixed (`/fr/...`, `/en/...`); `/` smart-redirects via cookie → Accept-Language → fr. UI strings live in XLIFF; editorial content (CMS-served) is locked to the admin-app pipeline. The locale switcher in the footer writes a `__Host-portal_locale` cookie and hard-refreshes to the matching bundle. The `@angular/localize` runtime mode, ngx-translate, and transloco alternatives are recorded as considered-and-rejected. ADR-0020 splits portal administration into a dedicated Angular SPA (`portal-admin`) sharing the existing `portal-bff` via `/api/admin/*` routes guarded by an Entra `admin` role plus fresh-MFA at entry. Same identity / sessions / audit / observability primitives reused, no infrastructure duplication. v1 ships four modules: CMS for static multilingual pages, sidebar menu management (activating the `requiredPermissions` field already on `MenuItem`), read-only user list, and an audit log viewer. Bundle budget relaxed to 500 KB gzip (vs 300 KB on `portal-shell`); same a11y + dark-mode baseline. Together they answer the two questions raised after the footer chantier: how the multilingual story works, and where the admin surface lives. Implementation lands across follow-up feature PRs; this commit is documentation only. CLAUDE.md picks up summary entries for both decisions and bumps the ADR coverage line to 0020.
19 KiB
19 KiB
status, date, decision-makers, tags
| status | date | decision-makers | tags | |||||
|---|---|---|---|---|---|---|---|---|
| accepted | 2026-05-11 | R&D Lead |
|
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
requiredPermissionsfield already reserved in theMenuItemshape). - User list (read-only) — see who has authenticated, when they last connected, which audience they came from.
- Audit log viewer — filter the
audit.eventstable 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:
- 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.
- Security context is elevated — every admin action mutates content / configuration / observes audit data. Defense in depth pays back here.
- 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 insideportal-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/adminparent.- 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
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 toportal-adminis not silently authenticated toportal-shelland 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@RequireMfadecorator 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:
- Editorial pages CMS — CRUD on a
cms_pagetable, fields:slug,locale,title,body(markdown),published_at,updated_by,version. Localised per ADR-0019 conventions (one row per(slug, locale)). - Menu management — CRUD on a
menu_itemtable that the BFF reads to compose the response forGET /api/me/menu. The shape mirrors the existing staticMenuIteminterface inportal-shell(label, icon, route,requiredPermissions[],displayOrder,enabled). - User list (read-only) —
GET /api/admin/usersreturns a paginated, filterable view derived fromaudit.events(login events) joined with whatever the BFF caches per user (per-tenant Entraoid↔ display name mapping). No write actions in v1; invitations stay in Entra Admin Center. - Audit log viewer —
GET /api/admin/auditwith filters onactor_id_hash,action, time range,targetresource. The query talks to theaudit_readerPostgres 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-adminis a separate Angular workspace app per the Nxappspreset. Same Angular major (latest LTS), same standalone + zoneless + Signals + CSR-only configuration asportal-shell(ADR-0004).- Shared UI primitives that turn out to need promoting (the
Iconfaçade,LayoutStateService, brand tokens, Tailwind dark-mode variant, button / form primitives) graduate tolibs/shared/uiandlibs/shared/statewhen the second consumer materialises — that is now. They stay app-local inportal-shelluntil 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 containsadmin. 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-adminships under a distinct origin / hostname (e.g.admin.portal.apf.frorportal.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 affectingportal-shell.
Auth — same Entra ID, same MSAL Node, admin role claim, fresh-MFA at entry
- Identity & auth flow per ADR-0008 / ADR-0009 are reused as-is.
portal-adminis not a new Entra app registration in v1 — it uses the same registration with an additionaladminapp role assignable in Entra Admin Center. - On every request to
/api/admin/*, the BFF guard readssession.user.rolesand requiresadmin. 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_sessioncookie 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-shelldoes not sign the user in toportal-adminand 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_URLpool (full CRUD). The audit log connection stays onAUDIT_DATABASE_URL(or the dev fallback per ADR-0018) — admin writes toaudit.eventsvia the audit module, admin reads via theaudit_readerrole, never directly via the admin schema.
Observability — same OTel pipeline, distinct service name
portal-adminregisters asservice.name=portal-adminin 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>), andbefore/aftersnapshots. Read actions (audit log viewing, user list browsing) are also captured at coarser granularity (admin.audit.query) to deter fishing expeditions.
Performance budgets
portal-admininherits 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.
- Initial bundle: ≤ 500 KB gzip (vs 300 KB for
- Bundle budgets enforced in
apps/portal-admin/project.jsonastype: "error"thresholds (same mechanism asportal-shell).
Accessibility & visual identity
portal-admininherits 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
LayoutStateServiceonce it graduates tolibs/shared/state.
Confirmation
Wired across a sequence of PRs, each independent and reviewable on its own:
- App skeleton:
nx g @nx/angular:app portal-adminper the conventions of ADR-0003. Smoke route/+ a stub<app-root>rendering a placeholder shell. CI gates apply. - Shared libs graduation: extract
Icon,LayoutStateService, brand tokens, dark-mode SCSS helpers intolibs/shared/ui+libs/shared/state.portal-shellconsumes from there;portal-adminconsumes the same. - BFF admin module:
AdminModulewithAdminRoleGuard+ first endpoint (GET /api/admin/mereturning the session for self-test). Audit log captures access decisions. - 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. - 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-adminis added to the affected-projects matrix in CI (no change needed —nx affected -t lint test buildpicks it up automatically).ci:perf(ADR-0017) runs Lighthouse againstportal-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
adminEntra 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
requiredPermissionsstory 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 (Nx apps preset), ADR-0003 (naming conventions), ADR-0004 (frontend stack — inherited), ADR-0005 (NestJS), ADR-0008 (Entra workforce identity), ADR-0009 (auth flow), ADR-0010 (sessions), ADR-0011 (MFA freshness), ADR-0013 (audit), ADR-0016 (a11y baseline — inherited), ADR-0017 (perf budgets — relaxed for admin), ADR-0018 (env config — shared), ADR-0019 (i18n — CMS content uses the same locales but is BFF-served, not bundle-baked).