跳转至主要内容

NestJS RBAC Tutorial: Role-Based Access Control with Guards

本教程用 node-casbin 为 NestJS API 加上基于角色的访问控制(RBAC)。每个路由用装饰器声明所需的权限,全局守卫询问 Casbin 当前用户是否拥有该权限;角色之间可以继承,所以 admin 自动拥有 editor 能做的一切。

权限以资源和操作命名(articles、delete),而不是 URL 路径,这与 NestJS 的控制器很契合。基于路径的规则见 Express 版本。

1. 安装​

从一个 NestJS 项目(nest new my-app)开始,加上 Casbin:

npm install casbin

2. 编写模型​

保存为项目根目录下的 model.conf:

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
  • r = sub, obj, act:每个请求由用户、资源和操作组成。
  • g = _, _:用户可以被分配到角色,角色也可以被分配到其他角色。
  • 当用户直接或通过继承的角色拥有策略规则中的角色时,g(r.sub, p.sub) 为真。

3. 编写策略​

保存为 policy.csv:

p, viewer, articles, read
p, editor, articles, create
p, editor, articles, update
p, admin, articles, delete

g, editor, viewer
g, admin, editor

g, alice, admin
g, bob, editor
g, carol, viewer

第一部分只授予每个角色本层新增的权限。第二部分构建层级:editor 同时也是 viewer,admin 同时也是 editor。最后一部分把用户分配到角色。结果如下:

用户角色readcreateupdatedelete
aliceadmin是是是yes
bob编辑器是是yes否
carolviewer是否否no
dave(无)否否否no

4. 添加权限装饰器​

src/permission.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const PERMISSION_KEY = 'permission';

export interface Permission {
resource: string;
action: string;
}

// Declares which permission a route needs, for example @RequirePermission('articles', 'delete').
export const RequirePermission = (resource: string, action: string) =>
SetMetadata(PERMISSION_KEY, { resource, action } as Permission);

5. 添加守卫​

守卫从路由的元数据中读取所需权限,然后询问 Casbin。没有装饰器的路由保持公开。

src/casbin.guard.ts
import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Enforcer } from 'casbin';
import { Request } from 'express';
import { Permission, PERMISSION_KEY } from './permission.decorator';

export const ENFORCER = 'CASBIN_ENFORCER';

@Injectable()
export class CasbinGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
@Inject(ENFORCER) private readonly enforcer: Enforcer,
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const permission = this.reflector.getAllAndOverride<Permission>(PERMISSION_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!permission) {
return true; // Routes without @RequirePermission are public.
}

const request = context.switchToHttp().getRequest<Request>();
// Replace this with the user your authentication guard puts on the request, such as request.user.
const user = request.header('X-User') ?? '';

const allowed = await this.enforcer.enforce(user, permission.resource, permission.action);
if (!allowed) {
throw new ForbiddenException();
}
return true;
}
}
备注

示例用 X-User 请求头保持简短。 Casbin 只负责授权。实际应用中,认证守卫(例如基于 @nestjs/passport 的守卫)会先运行并设置 request.user;从那里读取用户名或用户 ID。

6. 在控制器中使用​

src/articles.controller.ts
import { Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
import { RequirePermission } from './permission.decorator';

@Controller('articles')
export class ArticlesController {
@Get()
@RequirePermission('articles', 'read')
findAll() {
return { articles: [] };
}

@Post()
@RequirePermission('articles', 'create')
create() {
return { created: true };
}

@Put(':id')
@RequirePermission('articles', 'update')
update(@Param('id') id: string) {
return { updated: id };
}

@Delete(':id')
@RequirePermission('articles', 'delete')
remove(@Param('id') id: string) {
return { deleted: id };
}
}

7. 注册 enforcer 和守卫​

enforcer 由一个工厂 provider 创建一次,并注入到守卫中。用 APP_GUARD 注册守卫,它就会作用于应用中的每个路由。

src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { newEnforcer } from 'casbin';
import { ArticlesController } from './articles.controller';
import { CasbinGuard, ENFORCER } from './casbin.guard';

@Module({
controllers: [ArticlesController],
providers: [
{
provide: ENFORCER,
useFactory: () => newEnforcer('model.conf', 'policy.csv'),
},
{
provide: APP_GUARD,
useClass: CasbinGuard,
},
],
})
export class AppModule {}

8. 试一试​

npm run start
curl -i -X DELETE -H "X-User: alice" http://localhost:3000/articles/1   # 200, admins can delete
curl -i -X DELETE -H "X-User: bob" http://localhost:3000/articles/1 # 403, editors cannot
curl -i -X PUT -H "X-User: bob" http://localhost:3000/articles/1 # 200, editors can update
curl -i -X POST -H "X-User: carol" http://localhost:3000/articles # 403, viewers are read-only
curl -i -H "X-User: carol" http://localhost:3000/articles # 200
curl -i -H "X-User: dave" http://localhost:3000/articles # 403, no role

9. 在运行时管理角色​

在任何需要的地方(例如管理服务中)用 @Inject(ENFORCER) 注入 enforcer:

await enforcer.addRoleForUser('dave', 'editor');          // dave becomes an editor
await enforcer.getImplicitRolesForUser('alice'); // [ 'admin', 'editor', 'viewer' ]
await enforcer.getImplicitPermissionsForUser('bob');
// [ [ 'editor', 'articles', 'create' ], [ 'editor', 'articles', 'update' ], [ 'viewer', 'articles', 'read' ] ]

getImplicitPermissionsForUser 很适合做一个“我能做什么”的接口,供前端显示或隐藏按钮。完整列表见 RBAC API。

下一步​

  • 把策略存到数据库中。 用 适配器(例如 TypeORM、Prisma、Sequelize 或 MongoDB)代替 policy.csv。这样通过 API 做的策略修改会自动保存。
  • 多个实例。 使用 watcher,这样任何一个实例修改策略后,其他实例都会重新加载。
  • 所有权规则。 “作者只能编辑自己的文章”取决于文章本身,而不只是角色。在处理函数中加载文章,再用 ABAC 模型检查。
  • 多租户应用。 带域的 RBAC 让用户在不同租户中拥有不同角色;把租户作为 enforce 的第四个参数传入。
  • 现成的模块。 中间件列表 中链接了 nest-authz 等社区 NestJS 模块。

Comments are powered by giscus and GitHub Discussions. They load from giscus.app and GitHub only after you click the button below.