import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException, } from '@nestjs/common'; import type { Request } from 'express'; import { AuditWriter } from '../audit/audit.service'; /** * Single Entra app role that gates the entire `/api/admin/*` surface * per ADR-0020 §"Auth — `Portal.Admin` role claim". The value matches * the `value` field declared on the app role in the Entra app * registration manifest. Defined as a constant so spec assertions * can refer to the same source of truth. */ export const ADMIN_ROLE = 'Portal.Admin'; /** * `AdminRoleGuard` — enforces ADR-0020's role-based admin gate. * * Contract * -------- * - **No session → 401.** The user is not authenticated at all; the * SPA should kick off the login flow. We do not audit anonymous * probes here because the route family is not a target — any * unauthenticated 401 is normal traffic the absolute-timeout * middleware would surface anyway. * * - **Session but missing `Portal.Admin` role → 403 + audit.** The user * *is* authenticated; they just are not authorised for admin. * This is the privilege-escalation attempt audit signal — every * denial lands in `audit.events` with `outcome=denied`, the * attempted route as `subject`, and the roles the user did hold * in the payload. Per ADR-0013 §"Blocking writes": no audit ⇒ no * action — if the audit write fails, the request fails too * (consistent with the existing audit call sites in * `AuthController`). * * - **Session with `Portal.Admin` role → pass through.** Downstream * controllers see `req.session.user` populated and can rely on * the role check having happened. */ @Injectable() export class AdminRoleGuard implements CanActivate { constructor(private readonly audit: AuditWriter) {} async canActivate(context: ExecutionContext): Promise { const req = context.switchToHttp().getRequest(); const user = req.session?.user; if (!user) { throw new UnauthorizedException(); } if (!user.roles.includes(ADMIN_ROLE)) { await this.audit.adminAccessDenied({ actor: { oid: user.oid }, attemptedRoute: `${req.method} ${req.originalUrl}`, rolesHeld: user.roles, }); throw new ForbiddenException(); } return true; } }