2480d0dd6d
## 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
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
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 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 containsPortal.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-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, Portal.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 additionalPortal.Adminapp role assignable in Entra Admin Center. - On every request to
/api/admin/*, the BFF guard readssession.user.rolesand requiresPortal.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_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
Portal.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).