十四、基于RABC和CASL的权限管控(二)(nestjs+next.js从零开始一步一步创建通用后台管理系统)

208 阅读6分钟

casl概述

rabc模型中只实现了粗放的接口、菜单、按钮权限,数据权限无法实现。通过CASL访问列表控制可以实现更细粒度的权限控制,如作者角色有编辑文章的权限,在RABC中就可以实现该权限,但要实现只能编辑自己文章的业务规则就只能硬编码了。在CASL中编辑文章有两条控制策略:编辑自己文章、编辑所有文章的状态为已推荐,作者可以编辑自己文章,管理员可以推荐文章,通过路由守卫在用户访问文章编辑接口时,根据登录人的角色,查出角色拥有的控制列表,再查出该接口许可条件中的控制列表,两个列表进行比较就可以知道该用户能否访问该接口。 表结构设计:(只列出主要字段)

  user: 用户拥有多个角色
    userId:string,
    userName:string,
    roles:Role[]
  role:角色有多个权限,角色有多个策略
    roleId:string,
    roleName:string,
    permissions:Permission[],
    policies:Policy[]
  permission: 权限有多个策略
    permissionId:string,
    permissionName:string,
    roles:Role[],
    policies:Policy[]
  policy:策略
    policyId:string,
    policyName:string,
    effect:string;//允许can 拒绝cannot
    action:string;//操作 增删改查+manage
    subject:string;//如文章、订单
    fields?:string;//如文章状态、文章作者 
    conditions?:string;//如editor=currentuser.id
    args?:string;//args->函数的参数,如用户编号
    roles:Role[],
    permissions:Permission[],

数据举例:

  • 角色有管理员、作者两个角色。
  • 文章的update接口需要document:update权限
  • 这个document:update权限有两种策略:置顶文档(实际就是更新document的一个是否置顶这个字段为是)、修改作者自己的文档。
  • 管理员角色呢有两个策略,置顶文档、修改文档;作者角色只能修改文档,无权置顶文档。

判断逻辑:

  • 取到接口注解的权限
  • 取到注解权限对应的所有策略(指定+修改)
  • 根据当前用户取到该用户所属的所有角色对应的权限,如当前登陆人为作者,则只取到修改文档策略。如果是管理员,则取到置顶+修改文档两条策略。
  • 循环检查所有规则

代码实现

1、创建策略模块

1.1、创建模块

nest g res syste,/policy --no-spec

1.2、修改policy.service.ts

import { Inject, Injectable } from '@nestjs/common';
import { Policy } from './entities/policy.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { ListPolicyDto } from './dto/list-policy.dto';

@Injectable()
export class PolicyService {
  constructor(
    @Inject(DataSource)
    private dataSource: DataSource,
    @InjectRepository(Policy)
    private readonly policyRepository: Repository<Policy>,
  ) {}
  
  
  async getUserPolicies(userId: string): Promise<Policy[]> {
    let queryBuilder = this.dataSource
    .getRepository(Policy)
    .createQueryBuilder('p')
    .leftJoinAndSelect('p.roles', 'r')
    .leftJoinAndSelect('r.users', 'u');
    return queryBuilder.where('u.userId = :userId', { userId }).getMany();  
  }
}

1.3、修改policy.controller.ts

 import { Controller, Get, Post, Body, Patch, Param, Delete, Inject } from '@nestjs/common';
import { PolicyService } from './policy.service';

@Controller('policy')
export class PolicyController {
  constructor(private readonly policyService: PolicyService,
  ) {}

  @Get(':userid')
 async getUserPolicies(@Param('userid') userid: string) {
    return await this.policyService.getUserPolicies(userid);
  }
}


1.4、修改policy.module.ts

 import { Global, Module, forwardRef } from '@nestjs/common';
import { PolicyService } from './policy.service';
import { PolicyController } from './policy.controller';
import { UserModule } from '../user/user.module';
import { RoleModule } from '../role/role.module';
import { PermissionModule } from '../permission/permission.module';
import { SharedModule } from 'src/shared/shared.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Policy } from './entities/policy.entity';
import { AbilityFactory } from './ability.factory';

@Global() 
@Module({
  imports: [
    forwardRef(() => UserModule),
    forwardRef(() => RoleModule),
    forwardRef(() => PermissionModule),
    SharedModule,
    TypeOrmModule.forFeature([Policy]),
  ],
  controllers: [PolicyController],
  providers: [PolicyService,AbilityFactory],
  exports: [PolicyService,AbilityFactory],
})
export class PolicyModule {}

1.5、policy.entity.ts

定义一个实体类

import { ApiProperty } from "@nestjs/swagger";
import { IsOptional } from "class-validator";
import { Permission } from "src/system/permission/entities/permission.entity";
import { Role } from "src/system/role/entities/role.entity";
import { Column, Entity, JoinTable, ManyToMany, PrimaryGeneratedColumn } from "typeorm";

@Entity('sys_policy')
export class Policy {
  @ApiProperty({
    example: "自动生成",
    description: "用户ID",
  })
  @PrimaryGeneratedColumn('uuid',{name: 'policy_id', comment: '策略ID' })
  public policyId:string;

  @Column({ type: 'varchar', length: 10, comment: '策略类型' })
  type:string;//类型 json mongo func

  @Column({ type: 'varchar', length: 50, comment: '策略effect' })
  effect:string;//允许can 拒绝cannot

  @Column({ type: 'varchar', length: 50, comment: '策略action' })
  action:string;//操作 增删改查+manage
  
  @Column({ type: 'varchar', length: 200, comment: '策略subject' })
  subject:string;//subject->class类的实例 data->数据库中的数据
  
  @IsOptional()
  @Column({ type: 'varchar', length: 2000, nullable: true,comment: '策略fields' })
  fields?:string;//fields->数据库中的字段,{type,data} 

  @Column({ type: 'json', nullable: true,comment: '策略条件' })
  conditions?:any;// conditions->数据库中的条件

  @IsOptional()
  @Column({ type: 'varchar', length: 2000, nullable: true,comment: '函数参数' })
  args?:string;//args->函数的参数

  //角色策略
  @IsOptional()
  @ManyToMany(() => Role)
  @JoinTable({
    name: 'sys_role_policy',
    joinColumn: { name: 'policy_id', referencedColumnName: 'policyId' },
    inverseJoinColumn: { name: 'role_id', referencedColumnName: 'roleId' }
  })
  roles?: Role[];

  //权限策略
  @IsOptional()
  @ManyToMany(() => Permission)
  @JoinTable({
    name: 'sys_permission_policy',
    joinColumn: { name: 'policy_id', referencedColumnName: 'policyId' },
    inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'permissionId' }
  })
  permissions?:Permission[];

}

1.6、定义注解

import { SetMetadata } from "@nestjs/common";

export const PERMISSION_KEY="permission";

export const Permission=(permission:string)=>SetMetadata(PERMISSION_KEY,permission);

1.7、定义工厂方法

定义工厂方法,创建用户所有的策略(数据库中该用户的所有策略)

//ability.factory.ts
import { Injectable } from '@nestjs/common';
import { AbilityBuilder, createMongoAbility } from '@casl/ability';
import { PolicyService } from '../policy/policy.service'; // 策略服务
import { SubjectRegistry } from 'src/shared/subject.registry';
import { User } from '../user/entities/user.entity';
import { SharedService } from 'src/shared/shared.service';

@Injectable()
export class AbilityFactory {
  constructor(private readonly policyService: PolicyService,
    private sharedService: SharedService,
  ) {}

  async createForUser(user: User,inArgs:any) {
    const { can, cannot, build } = new AbilityBuilder(createMongoAbility);
    //todo:这是测试示例1
    // can('read', 'Article', { authorId: "1" });
    // return build();
    //todo:这是测试示例2
    // const args = ['user']; // 假设从数据库获取
    // const strfunc = "() => ({ authorId: user.userId })"; // 假设从数据库获取
    // // 修正1:正确创建函数并绑定参数
    // const func = new Function(...args, `return (${strfunc})()`);
    // can('read', 'Article', func(user)) 
    // return build();
    // 从数据库获取用户的所有策略
    const policies = await this.policyService.getUserPolicies(user.userId);
    // 应用策略规则
    for (const policy of policies) {
      const subjectType = SubjectRegistry.getType(policy.subject);
      
      if (!subjectType) {
        throw new Error(`未注册的 subject: ${policy.subject}`);
      }
      const action=this.determineAction(policy.effect,{can,cannot});
      if (policy.action === 'manage') {
        // 管理所有操作
        can('manage', subjectType, policy.conditions);
      } else if (policy.fields?.length > 0) {
        // 字段级权限 
        action(policy.action, subjectType, policy.fields, policy.conditions);
      } else {
         // 整个实体的权限
         if(policy.conditions.type==='object'){
          action(policy.action,policy.subject,null,JSON.parse(policy.conditions.data));
         }
         else{
          const args = [policy.args]; // 假设从数据库获取
          const strfunc = policy.conditions['data']; // 假设从数据库获取
          const func = new Function(...args, `return (${strfunc})()`);
          action(policy.action, policy.subject, func(...inArgs));
         }
      }
    };

    return build();
  }
  determineAction(effect:string,builder:any){
    return effect==='can'?builder.can:builder.cannot;
  }
}

1.8、定义守卫

定义守卫,判断用户所有策略和注解上的策略是否匹配

 //policy.guard.ts
import { CanActivate, ExecutionContext, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AbilityFactory } from './ability.factory';
import { EntityManager } from 'typeorm';
import { PERMISSION_KEY } from './policies.decorator';
import { PermissionService } from '../permission/permission.service';
import { Policy } from './entities/policy.entity';
import { SharedService } from 'src/shared/shared.service';
import { PureAbility, Subject } from '@casl/ability';

export type AppAbility = PureAbility<[string, Subject]>;
@Injectable()
export class PoliciesGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private abilityFactory: AbilityFactory,
    //private entityManager: EntityManager,
    @Inject(PermissionService)
    private permissionService: PermissionService,
    private sharedService: SharedService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {

    // 获取注解权限许可
    const permission = this.reflector.get<string>(PERMISSION_KEY, context.getHandler());
    if (!permission) return true;//没有则表示不管控权限
    //根据权限许可拿到权限策略
    const permissionPolicy=await this.permissionService.findByName(permission);
    const rules=permissionPolicy.policies;//许可的策略

    // 获取用户权限
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    if(!user){
      return false;
    }
    
    const ability = await this.abilityFactory.createForUser(user,[
        user,
        request,
        this.reflector,
    ]);
    // 检查所有规则
    for (const rule of rules) {
      const subject = await this.sharedService.resolveSubject(rule.subject,request);
      if (!this.isAllowed(ability, rule, subject)) {
        return false;
      }
    }
    
    return true;
  }
 

  private isAllowed(
    ability: AppAbility, 
    rule: Policy, 
    subject: any
  ): boolean {
    // 处理字段级权限
    if (rule.fields?.length > 0) {
      return rule.fields.split(",").every(field => 
        ability.can(rule.action, subject, field)
      );
    }
    return ability.can(rule.action, subject);
  }
}

1.9、在共享模块中定义subject实例获取方法

判断字段级别的规则,如只能更新作者自己的文章,此时必须使用subject(即article)的实例才行,所以必须能取到实例对象。

//shared.module.ts
import { Global, Module, forwardRef } from '@nestjs/common';
import { SharedService } from './shared.service';
import { SharedController } from './shared.controller';
import { SubjectRegistry } from './subject.registry';

@Global()//必须设置为全局模块
@Module({
  controllers: [SharedController],
  providers: [SharedService,SubjectRegistry],
  exports: [SharedService,SubjectRegistry],
})
export class SharedModule {}
//shared.service.ts
import { Injectable, NotFoundException, Type } from '@nestjs/common';
import { Policy } from 'src/system/policy/entities/policy.entity';
import { EntityManager } from 'typeorm';
import { SubjectRegistry } from './subject.registry';

@Injectable()
export class SharedService {
  constructor(
    private entityManager: EntityManager,
  ){}
  //在这定义一个解析对象实例的方法,在守卫中用于获取对象实例
  async resolveSubject(subject:string,request: any): Promise<any> {
    // 1. 如果规则指定了实例获取函数
    // if (typeof rule.getSubject === 'function') {
    //   return rule.getSubject(request);
    // }
    
    // 2. 尝试从请求中获取已加载的实例
    if (request.resourceInstance) {
      return request.resourceInstance;
    }
    // 3. 尝试从路径参数中获取ID并加载实体
    const { params } = request;
    if (params?.id && subject) {
      const subjectType = SubjectRegistry.getType(subject);
      
      if (!subjectType) {
        throw new Error(`未注册的 subject: ${subject}`);
      }
      let where;//TODO:全部主键改为id就不用这样分支判断了
      if(subject.toLowerCase() === 'user'){
        where={userId: params.id}
      }
      else{
        where={id: params.id}
      }
      const instance = await this.entityManager.findOne(subjectType, {
        where:where 
      });
      if (!instance) {
        throw new NotFoundException('资源不存在');
      }
      
      // 缓存到请求中供后续使用
      request.resourceInstance = instance;
      return instance;
    }
    
    // 4. 返回类作为回退
    return SubjectRegistry.getType(subject);
  }
}
//subject.registry.ts
//此文件定义实体类注册集合
import { Type } from '@nestjs/common';

export class SubjectRegistry {
  private static readonly registry = new Map<string, Type<any>>();

  static register(name: string, type: Type<any>) {
    this.registry.set(name, type);
  }

  static getType(name: string): Type<any> | undefined {
    return this.registry.get(name);
  }

  static getAllSubjects(): string[] {
    return Array.from(this.registry.keys());
  }
  
}

// 装饰器自动注册实体
export function RegisterSubject(name: string) {
  return (target: Type<any>) => {
    SubjectRegistry.register(name, target);
  };
}
//shared.controller.ts
//暂时没有其他功能,可以为空

1.10、测试

新创建一个article模块用于测试:

//article.entity.ts
import { RegisterSubject } from "src/shared/subject.registry";
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";

@RegisterSubject('Article') //此处注册实体类
@Entity("article")
export class Article {
  @PrimaryGeneratedColumn('uuid',{name: 'id', comment: 'ID' })
  public id: string;

  @Column({ type: 'varchar', name: 'title',nullable: true, length: 500, comment: '标题' })
  title: string;

  @Column({ type: 'boolean', nullable: true,name: 'is_moderator', comment: '是否版主' })
  isModerator:boolean;

  @Column({ type: 'varchar', nullable: true,name: 'author_id', comment: '作者' })
  authorId:string;
  @Column({ type: 'varchar', nullable: true,name: 'status', comment: '状态' })
  status:string;
}
//article.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { CreateArticleDto } from './dto/create-article.dto';
import { UpdateArticleDto } from './dto/update-article.dto';
import { Article } from './entities/article.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

@Injectable()
export class ArticleService {
  constructor(
    @InjectRepository(Article)
    protected repository: Repository<Article>,
  ){}
  create(createArticleDto: CreateArticleDto) {
    return 'This action adds a new article';
  }

  async findAll():Promise<Article[]> {
    return this.repository.find();
  }

  findOne(id: string) {
    return this.repository.findOne({where: {id: id}});
  }

  update(id: number, updateArticleDto: UpdateArticleDto) {
    return `This action updates a #${id} article`;
  }

  remove(id: number) {
    return `This action removes a #${id} article`;
  }
}
//article.module.ts
import { Module } from '@nestjs/common';
import { ArticleService } from './article.service';
import { ArticleController } from './article.controller';
import { SharedModule } from 'src/shared/shared.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Article } from './entities/article.entity';

@Module({
  imports: [SharedModule,
    TypeOrmModule.forFeature([Article]),
  ],
  controllers: [ArticleController],
  providers: [
    ArticleService,
  ],
})
export class ArticleModule {
  
}

//article.controller.ts
import { Controller, Get, Post, Body, Patch, Param, Delete, Inject } from '@nestjs/common';
import { ArticleService } from './article.service';
import { CreateArticleDto } from './dto/create-article.dto';
import { UpdateArticleDto } from './dto/update-article.dto';
import { Permission } from 'src/system/policy/policies.decorator';
import { AllowNoToken } from 'src/system/auth/decorators/token.decorator';
import { SharedService } from 'src/shared/shared.service';

@Controller('article')
export class ArticleController {
  constructor(private readonly articleService: ArticleService,
    @Inject(SharedService) private sharedService: SharedService
  ) {}

  @Post()
  create(@Body() createArticleDto: CreateArticleDto) {
    return this.articleService.create(createArticleDto);
  }

  @Get() 
  @Permission('article:read')
  async findAll() {
    return await this.articleService.findAll();
  }

  @Get(':id')
  @Permission('article:read')
  findOne(@Param('id') id: string) {
    return this.articleService.findOne(id);
  }

  @Patch(':id')
  update(@Param('id') id: string, @Body() updateArticleDto: UpdateArticleDto) {
    return this.articleService.update(+id, updateArticleDto);
  }

  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.articleService.remove(+id);
  }
}

** 测试步骤:**

表数据: sys_permission:

image.png

sys_permission_policy:

image.png

sys_policy:

image.png

sys_user:

image.png

sys_role:

image.png

sys_user_role:

image.png

sys_role_policy:

image.png

article :

image.png 第一步: 调用登录窗口取到token 1749794846255.png

第二步: 读取作者为当前登录用户的文章,正常读取: image.png

读取第二条(参见article表数据,第二条作者id为22,不是当前登陆人)

image.png

第三步:测试使用object类型策略 把角色策略换成id=6的记录: image.png

image.png

image.png