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
This commit was merged in pull request #145.
This commit is contained in:
@@ -48,7 +48,7 @@ The structural, security, observability, and quality choices are recorded as ADR
|
|||||||
- **Performance budgets:** Core Web Vitals at Google "Good" thresholds (LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1, TBT ≤ 200 ms, TTFB ≤ 800 ms), Lighthouse Performance ≥ 90 on critical routes. **Lighthouse CI** (`@lhci/cli`) runs in CI with median-of-3 mitigation, blocking on threshold breach. Angular bundle `budgets` (`type: "error"`): initial ≤ 300 KB gzip, lazy chunks ≤ 100 KB gzip. BFF p95/p99 SLOs per endpoint family observed via OTel (advisory in CI, alerting in prod). Weekly scheduled Lighthouse run on prod env. **a11y wins over perf** when they conflict — see [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md).
|
- **Performance budgets:** Core Web Vitals at Google "Good" thresholds (LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1, TBT ≤ 200 ms, TTFB ≤ 800 ms), Lighthouse Performance ≥ 90 on critical routes. **Lighthouse CI** (`@lhci/cli`) runs in CI with median-of-3 mitigation, blocking on threshold breach. Angular bundle `budgets` (`type: "error"`): initial ≤ 300 KB gzip, lazy chunks ≤ 100 KB gzip. BFF p95/p99 SLOs per endpoint family observed via OTel (advisory in CI, alerting in prod). Weekly scheduled Lighthouse run on prod env. **a11y wins over perf** when they conflict — see [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md).
|
||||||
- **Environment configuration:** SPA per-environment values via Angular `environment.ts` + `fileReplacements` at build time (no runtime config-fetch). BFF reads `process.env` directly with small per-key boot-time validators (no `@nestjs/config` overhead at this scale). The audit log uses a separate `AUDIT_DATABASE_URL` connection pool in production (`audit_writer`-only login, defense in depth) and falls back to the shared pool + `SET LOCAL ROLE` in dev — see [ADR-0018](docs/decisions/0018-environment-configuration-strategy.md).
|
- **Environment configuration:** SPA per-environment values via Angular `environment.ts` + `fileReplacements` at build time (no runtime config-fetch). BFF reads `process.env` directly with small per-key boot-time validators (no `@nestjs/config` overhead at this scale). The audit log uses a separate `AUDIT_DATABASE_URL` connection pool in production (`audit_writer`-only login, defense in depth) and falls back to the shared pool + `SET LOCAL ROLE` in dev — see [ADR-0018](docs/decisions/0018-environment-configuration-strategy.md).
|
||||||
- **Internationalisation:** `@angular/localize` in build-time mode, two locales (`fr` default served at `/`, `en`), source locale = English (project English-only rule). Path-based URLs always prefixed (`/fr/...`, `/en/...`); `/` smart-redirects via cookie → `Accept-Language` → `fr`. UI strings live in XLIFF (`messages.fr.xlf`); editorial / CMS content is BFF-served already localised (see admin app). Footer hosts the locale switcher; switching writes a `__Host-portal_locale` cookie and hard-refreshes — see [ADR-0019](docs/decisions/0019-internationalisation-angular-localize.md).
|
- **Internationalisation:** `@angular/localize` in build-time mode, two locales (`fr` default served at `/`, `en`), source locale = English (project English-only rule). Path-based URLs always prefixed (`/fr/...`, `/en/...`); `/` smart-redirects via cookie → `Accept-Language` → `fr`. UI strings live in XLIFF (`messages.fr.xlf`); editorial / CMS content is BFF-served already localised (see admin app). Footer hosts the locale switcher; switching writes a `__Host-portal_locale` cookie and hard-refreshes — see [ADR-0019](docs/decisions/0019-internationalisation-angular-localize.md).
|
||||||
- **Admin application (`portal-admin`):** dedicated Angular SPA alongside `portal-shell`, sharing the same `portal-bff` via `/api/admin/*` routes guarded by an Entra `admin` role + `@RequireMfa({ freshness: 600 })` at entry. Distinct origin / cookie / session from `portal-shell` (`__Host-portal_admin_session`). v1 modules: CMS for static pages (multilingual), menu management, user list (read-only), audit log viewer. Bundle budget relaxed to ≤ 500 KB gzip (vs 300 KB for `portal-shell`); same a11y + dark-mode baseline. Shared UI primitives (`Icon`, `LayoutStateService`, brand tokens) graduate to `libs/shared/*` as both apps need them — see [ADR-0020](docs/decisions/0020-portal-admin-app.md).
|
- **Admin application (`portal-admin`):** dedicated Angular SPA alongside `portal-shell`, sharing the same `portal-bff` via `/api/admin/*` routes guarded by an Entra `Portal.Admin` role + `@RequireMfa({ freshness: 600 })` at entry. Distinct origin / cookie / session from `portal-shell` (`__Host-portal_admin_session`). v1 modules: CMS for static pages (multilingual), menu management, user list (read-only), audit log viewer. Bundle budget relaxed to ≤ 500 KB gzip (vs 300 KB for `portal-shell`); same a11y + dark-mode baseline. Shared UI primitives (`Icon`, `LayoutStateService`, brand tokens) graduate to `libs/shared/*` as both apps need them — see [ADR-0020](docs/decisions/0020-portal-admin-app.md).
|
||||||
- **Local quality gates:** Husky + lint-staged + commitlint with Conventional Commits — see [ADR-0007](docs/decisions/0007-pre-commit-hooks-and-conventional-commits.md).
|
- **Local quality gates:** Husky + lint-staged + commitlint with Conventional Commits — see [ADR-0007](docs/decisions/0007-pre-commit-hooks-and-conventional-commits.md).
|
||||||
- **Runtime:** Node.js latest LTS major.
|
- **Runtime:** Node.js latest LTS major.
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import { AuditWriter } from '../audit/audit.service';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Single Entra app role that gates the entire `/api/admin/*` surface
|
* Single Entra app role that gates the entire `/api/admin/*` surface
|
||||||
* per ADR-0020 §"Auth — `admin` role claim". The value matches the
|
* per ADR-0020 §"Auth — `Portal.Admin` role claim". The value matches
|
||||||
* `value` field declared on the app role in the Entra app
|
* the `value` field declared on the app role in the Entra app
|
||||||
* registration manifest. Defined as a constant so spec assertions
|
* registration manifest. Defined as a constant so spec assertions
|
||||||
* can refer to the same source of truth.
|
* can refer to the same source of truth.
|
||||||
*/
|
*/
|
||||||
export const ADMIN_ROLE = 'admin';
|
export const ADMIN_ROLE = 'Portal.Admin';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `AdminRoleGuard` — enforces ADR-0020's role-based admin gate.
|
* `AdminRoleGuard` — enforces ADR-0020's role-based admin gate.
|
||||||
@@ -28,7 +28,7 @@ export const ADMIN_ROLE = 'admin';
|
|||||||
* unauthenticated 401 is normal traffic the absolute-timeout
|
* unauthenticated 401 is normal traffic the absolute-timeout
|
||||||
* middleware would surface anyway.
|
* middleware would surface anyway.
|
||||||
*
|
*
|
||||||
* - **Session but missing `admin` role → 403 + audit.** The user
|
* - **Session but missing `Portal.Admin` role → 403 + audit.** The user
|
||||||
* *is* authenticated; they just are not authorised for admin.
|
* *is* authenticated; they just are not authorised for admin.
|
||||||
* This is the privilege-escalation attempt audit signal — every
|
* This is the privilege-escalation attempt audit signal — every
|
||||||
* denial lands in `audit.events` with `outcome=denied`, the
|
* denial lands in `audit.events` with `outcome=denied`, the
|
||||||
@@ -38,7 +38,7 @@ export const ADMIN_ROLE = 'admin';
|
|||||||
* (consistent with the existing audit call sites in
|
* (consistent with the existing audit call sites in
|
||||||
* `AuthController`).
|
* `AuthController`).
|
||||||
*
|
*
|
||||||
* - **Session with `admin` role → pass through.** Downstream
|
* - **Session with `Portal.Admin` role → pass through.** Downstream
|
||||||
* controllers see `req.session.user` populated and can rely on
|
* controllers see `req.session.user` populated and can rely on
|
||||||
* the role check having happened.
|
* the role check having happened.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { RequireAdmin } from './require-admin.decorator';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* `/api/admin/*` controllers per ADR-0020. The `@RequireAdmin()`
|
* `/api/admin/*` controllers per ADR-0020. The `@RequireAdmin()`
|
||||||
* class-level decorator gates every route on the Entra `admin` role;
|
* class-level decorator gates every route on the Entra `Portal.Admin`
|
||||||
* downstream PRs will add per-method `@RequireMfa({ freshness: … })`
|
* role; downstream PRs will add per-method `@RequireMfa({ freshness: … })`
|
||||||
* for the entry route.
|
* for the entry route.
|
||||||
*
|
*
|
||||||
* `GET /api/admin/me` is the self-test endpoint named in ADR-0020's
|
* `GET /api/admin/me` is the self-test endpoint named in ADR-0020's
|
||||||
@@ -27,7 +27,7 @@ export class AdminController {
|
|||||||
@Get('me')
|
@Get('me')
|
||||||
me(@Req() req: Request) {
|
me(@Req() req: Request) {
|
||||||
// `AdminRoleGuard` has already established that `req.session.user`
|
// `AdminRoleGuard` has already established that `req.session.user`
|
||||||
// is present and has the `admin` role — the handler can read the
|
// is present and has the `Portal.Admin` role — the handler can read the
|
||||||
// field without re-checking. Using the non-null assertion keeps
|
// field without re-checking. Using the non-null assertion keeps
|
||||||
// the dead-branch noise out of the controller while leaving the
|
// the dead-branch noise out of the controller while leaving the
|
||||||
// safety contract anchored in the guard's spec.
|
// safety contract anchored in the guard's spec.
|
||||||
|
|||||||
@@ -196,8 +196,8 @@ export class AuditWriter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Typed event: `/api/admin/*` request rejected by `AdminRoleGuard`
|
* Typed event: `/api/admin/*` request rejected by `AdminRoleGuard`
|
||||||
* because the session's `roles` claim does not include `admin`.
|
* because the session's `roles` claim does not include `Portal.Admin`.
|
||||||
* Per ADR-0020 §"Auth — same Entra ID … `admin` role claim", every
|
* Per ADR-0020 §"Auth — same Entra ID … `Portal.Admin` role claim", every
|
||||||
* 403 from the admin surface is captured here with the attempted
|
* 403 from the admin surface is captured here with the attempted
|
||||||
* route and the roles the user actually held — auditors looking
|
* route and the roles the user actually held — auditors looking
|
||||||
* for privilege-escalation attempts pivot on `subject` (the route)
|
* for privilege-escalation attempts pivot on `subject` (the route)
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ flowchart TB
|
|||||||
|
|
||||||
Notes embedded in the diagram:
|
Notes embedded in the diagram:
|
||||||
|
|
||||||
- **Two SPAs, one BFF.** `portal-shell` is the end-user surface; `portal-admin` is a separate Angular app on a distinct origin with its own bundle, sign-in flow, and session cookie (per ADR-0020). They share libs but never a runtime — an admin session is not silently authenticated to `portal-shell` and vice versa. The admin entry route is gated by an Entra `admin` app-role claim + `@RequireMfa({ freshness: 600 })` per ADR-0011.
|
- **Two SPAs, one BFF.** `portal-shell` is the end-user surface; `portal-admin` is a separate Angular app on a distinct origin with its own bundle, sign-in flow, and session cookie (per ADR-0020). They share libs but never a runtime — an admin session is not silently authenticated to `portal-shell` and vice versa. The admin entry route is gated by an Entra `Portal.Admin` app-role claim + `@RequireMfa({ freshness: 600 })` per ADR-0011.
|
||||||
- The SPAs carry no token. Only the opaque session id cookie (`__Host-portal_session` / `__Host-portal_admin_session`) plus the CSRF cookie (`__Host-portal_csrf`, read by JS for double-submit).
|
- The SPAs carry no token. Only the opaque session id cookie (`__Host-portal_session` / `__Host-portal_admin_session`) plus the CSRF cookie (`__Host-portal_csrf`, read by JS for double-submit).
|
||||||
- `traceparent` (W3C) propagates from the browser through the BFF to the downstream APIs — same `trace_id` end-to-end.
|
- `traceparent` (W3C) propagates from the browser through the BFF to the downstream APIs — same `trace_id` end-to-end.
|
||||||
- The OTel Collector is the only piece coupled to the eventual on-prem observability backend. Choice of backend (Grafana Loki + Tempo, ELK, …) is deferred to the on-prem infrastructure ADR.
|
- The OTel Collector is the only piece coupled to the eventual on-prem observability backend. Choice of backend (Grafana Loki + Tempo, ELK, …) is deferred to the on-prem infrastructure ADR.
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ This ADR records **where the admin lives**, **what ships in v1**, and **what sta
|
|||||||
|
|
||||||
### How is admin access enforced
|
### 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.
|
- **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.
|
- **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).
|
- `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.
|
- 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`.
|
- `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.
|
- 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 `admin`. Missing role → 403 with an audit log entry (`action: 'admin.access_denied'`).
|
- 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.
|
- 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`
|
### 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 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 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 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.
|
- 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 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 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