feat(portal-bff): admin module + role guard + /api/admin/me self-test #127
Reference in New Issue
Block a user
Delete Branch "feat/portal-bff-admin-module-role-guard"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
roleslacksadmin→ 403 +admin.access_deniedaudit row with actor hash, attempted route (${METHOD} ${originalUrl}), and the roles the user did hold.adminrole → pass through.@RequireAdmin()— semantic sugar for@UseGuards(AdminRoleGuard)built withapplyDecoratorsso 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 +rolesso ops cancurlthe gate with three sessions (no cookie / non-admin cookie / admin cookie) and observe401/403+ audit /200respectively.AuditWriter.adminAccessDenied()— new typed method using the pre-existingdeniedoutcome enum value. Keeps the salt inside the audit module, matches the pattern ofsignIn/signOut/sessionExpired.Why the shared portal-shell session (for now)
ADR-0020 mandates a distinct
__Host-portal_admin_sessioncookie + Redis namespacesession: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.rolesis 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
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.AdminControllerdoes not depend onAuthService'stoPublicUserprojection — that helper is private to the auth module and pullsdisplayName/usernamewith 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)
adminmust 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-bff— 214 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/_nextwarnings inrate-limit.middleware.tsare unrelated).admin.access_deniedevents useoutcome=denied, store the route insubject, and persist{ rolesHeld: [...] }in the JSONB payload.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).