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.
This commit is contained in:
@@ -44,7 +44,7 @@ This ADR records **where the admin lives**, **what ships in v1**, and **what sta
|
||||
|
||||
### 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.
|
||||
- **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.
|
||||
|
||||
@@ -85,13 +85,13 @@ apps/
|
||||
|
||||
- `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 `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.
|
||||
- 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, `admin` role claim, fresh-MFA at entry
|
||||
### 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 `admin` app role assignable in Entra Admin Center.
|
||||
- On every request to `/api/admin/*`, the BFF guard reads `session.user.roles` and requires `admin`. Missing role → 403 with an audit log entry (`action: 'admin.access_denied'`).
|
||||
- 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`
|
||||
@@ -145,7 +145,7 @@ apps/
|
||||
- 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 `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 `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.
|
||||
|
||||
Reference in New Issue
Block a user