add rbac module

This commit is contained in:
liuyi 2025-06-26 15:25:01 +08:00
parent 46aa8312e7
commit 99cb459fb7
10 changed files with 533 additions and 0 deletions

View File

@ -1 +1,3 @@
export const DTO_VALIDATION_OPTIONS = 'dto_validation_options'; export const DTO_VALIDATION_OPTIONS = 'dto_validation_options';
export const ADDTIONAL_RELATIONSHIPS = 'addtional_relationships';

View File

@ -0,0 +1,11 @@
import { ObjectLiteral } from 'typeorm';
import { ADDTIONAL_RELATIONSHIPS } from '../contants';
import { DynamicRelation } from '../types';
export function AddRelations(relations: () => Array<DynamicRelation>) {
return <T extends ObjectLiteral>(target: T) => {
Reflect.defineMetadata(ADDTIONAL_RELATIONSHIPS, relations, target);
return target;
};
}

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-duplicate-type-constituents */
import { ModuleMetadata, PipeTransform, Type } from '@nestjs/common'; import { ModuleMetadata, PipeTransform, Type } from '@nestjs/common';
import { IAuthGuard } from '@nestjs/passport'; import { IAuthGuard } from '@nestjs/passport';
import { NestFastifyApplication } from '@nestjs/platform-fastify'; import { NestFastifyApplication } from '@nestjs/platform-fastify';
@ -5,6 +6,7 @@ import { NestFastifyApplication } from '@nestjs/platform-fastify';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Ora } from 'ora'; import { Ora } from 'ora';
import { StartOptions } from 'pm2'; import { StartOptions } from 'pm2';
import { ManyToMany, ManyToOne, OneToMany, OneToOne } from 'typeorm';
import { CommandModule } from 'yargs'; import { CommandModule } from 'yargs';
import { Configure } from '../config/configure'; import { Configure } from '../config/configure';
@ -131,3 +133,12 @@ export type CommandCollection = Array<CommandItem<any, any>>;
export interface CreateOption { export interface CreateOption {
commands: () => CommandCollection; commands: () => CommandCollection;
} }
export interface DynamicRelation {
relation:
| ReturnType<typeof OneToOne>
| ReturnType<typeof OneToMany>
| ReturnType<typeof ManyToOne>
| ReturnType<typeof ManyToMany>;
column: string;
}

View File

@ -0,0 +1,8 @@
export enum SystemRoles {
USER = 'user',
SUPER_ADMIN = 'super_admin',
}
export const SYSTEM_PERMISSION = 'system-manage';
export const PERMISSION_CHECKERS = 'permission_checkers';

View File

@ -0,0 +1,18 @@
import { IsOptional, IsUUID } from 'class-validator';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { IsDataExist } from '@/modules/database/constraints';
import { PaginateDto } from '@/modules/restful/dtos/paginate.dto';
import { RoleEntity } from '../entities/role.entity';
@DtoValidation({ type: 'query' })
export class QueryPermissionDto extends PaginateDto {
/**
* 角色ID:通过角色过滤权限列表
*/
@IsDataExist(RoleEntity, { groups: ['update'], message: '指定的角色不存在' })
@IsUUID(undefined, { message: '角色ID格式错误' })
@IsOptional()
role?: string;
}

View File

@ -0,0 +1,56 @@
import { PartialType } from '@nestjs/swagger';
import { IsDefined, IsNotEmpty, IsOptional, IsUUID, MaxLength } from 'class-validator';
import { DtoValidation } from '@/modules/core/decorator/dto.validation.decorator';
import { IsDataExist } from '@/modules/database/constraints';
import { PaginateWithTrashedDto } from '@/modules/restful/dtos/paginate-width-trashed.dto';
import { UserEntity } from '@/modules/user/entities';
import { PermissionEntity } from '../entities/permission.entity';
@DtoValidation({ type: 'query' })
export class QueryRoleDto extends PaginateWithTrashedDto {
/**
* 用户ID:通过用户过滤角色列表
*/
@IsDataExist(UserEntity, { groups: ['update'], message: '指定的用户不存在' })
@IsUUID(undefined, { message: '用户ID格式错误' })
@IsOptional()
user?: string;
}
@DtoValidation({ groups: ['create'] })
export class CreateRoleDto {
/**
*
*/
@MaxLength(100, { always: true, message: '名称长度最大为$constraint1' })
@IsNotEmpty({ groups: ['create'], message: '名称必须填写' })
@IsOptional({ groups: ['update'] })
name: string;
/**
* 权限标识:如果没有设置则在查询后为权限名称
*/
@MaxLength(100, { always: true, message: 'Label长度最大为$constraint1' })
@IsOptional({ always: true })
label?: string;
/**
* 关联权限ID列表:一个角色可以关联多个权限,
*/
@IsDataExist(PermissionEntity, { each: true, always: true, message: '权限不存在' })
@IsUUID(undefined, { each: true, always: true, message: '权限ID格式不正确' })
@IsOptional({ always: true })
permissions?: string[];
}
@DtoValidation({ groups: ['update'] })
export class UpdateRoleDto extends PartialType(CreateRoleDto) {
/**
* ID
*/
@IsUUID(undefined, { message: 'ID格式错误', groups: ['update'] })
@IsDefined({ groups: ['update'], message: 'ID必须指定' })
id: string;
}

View File

@ -0,0 +1,267 @@
import { AbilityOptions, AbilityTuple, MongoQuery, SubjectType } from '@casl/ability';
import { InternalServerErrorException, OnApplicationBootstrap } from '@nestjs/common';
import { isArray, isNil, omit } from 'lodash';
import { DataSource, EntityManager, In, Not } from 'typeorm';
import { Configure } from '../config/configure';
import { deepMerge } from '../core/helpers';
import { UserEntity } from '../user/entities';
import { SYSTEM_PERMISSION, SystemRoles } from './constants';
import { PermissionEntity } from './entities/permission.entity';
import { RoleEntity } from './entities/role.entity';
import { PermissionType, Role } from './types';
const getSubject = <R extends SubjectType>(subject: R) => {
if (typeof subject === 'string') {
return subject;
}
if (subject.modelName) {
return subject;
}
return subject.name;
};
export class RbacResolver<P extends AbilityTuple = AbilityTuple, T extends MongoQuery = MongoQuery>
implements OnApplicationBootstrap
{
private setuped = false;
private options: AbilityOptions<P, T>;
private _roles: Role[] = [
{
name: SystemRoles.USER,
label: '普通用户',
description: '新用户的默认角色',
permissions: [],
},
{
name: SystemRoles.SUPER_ADMIN,
label: '超级管理员',
description: '拥有整个系统的管理权限',
permissions: [],
},
];
private _permissions: PermissionType<P, T>[] = [
{
name: SYSTEM_PERMISSION,
label: '系统管理',
description: '管理系统的所有功能',
rule: {
action: 'manage',
subject: 'all',
} as any,
},
];
constructor(
protected dataSource: DataSource,
protected configure: Configure,
) {}
setOptions(options: AbilityOptions<P, T>) {
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: PermissionType<P, T>[]) {
this._permissions = [...this._permissions, ...data].map((perm) => {
let subject: typeof perm.rule.subject;
if (isArray(perm.rule.subject)) {
subject = perm.rule.subject.map((v) => getSubject(v));
} else {
subject = getSubject(perm.rule.subject);
}
const rule = { ...perm.rule, subject };
return { ...perm, rule };
});
}
async onApplicationBootstrap() {
if (!this.dataSource.isInitialized) {
return null;
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await this.syncRoles(queryRunner.manager);
await this.syncPermissions(queryRunner.manager);
await this.syncSuperAdmin(queryRunner.manager);
await queryRunner.commitTransaction();
} catch (e) {
console.log(e);
await queryRunner.rollbackTransaction();
} finally {
await queryRunner.release();
}
return true;
}
/**
*
* @param manager
*/
async syncRoles(manager: EntityManager) {
this._roles = this.roles.reduce((o, n) => {
if (o.map(({ name }) => name).includes(n.name)) {
return o.map((e) => (e.name === n.name ? deepMerge(e, n, 'merge') : e));
}
return [...o, n];
}, []);
for (const item of this.roles) {
let role = await manager.findOne(RoleEntity, {
relations: ['permissions'],
where: { name: item.name },
});
if (isNil(role)) {
role = await manager.save(
manager.create(RoleEntity, {
name: item.name,
label: item.label,
description: item.description,
systemed: true,
}),
{
reload: true,
},
);
} else {
await manager.update(RoleEntity, role.id, { systemed: true });
}
}
const systemRoles = await manager.findBy(RoleEntity, { systemed: true });
const toDels: string[] = [];
for (const item of systemRoles) {
if (isNil(this.roles.find(({ name }) => item.name === name))) {
toDels.push(item.id);
}
}
if (toDels.length > 0) {
await manager.delete(RoleEntity, toDels);
}
}
async syncPermissions(manager: EntityManager) {
const permissions = await manager.find(PermissionEntity);
const roles = await manager.find(RoleEntity, {
relations: ['permissions'],
where: { name: Not(SystemRoles.SUPER_ADMIN) },
});
const roleRepo = manager.getRepository(RoleEntity);
// 合并并去除重复权限
this._permissions = this.permissions.reduce(
(o, n) => (o.map(({ name }) => name).includes(n.name) ? o : [...o, n]),
[],
);
const names = this.permissions.map(({ name }) => name);
for (const item of this.permissions) {
const perm = omit(item, ['conditions']);
const old = await manager.findOneBy(PermissionEntity, { name: perm.name });
if (isNil(old)) {
await manager.save(manager.create(PermissionEntity, perm));
} else {
await manager.update(PermissionEntity, old.id, perm);
}
}
// 删除冗余权限
const toDels: string[] = [];
for (const item of permissions) {
if (!names.includes(item.name) && item.name !== SYSTEM_PERMISSION) {
toDels.push(item.id);
}
}
if (toDels.length > 0) {
await manager.delete(PermissionEntity, toDels);
}
// 同步普通角色
for (const role of roles) {
const rolePermissions = await manager.findBy(PermissionEntity, {
name: In(this.roles.find(({ name }) => name === role.name).permissions),
});
await roleRepo
.createQueryBuilder('role')
.relation(RoleEntity, 'permissions')
.of(role)
.addAndRemove(
rolePermissions.map(({ id }) => id),
(role.permissions ?? []).map(({ id }) => id),
);
}
// 同步超级管理员角色
const superRole = await manager.findOneOrFail(RoleEntity, {
relations: ['permissions'],
where: { name: SystemRoles.SUPER_ADMIN },
});
const systemManage = await manager.findOneOrFail(PermissionEntity, {
where: { name: SYSTEM_PERMISSION },
});
await roleRepo
.createQueryBuilder('role')
.relation(RoleEntity, 'permissions')
.of(superRole)
.addAndRemove(
[systemManage.id],
(superRole.permissions ?? []).map(({ id }) => id),
);
}
async syncSuperAdmin(manager: EntityManager) {
const superRole = await manager.findOneOrFail(RoleEntity, {
relations: ['permissions'],
where: { name: SystemRoles.SUPER_ADMIN },
});
const superUsers = await manager
.createQueryBuilder(UserEntity, 'user')
.leftJoinAndSelect('user.roles', 'roles')
.where('roles.id IN (:...ids', { ids: [superRole.id] })
.getMany();
if (superUsers.length < 1) {
const userRepo = manager.getRepository(UserEntity);
if ((await userRepo.count()) < 1) {
throw new InternalServerErrorException(
'Please add a super-admin user first before run server!',
);
}
const firstUser = await userRepo.findOneByOrFail({ id: undefined });
await userRepo
.createQueryBuilder('user')
.relation(UserEntity, 'roles')
.of(firstUser)
.addAndRemove(
[superRole.id],
(firstUser.roles ?? []).map(({ id }) => id),
);
}
}
}

View File

@ -0,0 +1,48 @@
import { AbilityTuple, MongoQuery } from '@casl/ability';
import { Injectable } from '@nestjs/common';
import { isNil } from 'lodash';
import { SelectQueryBuilder } from 'typeorm';
import { BaseService } from '@/modules/database/base/service';
import { QueryHook } from '@/modules/database/types';
import { QueryPermissionDto } from '../dtos/permission.dto';
import { PermissionEntity } from '../entities/permission.entity';
import { PermissionRepository } from '../repositories/permission.repository';
type FindParams = {
[key in keyof Omit<QueryPermissionDto, 'limit' | 'page'>]: QueryPermissionDto[key];
};
@Injectable()
export class PermissionService extends BaseService<
PermissionEntity,
PermissionRepository,
FindParams
> {
constructor(protected permissionRepository: PermissionRepository) {
super(permissionRepository);
}
protected async buildListQuery(
queryBuilder: SelectQueryBuilder<PermissionEntity>,
options: FindParams,
callback?: QueryHook<PermissionEntity>,
) {
const qb = await super.buildListQB(queryBuilder, options, callback);
if (!isNil(options.role)) {
qb.andWhere('role.id IN (:...roles', { roles: [options.role] });
}
return qb;
}
create(data: PermissionEntity): Promise<PermissionEntity<AbilityTuple, MongoQuery>> {
throw new Error('Method not implemented.');
}
update(data: PermissionEntity): Promise<PermissionEntity<AbilityTuple, MongoQuery>> {
throw new Error('Method not implemented.');
}
}

View File

@ -0,0 +1,81 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { isNil, omit } from 'lodash';
import { In, SelectQueryBuilder } from 'typeorm';
import { BaseService } from '@/modules/database/base/service';
import { QueryHook } from '@/modules/database/types';
import { CreateRoleDto, QueryRoleDto, UpdateRoleDto } from '../dtos/role.dtos';
import { RoleEntity } from '../entities/role.entity';
import { PermissionRepository } from '../repositories/permission.repository';
import { RoleRepository } from '../repositories/role.repository';
@Injectable()
export class RoleService extends BaseService<RoleEntity, RoleRepository> {
protected enableTrash = true;
constructor(
protected roleRepository: RoleRepository,
protected permissionRepository: PermissionRepository,
) {
super(roleRepository);
}
async create(data: CreateRoleDto): Promise<RoleEntity> {
const createRole = {
...data,
permissions: data.permissions
? await this.permissionRepository.findBy({ id: In(data.permissions) })
: [],
};
const item = await this.repository.save(createRole);
return this.detail(item.id);
}
async update(data: UpdateRoleDto): Promise<RoleEntity> {
const role = await this.detail(data.id);
if (data.permissions) {
await this.repository
.createQueryBuilder('role')
.relation(RoleEntity, 'permissions')
.of(role)
.addAndRemove(data.permissions, role.permissions ?? []);
}
await this.repository.update(data.id, omit(data, ['id', 'permissions']));
return this.detail(data.id);
}
async delete(items: string[], trash = true): Promise<RoleEntity[]> {
const roles = await this.repository.find({ where: { id: In(items) }, withDeleted: true });
for (const role of roles) {
if (role.systemed) {
throw new ForbiddenException('can not remove systemed role!');
}
}
if (!trash) {
return this.repository.remove(roles);
}
const directs = roles.filter((item) => !isNil(item.deletedAt));
const softs = roles.filter((item) => isNil(item.deletedAt));
return [
...(await this.repository.remove(directs)),
...(await this.repository.softRemove(softs)),
];
}
protected async buildListQuery(
queryBuilder: SelectQueryBuilder<RoleEntity>,
options: QueryRoleDto,
callback?: QueryHook<RoleEntity>,
) {
const qb = await super.buildListQB(queryBuilder, options, callback);
qb.leftJoinAndSelect(`${this.repository.qbName}.users`, 'users');
if (!isNil(options.user)) {
qb.andWhere('users.id IN (:...users)', { users: [options.user] });
}
return qb;
}
}

31
src/modules/rbac/types.ts Normal file
View File

@ -0,0 +1,31 @@
/* eslint-disable import/no-extraneous-dependencies */
import { AbilityTuple, MongoAbility, MongoQuery, RawRuleFrom } from '@casl/ability';
import { ModuleRef } from '@nestjs/core';
import { FastifyRequest as Request } from 'fastify';
import { UserEntity } from '../user/entities';
import { PermissionEntity } from './entities/permission.entity';
import { RoleEntity } from './entities/role.entity';
export type Role = Pick<ClassToPlain<RoleEntity>, 'name' | 'label' | 'description'> & {
permissions: string[];
};
export type PermissionType<P extends AbilityTuple, T extends MongoQuery> = Pick<
ClassToPlain<PermissionEntity<P, T>>,
'name'
> &
Partial<Pick<ClassToPlain<PermissionEntity<P, T>>, 'label' | 'description'>> & {
rule: Omit<RawRuleFrom<P, T>, 'conditions'> & {
conditions?: (user: ClassToPlain<UserEntity>) => RecordAny;
};
};
export type PermissionChecker = (
ability: MongoAbility,
ref?: ModuleRef,
request?: Request,
) => Promise<boolean>;