模型动态关联与基于CASL的动态权限的实现
学习目标
- 实现模型间的动态关联
- 掌握TypeORM事务构建
- 深入了解Nestjs模块的生命周期以及ModuleRef的灵活运用
- 编写一个用于存储动态权限与角色的RBAC模块
- 实现websocket和Http的权限验证守卫
- 实现CRUD框架的RBAC装饰器
- 实现基于CASL的动态权限验证
- 分离应用的前后台API
流程图
预装类库
在开始编码之前请安装以下类库
~ pnpm add @casl/ability
代码结构
这一节课我们添加了一个RBAC模块用于验证权限
src/modules/rbac
├── constants.ts
├── controllers
│ ├── index.ts
│ ├── permission.controller.ts
│ └── role.controller.ts
├── decorators
│ ├── has-permission.decorator.ts # 用于添加API权限验证的装饰器
│ ├── index.ts
│ └── rbac-crud.decorator.ts # CRUD装饰器的RBAC实现
├── dtos
│ ├── index.ts
│ ├── permission.dto.ts
│ └── role.dto.ts
├── entities
│ ├── index.ts
│ ├── permission.entity.ts
│ └── role.entity.ts
├── guards
│ ├── checker.ts
│ ├── index.ts
│ ├── rbac-ws.guard.ts # 适用于websocket的权限守卫
│ └── rbac.guard.ts # 适用于http的权限守卫
├── helpers.ts
├── rbac.module.ts
├── repositories
│ ├── index.ts
│ ├── permission.repository.ts
│ └── role.repository.ts
├── resolver
│ ├── index.ts
│ └── rbac.resolver.ts # 用于同步权限和系统角色的提供者
├── services
│ ├── index.ts
│ ├── permission.service.ts
│ └── role.service.ts
├── subscribers
│ ├── index.ts
│ ├── permission.subscriber.ts
│ └── role.subscriber.ts
└── types.ts
动态关联
比较常用的需要动态关联的场景是用户模型,我们为用户模型添加一个动态关联
首先添加一个用于定义动态关联的类型
// src/modules/core/types.ts
export interface DynamicRelation {
relation:
| ReturnType<typeof OneToOne>
| ReturnType<typeof OneToMany>
| ReturnType<typeof ManyToOne>
| ReturnType<typeof ManyToMany>;
column: string;
}
添加一个动态关联装饰器,用于存储关联关系
// src/modules/core/constants.ts
export const ADDTIONAL_RELATIONS = 'additional_relations';
// src/modules/core/decorators/dynamic-relation.decorator.ts
export function AddRelations(relations: () => Array<DynamicRelation>) {
return <E extends ObjectLiteral>(target: E) => {
Reflect.defineMetadata(ADDTIONAL_RELATIONS, relations, target);
return target;
};
}
动态关联实现逻辑为读取ADDTIONAL_RELATIONS
常量,通过该常量存储的值来添加关联的column
字段与关联关系,最后把修改后的类通过TypeOrmModule.forFeature
加载
export const loadEntities = (
entities: EntityClassOrSchema[] = [],
dataSource?: DataSource | DataSourceOptions | string,
) => {
...
return TypeOrmModule.forFeature(es, dataSource);
};
使用
配置用户模型的动态关联
// src/modules/user/types.ts
/**
* 自定义用户模块配置
*/
export interface UserConfig {
super: {
username: string;
password: string;
};
...
}
/**
* 默认用户模块配置
*/
export interface DefaultUserConfig {
super: {
username: string;
password: string;
};
...
}
// src/config/user.config.ts
/**
* 用户模块配置
*/
export const userConfig: () => UserConfig = () => ({
...
relations: [
{
column: 'posts',
relation:
},
{
column: 'comment',
relation:
},
],
});
为模型添加装饰器并传入关联配置
// src/modules/user/entities/user.entity.ts
@AddRelations(() => getUserConfig<DynamicRelation[]>('relations'))
@Exclude()
@Entity('users')
export class UserEntity extends BaseEntity {
...
}
替换模型注册方式
@Module({
imports: [
loadEntities(entities),
...
})
export class UserModule {}
RBAC模块
RBAC模块基于CASL实现权限验证并通过数据库存储来构建动态权限
模型
添加两个模型PermissionEntity
和RoleEntity
,分别用于存储权限和角色数据
权限,用户,角色三者的关系均为多对多
需要注意的是RoleEntity中如果把systemed设置成就几位无法删除而是自动同步到数据表的系统角色
// src/modules/rbac/entities/permission.entity.ts
export class PermissionEntity<
A extends AbilityTuple = AbilityTuple,
C extends MongoQuery = MongoQuery,
> {
...
@Expose({ groups: ['permission-list', 'permission-detail'] })
@ManyToMany((type) => RoleEntity, (role) => role.permissions)
@JoinTable()
roles!: RoleEntity[];
@ManyToMany(() => UserEntity, (user) => user.permissions)
@JoinTable()
users!: UserEntity[];
}
// src/modules/rbac/entities/role.entity.ts
@Exclude()
@Entity('rbac_roles')
export class RoleEntity extends BaseEntity {
...
@Column({ comment: '是否为不可更改的系统权限', default: false })
systemed?: boolean;
@Expose({ groups: ['role-detail'] })
@Type(() => PermissionEntity)
@ManyToMany(() => PermissionEntity, (permission) => permission.roles, {
cascade: true,
eager: true,
})
permissions!: PermissionEntity[];
@ManyToMany(() => UserEntity, (user) => user.roles, { deferrable: 'INITIALLY IMMEDIATE' })
@JoinTable()
users!: UserEntity[];
}
存储类
存储类继承我们前面编写的BaseRepository
,两个存储类的作用在于在查询时添加角色关联的权限,以及添加权限关联的角色
// src/modules/rbac/repositories/permission.repository.ts
@CustomRepository(PermissionEntity)
export class PermissionRepository extends BaseRepository<PermissionEntity> {
protected qbName = 'permission';
buildBaseQuery() {
return this.createQueryBuilder(this.getQBName()).leftJoinAndSelect(
`${this.getQBName()}.roles`,
'roles',
);
}
}
// src/modules/rbac/repositories/role.repository.ts
@CustomRepository(RoleEntity)
export class RoleRepository extends BaseRepository<RoleEntity> {
protected qbName = 'role';
buildBaseQuery() {
return this.createQueryBuilder(this.getQBName()).leftJoinAndSelect(
`${this.getQBName()}.permissions`,
'permssions',
);
}
}
订阅者
两个订阅者的作用在于在查询时为没有设置label
字段的权限和角色,把它们的name
设置成label
// src/modules/rbac/subscribers/permission.subscriber.ts
@EventSubscriber()
export class PermssionSubscriber extends BaseSubscriber<PermissionEntity> {
...
async afterLoad(entity: PermissionEntity) {
if (isNil(entity.label)) {
entity.label = entity.name;
}
}
}
@EventSubscriber()
export class RoleSubscriber extends BaseSubscriber<RoleEntity> {
...
async afterLoad(entity: RoleEntity) {
if (isNil(entity.label)) {
entity.label = entity.name;
}
}
}
数据验证
对于权限,因为是固定不变的,只有在启动时同步一下到数据库,所以只需要查询的API即可
权限可根据其关联的角色进行过滤
export class QueryPermssionDto implements PaginateDto, TrashedDto {
@IsModelExist(RoleEntity, {
groups: ['update'],
message: '指定的角色不存在',
})
@IsUUID(undefined, { message: '角色ID格式错误' })
@IsOptional()
role?: string;
...
}
角色需要支持CRUD操作(系统角色只能读取,而不可进行其它操作)
角色可根据其关联的用户过滤
export class QueryRoleDto implements PaginateDto, TrashedDto {
@IsModelExist(UserEntity, {
groups: ['update'],
message: '指定的用户不存在',
})
@IsUUID(undefined, { message: '用户ID格式错误' })
@IsOptional()
user?: string;
...
}
@Injectable()
@DtoValidation({ groups: ['create'] })
export class CreateRoleDto {
、...
@IsModelExist(PermissionEntity, {
each: true,
always: true,
message: '权限不存在',
})
@IsUUID(undefined, {
each: true,
always: true,
message: '权限ID格式不正确',
})
@IsOptional({ always: true })
permissions?: string[];
}
@Injectable()
@DtoValidation({ groups: ['update'] })
export class UpdateRoleDto extends PartialType(CreateRoleDto) {
...
}
服务
权限服务继承课程前面编写的BaseService
,因为没有CRUD操作,所以只要重载一下查询方法,在查询列表时可根据角色过滤即可
@Injectable()
export class PermissionService extends BaseService<PermissionEntity, PermissionRepository> {
constructor(protected permissionRepository: PermissionRepository) {
super(permissionRepository);
}
protected async buildListQuery(
queryBuilder: SelectQueryBuilder<PermissionEntity>,
options: QueryPermssionDto,
callback?: QueryHook<PermissionEntity>,
) {
const qb = await super.buildListQuery(queryBuilder, options, callback);
if (!isNil(options.role)) {
qb.andWhere('roles.id IN (:...roles)', {
roles: [options.role],
});
}
return qb;
}
}
角色服务同样继承BaseService
,支持CRUD操作
注意删除权限时判断一下是否
systemed
,如果是的话就抛出异常
@Injectable()
export class RoleService extends BaseService<RoleEntity, RoleRepository> {
protected enable_trash = true;
constructor(
protected roleRepository: RoleRepository,
protected permissionRepository: PermissionRepository,
) {
super(roleRepository);
}
async create(data: CreateRoleDto) {
...
}
async update(data: UpdateRoleDto) {
...
}
/**
* 删除数据
* @param id
* @param trash
*/
async delete(id: string, trash = true) {
const item = await this.repository.findOneOrFail({
where: { id } as any,
withDeleted: this.enable_trash ? true : undefined,
});
if (item.systemed) {
throw new ForbiddenException('can not remove systemed role!');
}
if (this.enable_trash && trash && isNil(item.deletedAt)) {
// await this.repository.softRemove(item);
(item as any).deletedAt = new Date();
await this.repository.save(item);
return this.detail(id, true);
}
return this.repository.remove(item);
}
protected async buildListQuery(
queryBuilder: SelectQueryBuilder<RoleEntity>,
options: QueryRoleDto,
callback?: QueryHook<RoleEntity>,
) {
const qb = await super.buildListQuery(queryBuilder, options, callback);
qb.leftJoinAndSelect(`${this.repository.getQBName()}.users`, 'users');
if (!isNil(options.user)) {
qb.andWhere('users.id IN (:...users)', {
roles: [options.user],
});
}
return qb;
}
}
同步数据
同步数据的操作需要我们先掌握以下两个概念
- Nestjs的应用启动生命周期
- TypeORM的事务操作
编写一个RbacResolver
提供者,并实现OnApplicationBootstrap
options
和setOptions
用于设置CASL
的选项_roles
与addRoles
用于同步系统角色_permissions
与addPermissions
用于同步权限
默认自带两个角色,分别为普通用户与超级管理员
@Injectable()
export class RbacResolver<A extends AbilityTuple = AbilityTuple, C extends MongoQuery = MongoQuery>
implements OnApplicationBootstrap
{
protected setuped = false;
protected options: AbilityOptions<A, C>;
protected _roles: Role[] = [
{
name: SystemRoles.USER,
label: '普通用户',
description: '新用户的默认角色',
permissions: [],
},
{
name: SystemRoles.ADMIN,
label: '超级管理员',
description: '拥有整个系统的管理权限',
permissions: [],
},
];
protected _permissions: Permission<A, C>[] = [
{
name: 'system-manage',
label: '系统管理',
description: '管理系统的所有功能',
rule: {
action: 'manage',
subject: 'all',
} as any,
},
];
constructor(protected dataSource: DataSource) {}
setOptions(options: AbilityOptions<A, C>) {
if (!this.setuped) {
this.options = options;
this.setuped = true;
}
return this;
}
get roles() {
return this._roles;
}
get permissions() {
return this._permissions;
}
addRoles(data: Role[]) {
this._roles = [...this.roles, ...data];
}
addPermissions(data: Permission<A, C>[]) {
this._permissions = [...this.permissions, ...data].map((p) => {
if (typeof p.rule.subject === 'string') return p;
if ('modelName' in p.rule.subject) {
const { modelName } = p.rule.subject;
return { ...p, rule: { ...p.rule, subject: modelName } };
}
return { ...p, rule: { ...p.rule, subject: (p.rule.subject as any).name } };
});
}
...
}
构建一个用于同步权限和角色的事务
具体实现请查看源代码
async onApplicationBootstrap() {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await this.syncRoles(queryRunner.manager);
await this.syncPermissions(queryRunner.manager);
await queryRunner.commitTransaction();
} catch (err) {
console.log(err);
await queryRunner.rollbackTransaction();
} finally {
await queryRunner.release();
}
}
protected async syncRoles(manager: EntityManager) {
...
}
protected async syncPermissions(manager: EntityManager) {
...
}
有一个比较重要的规则是,权限虽然放入数据库,但是不会把判断条件的conditions
属性存储,因为它是一个函数,此属性存储在_permissions
中,在读取权限时根据名称获取此属性用于作为casl判断的条件
除了通过RbacResolver
同步的系统角色外,其它角色我们可以灵活添加与移除,而权限总是固定的,即时移除,在重启或下一次启动应用时又会自动同步。
那么这些角色和权限在哪里定义,什么时候定义呢?
这几需要了解nestjs的生命周期了,可以看到下图
在启动应用时,对于提供者,首先我们会去判断是否有onModuleinit
方法,有的话就执行,然后会去判断是否有onApplicationBootstrap
方法,有的话就执行,利用这个机制就很容易构建出我们的权限同步功能
首先我们需要在各个模块增加一个用于添加角色和权限的提供者,并且在onMoudleinit
时期使用RbacResolver
添加好权限与角色,并在RbacResolver
的onApplicationBootstrap
时期同步权限与角色即可
以ContentRbac
为例
使用
addRoles
方法不仅仅是添加角色,并且也会对已存在或已经在其它模块添加的角色进行更新
@Injectable()
export class ContentRbac implements OnModuleInit {
constructor(private moduleRef: ModuleRef) {}
onModuleInit() {
const resolver = this.moduleRef.get(RbacResolver, { strict: false });
resolver.addPermissions([
{
name: 'post.create',
rule: {
action: PermissionAction.CREATE,
subject: PostEntity,
},
},
{
name: 'post.owner',
rule: {
action: PermissionAction.OWNER,
subject: PostEntity,
conditions: (user) => ({
'author.id': user.id,
}),
},
},
...
]);
resolver.addRoles([
{
name: SystemRoles.USER,
permissions: [
'post.read',
'post.create',
'post.owner',
'comment.create',
'comment.owner',
],
},
{
name: 'content-manage',
label: '内容管理员',
description: '管理内容模块',
permissions: ['post.manage', 'category.manage', 'comment.manage'],
},
]);
}
}
然后我们把这些模块的rbac.ts
提供者在本模块注册,把RbacResolver
在Rbac
模块注册,使它们成为提供者即可。需要注意的是,要使用Rbac
功能的模块需要导入RbacModule
因为UserModule
与RbacModule
是相互循环依赖,所以需要用forWord
相互导入
// src/modules/user/user.module.ts
@Module({
imports: [
loadEntities(entities),
...
forwardRef(() => RbacModule),
],
})
export class UserModule {}
// src/modules/rbac/rbac.module.ts
@Module({
imports: [
forwardRef(() => UserModule),
TypeOrmModule.forFeature(entities),
CoreModule.forRepository(repositories),
],
controllers,
providers: [
...
{
provide: RbacResolver,
useFactory: async (dataSource: DataSource) => {
const resolver = new RbacResolver(dataSource);
resolver.setOptions({});
return resolver;
},
inject: [getDataSourceToken()],
},
],
exports: [CoreModule.forRepository(repositories), RbacResolver],
})
export class RbacModule {}
守卫与装饰器
在添加守卫之前先增加以下两个函数
文件位置: src/modules/rbac/guards/rbac.guard.ts
它们分别用于
getCheckers
用于通过PERMISSION_CHECKERS
的装饰器元数据获取控制器方法上的权限验证器exeChecker
用于执行控制器方法上的权限验证器函数或类solveChecker
根据当前登录用户关联的权限调用exeChcker
用于验证控制器方法上的所有验证器,并返回最终结果
当前用户的权限是根据其关联的角色下的所有权限以及其直接关联的权限合并去重后所得,这需要为用户模型的订阅者添加如下代码
// src/modules/user/subscribers/user.subscriber.ts
async afterLoad(entity: UserEntity): Promise<void> {
let permissions = (entity.permissions ?? []) as PermissionEntity[];
for (const role of entity.roles ?? []) {
const roleEntity = await RoleEntity.findOneOrFail({
relations: ['permissions'],
where: { id: role.id },
});
permissions = [...permissions, ...(roleEntity.permissions ?? [])];
}
entity.permissions = permissions.reduce((o, n) => {
if (o.find(({ name }) => name === n.name)) return o;
return [...o, n];
}, []);
}
然后添加一个用于定义PERMISSION_CHECKERS
(权限验证器列表)的装饰器
// src/modules/rbac/decorators/has-permission.decorator.ts
export const Permission = (...checkers: PermissionChecker[]) =>
SetMetadata(PERMISSION_CHECKERS, checkers);
同时,为了方便给Crud
装饰器装饰的控制器方法添加权限验证器,需要定义一个RbacCrud
装饰器,此装饰器首先执行Crud
,然后为原来的CurdOptions
添加一个rbac
选项,用于配置权限验证器列表
// src/modules/rbac/types.ts
export type RbacCurdOption = CrudMethodOption & { rbac?: PermissionChecker[] };
export interface RbacCurdItem {
name: CurdMethod;
option?: RbacCurdOption;
}
export type RbacCurdOptions = Omit<CurdOptions, 'enabled'> & {
enabled: Array<CurdMethod | RbacCurdItem>;
};
// src/modules/rbac/decorators/rbac-crud.decorator.ts
export const RbacCrud =
(options: RbacCurdOptions) =>
<T extends BaseController<any>>(Target: Type<T>) => {
Crud(options)(Target);
const { enabled } = Reflect.getMetadata(CRUD_OPTIONS, Target) as RbacCurdOptions;
// 添加验证DTO类
for (const value of enabled) {
const { name } = (typeof value === 'string' ? { name: value } : value) as RbacCurdItem;
const find = enabled.find((v) => v === name || (v as any).name === name);
const option = typeof find === 'string' ? {} : find.option ?? {};
if (option.rbac) {
Reflect.defineMetadata(PERMISSION_CHECKERS, option.rbac, Target.prototype, name);
}
}
return Target;
};
添加一个继承自JwtAuthGuard
的守卫,用于在登录守卫之后验证权限
// src/modules/rbac/guards/rbac.guard.ts
@Injectable()
export class RbacGuard extends JwtAuthGuard {
constructor(
protected reflector: Reflector,
protected resolver: RbacResolver,
protected tokenService: TokenService,
protected userRepository: UserRepository,
protected moduleRef: ModuleRef,
) {
super(reflector, tokenService);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const authCheck = await super.canActivate(context);
...
return solveChecker({
resolver: this.resolver,
checkers,
moduleRef: this.moduleRef,
user,
request,
});
}
}
并且把它替换掉JwtAuthGuard
来作为全局守卫
// src/modules/user/user.module.ts
@Module({
// {
// provide: APP_GUARD,
// useClass: JwtAuthGuard,
// },
...
})
// src/modules/rbac/rbac.module.ts
@Module({
providers: [
{
provide: APP_GUARD,
useClass: RbacGuard,
},
...
],
exports: [CoreModule.forRepository(repositories), RbacResolver],
})
export class RbacModule {}