前言
权限控制是企业级应用中最容易"看起来做了,实则一捅就破"的模块。常见的伪 RBAC 实现往往是在 Controller 里直接判断 user.role === 'admin',把角色硬编码散落在各处,后期维护极其痛苦。
本文将从架构设计出发,基于 NestJS + Prisma + Redis + CASL 构建一套真正可扩展的 RBAC 体系,覆盖以下核心问题:
- 纯角色判断为何不够?如何建模"资源:动作"级别的原子权限?
- NestJS 的守卫管道中,认证与授权如何职能分离?
- 每次请求都查数据库权限太慢,如何用 Redis 将查询压缩到毫秒级?
- 用户拥有
article:delete 权限,如何限制只能删自己的文章?
阅读本文需要具备 NestJS 模块化开发经验,熟悉 Prisma 基本用法和 JWT 认证流程。建议先阅读本系列的 《10 NestJS JWT 身份验证完全指南》 和 《12 NestJS 集成 Prisma ORM 完全指南》。
[hide]
一、架构设计与理论基础
1.1 RBAC 模型的演进
基础 RBAC(User ➔ Role)
最简单的实现:给用户打上 admin、editor、viewer 标签,守卫里判断角色字符串。问题在于角色是粗粒度的——同样是 editor,有人可以发布文章,有人只能保存草稿。随着业务增长,角色数量膨胀(senior_editor、junior_editor……),维护成本失控。
标准 RBAC(User ➔ Role ➔ Permission)
在角色和用户之间引入"权限"这一中间层。权限采用 [资源]:[动作] 的命名规范:
article:create article:read article:update article:delete article:publish
user:create user:read user:update user:delete
system:config:read system:config:update
角色是权限的集合,用户通过角色继承权限。这样可以精确控制每个角色的能力边界,新增权限点只需修改角色配置,不用改代码。
RBAC + ABAC 融合(行级数据权限)
标准 RBAC 仍然无法解决"只能操作自己创建的数据"这类行级权限问题。此时需要引入属性访问控制(ABAC)的思想——判断资源的属性(authorId)是否与当前用户匹配。本文第六节使用 CASL 处理这个场景。
1.2 NestJS 授权生命周期
请求在 NestJS 管道中的执行顺序:
HTTP 请求
↓
Middleware(如日志、请求 ID)
↓
Guards(身份验证 → 权限验证) ← 授权在这里发生
↓
Interceptors(前置)
↓
Pipes(参数转换与校验)
↓
Route Handler
↓
Interceptors(后置)
↓
Exception Filters(异常时触发)
守卫(Guard)是授权逻辑的正确位置。本文将守卫拆为两层:
| 守卫 | 职责 |
|---|
JwtAuthGuard | 认证:验证 token 有效性,将用户信息挂载到 req.user |
PermissionsGuard | 授权:检查当前用户是否拥有访问该端点所需的权限 |
两者注册顺序必须保证认证先于授权,详见第四节。
1.3 技术栈
| 库 | 用途 |
|---|
@nestjs/jwt | JWT 签发与验证 |
prisma | ORM,权限数据持久化 |
ioredis | Redis 客户端,权限缓存 |
@casl/ability | ABAC 策略引擎,处理行级权限 |
二、数据库建模
2.1 Prisma Schema 设计
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
/// 用户唯一标识,自增主键
id Int @id @default(autoincrement())
/// 登录邮箱,全局唯一
email String @unique
/// Argon2id 哈希后的密码,禁止明文存储
password String
/// 记录创建时间,由数据库自动填充
createdAt DateTime @default(now())
/// 记录最后更新时间,由 Prisma 自动维护
updatedAt DateTime @updatedAt
/// 软删除时间戳;非 null 表示该用户已被删除,所有查询必须附加 deletedAt: null 过滤条件
deletedAt DateTime?
@@map("users")
}
model Role {
/// 角色唯一标识,自增主键
id Int @id @default(autoincrement())
/// 角色英文标识符,如 super_admin、editor、viewer,全局唯一,用于代码逻辑判断
name String @unique
/// 角色描述,供管理界面展示,可为空
description String?
/// 记录创建时间,由数据库自动填充
createdAt DateTime @default(now())
@@map("roles")
}
model Permission {
/// 权限唯一标识,自增主键
id Int @id @default(autoincrement())
/// 权限标识符,格式为 [模块]:[资源]:[动作],如 cms:article:publish、system:user:delete,全局唯一
action String @unique
/// 权限描述,供管理界面展示,可为空
description String?
/// 记录创建时间,由数据库自动填充
createdAt DateTime @default(now())
@@map("permissions")
}
model UserRole {
/// 用户 ID,逻辑上关联 users.id,不设数据库外键约束,由应用层保证一致性
userId Int
/// 角色 ID,逻辑上关联 roles.id,不设数据库外键约束,由应用层保证一致性
roleId Int
/// 角色分配时间,由数据库自动填充
createdAt DateTime @default(now())
/// 复合主键,天然防止同一用户重复分配同一角色
@@id([userId, roleId])
@@index([userId])
@@index([roleId])
@@map("user_roles")
}
model RolePermission {
/// 角色 ID,逻辑上关联 roles.id,不设数据库外键约束,由应用层保证一致性
roleId Int
/// 权限 ID,逻辑上关联 permissions.id,不设数据库外键约束,由应用层保证一致性
permissionId Int
/// 权限分配时间,由数据库自动填充
createdAt DateTime @default(now())
/// 复合主键,天然防止同一角色重复分配同一权限
@@id([roleId, permissionId])
@@index([roleId])
@@map("role_permissions")
}
几个设计要点:
- 软删除:
User 表的 deletedAt 字段在查询时需要配合 where: { deletedAt: null } 过滤,防止已删除用户仍能登录。 - 复合主键:
@@id([userId, roleId]) 比单独的自增 id 加唯一索引更简洁,同时避免重复分配。 - 无外键约束:遵循阿里规范,
UserRole 和 RolePermission 中的关联 ID 均为普通整型字段,不设数据库外键。删除用户或角色时,需在应用层手动清理关联记录(见 Service 层事务处理)。
2.2 权限命名规范
推荐使用三段式命名,确保全局唯一且语义清晰:
[模块]:[资源]:[动作]
示例:
| 权限字符串 | 说明 |
|---|
cms:article:create | CMS 模块,创建文章 |
cms:article:publish | CMS 模块,发布文章 |
system:user:delete | 系统模块,删除用户 |
system:role:assign | 系统模块,分配角色 |
*:*:* | 超级管理员通配符 |
在 TypeScript 中强类型化:
// src/common/types/permission.type.ts
// 从字符串字面量构造权限类型,IDE 可以提供自动补全
export const PERMISSIONS = {
CMS_ARTICLE_CREATE: "cms:article:create",
CMS_ARTICLE_READ: "cms:article:read",
CMS_ARTICLE_UPDATE: "cms:article:update",
CMS_ARTICLE_DELETE: "cms:article:delete",
CMS_ARTICLE_PUBLISH: "cms:article:publish",
SYSTEM_USER_CREATE: "system:user:create",
SYSTEM_USER_READ: "system:user:read",
SYSTEM_USER_UPDATE: "system:user:update",
SYSTEM_USER_DELETE: "system:user:delete",
SYSTEM_ROLE_ASSIGN: "system:role:assign",
WILDCARD: "*:*:*",
} as const;
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
2.3 Seed 脚本
执行迁移后,通过 seed 脚本初始化系统预置数据:
// prisma/seed.ts
import { PrismaClient } from "../src/generated/prisma";
import { hash } from "@node-rs/argon2";
const prisma = new PrismaClient();
async function main() {
// 1. 创建原子权限
const permissions = await Promise.all([
prisma.permission.upsert({
where: { action: "cms:article:create" },
update: {},
create: { action: "cms:article:create", description: "创建文章" },
}),
prisma.permission.upsert({
where: { action: "cms:article:read" },
update: {},
create: { action: "cms:article:read", description: "查看文章" },
}),
prisma.permission.upsert({
where: { action: "cms:article:update" },
update: {},
create: { action: "cms:article:update", description: "编辑文章" },
}),
prisma.permission.upsert({
where: { action: "cms:article:delete" },
update: {},
create: { action: "cms:article:delete", description: "删除文章" },
}),
prisma.permission.upsert({
where: { action: "cms:article:publish" },
update: {},
create: { action: "cms:article:publish", description: "发布文章" },
}),
prisma.permission.upsert({
where: { action: "system:user:delete" },
update: {},
create: { action: "system:user:delete", description: "删除用户" },
}),
prisma.permission.upsert({
where: { action: "system:role:assign" },
update: {},
create: { action: "system:role:assign", description: "分配角色" },
}),
prisma.permission.upsert({
where: { action: "*:*:*" },
update: {},
create: { action: "*:*:*", description: "超级管理员" },
}),
]);
const permMap = Object.fromEntries(permissions.map((p) => [p.action, p]));
// 2. 创建角色并分配权限
const superAdminRole = await prisma.role.upsert({
where: { name: "super_admin" },
update: {},
create: { name: "super_admin", description: "超级管理员,拥有所有权限" },
});
const editorRole = await prisma.role.upsert({
where: { name: "editor" },
update: {},
create: { name: "editor", description: "内容编辑,可管理文章" },
});
// 为 super_admin 分配通配符权限
await prisma.rolePermission.upsert({
where: {
roleId_permissionId: { roleId: superAdminRole.id, permissionId: permMap["*:*:*"].id },
},
update: {},
create: { roleId: superAdminRole.id, permissionId: permMap["*:*:*"].id },
});
// 为 editor 分配文章相关权限(不含删除)
for (const action of [
"cms:article:create",
"cms:article:read",
"cms:article:update",
"cms:article:publish",
]) {
await prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: editorRole.id, permissionId: permMap[action].id } },
update: {},
create: { roleId: editorRole.id, permissionId: permMap[action].id },
});
}
// 3. 创建超级管理员账号
const hashedPassword = await hash("Admin@123456");
const superAdmin = await prisma.user.upsert({
where: { email: "admin@example.com" },
update: {},
create: { email: "admin@example.com", password: hashedPassword },
});
await prisma.userRole.upsert({
where: { userId_roleId: { userId: superAdmin.id, roleId: superAdminRole.id } },
update: {},
create: { userId: superAdmin.id, roleId: superAdminRole.id },
});
console.log("Seed 完成");
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());
Prisma v7 通过 prisma.config.ts 统一管理配置,seed 命令不再需要写在 package.json 中。在项目根目录创建 prisma.config.ts:
// prisma.config.ts
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "tsx prisma/seed.ts",
},
datasource: {
url: env("DATABASE_URL"),
},
});
执行:
pnpm prisma db seed
三、认证前置与上下文传递
3.1 JWT Payload 设计
权限数据不应存入 JWT。原因有两点:
- Token 体积:一个用户可能拥有数十条权限,全部写入 payload 会使 token 体积膨胀数倍,每次请求都在 Header 中传输。
- 权限实时性:JWT 签发后在有效期内不可更改。若管理员在 token 有效期内撤销了某用户的角色,token 中的权限信息仍然有效,存在安全漏洞。
正确做法是 payload 只携带最小必要字段,每次请求到守卫时动态查询(或命中 Redis 缓存):
// src/auth/types/jwt-payload.type.ts
export interface JwtPayload {
sub: number; // 用户 ID(标准字段)
email: string; // 少量辅助信息,方便日志
iat?: number; // 签发时间(自动注入)
exp?: number; // 过期时间(自动注入)
}
3.2 自定义装饰器封装
@CurrentUser() 参数装饰器:从 req.user 安全提取当前用户,避免在每个 Handler 里重复写 @Req() req:
// src/common/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
import { Request } from "express";
import { JwtPayload } from "../../auth/types/jwt-payload.type";
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): JwtPayload => {
const request = ctx.switchToHttp().getRequest<Request>();
return request.user as JwtPayload;
},
);
@Public() 元数据装饰器:标记无需鉴权的路由(登录、注册、公开接口):
// src/common/decorators/public.decorator.ts
import { SetMetadata } from "@nestjs/common";
export const IS_PUBLIC_KEY = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
JwtAuthGuard 整合 @Public():
// src/common/guards/jwt-auth.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { JwtService } from "@nestjs/jwt";
import { Request } from "express";
import { IS_PUBLIC_KEY } from "../decorators/public.decorator";
import { JwtPayload } from "../../auth/types/jwt-payload.type";
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// 检查路由是否标记为公开
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractTokenFromHeader(request);
if (!token) throw new UnauthorizedException("缺少认证 Token");
try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
// 挂载到 request,供后续守卫和装饰器使用
request["user"] = payload;
} catch {
throw new UnauthorizedException("Token 无效或已过期");
}
return true;
}
private extractTokenFromHeader(request: Request): string | null {
const [type, token] = request.headers.authorization?.split(" ") ?? [];
return type === "Bearer" ? token : null;
}
}
四、核心实现:声明式权限守卫
4.1 权限元数据装饰器
// src/common/decorators/require-permissions.decorator.ts
import { Reflector } from "@nestjs/core";
import { Permission } from "../types/permission.type";
export interface PermissionOptions {
permissions: Permission[];
// ALL = 需要满足所有权限;ANY = 满足其中一个即可(默认)
mode?: "ALL" | "ANY";
}
export const RequirePermissions = Reflector.createDecorator<PermissionOptions>();
使用示例:
// 需要同时拥有 create 和 publish 权限
@RequirePermissions({ permissions: [PERMISSIONS.CMS_ARTICLE_CREATE, PERMISSIONS.CMS_ARTICLE_PUBLISH], mode: 'ALL' })
@Post('publish')
publishArticle() {}
// 拥有 read 或 wildcard 其中一个即可
@RequirePermissions({ permissions: [PERMISSIONS.CMS_ARTICLE_READ] })
@Get()
listArticles() {}
4.2 权限查询服务
将数据库查询封装到独立服务,便于 Guard 调用和测试:
// src/auth/permission.service.ts
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../database/prisma.service";
@Injectable()
export class PermissionService {
constructor(private readonly prisma: PrismaService) {}
// 分三步独立查询,避免嵌套联表
async getUserPermissions(userId: number): Promise<Set<string>> {
// 第一步:查出用户拥有的所有角色 ID
const userRoles = await this.prisma.userRole.findMany({
where: { userId },
select: { roleId: true },
});
const roleIds = userRoles.map((ur) => ur.roleId);
if (roleIds.length === 0) return new Set();
// 第二步:查出这些角色关联的所有权限 ID
const rolePermissions = await this.prisma.rolePermission.findMany({
where: { roleId: { in: roleIds } },
select: { permissionId: true },
});
const permissionIds = rolePermissions.map((rp) => rp.permissionId);
if (permissionIds.length === 0) return new Set();
// 第三步:查出权限的 action 字符串
const permissions = await this.prisma.permission.findMany({
where: { id: { in: permissionIds } },
select: { action: true },
});
return new Set(permissions.map((p) => p.action));
}
}
4.3 核心 PermissionsGuard 实现
// src/common/guards/permissions.guard.ts
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import { PermissionOptions, RequirePermissions } from "../decorators/require-permissions.decorator";
import { PermissionService } from "../../auth/permission.service";
import { JwtPayload } from "../../auth/types/jwt-payload.type";
import { PERMISSIONS } from "../types/permission.type";
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly permissionService: PermissionService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// 合并 Class 级别和 Handler 级别的元数据,Handler 优先
const options = this.reflector.getAllAndOverride<PermissionOptions>(RequirePermissions, [
context.getHandler(),
context.getClass(),
]);
// 未声明权限要求,直接放行
if (!options) return true;
const request = context.switchToHttp().getRequest<Request>();
const user = request["user"] as JwtPayload;
// JwtAuthGuard 应在此之前执行,正常不会走到这里
if (!user) throw new ForbiddenException("无法识别当前用户");
const userPermissions = await this.permissionService.getUserPermissions(user.sub);
// 超级管理员通配符快速放行
if (userPermissions.has(PERMISSIONS.WILDCARD)) return true;
const { permissions, mode = "ANY" } = options;
const hasPermission =
mode === "ALL"
? permissions.every((p) => userPermissions.has(p))
: permissions.some((p) => userPermissions.has(p));
if (!hasPermission) {
throw new ForbiddenException("权限不足");
}
return true;
}
}
4.4 全局注册
将两个守卫通过 APP_GUARD 注册为全局守卫,注意顺序:JwtAuthGuard 必须在 PermissionsGuard 之前,因为 PermissionsGuard 依赖 req.user:
// src/app.module.ts
import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
import { PermissionsGuard } from "./common/guards/permissions.guard";
@Module({
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard, // 第一个执行
},
{
provide: APP_GUARD,
useClass: PermissionsGuard, // 第二个执行
},
],
})
export class AppModule {}
确保 PermissionService 所在模块已在 AppModule 中导入,或将其放在 AuthModule 并 export:
// src/auth/auth.module.ts(片段)
@Module({
providers: [AuthService, PermissionService],
exports: [AuthService, PermissionService, JwtModule],
})
export class AuthModule {}
Controller 中的实际用法:
// src/cms/article.controller.ts
import { Controller, Get, Post, Delete, Param, Body } from "@nestjs/common";
import { RequirePermissions } from "../common/decorators/require-permissions.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { Public } from "../common/decorators/public.decorator";
import { PERMISSIONS } from "../common/types/permission.type";
import { JwtPayload } from "../auth/types/jwt-payload.type";
@Controller("articles")
export class ArticleController {
@Public()
@Get()
listPublished() {
// 公开接口,无需登录
}
@RequirePermissions({ permissions: [PERMISSIONS.CMS_ARTICLE_CREATE] })
@Post()
create(@Body() dto: CreateArticleDto, @CurrentUser() user: JwtPayload) {}
@RequirePermissions({ permissions: [PERMISSIONS.CMS_ARTICLE_DELETE] })
@Delete(":id")
remove(@Param("id") id: string, @CurrentUser() user: JwtPayload) {}
}
五、性能优化:Redis 权限缓存
5.1 问题分析
每次 HTTP 请求到达 PermissionsGuard 时,getUserPermissions 都会触发三次独立数据库查询。在并发较高的场景下,这既是数据库压力,也是响应时延的主要来源。
解决方案:用户登录后(或首次权限查询时)将权限集合缓存到 Redis,后续请求直接读 Redis,权限变更时主动清除缓存。
5.2 Redis 客户端模块
// src/redis/redis.module.ts
import { Module, Global } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import Redis from "ioredis";
export const REDIS_CLIENT = "REDIS_CLIENT";
@Global()
@Module({
providers: [
{
provide: REDIS_CLIENT,
inject: [ConfigService],
useFactory: (config: ConfigService) => {
return new Redis({
host: config.get("REDIS_HOST", "localhost"),
port: config.get<number>("REDIS_PORT", 6379),
password: config.get("REDIS_PASSWORD"),
db: config.get<number>("REDIS_DB", 0),
});
},
},
],
exports: [REDIS_CLIENT],
})
export class RedisModule {}
5.3 缓存键规范与 TTL 策略
// src/auth/permission-cache.service.ts
import { Inject, Injectable } from "@nestjs/common";
import { Redis } from "ioredis";
import { REDIS_CLIENT } from "../redis/redis.module";
const PERM_CACHE_KEY = (userId: number) => `user:perms:${userId}`;
// 权限缓存 TTL 设为 15 分钟,与 access token 有效期对齐
const PERM_CACHE_TTL = 15 * 60;
@Injectable()
export class PermissionCacheService {
constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {}
async getPermissions(userId: number): Promise<Set<string> | null> {
const key = PERM_CACHE_KEY(userId);
const members = await this.redis.smembers(key);
if (members.length === 0) return null;
return new Set(members);
}
async setPermissions(userId: number, permissions: Set<string>): Promise<void> {
const key = PERM_CACHE_KEY(userId);
const pipeline = this.redis.pipeline();
pipeline.del(key);
if (permissions.size > 0) {
pipeline.sadd(key, ...permissions);
pipeline.expire(key, PERM_CACHE_TTL);
}
await pipeline.exec();
}
/** 管理员修改角色权限时,批量清除受影响用户的缓存 */
async invalidateByUserIds(userIds: number[]): Promise<void> {
if (userIds.length === 0) return;
const keys = userIds.map(PERM_CACHE_KEY);
await this.redis.del(...keys);
}
async invalidate(userId: number): Promise<void> {
await this.redis.del(PERM_CACHE_KEY(userId));
}
}
5.4 改造 PermissionService
// src/auth/permission.service.ts
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../database/prisma.service";
import { PermissionCacheService } from "./permission-cache.service";
@Injectable()
export class PermissionService {
constructor(
private readonly prisma: PrismaService,
private readonly cache: PermissionCacheService,
) {}
async getUserPermissions(userId: number): Promise<Set<string>> {
// 1. 优先命中缓存
const cached = await this.cache.getPermissions(userId);
if (cached) return cached;
// 2. 缓存未命中,分三步独立查询
const userRoles = await this.prisma.userRole.findMany({
where: { userId },
select: { roleId: true },
});
const roleIds = userRoles.map((ur) => ur.roleId);
const permissions = new Set<string>();
if (roleIds.length > 0) {
const rolePermissions = await this.prisma.rolePermission.findMany({
where: { roleId: { in: roleIds } },
select: { permissionId: true },
});
const permissionIds = rolePermissions.map((rp) => rp.permissionId);
if (permissionIds.length > 0) {
const permRecords = await this.prisma.permission.findMany({
where: { id: { in: permissionIds } },
select: { action: true },
});
permRecords.forEach((p) => permissions.add(p.action));
}
}
// 3. 回写缓存
await this.cache.setPermissions(userId, permissions);
return permissions;
}
}
5.5 权限变更时的缓存失效
当管理员修改角色的权限时,需要找出所有拥有该角色的用户并清除其缓存:
// src/system/role.service.ts(片段)
async updateRolePermissions(roleId: number, permissionIds: number[]): Promise<void> {
await this.prisma.$transaction(async (tx) => {
// 删除旧权限关联
await tx.rolePermission.deleteMany({ where: { roleId } });
// 写入新权限关联
await tx.rolePermission.createMany({
data: permissionIds.map(permissionId => ({ roleId, permissionId })),
});
});
// 查找所有拥有该角色的用户 ID
const affectedUserRoles = await this.prisma.userRole.findMany({
where: { roleId },
select: { userId: true },
});
const userIds = affectedUserRoles.map(ur => ur.userId);
// 批量清除缓存,下次请求时重新从数据库加载
await this.permissionCache.invalidateByUserIds(userIds);
}
六、进阶:CASL 策略权限控制
6.1 纯 RBAC 的局限
假设 editor 角色拥有 cms:article:delete 权限,但业务规则是"编辑只能删除自己创建的文章"。纯 RBAC 无法表达这种"属于谁"的条件,需要引入 CASL。
安装:
pnpm add @casl/ability
6.2 定义 Ability 类型
// src/casl/casl.types.ts
import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability";
// 定义系统中所有可操作的动作
export type Action = "create" | "read" | "update" | "delete" | "publish" | "manage";
// 定义所有受保护的资源类型
export type Subject = "Article" | "User" | "Role" | "all";
export type AppAbility = MongoAbility<[Action, Subject]>;
export type AbilityBuilderType = AbilityBuilder<AppAbility>;
6.3 CaslAbilityFactory
// src/casl/casl-ability.factory.ts
import { Injectable } from "@nestjs/common";
import { AbilityBuilder, createMongoAbility } from "@casl/ability";
import { AppAbility, Action, Subject } from "./casl.types";
import { JwtPayload } from "../auth/types/jwt-payload.type";
import { PermissionService } from "../auth/permission.service";
// 文章实体的简化类型,包含 authorId 供行级检查
export interface ArticleSubject {
__type: "Article";
id: number;
authorId: number;
[key: string]: unknown;
}
@Injectable()
export class CaslAbilityFactory {
constructor(private readonly permissionService: PermissionService) {}
async createForUser(user: JwtPayload): Promise<AppAbility> {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
const permissions = await this.permissionService.getUserPermissions(user.sub);
// 超级管理员拥有所有能力
if (permissions.has("*:*:*")) {
can("manage", "all");
return build();
}
// 根据权限集合构建 Ability 规则
if (permissions.has("cms:article:read")) can("read", "Article");
if (permissions.has("cms:article:create")) can("create", "Article");
if (permissions.has("cms:article:publish")) can("publish", "Article");
if (permissions.has("cms:article:update")) {
// 普通用户只能编辑自己的文章
can("update", "Article", { authorId: user.sub });
}
if (permissions.has("cms:article:delete")) {
// 普通用户只能删除自己的文章
can("delete", "Article", { authorId: user.sub });
}
return build();
}
}
6.4 策略守卫与装饰器
策略接口:
// src/casl/casl.types.ts(追加)
export interface IPolicyHandler {
handle(ability: AppAbility): boolean;
}
export type PolicyHandlerCallback = (ability: AppAbility) => boolean;
export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback;
@CheckPolicies() 装饰器:
// src/common/decorators/check-policies.decorator.ts
import { Reflector } from "@nestjs/core";
import { PolicyHandler } from "../../casl/casl.types";
export const CheckPolicies = Reflector.createDecorator<PolicyHandler[]>();
PoliciesGuard:
// src/common/guards/policies.guard.ts
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import { CheckPolicies } from "../decorators/check-policies.decorator";
import { CaslAbilityFactory } from "../../casl/casl-ability.factory";
import { AppAbility, PolicyHandler } from "../../casl/casl.types";
import { JwtPayload } from "../../auth/types/jwt-payload.type";
@Injectable()
export class PoliciesGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly caslAbilityFactory: CaslAbilityFactory,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const policyHandlers = this.reflector.getAllAndOverride<PolicyHandler[]>(CheckPolicies, [
context.getHandler(),
context.getClass(),
]);
if (!policyHandlers) return true;
const request = context.switchToHttp().getRequest<Request>();
const user = request["user"] as JwtPayload;
const ability = await this.caslAbilityFactory.createForUser(user);
const allowed = policyHandlers.every((handler) =>
typeof handler === "function" ? handler(ability) : handler.handle(ability),
);
if (!allowed) throw new ForbiddenException("权限不足");
return true;
}
}
Controller 中的实际应用:
// src/cms/article.controller.ts(行级权限场景)
import { CheckPolicies } from "../common/decorators/check-policies.decorator";
import { AppAbility } from "../casl/casl.types";
@Controller("articles")
export class ArticleController {
constructor(private readonly articleService: ArticleService) {}
// 删除文章:守卫先检查 RBAC 层(有无 cms:article:delete 权限),
// 再通过 CASL 检查行级(是否为作者)
@RequirePermissions({ permissions: [PERMISSIONS.CMS_ARTICLE_DELETE] })
@CheckPolicies([(ability: AppAbility) => ability.can("delete", "Article")])
@Delete(":id")
async remove(@Param("id") id: string, @CurrentUser() user: JwtPayload) {
// 此处还需在 Service 层查出文章,结合 subject 做最终检查
return this.articleService.removeIfAllowed(+id, user.sub);
}
}
Service 层的最终校验:
// src/cms/article.service.ts(片段)
async removeIfAllowed(articleId: number, currentUserId: number): Promise<void> {
const article = await this.prisma.article.findUniqueOrThrow({
where: { id: articleId },
});
// 构造带 __type 标记的 subject 供 CASL 匹配
const subject = { __type: 'Article' as const, ...article };
const ability = await this.caslAbilityFactory.createForUser({ sub: currentUserId } as any);
if (ability.cannot('delete', subject)) {
throw new ForbiddenException('只能删除自己创建的文章');
}
await this.prisma.article.delete({ where: { id: articleId } });
}
七、异常处理与安全审计
7.1 精细化 403 响应
到这里为止,权限判断已经可以工作,但还缺少生产环境必须关注的两件事:
- 对外响应必须稳定:前端、网关、客户端 SDK 不能因为不同守卫抛出的异常不同,就收到不同结构的错误对象。
- 对内日志必须足够具体:安全团队和后端排查问题时,需要知道是谁、在什么时候、访问了哪个接口、为什么被拒绝。
这两者不能混在一起。对外响应越克制越好,避免暴露内部权限点;对内日志越完整越好,便于审计和追踪。
权限不足时,PermissionsGuard、PoliciesGuard 或 Service 层最终校验都会抛出 ForbiddenException。它不应该在守卫内部手动拼响应,而是交给全局异常过滤器统一处理。
如果项目已经按本系列 《14 NestJS 生产级错误过滤方案》 和 《15 NestJS 统一响应体设计(信封模式)》 实现了过滤器链路,那么最终对外响应应保持统一的信封格式:
{
"code": 40301,
"message": "权限不足",
"data": null,
"requestId": "abc-123"
}
其中 code 可以使用通用的 ErrorCode.FORBIDDEN。如果想区分“登录了但没有权限”和“具备权限点但不满足行级条件”,也可以在第 15 篇定义的错误码枚举中增加更细的权限错误码:
// src/common/exceptions/error-codes.ts
export enum ErrorCode {
// ...
FORBIDDEN = 40301,
PERMISSION_DENIED = 40302,
RESOURCE_OWNERSHIP_DENIED = 40303,
}
然后在业务代码中抛出带业务码的异常:
// src/common/exceptions/business.exception.ts
throw new BusinessException("权限不足", HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED);
对于普通的 NestJS ForbiddenException("权限不足"),第 15 篇里的 AllExceptionsFilter 会根据 HTTP 状态码生成默认业务码,最终仍然返回统一的 ApiResponseDto.failed() 结构:
// src/common/filters/all-exceptions.filter.ts(关键逻辑)
const responseBody = ApiResponseDto.failed(code ?? this.getDefaultCode(statusCode), message);
if (meta.requestId) responseBody.requestId = meta.requestId;
response.status(statusCode).json(responseBody);
注意不要在响应中暴露“需要 cms:article:delete 权限”“缺少 system:user:delete 权限”之类的具体提示。这类信息应该进入服务端日志,而不是返回给客户端,否则会给攻击者提供枚举权限点和接口能力边界的线索。
推荐策略:
| 场景 | 对外 message | 对内日志 |
|---|
| 未登录访问受保护接口 | 请先登录 | 记录 IP、URL、User-Agent、requestId |
| 登录但缺少接口权限 | 权限不足 | 记录 userId、URL、requiredPermissions、userPermissions |
| 不满足行级权限 | 权限不足 | 记录 userId、resourceType、resourceId、ownerId、action |
| 权限配置异常 | 权限不足 | 记录 routeKey、metadata、缺失的权限配置,并触发告警 |
7.2 安全审计日志
权限系统的日志不能只依赖普通应用日志。普通日志关注“接口是否报错”,而安全审计关注“是否存在越权尝试、权限探测、异常访问模式”。
审计日志建议覆盖三类事件:
| 事件 | 触发位置 | 示例 |
|---|
| 认证失败 | JwtAuthGuard | Token 缺失、过期、伪造 |
| 接口权限不足 | PermissionsGuard | 没有 cms:article:delete 权限 |
| 行级权限不足 | Service 层或 CASL 最终校验 | 试图删除不属于自己的文章 |
最简单的做法是在全局拦截器中捕获 ForbiddenException,记录越权访问尝试:
// src/common/interceptors/audit.interceptor.ts
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
ForbiddenException,
Logger,
} from "@nestjs/common";
import { Observable, catchError, throwError } from "rxjs";
import { Request } from "express";
import { JwtPayload } from "../../auth/types/jwt-payload.type";
@Injectable()
export class AuditInterceptor implements NestInterceptor {
private readonly logger = new Logger("AuditLog");
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest<Request>();
const user = request["user"] as JwtPayload | undefined;
const { method, url, ip } = request;
const requestId = request.headers["x-request-id"] as string | undefined;
const userAgent = request.headers["user-agent"];
return next.handle().pipe(
catchError((err) => {
if (err instanceof ForbiddenException) {
this.logger.warn({
event: "UNAUTHORIZED_ACCESS_ATTEMPT",
userId: user?.sub ?? "anonymous",
method,
url,
ip,
userAgent,
requestId,
timestamp: new Date().toISOString(),
});
}
return throwError(() => err);
}),
);
}
}
全局注册:
// src/app.module.ts
import { APP_INTERCEPTOR } from "@nestjs/core";
import { AuditInterceptor } from "./common/interceptors/audit.interceptor";
@Module({
providers: [{ provide: APP_INTERCEPTOR, useClass: AuditInterceptor }],
})
export class AppModule {}
审计日志样例输出:
{
"event": "UNAUTHORIZED_ACCESS_ATTEMPT",
"userId": 42,
"method": "DELETE",
"url": "/articles/99",
"ip": "::1",
"userAgent": "Mozilla/5.0 ...",
"requestId": "abc-123",
"timestamp": "2026-08-16T00:24:10.201Z"
}
这个拦截器能覆盖进入 Controller 之后抛出的 403,但有一个边界要注意:如果全局 Guard 在进入拦截器之前就拒绝了请求,某些场景下拦截器可能拿不到这次异常。因此更稳妥的生产实践是:
- 守卫负责抛异常,也可以记录必要的权限上下文,例如
requiredPermissions、mode、userId。 - 全局异常过滤器负责统一响应结构,并记录所有 4xx/5xx 的基础请求信息。
- 安全审计服务负责沉淀结构化事件,可以写入日志平台、数据库、Kafka 或 SIEM 系统。
如果希望把权限上下文记录得更完整,可以在 PermissionsGuard 中增加审计日志:
// src/common/guards/permissions.guard.ts(片段)
if (!hasPermission) {
this.logger.warn({
event: "PERMISSION_DENIED",
userId: user.sub,
requiredPermissions: permissions,
mode,
path: request.url,
timestamp: new Date().toISOString(),
});
throw new ForbiddenException("权限不足");
}
生产环境还可以进一步把审计日志抽成独立服务,避免每个守卫都直接依赖 Logger:
// src/security/audit-log.service.ts
import { Injectable, Logger } from "@nestjs/common";
export interface AuditEvent {
event: string;
userId?: number | "anonymous";
method?: string;
url?: string;
requestId?: string;
metadata?: Record<string, unknown>;
}
@Injectable()
export class AuditLogService {
private readonly logger = new Logger("AuditLog");
warn(event: AuditEvent): void {
this.logger.warn({
...event,
timestamp: new Date().toISOString(),
});
}
}
这样 Guard、Service、异常过滤器都可以写入同一套结构化审计日志,后续接入 ELK、Loki、Datadog、Sentry 或安全审计平台时,不需要再改业务代码。
这里是通过拦截器的方式,捕获控制器抛出的 ForbiddenException,记录越权访问尝试。
7.3 权限变更审计
除了记录“谁被拒绝访问”,还必须记录“谁改了权限”。RBAC 系统中真正高风险的操作往往不是访问某个接口,而是修改角色、权限和用户角色关系。
以下操作建议全部进入审计日志:
| 操作 | 风险 |
|---|
| 创建、禁用权限点 | 可能改变系统能力边界 |
| 修改角色权限 | 可能扩大或收缩一批用户的访问范围 |
| 给用户分配角色 | 可能直接授予管理能力 |
| 移除用户角色 | 可能影响线上业务操作 |
| 清空权限缓存 | 可能导致短时间内权限判断结果变化 |
以修改角色权限为例,建议同时记录修改前后的权限集合:
// src/system/role.service.ts(片段)
async updateRolePermissions(roleId: number, permissionIds: number[], operatorId: number): Promise<void> {
const before = await this.prisma.rolePermission.findMany({
where: { roleId },
select: { permissionId: true },
});
await this.prisma.$transaction(async (tx) => {
await tx.rolePermission.deleteMany({ where: { roleId } });
await tx.rolePermission.createMany({
data: permissionIds.map((permissionId) => ({ roleId, permissionId })),
});
});
const affectedUserRoles = await this.prisma.userRole.findMany({
where: { roleId },
select: { userId: true },
});
const affectedUserIds = affectedUserRoles.map((item) => item.userId);
await this.permissionCache.invalidateByUserIds(affectedUserIds);
this.auditLog.warn({
event: "ROLE_PERMISSIONS_UPDATED",
userId: operatorId,
metadata: {
roleId,
before: before.map((item) => item.permissionId),
after: permissionIds,
},
});
}
这里的 operatorId 是当前执行管理操作的管理员 ID,不是被修改权限的用户 ID。审计日志必须能回答三个问题:
谁改的?改了什么?什么时候改的?
如果是多租户系统,还要额外记录 tenantId,否则后期排查跨租户越权问题会非常困难。
7.4 生产环境注意事项
RBAC 的异常和审计设计,最终目标不是“报错好看”,而是让系统在出问题时可追踪、可定位、可止损。落地时建议遵循以下规则:
- 对外统一:所有权限失败都返回统一信封结构,例如
{ code, message, data, requestId }。 - 对外克制:不要返回具体缺失的权限点、角色名、策略条件。
- 对内详细:日志中记录用户、接口、权限点、资源 ID、请求 ID、IP、User-Agent。
- 高危操作留痕:角色授权、权限禁用、用户角色变更必须记录操作人和变更前后内容。
- 日志避免敏感数据:不要记录 token、密码、完整手机号、身份证号等敏感字段。
- 异常和审计分层:过滤器负责响应结构,守卫和 Service 负责提供权限上下文,审计服务负责统一落盘或上报。
[/hide]