概述
- 在現代權限管理系統中,RBAC(基于角色的訪問控制)是廣泛采用的一種模型
- RBAC 核心思想是通過角色來管理用戶權限
- 通過角色綁定用戶、資源和權限,實現細粒度的訪問控制
- 為了實現這一目標,我們需要在數據庫中設計合理的模型結構
- 并在代碼中通過裝飾器的方式對模塊(controller)和路由(route)進行權限標記
- 通過裝飾器為每個模塊設置唯一標識符(如字符串)
- 使用 Reflector 機制讀取模塊和路由上的元信息
- 從而實現權限的動態控制與驗證
- Guard 通過 Reflector 讀取模塊和路由的信息,在元信息上設置唯一的字符串
- 之后,我們要讀取用戶角色,訪問 UserRepo, 進而訪問用戶實體,聯合查詢出對應的角色和權限
Schema 完整版
這里基于 Prisma 演示
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-initgenerator client {provider = "prisma-client-js"output = "./clients/postgresql"
}datasource db {provider = "postgresql"url = env("DATABASE_URL")
}model User {id Int @id @default(autoincrement())username String @uniquepassword StringuserRole UserRole[]@@map("users")
}model Role {id Int @id @default(autoincrement())name String @unique // 角色名稱(如:admin、editor) description String? // 角色描述 createdAt DateTime @default(now())updatedAt DateTime @updatedAtusers UserRole[] // 關聯用戶(多對多) rolePermissions RolePermissions[] // 關聯權限(多對多) @@map("roles")
}model UserRole {userId IntroleId Intuser User @relation(fields: [userId], references: [id], onDelete: Cascade)role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)@@id([userId, roleId]) // 聯合主鍵 @@map("user_roles") // 數據庫表名
}model Permission {id Int @id @default(autoincrement())name String @unique // ctrlName + routeName, 如:user:createaction String // crudm description String?RolePermissions RolePermissions[] // role 和 perm 是 一對多的關系@@map("permissions")
}model RolePermissions {roleId IntpermissionId Introle Role @relation(fields: [roleId], references: [id])permission Permission @relation(fields: [permissionId], references: [id])@@id([roleId, permissionId])@@map("role_permissions")
}
我們同步到遠程數據庫 $ npx prisma db push
創建enum
在 src/common/enum/actions.enum.ts
export enum Action {Manage = 'MANAGE', // 后續會存到數據庫中Create = 'CREATE',Delete = 'DELETE',Update = 'UPDATE',Read = 'READ',
}
這些枚舉后續會存入數據庫中
創建裝飾器
在 src/common/decorators/role-permission.decorator.ts
// decorator 是為后面的模塊和路由打標簽的import { SetMetadata } from '@nestjs/common';
import { Action } from '../enum/actions.enum';
import { Reflector } from '@nestjs/core';export const PERMISSION_KEY = 'permission';// 累積屬性
const accumulateMetadata = (key: string, permission: string): any => {return (target: any,propertyKey: string | symbol,descriptor?: TypedPropertyDescriptor<any>) => {const reflector = new Reflector();// 針對方法的 functionif (descriptor?.value) {const existingPermissions = reflector.get(key, descriptor.value) || [];const newPermissions = [...existingPermissions, permission];SetMetadata(key, newPermissions)(target, propertyKey, descriptor);} else {// 針對類的 constructorconst existingPermissions = reflector.get(key, target) || [];const newPermissions = [...existingPermissions, permission];SetMetadata(key, newPermissions)(target);}}
}export const Permission = (permission: string) =>accumulateMetadata(PERMISSION_KEY, permission);export const Create = () =>accumulateMetadata(PERMISSION_KEY, Action.Create.toLowerCase());export const Update = () =>accumulateMetadata(PERMISSION_KEY, Action.Update.toLowerCase());export const Delete = () =>accumulateMetadata(PERMISSION_KEY, Action.Delete.toLowerCase());export const Read = () =>accumulateMetadata(PERMISSION_KEY, Action.Read.toLowerCase());
當多個裝飾器疊加使用時,需要確保它們不會被覆蓋,而是以數組形式保存
該方法解決了裝飾器疊加時的覆蓋問題;
支持多個權限標簽在同一個路由或模塊上共存;
示例:@read() @update() 會生成 [‘read’, ‘update’] 的權限數組
創建相關 Guard
$ nest g guard common/guards/role-permission --flat --no-spec
這里生成了 src/common/guards/role-permission.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { PERMISSION_KEY } from '../decorators/role-permission.decorator';@Injectable()
export class RolePermissionGuard implements CanActivate {constructor(private reflector: Reflector) {}canActivate(context: ExecutionContext,): boolean | Promise<boolean> | Observable<boolean> {const classPermission = this.reflector.get<string>(PERMISSION_KEY, context.getClass())const handlerPermission = this.reflector.get<string>(PERMISSION_KEY, context.getHandler())console.log('classPermission', classPermission); // userconsole.log('handlerPermission', handlerPermission); // readreturn true;}
}
通過上面我們知道
- Guard 通過 Reflector 獲取裝飾器信息
- 讀取 controller 和 route 上的權限裝飾器信息
- 返回 true(允許訪問)或拋出異常(拒絕訪問)
后續,我們可以
- 獲取當前用戶的角色信息
- 比對用戶權限與訪問路徑權限是否匹配
- 權限拼接格式為 modulename:action,如 user:create
- 用戶權限數據應從數據庫中聯合查詢角色與權限表獲取
- Guard 的核心邏輯是權限比對,確保用戶具有訪問對應路由的權限
對控制器應用裝飾器和Guards
import { Controller, Get, UseGuards } from '@nestjs/common';
import { Permission, Read, Update } from '@/common/decorators/role-permission.decorator';
import { RolePermissionGuard } from '@/common/guards/role-permission.guard';@Controller('user')
@UseGuards(RolePermissionGuard)
@Permission('user')
@Permission('user1')
export class UserController {@Read()@Update()@Get('/test')async test(): Promise<any> {return 'ok';}
}
我們測試一下
curl --request GET \--url http://localhost:3000/user/test
我們看到程序中輸出
classPermission [ 'user1', 'user' ]
handlerPermission [ 'update', 'read' ]
可以看到這里效果出來了,實現了裝飾器的累加,并且支持方法和類的篩選
完成 role 模塊
1 ) 通過 $ nest g res role --no-spec
生成 role 資源模塊,因為我們用到的是 Prisma,所以里面的 entities 可以刪除
2 )role/dto/create-role.dto.ts
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';// 按照 schema 來寫 dto
export class CreateRoleDto {@IsNotEmpty()@IsString()name: string;@IsOptional()@IsString()description?: string;
}
3 )role.controller.ts
import { Controller, Get, Post, Body, Patch, Param, Delete, Query, ParseIntPipe, Optional } from '@nestjs/common';
import { RoleService } from './role.service';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';@Controller('role')
export class RoleController {constructor(private readonly roleService: RoleService) {}@Post()create(@Body() createRoleDto: CreateRoleDto) {return this.roleService.create(createRoleDto);}@Get()findAll(@Query('page', new ParseIntPipe( {optional: true })) page: number,@Query('limit', new ParseIntPipe( {optional: true })) limit: number,) {return this.roleService.findAll(page, limit);}@Get(':id')findOne(@Param('id') id: string) {return this.roleService.findOne(+id);}@Patch(':id')update(@Param('id') id: string, @Body() updateRoleDto: UpdateRoleDto) {return this.roleService.update(+id, updateRoleDto);}@Delete(':id')remove(@Param('id') id: string) {return this.roleService.remove(+id);}
}
4 ) role.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
import { PrismaClient } from 'prisma-postgresql'; // 注意這里
import { PRISMA_DB_CLIENT } from '@/database/database.constants';@Injectable()
export class RoleService {constructor(@Inject(PRISMA_DB_CLIENT) private prismaClient: PrismaClient) {}create(createRoleDto: CreateRoleDto) {return this.prismaClient.role.create({data: createRoleDto,});}findAll(page: number = 1, limit: number = 10) {const skip = (page - 1) * limit;return this.prismaClient.role.findMany({skip,take: limit,})}findOne(id: number) {return this.prismaClient.role.findUnique({ where: { id }});}update(id: number, updateRoleDto: UpdateRoleDto) {return this.prismaClient.role.update({where: { id },data: updateRoleDto,});}remove(id: number) {return this.prismaClient.role.delete({ where: {id }})}
}
之后,可以自行自行測一下上面的 crud 功能
完成 permission 模塊
和上面的 role 模塊類似,$ nest g res permission --no-spec
之后,基于 Permission 的 Model 寫相關 dto 和 service 并改造 controller
寫完之后,自己測試一下
完成 User 模塊的改造
1 ) user/dto/create-user.dto.ts
import { IsArray, IsInt, IsNotEmpty, IsOptional, IsString } from "class-validator";export class CreateUserDto {@IsString()@IsNotEmpty()username: string;@IsString()@IsNotEmpty()password: string;@IsArray()@IsInt({ each: true })@IsOptional()roleIds: number[];
}
2 ) user.controller.ts
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { UserRepository } from './user.repository';
import { Permission, Read, Update } from '@/common/decorators/role-permission.decorator';
import { RolePermissionGuard } from '@/common/guards/role-permission.guard';
import { CreateUserDto } from './dto/create-user.dto';@Controller('user')
@UseGuards(RolePermissionGuard)
@Permission('user')
@Permission('user1')
export class UserController {constructor(private userRepository: UserRepository,) {}@Post()// @Serialize(PublicUserDto) // 這個后續添加create(@Body() createUserDto: CreateUserDto) {return this.userRepository.create(createUserDto);}
}
3 ) .env 配置一條默認角色
# Role 默認角色
ROLE_ID=4
確保數據庫 role 表有這一條數據
4 )user/repository/repository.prisma
import { Inject } from '@nestjs/common';
import { PRISMA_DB_CLIENT } from '@/database/database.constants';
import { UserAdapter } from '../user.interfaces';
import { PrismaClient } from 'prisma-postgresql';
import { ConfigService } from '@nestjs/config';export class UserPrismaRepository implements UserAdapter {constructor(@Inject(PRISMA_DB_CLIENT) private prismaClient: PrismaClient,private configService: ConfigService) {}async create(userObj: any): Promise<any> {// 讀取默認角色信息const DEFAULT_ROLE_ID = +this.configService.get('ROLE_ID');// 判斷角色信息是否在數據庫中// 在,則寫入角色與用戶關系表,并創建用戶return await this.prismaClient.$transaction(async (prisma: PrismaClient) => {const roleIds = userObj?.roleIds ?? [DEFAULT_ROLE_ID];const validRoleIds = [];for (const roleId of roleIds) {const role = await prisma.role.findUnique({ where: { id: roleId } });if (role) {validRoleIds.push(roleId);}}if (validRoleIds.length) {validRoleIds.push(DEFAULT_ROLE_ID);}delete userObj['roleIds']; // 這個 schema 中沒有定義,會報錯return prisma.user.create({data: {...userObj,userRole: {create: validRoleIds.map(( roleId ) => ({ roleId })),},}})});}
}
5 )測試
發送
curl --request POST \--url http://localhost:3000/user \--header 'content-type: application/json' \--header 'x-tenant-id: prisma2' \--data '{"username":"toimc2","password": "123456","roleIds": []
}'
響應
{"id": 3,"username": "toimc2","password": "$argon2id$v=19$m=65536,t=3,p=4$NdfibfeW7LDZsLIgSjZQdw$hi2j5VRPUtGIBCmzXXEl8Ps0lnNbfvc6xuCEwaJanj0"
}
這樣,就創建成功了, 可同步驗證 users 和 user_roles 表數據是否同步
關于密碼脫敏的問題,可以考慮 攔截器處理
好,以上完成了創建 users 的時,關聯了 role
改造 role 創建的時候,關聯 permission
1 ) role/dto/create-role.dto
import { CreatePermissionDto } from '@/permission/dto/create-permission.dto';
import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { Type } from 'class-transformer';// 補充
interface PermissionType {id?: number;name: string;action: string;description?: string;
}// 按照 schema 來寫 dto
export class CreateRoleDto {@IsNotEmpty()@IsString()name: string;@IsOptional()@IsString()description?: string;// 補充@IsOptional()@IsArray()@Type(() => CreatePermissionDto)permissions: PermissionType[];
}
2 ) permission/dto/create-permission.dto
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';// 按照 schema 來寫 dto
export class CreatePermissionDto {@IsNotEmpty()@IsString()name: string;@IsString()action: string;@IsOptional()@IsString()description?: string;
}
3 )main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';async function bootstrap() {const app = await NestFactory.create(AppModule);app.enableShutdownHooks();// 全局類校驗管道app.useGlobalPipes(new ValidationPipe({// true - 去除類上不存在的字段,false 則不會去除whitelist: true,// transform: 自動轉換請求對象到 DTO 實例transform: true,transformOptions: {// 允許類轉換器隱式轉換字段類型,如將字符串轉換為數字等enableImplicitConversion: true,}}))await app.listen(process.env.PORT ?? 3000);
}bootstrap();
4 ) role/role.service
import { Inject, Injectable } from '@nestjs/common';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
import { PRISMA_DATABASE } from '@/database/databse.constants';
import { PrismaClient } from 'prisma/clients/postgresql';@Injectable()
export class RoleService {constructor(@Inject(PRISMA_DATABASE) private prismaClient: PrismaClient) {}async create(createRoleDto: CreateRoleDto) {return await this.prismaClient.$transaction(async (prisma: PrismaClient) => {const { permissions, ...restData } = createRoleDto;return prisma.role.create({data: {...restData,RolePermissions: {create: permissions.map((permission) => ({permission: {connectOrCreate: {where: {name: permission.name,},create: {...permission,},},},})),},},});},);}findAll(page: number, limit: number) {const skip = (page - 1) * limit;return this.prismaClient.role.findMany({skip,take: limit,});}// 這里關聯一下permissionfindOne(id: number) {return this.prismaClient.role.findUnique({ where: { id },include: {rolePermissions: {include: {permission: true,}}}});}update(id: number, updateRoleDto: UpdateRoleDto) {return this.prismaClient.role.update({where: { id },data: updateRoleDto,});}remove(id: number) {return this.prismaClient.role.delete({ where: { id } });}
}
5 ) 測試
創建請求
curl --request POST \--url http://localhost:3000/role \--header 'content-type: application/json' \--header 'x-tenant-id: prisma2' \--data '{"name": "普通用戶6","description":"測試","permissions": [{"name": "log:read","action": "read"},{"name": "log:update","action": "update"}]
}'
創建響應
{"id": 5,"name": "普通用戶6","description": "測試","createdAt": "2025-08-22T13:43:52.596Z","updatedAt": "2025-08-22T13:43:52.596Z"
}
查詢請求
curl --request GET \--url http://localhost:3000/role/5 \--header 'content-type: application/json' \--header 'x-tenant-id: prisma2'
查詢響應
{"id": 5,"name": "普通用戶6","description": "測試","createdAt": "2025-08-22T13:43:52.596Z","updatedAt": "2025-08-22T13:43:52.596Z","rolePermissions": [{"roleId": 5,"permissionId": 2,"permission": {"id": 2,"name": "log:read","action": "read","description": null}},{"roleId": 5,"permissionId": 3,"permission": {"id": 3,"name": "log:update","action": "update","description": null}}]
}
6 )后續可以再控制器上加上一個 序列化的 dto 對數據進行簡化,如:
role/dto/public-role.dto
import { Transform } from 'class-transformer';
import { CreateRoleDto } from "./create-role.dto";export class PublicRoleDto extends CreateRoleDto {@Transform(( { value } ) => value.map((permission) => permission.permission.name))rolePermissions: any[]
}
role/role.controller
@Get(':id')
@Serialize(PublicRoleDto)
findOne(@Param('id') id: string) {return this.roleService.findOne(+id);
}
響應結果就會簡化為
{"id": 5,"name": "普通用戶6","description": "測試","createdAt": "2025-08-22T13:43:52.596Z","updatedAt": "2025-08-22T13:43:52.596Z","rolePermissions": ["log:read","log:update"]
}
繼續改造User,基于User查詢Role, 基于Role查詢Permission
1 ) 改造 user/user.interfaces
export interface UserAdapter {findAll(page?:number, limit?: number): Promise<any[]>;findOne(username: string): Promise<any[]>;create(userObj: any): Promise<any>;update(userObj: any): Promise<any>;delete(id: string | number): Promise<any>;
}
2 )改造 repositories
user/repositories/repository.mongoose
findAll(page: number = 1, limit: number = 10): Promise<any[]> {return this.userModel.find().limit(limit).skip((page - 1) * limit).exec();
}findOne(username: string): Promise<any[]> {return this.userModel.findOne({ username });
}
user/repositories/repository.prisma
findAll(page: number = 1, limit: number = 10): Promise<any[]> {const skip = (page - 1) * limit;return this.prismaClient.user.findMany({ skip, take: limit });
}
findOne(username: string): Promise<any[]> {return this.prismaClient.user.findMany( {where : { username }} );
}
user/repositories/repository.typeorm
findAll(page: number = 1, limit: number = 10): Promise<any[]> {return this.userRepository.find({ skip: (page - 1) * limit, take: limit});
}
findOne(username: string): Promise<any> {// return this.userRepository.findOne({where: { username }});return this.userRepository.findOneBy({ username });
}
3 )改造 user.repository
findAll(page?: number, limit?: number): Promise<any[]> {const client = this.getRepository();return client.findAll(page, limit);
}
findOne(username: string): Promise<any[]> {const client = this.getRepository();return client.findOne(username);
}
4 ) 改造 user.controller
@Get()
findAll(@Query('page', new ParseIntPipe({ optional: true})) page,@Query('limit', new ParseIntPipe({optional: true})) limit,
) {return this.userRepository.findAll(page, limit);
}@Get(':username')
findOne(@Param('username') username: string) {return this.userRepository.findOne(username);
}
5 )測試一下
請求
curl --request GET \--url http://localhost:3000/user/wang1 \--header 'content-type: application/json' \--header 'x-tenant-id: prisma2'
響應
{"id": 4,"username": "wang1","password": "$argon2id$v=19$m=65536,t=3,p=4$5e2iqzVvSXK6e66adpsA+A$8dC09rixIdJiJMK5PjOkLlDr4VreYb9wq2WoB2KSsjo","userRole": [{"userId": 4,"roleId": 4,"role": {"id": 4,"name": "x管理","description": "測試","createdAt": "2025-08-15T14:31:17.392Z","updatedAt": "2025-08-15T14:31:17.392Z","rolePermissions": []}}]
}
5 ) 上面響應比較繁瑣, 可以進行序列化操作
改造通用 dto: auth/dto/public-user.dto
import { Exclude, Expose, Transform } from 'class-transformer';
import { SignUserDto } from './signin-user.dto';export class PublicUserDto extends SigninUserDto {@Expose()id: string;@Expose()username: string;@Expose()password: string;@Transform(({ value }) => {return value.map((item) => ({name: item.role.name,id: item.role.id,permissions: item.role.rolePermissions,}));})@Expose({name: 'userRole'}) // 這里 將原來的 userRole 改了一個別名: rolesroles: any; // 這里是 別名
}
更新 user.controller 應用序列化
@Get(':username')
@Serialize(PublicUserDto)
findOne(@Param('username') username: string) {return this.userRepository.findOne(username);
}
重新請求后的響應
{"id": 4,"username": "wang1","roles": [{"name": "x管理","id": 4,"permissions": []}}]
}
User 模塊用戶更新的改造
1 ) 新建 user/dto/update-user.dto
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
import { CreateRoleDto } from '@/role/dto/create-role.dto';
import { IsArray, IsInt, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';export class UpdateUserDto extends PartialType(CreateUserDto) {@IsInt()@IsOptional()id?: number;@IsArray()@Type(() => CreateRoleDto)roles?: any[]
}
2 ) user/user.controller
@Patch()
update(@Body() updateUserDto: UpdateUserDto) {// return updateUserDto; // 測試的時候使用return this.userRepository.update(updateUserDto);
}@Delete(':id')
delete(@Param('id') id: string) {return this.userRepository.delete(id);
}
3 )改造 /role/dto/create-role.dto.ts
import { CreatePermissionDto } from '@/permission/dto/create-permission.dto';
import { Type, Transform, plainToInstance } from 'class-transformer';
import { IsNotEmpty, IsOptional, IsString, IsArray } from 'class-validator';interface PermissionType {id?: number;name: string;action: string;description?: string;
}export class CreateRoleDto {@IsNotEmpty()@IsString()name: string;@IsOptional()@IsString()description?: string;@IsOptional()@IsArray()// permissions 兩種類型: 1.string[] 2. 對象[]// 當傳遞為對象 -> 直接轉換成對應的 dto 實例@Type(() => CreatePermissionDto) // 這里是偷懶的寫法,如果傳遞的和類型不匹配,會直接走到后面的 Transform// 這里支持2種類型@Transform(( { value }) => {return value.map((item) => {if (typeof item === 'string') {// 當傳遞為 string -> split -> { name, action } 對象數組const parts = item.split(":");return plainToInstance(CreatePermissionDto, {name: item,action: parts[1] || '',})}return plainToInstance(CreatePermissionDto, item);})})permissions: PermissionType[] | string[];
}
或 基于官網文檔,有個高級的Type用法來支持多個類型
參考:https://github.com/typestack/class-transformer#providing-more-than-one-type-option
import { Type, Transform, plainToInstance } from 'class-transformer';
import { IsNotEmpty, IsOptional, IsString, IsArray } from 'class-validator';interface PermissionType {id?: number;name: string;action: string;description?: string;
}abstract class Permission {abstract type: string;
}class StringPermission extends Permission {type = 'string';value: string;
}class DetailedPermission extends Permission {type = 'detailed';name: string;action: string;
}export class CreateRoleDto {@IsNotEmpty()@IsString()name: string;@IsOptional()@IsString()description?: string;@IsOptional()@IsArray()// permissions 兩種類型: 1.string[] 2. 對象[]// 當傳遞為對象 -> 直接轉換成對應的 dto 實例@Type(() => Permission, {discriminator: {property: 'type',subTypes: [{ value: StringPermission, name: 'string' },{ value: DetailedPermission, name: 'detail' },]}})permissions: PermissionType[] | string[];
}
上面兩種 達到的效果一致
4 )改造 user/repository/repository.prisma
async update(userObj: any): Promise<any> {return await this.prismaClient.$transaction(async (prisma: PrismaClient) => {const { id, username, password, roles, ...rest } = userObj;// 更新的 where 條件const whereCond = id ? { id }: { username };let updateData: any = {};if (password) {const newHashPass = await argon2.hash(password);updateData.password = newHashPass;}updateData = { ...updateData, ...rest };const roleIds = [];// 角色權限的更新,放置在前await Promise.all(roles.map(async (role) => {roleIds.push(role.id); // 存儲 idconst { permissions, ...restRole } = role;await prisma.role.update({where: { id: role.id },data: {...restRole,rolePermissions: {deleteMany: {},create: (permissions || []).map(permission => ({permission: {connectOrCreate: {where: {name: permission.name,},create: permission}}}))}}})}))// 用戶角色更新const updatedUser = await prisma.user.update({where: whereCond,data: {...updateData,userRole: {deleteMany: {},create: roleIds.map((roleId) => ({ roleId })),}},include: {userRole: true,},});return updatedUser;})
}delete(id: number): Promise<any> {return this.prismaClient.user.delete({ where: { id }})
}
5 ) 同步改造 user/dto/update-user.dto
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
import { CreateRoleDto } from '@/role/dto/create-role.dto';
import { IsArray, IsInt, IsString, ValidateIf } from 'class-validator';
import { Type } from 'class-transformer';export class UpdateUserDto extends PartialType(CreateUserDto) {@IsInt()@ValidateIf((o) => !o.username)id?: number;@IsString()@ValidateIf((o) => !o.id)username?: string;@IsArray()@Type(() => CreateRoleDto)roles?: any[]
}
這樣,id 和 username 至少傳一個,否則報錯
6 ) 來測試一下
第一種方式
請求
curl --request PATCH \--url http://localhost:3000/user \--header 'content-type: application/json' \--header 'x-tenant-id: prisma2' \--data '{"id": 4,"roles": [{"id": 5,"name": "普通用戶6","permissions": [{ "name": "user:read", "action": "read"}]}]
}'
響應
{"id": 4,"username": "wang1","password": "$argon2id$v=19$m=65536,t=3,p=4$5e2iqzVvSXK6e66adpsA+A$8dC09rixIdJiJMK5PjOkLlDr4VreYb9wq2WoB2KSsjo","userRole": [{"userId": 4,"roleId": 5}]
}
第二種方式
請求
{"username": "wang1","password": "$argon2id$v=19$m=65536,t=3,p=4$5e2iqzVvSXK6e66adpsA+A$8dC09rixIdJiJMK5PjOkLlDr4VreYb9wq2WoB2KSsjo","roles": [{"id": 5,"name": "普通用戶6","permissions": ["user:read","log:read","user:update"]}]
}
響應
{"id": 4,"username": "wang1","userRole": [{"userId": 4,"roleId": 5}]
}
可見兩種方式沒有區別
7 )響應中的一些敏感數據,用序列化的方式來排除
新建 user/dto/pbulic-update-user.dto
import { PartialType } from '@nestjs/mapped-types';
import { IsArray } from 'class-validator';
import { Exclude, Expose, Transform } from 'class-transformer';
import { UpdateUserDto } from './update-user.dto';export class PublicUpdateUserDto extends PartialType(UpdateUserDto) {@Exclude()password?: string;@IsArray()@Expose({ name: 'userRole'} )@Transform((value) => value.map((item) => item.roleId))roleIds: number[]
}
控制器上應用序列化 user/user.controller
@Patch()@Serialize(PublicUpdateUserDto)update(@Body() updateUserDto: UpdateUserDto) {// return updateUserDto; // 測試的時候使用return this.userRepository.update(updateUserDto);}
重新請求,發現響應被優化了
{"id": 4,"username": "wang1","roleIds": [5]
}
8 ) 更多擴展
如果響應中要包含 permission 信息
可以在 repository.prisma 中繼續進行改造,此處不再贅述
下面就可以在 Guard 上 基于 UserRepo 中查詢出來的信息,添加判斷了
9 )守衛的處理
之前打通了守衛和路由和模塊的關聯關系,可以讀取路由和模塊上面的唯一標識
通過用戶的id和username來獲取用戶的角色和權限
我們之前寫過一個 role-permission.guard
, 如果這個 guard 要使用 userRepository 的話
需要這個 guard 包含 userModule, 否則沒辦法使用 user里面的各類服務的
9.1 改造 user/user.repository
在 @Module 中加入
exports: [UserRepository],
這樣,就可以在DI系統中,在guard中使用UserRepository
很多模塊都需要使用到 UserModule,那么就可以把這個模塊設置為 @Global()
@Global() // 這里
@Module({imports: [TypeOrmModule.forFeature([User], TYPEORM_DB_CLIENT),MongooseModule.forFeature([{ name: 'User', schema: UserSchema }]),],controllers: [UserController],exports: [UserRepository],providers: [UserTypeOrmRepository,UserPrismaRepository,UserMongooseRepository,UserRepository]
})
export class UserModule {}
但并不意味著 全局模塊 設置的 越多越好,因為程序在首次加載的時候,要初始化這些實例的,是需要耗費資源的,全局模塊是很多地方都需要的模塊才可以設置為全局模塊
9.2 改造 user.controller
在 findAll 方法上加入 @Read()
的裝飾器
@Get()
@Read() // 加上這個
findAll(@Query('page', new ParseIntPipe({ optional: true})) page,@Query('limit', new ParseIntPipe({optional: true})) limit,
) {return this.userRepository.findAll(page, limit);
}
現在需要 user:read 的權限才能訪問這個 findAll 接口
9.3 請求一下試試
獲取用戶列表
請求
curl --request GET \--url http://localhost:3000/user/ \--header 'content-type: application/json' \--header 'x-tenant-id: prisma2'
響應
[{"id": 4,"username": "wang1","password": "$argon2id$v=19$m=65536,t=3,p=4$5e2iqzVvSXK6e66adpsA+A$8dC09rixIdJiJMK5PjOkLlDr4VreYb9wq2WoB2KSsjo"}
]
9.4 改造之前的 guard:src/common/guards/role-permission.guard.ts
上面請求之后,會打印出 read, 我們現在需要拼裝一下并完成后面的邏輯
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { PERMISSION_KEY } from '../decorators/role-permission.decorator';
import { UserRepository } from '@/user/user.repository';
import { RoleService } from '@/role/role.service';
import { ConfigService } from '@nestjs/config';@Injectable()
export class RolePermissionGuard implements CanActivate {constructor(private reflector: Reflector,private userRepository: UserRepository,private roleService: RoleService,private configService: ConfigService,) {}async canActivate(context: ExecutionContext,): Promise<boolean> {const classPermission = this.reflector.get(PERMISSION_KEY, context.getClass())const handlerPermission = this.reflector.get(PERMISSION_KEY, context.getHandler())// console.log('classPermission', classPermission); // user// console.log('handlerPermission', handlerPermission); // readconst cls = classPermission instanceof Array ? classPermission.join('') : classPermission;const handler = handlerPermission instanceof Array ? handlerPermission.join('') : handlerPermission;const right = `${cls}:${handler}`;// console.log(right);const req = context.switchToHttp().getRequest(); // 這里 控制器中的 Guard 掛載了,被當前這個guard拿到const { username } = req.user;const user = await this.userRepository.findOne(username);// console.log(user);if (!user) return false;const roleIds = user.userRole.map(o => o.roleId);// 如果 whitelist 中的用戶對應的 roleId 直接返回 trueconst whitelist = this.configService.get('ROLE_ID_WHITELIST');if (whitelist) {const whitelistArr = whitelist.split(',');// console.log('whitelistArr: ', whitelistArr);// 判斷whiltelistArr中包含roleIds中的數據,則返回trueif (whitelistArr.some(id => roleIds.includes(+id))) return true;}const permissions = await this.roleService.findAllByIds(roleIds);// console.log('permissions: ', permissions);// 處理數據const permArr = permissions.map(o => o.rolePermissions.map(one => one.permission.name)).reduce((acc, cur) => {return [...new Set([...acc, ...cur])];}, []);// console.log('permArr', permArr);return permArr.some(o => o === right);}
}
9.5 改造 user 控制器
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { UserRepository } from './user.repository';
import { Permission, Read, Update } from '@/common/decorators/role-permission.decorator';
import { RolePermissionGuard } from '@/common/guards/role-permission.guard';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { PublicUpdateUserDto } from './dto/public-update-user.dto';
// import { AuthGuard } from '@nestjs/passport';
import { AdminGuard } from '@/common/guards/admin.guard';
import { JwtGuard } from '@/common/guards/jwt.guard';
import { Public } from '@/common/decorators/public.decorator';@Controller('user')
// @UseGuards(AuthGuard('jwt'), AdminGuard, RolePermissionGuard)
// @UseGuards(JwtGuard, AdminGuard, RolePermissionGuard)
@UseGuards(JwtGuard, RolePermissionGuard)
@Permission('user')
// @Permission('user1') // 后期再支持
export class UserController {constructor(private userRepository: UserRepository,) {}@Get()// @Read()@Update()findAll(@Query('page', new ParseIntPipe({ optional: true})) page,@Query('limit', new ParseIntPipe({optional: true})) limit,) {return this.userRepository.findAll(page, limit);}
}
9.6 改造 role/role.service.ts 添加
findAllByIds(ids: number[]) {return this.prismaClient.role.findMany({where: {id: {in: ids,},},include: {rolePermissions: {include: {permission: true,}}}})}
并將 role.service 暴露出來,在 role.module 中配置
import { Module } from '@nestjs/common';
import { RoleService } from './role.service';
import { RoleController } from './role.controller';@Module({controllers: [RoleController],providers: [RoleService],exports: [RoleService], // 這里
})
export class RoleModule {}
并在 user.module 中使用
@Global()
@Module({imports: [// ... ,RoleModule, // user Module 是全局模塊,這里也會可以直接使用],// ...
})
export class UserModule {}
9.7 添加 .env 配置,如下,這里是測試數據
# Role 默認角色
ROLE_ID=4
ROLE_ID_WHITELIST=4,5 # 超級管理員
9.6 之后通過 signup 接口獲取token之后,填入訪問用戶列表的接口進行訪問,可驗證上述權限已生效
總結
- 我們完成了基于 NestJS 框架的 RBAC(Role-Based Access Control,基于角色的訪問控制)權限系統的完整實現
- 整個實現過程雖然較為復雜,但涉及的技術細節豐富,具有極高的工程實踐價值
1 ) RBAC 的核心邏輯與架構設計
RBAC 的核心思想是將權限通過角色進行抽象,實現用戶與權限之間的間接關聯
傳統權限模型中若直接將用戶與權限綁定,會導致權限管理復雜度呈指數級上升
尤其是在用戶數量龐大、權限種類繁多的系統中。
通過引入“角色”這一中間層,我們可以將具有相同權限的用戶歸類,從而實現權限的集中管理。例如:
- 用戶 A 屬于“管理員”角色;
- “管理員”角色擁有“創建用戶”、“刪除文章”等權限;
- 其他用戶如果也被賦予該角色,則自動繼承這些權限。
2 ) 基于 AOP 的裝飾器設計與路由權限控制
為了提升代碼的可維護性與擴展性,我們在 NestJS 中充分運用了 AOP(面向切面編程) 的思想,通過自定義裝飾器來實現權限控制的模塊化管理
- 模塊標識裝飾器的設計
我們為每個 Controller 設計了一個唯一的標識符(字符串),用于標識該模塊。這種設計避免了因模塊名或路由路徑變更而導致數據庫權限配置失效的問題。
示例代碼如下:
@RoleGuard('user_module') // 標識該模塊為 "user_module"
@Controller('users')
export class UserController {}
- 路由權限裝飾器的設計
在 Controller 的具體方法上,我們進一步設置了權限標識,例如:
@Permission('read_user') // 表示訪問該路由需要 "read_user" 權限
@Get(':id')
findOne(@Param('id') id: string) {return this.userService.findOne(id);
}
這種設計使權限控制粒度精確到具體的接口方法,便于后續權限的動態配置與管理
3 ) 權限數據的動態加載與角色管理
在實際項目中,權限信息通常存儲在數據庫中,而不是硬編碼在程序中。我們實現了從數據庫動態加載用戶角色與權限的功能,并對以下關鍵操作進行了處理:
- 用戶角色的讀取:包括單用戶多角色、多用戶多角色的場景
- 權限的增刪改查:特別是更新操作中,涉及嵌套邏輯的處理
- 關聯關系的管理:如用戶與角色、角色與權限之間的一對多關系。
在更新角色或權限時,我們采用的策略是先刪除原有關聯,再重新插入新數據,以確保數據一致性
但需注意的是,某些情況下(如權限本身不發生變化),我們不需要刪除權限實體,只需更新關聯關系。
4 ) 白名單機制與超級用戶權限的實現
為了應對系統初始化或特殊用戶訪問所有接口的需求,我們引入了 白名單機制(Whitelist)。
- 白名單角色可以訪問系統中所有接口
- 無需逐個配置權限
- 可用于超級管理員、系統初始化用戶等特殊場景
這部分邏輯可定義在權限守衛(Guard)中:
const whitelistRoles = ['admin', 'super_admin'];if (whitelistRoles.includes(userRole)) {return true; // 白名單角色直接放行
}
5 ) 權限控制的擴展方向 —— 基于策略的權限(Policy-Based Access Control)
當前我們實現的 RBAC 權限控制是基于模塊與路由的“決策級”權限控制,但其顆粒度仍不夠精細
未來可以擴展為 基于策略的權限控制(PBAC),進一步提升權限管理的靈活性
PBAC 的核心思想:
- 在方法內部根據具體數據字段進行權限控制;
- 實現“數據級”而非“接口級”的權限控制;
- 例如,限制用戶只能查看自己創建的文章,或只能編輯特定字段。
這種控制方式在 NestJS 官方文檔中也有提及,常見實現方式之一是與 CASL(Capability-based Access Control Library) 集成
示例邏輯如下:
@UseGuards(CaslGuard)
@Get(':id')
findOne(@Param('id') id: string, @User() user: User) {const article = this.articleService.findOne(id);if (!userCanAccess(user, article)) {throw new ForbiddenException('無權訪問該文章');}return article;
}