feat(portal-bff): admin module + role guard + /api/admin/me self-test #127

Merged
julien merged 1 commits from feat/portal-bff-admin-module-role-guard into main 2026-05-14 01:17:31 +02:00
Owner

Summary

Lays the foundation for the /api/admin/* surface per ADR-0020. This PR ships the role guard, the @RequireAdmin() decorator, and a self-test endpoint — no business routes yet. The next consumer (audit log viewer) lands in a later PR once the distinct admin session is in place.

What ships

  • AdminRoleGuard — three branches:
    • No session at all → 401. No audit; unauthenticated probes are normal traffic, not a privilege-escalation signal.
    • Session but roles lacks admin403 + admin.access_denied audit row with actor hash, attempted route (${METHOD} ${originalUrl}), and the roles the user did hold.
    • Session with admin role → pass through.
    • Audit-write failures propagate (no audit ⇒ no action, consistent with the existing call sites in AuthController).
  • @RequireAdmin() — semantic sugar for @UseGuards(AdminRoleGuard) built with applyDecorators so future composition (e.g. with the upcoming @RequireMfa({ freshness }) for the admin entry route) is mechanical.
  • GET /api/admin/me — self-test endpoint named in ADR-0020 §"Confirmation" step 3. Returns the public user payload + roles so ops can curl the gate with three sessions (no cookie / non-admin cookie / admin cookie) and observe 401 / 403 + audit / 200 respectively.
  • AuditWriter.adminAccessDenied() — new typed method using the pre-existing denied outcome enum value. Keeps the salt inside the audit module, matches the pattern of signIn / signOut / sessionExpired.

Why the shared portal-shell session (for now)

ADR-0020 mandates a distinct __Host-portal_admin_session cookie + Redis namespace session:admin:* for the admin app. That is not in this PR. The chantier sequence splits it out: this PR proves the guard semantics + audit integration on the existing session; the next PR introduces the distinct session middleware + admin-specific auth flow.

Rationale: the guard logic is independent of the session implementation — session.user.roles is the only field it reads. Landing it first means a smaller diff to review, a faster opportunity to validate the audit emission on a real Postgres, and a clean baseline to layer the session split onto.

Notes for the reviewer

  • The non-null assertion on req.session.user! in admin.controller.ts:27 is explicitly disabled with an inline comment pointing at the guard's contract. The alternative (a defensive runtime check) duplicates the guard's logic without adding safety. The spec for the guard covers every branch including the absent-user path.
  • AdminController does not depend on AuthService's toPublicUser projection — that helper is private to the auth module and pulls displayName / username with extra account-object fallbacks specific to the OIDC callback. The admin response is built from the already-populated session, so a duplicated projection here is the simpler shape.

Open questions (out of scope)

  • The Entra app role admin must be declared on the app registration manifest and assigned to at least one test user before this gate can be exercised end-to-end. That's an Entra Admin Center operation, not code. The guard's behaviour under all three branches is covered by unit tests; e2e validation waits until the role is assigned.

Test plan

  • pnpm nx test portal-bff214 specs pass (was 203; +11 covering guard branches, controller projection, audit method).
  • pnpm exec nx affected -t format:check lint test build --base=origin/main — clean (the pre-existing _res / _next warnings in rate-limit.middleware.ts are unrelated).
  • Audit row schema verified — admin.access_denied events use outcome=denied, store the route in subject, and persist { rolesHeld: [...] } in the JSONB payload.
  • e2e — pending Entra role declaration + assignment. Will be covered by manual ops curl checks once the role exists.
## Summary Lays the foundation for the `/api/admin/*` surface per [ADR-0020](docs/decisions/0020-portal-admin-app.md). This PR ships the role guard, the `@RequireAdmin()` decorator, and a self-test endpoint — no business routes yet. The next consumer (audit log viewer) lands in a later PR once the distinct admin session is in place. ## What ships - **[AdminRoleGuard](apps/portal-bff/src/admin/admin-role.guard.ts)** — three branches: - No session at all → **401**. No audit; unauthenticated probes are normal traffic, not a privilege-escalation signal. - Session but `roles` lacks `admin` → **403** + `admin.access_denied` audit row with actor hash, attempted route (`${METHOD} ${originalUrl}`), and the roles the user did hold. - Session with `admin` role → pass through. - Audit-write failures propagate (no audit ⇒ no action, consistent with the existing call sites in [AuthController](apps/portal-bff/src/auth/auth.controller.ts)). - **[`@RequireAdmin()`](apps/portal-bff/src/admin/require-admin.decorator.ts)** — semantic sugar for `@UseGuards(AdminRoleGuard)` built with `applyDecorators` so future composition (e.g. with the upcoming `@RequireMfa({ freshness })` for the admin entry route) is mechanical. - **`GET /api/admin/me`** — self-test endpoint named in ADR-0020 §"Confirmation" step 3. Returns the public user payload + `roles` so ops can `curl` the gate with three sessions (no cookie / non-admin cookie / admin cookie) and observe `401` / `403` + audit / `200` respectively. - **[`AuditWriter.adminAccessDenied()`](apps/portal-bff/src/audit/audit.service.ts)** — new typed method using the pre-existing `denied` outcome enum value. Keeps the salt inside the audit module, matches the pattern of `signIn` / `signOut` / `sessionExpired`. ## Why the shared portal-shell session (for now) ADR-0020 mandates a distinct `__Host-portal_admin_session` cookie + Redis namespace `session:admin:*` for the admin app. **That is not in this PR.** The chantier sequence splits it out: this PR proves the guard semantics + audit integration on the existing session; the next PR introduces the distinct session middleware + admin-specific auth flow. Rationale: the guard logic is independent of the session implementation — `session.user.roles` is the only field it reads. Landing it first means a smaller diff to review, a faster opportunity to validate the audit emission on a real Postgres, and a clean baseline to layer the session split onto. ## Notes for the reviewer - The non-null assertion on `req.session.user!` in [admin.controller.ts:27](apps/portal-bff/src/admin/admin.controller.ts#L27) is explicitly disabled with an inline comment pointing at the guard's contract. The alternative (a defensive runtime check) duplicates the guard's logic without adding safety. The spec for the guard covers every branch including the absent-user path. - `AdminController` does not depend on `AuthService`'s `toPublicUser` projection — that helper is private to the auth module and pulls `displayName` / `username` with extra account-object fallbacks specific to the OIDC callback. The admin response is built from the already-populated session, so a duplicated projection here is the simpler shape. ## Open questions (out of scope) - The Entra app role `admin` must be **declared on the app registration manifest** and **assigned to at least one test user** before this gate can be exercised end-to-end. That's an Entra Admin Center operation, not code. The guard's behaviour under all three branches is covered by unit tests; e2e validation waits until the role is assigned. ## Test plan - [x] `pnpm nx test portal-bff` — **214 specs pass** (was 203; +11 covering guard branches, controller projection, audit method). - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean (the pre-existing `_res` / `_next` warnings in `rate-limit.middleware.ts` are unrelated). - [x] Audit row schema verified — `admin.access_denied` events use `outcome=denied`, store the route in `subject`, and persist `{ rolesHeld: [...] }` in the JSONB payload. - [ ] e2e — pending Entra role declaration + assignment. Will be covered by manual ops curl checks once the role exists.
julien added 1 commit 2026-05-14 01:17:19 +02:00
feat(portal-bff): admin module + role guard + /api/admin/me self-test
CI / commits (pull_request) Successful in 2m35s
CI / scan (pull_request) Successful in 2m46s
CI / check (pull_request) Successful in 3m10s
CI / a11y (pull_request) Successful in 1m29s
CI / perf (pull_request) Successful in 6m17s
c30b663c51
Lays the foundation for the `/api/admin/*` surface per ADR-0020.
This PR ships the guard, the `@RequireAdmin()` decorator, and a
self-test endpoint — no business routes yet.

- `AdminRoleGuard` (apps/portal-bff/src/admin/admin-role.guard.ts):
  - No session → 401 (no audit; unauthenticated probes are noise).
  - Session but `roles` lacks `admin` → 403 + `admin.access_denied`
    audit row capturing actor hash, attempted route, and roles held.
  - Session with `admin` → pass through.
  - Audit-write failures propagate (no audit ⇒ no action,
    consistent with the existing call sites in AuthController).

- `@RequireAdmin()` decorator wraps `@UseGuards(AdminRoleGuard)`
  via `applyDecorators` so future composition (e.g. with the
  upcoming `@RequireMfa()` for the admin entry route) is mechanical.

- `GET /api/admin/me` returns the public user payload + `roles` for
  ops to verify the gate is wired correctly end-to-end. The handler
  intentionally has no logic beyond the projection — the value of
  the endpoint is the guard chain it forces traffic through.

- `AuditWriter.adminAccessDenied()` typed method keeps the salt
  inside the audit module and matches the pattern of the existing
  typed events. Uses the pre-existing `denied` outcome enum value.

Surface impact:
- No new env vars, no new dependencies.
- The shared portal-shell session is the auth source for this PR.
  The distinct `__Host-portal_admin_session` cookie + admin auth
  flow land in the next PR per the chantier sequence.

Tests: +11 specs (7 guard, 2 controller, 2 typed audit method).
julien merged commit 3ed6dae3a5 into main 2026-05-14 01:17:31 +02:00
julien deleted branch feat/portal-bff-admin-module-role-guard 2026-05-14 01:17:33 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#127