Files
gongxue-base/apps/server/src/auth/guards/permission.guard.ts

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)),
);
}
}