概述
本节完成 PolicyGuard 的核心判断逻辑编码,创建共享模块用于获取 Subject 实例,实现两层循环的交叉匹配算法。同时讲解测试前的数据库清理工作,确保策略权限测试基于干净的数据环境。
共享模块(SharedModule)创建
使用 NestJS CLI 创建模块
nest g res modules/shared --no-spec
bash
全局模块配置
// shared/shared.module.ts
import { Global, Module } from '@nestjs/common';
import { SharedService } from './shared.service';
@Global()
@Module({
providers: [SharedService],
exports: [SharedService],
})
export class SharedModule {}
typescript
使用 @Global() 装饰器使 SharedModule 在所有模块中可用,无需手动导入。
SharedService 实现
// shared/shared.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class SharedService {
constructor(private readonly prismaClient: PrismaClient) {}
async getSubject(subject: string, user: any, ...x: any[]) {
return this.prismaClient[subject.toLowerCase()].findUnique({
where: { id: user.id },
...(x.length ? x[0] : {}),
});
}
}
typescript
此方法将 subject 字符串映射为 Prisma 的模型查询,通过动态属性访问实现。
PolicyGuard 核心逻辑
完整代码结构
// policy.guard.ts
@Injectable()
export class PolicyGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly userService: UserService,
private readonly caslAbilityService: CaslAbilityService,
private readonly sharedService: SharedService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// 1. 获取路由标识
// 2. 查询接口要求的 Policy
// 3. 查询用户拥有的 Ability
// 4. 交叉匹配判断
// 5. 返回结果
}
}
typescript
白名单与早退出
// 接口没有关联策略 → 直接通过
if (permissionPolicies.length === 0) {
return true;
}
// TODO: 白名单判断(类似 RBAC Guard 的管理员判断)
if (this.isWhitelisted(user.roles)) {
return true;
}
typescript
两层循环匹配算法
// 创建临时副本用于匹配过程中移除
let tempPermissionPolicies = [...permissionPolicies];
for (const policy of permissionPolicies) {
const { action, subject, fields } = policy;
let permissionGranted = false;
// 获取 subject 实例
const subjectInstance = await this.sharedService.getSubject(
subject, user,
);
// 第二层循环:遍历用户的 Ability
for (const ability of abilities) {
let result: boolean;
if (Array.isArray(fields) && fields.length > 0) {
// 场景一:有字段限制 → 每个字段都必须通过
result = fields.every((field) =>
ability.can(action, subjectInstance, String(field)),
);
} else if (fields?.data) {
// 场景二:fields 为 JSON 对象 → 取 data 属性
result = fields.data.every((field: string) =>
ability.can(action, subjectInstance, String(field)),
);
} else {
// 场景三:无字段限制 → 直接判断 action + subject
result = ability.can(action, subjectInstance);
}
if (result) {
permissionGranted = true;
break; // 当前 Policy 通过,跳出 Ability 循环
}
}
// 通过的 Policy 从待验证列表中移除
if (permissionGranted) {
const index = tempPermissionPolicies.findIndex(p => p === policy);
if (index > -1) {
tempPermissionPolicies.splice(index, 1);
}
}
}
// 最终判断:剩余未通过的 Policy
if (tempPermissionPolicies.length !== 0) {
allPermissionsGranted = false;
}
typescript
三种字段判断场景
| 场景 | fields 值 | 判断方式 |
|---|---|---|
| 字段数组 | ['title', 'content'] | fields.every(f => ability.can(action, subject, f)) |
| JSON 包装 | { data: ['title'] } | fields.data.every(f => ability.can(action, subject, f)) |
| 无字段限制 | undefined / null | ability.can(action, subject) |
ability.can() 字段判断的关键区别
定义时 vs 判断时的 fields 含义不同:
// 定义时:fields 表示"用户必须拥有这些字段的操作权限"
can('update', 'Article', ['title', 'description'], { authorId: user.id });
// 判断时:fields 是逐个字段检查
ability.can('update', article, 'title'); // 检查单个字段
ability.can('update', article, 'content'); // 检查单个字段
typescript
在 Guard 中使用
fields.every()确保每个字段都通过权限检查。
Subject 映射注册
在 SharedModule 中注册 subject 到查询方法的映射:
// subject-map.ts 或直接在 SharedService 中
const subjectMap = {
'Post': {
getSubject: (user) => prismaClient.post.findUnique({
where: { id: user.id },
}),
},
'User': {
getSubject: (user) => prismaClient.user.findUnique({
where: { id: user.id },
}),
},
// 按需扩展其他实体
};
typescript
需要确保传入的 subject 字符串经过校验,防止非法的 Prisma 模型访问。建议在 DTO 层增加 subject 的白名单校验。
测试数据准备
清理数据库脏数据
测试策略权限前,必须清理所有历史数据:
-- 清理顺序(先删关联表,再删主表)
DELETE FROM role_policy;
DELETE FROM role_permission;
DELETE FROM policy;
DELETE FROM permission;
-- user、user_role、role 可保留
sql
脏数据产生原因
- 前期开发时 DTO 未加
class-validator校验 - Prisma
Json类型字段允许存入任意结构 - 多次测试产生了不符合规则的数据
清理验证
// 确保以下表为空
- policy → 策略表
- role_policy → 角色-策略关联表
- role_permission → 角色-权限关联表
- permission → 权限表
typescript
必须清理干净后再测试,否则脏数据会导致策略判断结果不可预期。
完整判断流程总结
请求 → PolicyGuard
│
├── 读取路由 Permission 标识
│
├── 查询 Permission 关联的 Policy 列表
│ └── 为空?→ 直接通过 (return true)
│
├── 查询用户角色 → RolePolicy → Policy
│ └── 构建 Ability 实例列表
│
├── 两层循环匹配
│ ├── 外层:遍历接口 Policy
│ │ ├── 获取 Subject 实例
│ │ └── 内层:遍历用户 Ability
│ │ ├── fields 判断(every)
│ │ └── 无 fields → 直接 can()
│ └── 通过 → 从列表移除
│
└── 列表为空 → true / 不为空 → false
text
关键知识点总结
| 知识点 | 说明 |
|---|---|
@Global() 模块 | SharedModule 全局可用,无需手动导入 |
| 两层循环 | 外层遍历接口 Policy,内层遍历用户 Ability |
fields.every() | 字段级权限必须全部通过才算授权 |
splice() 移除 | 匹配成功的 Policy 从待验证列表移除 |
| 早退出 | 匹配成功即 break,不浪费后续 Ability |
| 数据清理 | 测试前必须清理关联表中的脏数据 |
| subject 映射 | 通过共享模块动态获取实体类实例 |
↑