93 lines
3.7 KiB
TypeScript
93 lines
3.7 KiB
TypeScript
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:<code>`.
|
|
* 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<boolean>(IS_PUBLIC_KEY, [
|
|
context.getHandler(),
|
|
context.getClass(),
|
|
]);
|
|
if (isPublic) return true;
|
|
|
|
const request = context.switchToHttp().getRequest<AuthorizationRequest>();
|
|
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<boolean>(AUTHENTICATED_KEY, [
|
|
context.getHandler(),
|
|
context.getClass(),
|
|
]);
|
|
|
|
// 3. Get required permissions (handler + class merged)
|
|
const requiredPermissions = this.reflector.getAllAndMerge<string[]>(PERMISSION_KEY, [
|
|
context.getHandler(),
|
|
context.getClass(),
|
|
]);
|
|
|
|
// 4. Check for @CheckPolicies — defer to PoliciesGuard
|
|
const hasPolicies = this.reflector.getAllAndMerge<unknown[]>(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)),
|
|
);
|
|
}
|
|
}
|