Skip to content

RBAC 权限控制

一、权限模型概述

RBAC(Role-Based Access Control) 是最常用的权限模型:

用户 → 角色 → 权限
User → Role → Permission

示例:
  alice  → ADMIN   → [read:user, create:user, delete:user, read:post, ...]
  bob    → EDITOR  → [read:post, create:post, update:post]
  carol  → USER    → [read:post]

NestJS 中 RBAC 通过元数据(Metadata)+ 守卫(Guard)+ 装饰器(Decorator) 三者配合实现。


二、基础角色系统

定义角色枚举

typescript
// common/enums/role.enum.ts
export enum Role {
  USER = 'user',
  MODERATOR = 'moderator',
  ADMIN = 'admin',
}

角色装饰器

typescript
// common/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { Role } from '../enums/role.enum';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

角色守卫

typescript
// common/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '../enums/role.enum';
import { ROLES_KEY } from '../decorators/roles.decorator';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    // 读取方法或类上的 @Roles() 元数据
    const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
      context.getHandler(),  // 方法级别优先
      context.getClass(),    // 类级别兜底
    ]);

    // 没有设置角色限制,放行(任何已认证用户都可访问)
    if (!requiredRoles || requiredRoles.length === 0) return true;

    const { user } = context.switchToHttp().getRequest();

    // 未认证用户(应先被 JwtAuthGuard 拦截,这里是保底)
    if (!user) return false;

    return requiredRoles.includes(user.role);
  }
}

三、公开接口与全局守卫

typescript
// common/decorators/public.decorator.ts
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
typescript
// common/guards/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext) {
    // @Public() 标记的路由跳过 JWT 验证
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) return true;
    return super.canActivate(context);
  }

  handleRequest(err: any, user: any, info: any) {
    if (err || !user) {
      const message = info?.name === 'TokenExpiredError'
        ? 'Token 已过期'
        : '请先登录';
      throw new UnauthorizedException(message);
    }
    return user;
  }
}
typescript
// app.module.ts:全局注册两个守卫
providers: [
  { provide: APP_GUARD, useClass: JwtAuthGuard },   // 所有路由默认需认证
  { provide: APP_GUARD, useClass: RolesGuard },     // 需要角色时检查
],

四、控制器中的使用

typescript
@ApiTags('用户管理')
@Controller('users')
export class UsersController {
  // 获取自己的信息(任何已登录用户)
  @Get('me')
  getProfile(@CurrentUser() user: UserEntity) {
    return user;
  }

  // 获取所有用户(管理员才能)
  @Roles(Role.ADMIN)
  @Get()
  findAll() {
    return this.usersService.findAll();
  }

  // 删除用户(管理员才能)
  @Roles(Role.ADMIN)
  @Delete(':id')
  remove(@Param('id', ParseIntPipe) id: number) {
    return this.usersService.remove(id);
  }

  // 公开接口(不需要登录)
  @Public()
  @Get('count')
  countUsers() {
    return this.usersService.count();
  }
}

五、细粒度权限控制(CASL)

基础 RBAC 只能控制角色,无法表达"用户只能编辑自己的文章"这类资源级权限。使用 CASL:

bash
npm install @casl/ability @casl/prisma   # 或 @casl/typeorm

定义能力

typescript
// ability/ability.factory.ts
import { AbilityBuilder, createMongoAbility } from '@casl/ability';

export type Actions = 'manage' | 'create' | 'read' | 'update' | 'delete';
export type Subjects = 'Post' | 'User' | 'Comment' | 'all';

export type AppAbility = ReturnType<typeof createMongoAbility<[Actions, Subjects]>>;

@Injectable()
export class AbilityFactory {
  defineAbility(user: { id: number; role: Role }) {
    const { can, cannot, build } = new AbilityBuilder(
      createMongoAbility<[Actions, Subjects]>
    );

    if (user.role === Role.ADMIN) {
      can('manage', 'all');  // 管理员可以做一切
    } else if (user.role === Role.MODERATOR) {
      can('read', 'Post');
      can('update', 'Post');
      can('delete', 'Post');
      can('read', 'User');
      cannot('delete', 'User');  // 不能删除用户
    } else {
      // 普通用户
      can('read', 'Post');
      can('create', 'Post');
      // 只能修改自己的文章(条件规则)
      can('update', 'Post', { authorId: user.id });
      can('delete', 'Post', { authorId: user.id });
      can('update', 'User', { id: user.id });  // 只能改自己的资料
    }

    return build({
      detectSubjectType: (item: any) => item.constructor?.name ?? item,
    });
  }
}

守卫检查能力

typescript
// ability/abilities.guard.ts
export type PolicyHandler = (ability: AppAbility) => boolean;

export const CHECK_POLICIES_KEY = 'check_policy';
export const CheckPolicies = (...handlers: PolicyHandler[]) =>
  SetMetadata(CHECK_POLICIES_KEY, handlers);

@Injectable()
export class PoliciesGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private abilityFactory: AbilityFactory,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const policyHandlers = this.reflector.get<PolicyHandler[]>(
      CHECK_POLICIES_KEY,
      context.getHandler(),
    );
    if (!policyHandlers) return true;

    const { user } = context.switchToHttp().getRequest();
    const ability = this.abilityFactory.defineAbility(user);

    return policyHandlers.every(handler => handler(ability));
  }
}

使用细粒度权限

typescript
@Controller('posts')
export class PostsController {
  // 只有有 create:Post 权限才能创建
  @CheckPolicies((ability) => ability.can('create', 'Post'))
  @Post()
  create(@Body() dto: CreatePostDto, @CurrentUser() user: User) {
    return this.postsService.create(dto, user.id);
  }

  // 删除文章:需要拿到文章实体再判断(Service 层检查)
  @Delete(':id')
  async remove(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: User) {
    const post = await this.postsService.findOne(id);
    const ability = this.abilityFactory.defineAbility(user);

    if (!ability.can('delete', post)) {
      throw new ForbiddenException('无权删除此文章');
    }
    return this.postsService.remove(id);
  }
}

六、组合装饰器(减少样板代码)

typescript
// common/decorators/auth.decorator.ts
import { applyDecorators, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiUnauthorizedResponse, ApiForbiddenResponse } from '@nestjs/swagger';

export function Auth(...roles: Role[]) {
  return applyDecorators(
    roles.length > 0 ? Roles(...roles) : () => {},
    UseGuards(JwtAuthGuard, RolesGuard),
    ApiBearerAuth(),
    ApiUnauthorizedResponse({ description: '未登录或 Token 已过期' }),
    roles.length > 0
      ? ApiForbiddenResponse({ description: `需要 ${roles.join('/').toUpperCase()} 角色` })
      : () => {},
  );
}

// 使用
@Auth()                    // 任何已登录用户
@Get('me')
getProfile() {}

@Auth(Role.ADMIN)          // 仅管理员
@Delete(':id')
remove() {}

@Auth(Role.ADMIN, Role.MODERATOR)  // 管理员或版主
@Put(':id/publish')
publishPost() {}

七、数据库驱动的动态权限

角色和权限存数据库,支持运行时动态配置:

typescript
// 数据库设计
// users → user_roles → roles → role_permissions → permissions

// permissions 表:{ id, action: 'create', resource: 'post' }
// roles 表:{ id, name: 'editor' }
// role_permissions:角色与权限多对多中间表

@Injectable()
export class DynamicRolesGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private permissionsService: PermissionsService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const required = this.reflector.get<{ action: string; resource: string }>(
      'permission',
      context.getHandler(),
    );
    if (!required) return true;

    const { user } = context.switchToHttp().getRequest();

    // 从数据库加载用户的所有权限
    const userPermissions = await this.permissionsService.getUserPermissions(user.id);

    return userPermissions.some(
      p => p.action === required.action && p.resource === required.resource,
    );
  }
}

八、最佳实践

  1. 全局守卫默认拦截,白名单放行@Public() 比每个接口加 @UseGuards 更安全
  2. 角色继承:用数组包含判断,[Role.ADMIN, Role.MODERATOR] 优于嵌套 if
  3. 资源级权限用 CASL:判断"是否拥有者"等条件权限,不要在 Guard 里查数据库
  4. 权限检查放 Service 层:Guard 负责认证,Service 负责业务权限验证(可测试性更好)
  5. 不要在 JWT payload 存完整权限列表:Token 会变大,且权限更改需要 Token 过期才生效;从数据库实时查更安全

可运行 Demo: practice/04-auth-system — RolesGuard + @Roles 装饰器,三级角色权限验证


常见错误

错误原因解决
RolesGuard 总是放行Reflector.getAllAndOverride 返回 undefined检查 @Roles() 装饰器是否正确加在 Handler 或 Class 上
CASL ability.can() 返回 false 但应该通过规则中 subject 类型字符串拼写错误subject('Post', post) 替代裸字符串,避免拼写错误
动态权限未实时生效权限从 Token payload 读取,Token 未刷新动态权限应从数据库实时查询,而不是缓存在 Token 中

NestJS 深度学习体系