diff --git a/src/modules/core/contants.ts b/src/modules/core/contants.ts index eafa87d..ee8cf50 100644 --- a/src/modules/core/contants.ts +++ b/src/modules/core/contants.ts @@ -1 +1,3 @@ export const DTO_VALIDATION_OPTIONS = 'dto_validation_options'; + +export const ADDTIONAL_RELATIONSHIPS = 'addtional_relationships'; diff --git a/src/modules/core/decorator/dynamic.relationship.decorator.ts b/src/modules/core/decorator/dynamic.relationship.decorator.ts new file mode 100644 index 0000000..2ca7a69 --- /dev/null +++ b/src/modules/core/decorator/dynamic.relationship.decorator.ts @@ -0,0 +1,11 @@ +import { ObjectLiteral } from 'typeorm'; + +import { ADDTIONAL_RELATIONSHIPS } from '../contants'; +import { DynamicRelation } from '../types'; + +export function AddRelations(relations: () => Array) { + return (target: T) => { + Reflect.defineMetadata(ADDTIONAL_RELATIONSHIPS, relations, target); + return target; + }; +} diff --git a/src/modules/core/types.ts b/src/modules/core/types.ts index 4c81f59..ee13d08 100644 --- a/src/modules/core/types.ts +++ b/src/modules/core/types.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-duplicate-type-constituents */ import { ModuleMetadata, PipeTransform, Type } from '@nestjs/common'; import { IAuthGuard } from '@nestjs/passport'; import { NestFastifyApplication } from '@nestjs/platform-fastify'; @@ -5,6 +6,7 @@ import { NestFastifyApplication } from '@nestjs/platform-fastify'; import dayjs from 'dayjs'; import { Ora } from 'ora'; import { StartOptions } from 'pm2'; +import { ManyToMany, ManyToOne, OneToMany, OneToOne } from 'typeorm'; import { CommandModule } from 'yargs'; import { Configure } from '../config/configure'; @@ -131,3 +133,12 @@ export type CommandCollection = Array>; export interface CreateOption { commands: () => CommandCollection; } + +export interface DynamicRelation { + relation: + | ReturnType + | ReturnType + | ReturnType + | ReturnType; + column: string; +} diff --git a/src/modules/rbac/constants.ts b/src/modules/rbac/constants.ts new file mode 100644 index 0000000..9dc1f0c --- /dev/null +++ b/src/modules/rbac/constants.ts @@ -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'; diff --git a/src/modules/rbac/dtos/permission.dto.ts b/src/modules/rbac/dtos/permission.dto.ts new file mode 100644 index 0000000..090553d --- /dev/null +++ b/src/modules/rbac/dtos/permission.dto.ts @@ -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; +} diff --git a/src/modules/rbac/dtos/role.dtos.ts b/src/modules/rbac/dtos/role.dtos.ts new file mode 100644 index 0000000..4ba50c9 --- /dev/null +++ b/src/modules/rbac/dtos/role.dtos.ts @@ -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; +} diff --git a/src/modules/rbac/rbac.resolver.ts b/src/modules/rbac/rbac.resolver.ts new file mode 100644 index 0000000..73712db --- /dev/null +++ b/src/modules/rbac/rbac.resolver.ts @@ -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 = (subject: R) => { + if (typeof subject === 'string') { + return subject; + } + if (subject.modelName) { + return subject; + } + return subject.name; +}; + +export class RbacResolver

+ implements OnApplicationBootstrap +{ + private setuped = false; + + private options: AbilityOptions; + + private _roles: Role[] = [ + { + name: SystemRoles.USER, + label: '普通用户', + description: '新用户的默认角色', + permissions: [], + }, + { + name: SystemRoles.SUPER_ADMIN, + label: '超级管理员', + description: '拥有整个系统的管理权限', + permissions: [], + }, + ]; + + private _permissions: PermissionType[] = [ + { + name: SYSTEM_PERMISSION, + label: '系统管理', + description: '管理系统的所有功能', + rule: { + action: 'manage', + subject: 'all', + } as any, + }, + ]; + + constructor( + protected dataSource: DataSource, + protected configure: Configure, + ) {} + + setOptions(options: AbilityOptions) { + 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[]) { + 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), + ); + } + } +} diff --git a/src/modules/rbac/services/permission.service.ts b/src/modules/rbac/services/permission.service.ts new file mode 100644 index 0000000..df97207 --- /dev/null +++ b/src/modules/rbac/services/permission.service.ts @@ -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[key]; +}; + +@Injectable() +export class PermissionService extends BaseService< + PermissionEntity, + PermissionRepository, + FindParams +> { + constructor(protected permissionRepository: PermissionRepository) { + super(permissionRepository); + } + + protected async buildListQuery( + queryBuilder: SelectQueryBuilder, + options: FindParams, + callback?: QueryHook, + ) { + 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> { + throw new Error('Method not implemented.'); + } + update(data: PermissionEntity): Promise> { + throw new Error('Method not implemented.'); + } +} diff --git a/src/modules/rbac/services/role.service.ts b/src/modules/rbac/services/role.service.ts new file mode 100644 index 0000000..17cb340 --- /dev/null +++ b/src/modules/rbac/services/role.service.ts @@ -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 { + protected enableTrash = true; + + constructor( + protected roleRepository: RoleRepository, + protected permissionRepository: PermissionRepository, + ) { + super(roleRepository); + } + + async create(data: CreateRoleDto): Promise { + 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 { + 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 { + 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, + options: QueryRoleDto, + callback?: QueryHook, + ) { + 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; + } +} diff --git a/src/modules/rbac/types.ts b/src/modules/rbac/types.ts new file mode 100644 index 0000000..2e626f9 --- /dev/null +++ b/src/modules/rbac/types.ts @@ -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, 'name' | 'label' | 'description'> & { + permissions: string[]; +}; + +export type PermissionType

= Pick< + ClassToPlain>, + 'name' +> & + Partial>, 'label' | 'description'>> & { + rule: Omit, 'conditions'> & { + conditions?: (user: ClassToPlain) => RecordAny; + }; + }; + +export type PermissionChecker = ( + ability: MongoAbility, + ref?: ModuleRef, + request?: Request, +) => Promise;