import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { CaslAbilityFactory } from '../../authorization/casl-ability.factory'; import { CaslAction, permissionCodeSubject } from '../../authorization/casl.constants'; import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; import { PERMISSION_KEY } from '../decorators/permission.decorator'; import { AUTHENTICATED_KEY } from '../decorators/authenticated.decorator'; import { CHECK_POLICIES_KEY } from '../../authorization/decorators/check-policies.decorator'; import type { AuthorizationRequest } from '../../authorization/interfaces'; /** * Permission guard — deny-by-default (security-critical). * * When a handler/controller has no @RequirePermission, @Authenticated, * @CheckPolicies, or @Public annotation, the guard denies access. * * Authorization is via CASL exact-code matching: each permission code * the user holds is registered as `Access PermissionCode:`. * Checking `@RequirePermission('bill:export-excel')` verifies * `ability.can('access', 'PermissionCode:bill:export-excel')` — a user * with only `bill:view` will NOT pass. */ @Injectable() export class PermissionGuard implements CanActivate { constructor( private reflector: Reflector, private abilityFactory: CaslAbilityFactory, ) {} canActivate(context: ExecutionContext): boolean { // 1. @Public() exemption const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ context.getHandler(), context.getClass(), ]); if (isPublic) return true; const request = context.switchToHttp().getRequest(); const user = request.user; // 2. Read @Authenticated, but only use it as a fallback after checking // more specific permission and policy declarations. const authenticatedOnly = this.reflector.getAllAndOverride(AUTHENTICATED_KEY, [ context.getHandler(), context.getClass(), ]); // 3. Get required permissions (handler + class merged) const requiredPermissions = this.reflector.getAllAndMerge(PERMISSION_KEY, [ context.getHandler(), context.getClass(), ]); // 4. Check for @CheckPolicies — defer to PoliciesGuard const hasPolicies = this.reflector.getAllAndMerge(CHECK_POLICIES_KEY, [ context.getHandler(), context.getClass(), ]); const hasCheckPolicies = Array.isArray(hasPolicies) && hasPolicies.length > 0; // 5. @Authenticated is a fallback only when no more specific authorization // declaration exists. This prevents controller-level @Authenticated from // bypassing handler-level @RequirePermission or @CheckPolicies. if ( (!requiredPermissions || requiredPermissions.length === 0) && !hasCheckPolicies && authenticatedOnly ) { return !!user; } // No authorization declaration at any level → deny. if ((!requiredPermissions || requiredPermissions.length === 0) && !hasCheckPolicies) { return false; } // 6. @CheckPolicies present but no @RequirePermission → let PoliciesGuard handle it if ((!requiredPermissions || requiredPermissions.length === 0) && hasCheckPolicies) { return !!user; // deny unauthenticated, pass-through for PoliciesGuard } // 7. User must exist and have permissions array if (!user || !user.permissions || !Array.isArray(user.permissions)) return false; // 8. CASL exact-code check — maps each required code to an exact PermissionCode subject const ability = this.abilityFactory.createForUser(user); return requiredPermissions.some((code: string) => ability.can(CaslAction.Access, permissionCodeSubject(code)), ); } }