import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import type { Request } from 'express'; import { principalCoversResource, type ScopableResource } from 'shared-auth'; import { AuditWriter } from '../audit/audit.service'; import { readSessionPrincipal, scopesToAuditStrings } from './principal-extractor'; import { REQUIRE_SCOPE_METADATA, type RequireScopeExtractor } from './require-scope.decorator'; /** * `RequireScopeGuard` — enforces ADR-0025 scope gates. * * Contract * -------- * - **No session → 401.** Same posture as the privilege / role * guards. * - **Extractor returned `null` → 403 + audit.** The route's * "what resource am I protecting?" question has no answer for * this request; deny is the safe direction. Audit row carries * `required: []` so an auditor can spot the missing-resource * pattern. * - **No matching scope → 403 + audit.** Standard denial; audit * captures the resource descriptor as `required` and the * principal's actual scope list as `held`. * - **Matching scope → pass.** * * Like the other two new guards, the response body is the * ADR-0021 structured-error envelope with a generic `forbidden` * code — no resource fingerprint leaks back to the caller. */ @Injectable() export class RequireScopeGuard implements CanActivate { constructor( private readonly reflector: Reflector, private readonly audit: AuditWriter, ) {} async canActivate(context: ExecutionContext): Promise { const extractor = this.reflector.getAllAndOverride( REQUIRE_SCOPE_METADATA, [context.getHandler(), context.getClass()], ); if (extractor === undefined) { return true; } const req = context.switchToHttp().getRequest(); const principal = readSessionPrincipal(req); if (principal === null) { throw new UnauthorizedException({ code: 'unauthenticated', message: 'Unauthenticated', }); } const resource = await extractor(req); if (resource === null) { await this.audit.authorizationDenied({ actor: { oid: principal.user.entraOid }, attemptedRoute: `${req.method} ${req.originalUrl}`, kind: 'scope', required: [], held: scopesToAuditStrings(principal.scopes), }); throw new ForbiddenException({ code: 'forbidden', message: 'Forbidden', }); } if (principalCoversResource(principal, resource)) { return true; } await this.audit.authorizationDenied({ actor: { oid: principal.user.entraOid }, attemptedRoute: `${req.method} ${req.originalUrl}`, kind: 'scope', required: describeResource(resource), held: scopesToAuditStrings(principal.scopes), }); throw new ForbiddenException({ code: 'forbidden', message: 'Forbidden', }); } } /** * Serialise the resource descriptor into the `string[]` shape the * audit row's `required` field expects. Mirrors the `scope:value` * convention of {@link scopesToAuditStrings} so the two sides of * the audit row read consistently — easy `requiredVs.held` diff * in a log dashboard. */ function describeResource(resource: ScopableResource): string[] { const out: string[] = []; if (resource.personId !== undefined) { out.push(`self:${resource.personId}`); } if (resource.etablissementFiness !== undefined) { out.push(`etablissement:${resource.etablissementFiness}`); } if (resource.delegationCode !== undefined) { out.push(`delegation:${resource.delegationCode}`); } if (resource.regionCode !== undefined) { out.push(`region:${resource.regionCode}`); } if (resource.isSiege === true) { out.push('siege'); } return out; }