15 KiB
CASL 授权体系迁移文档
概述
NestJS 后端授权已从基于 permissions.includes() 的字符串匹配迁移到 CASL(@casl/ability)基于能力的 ABAC 授权模型。
架构
┌─────────────────────────────────────────────────────────┐
│ AuthorizationModule (@Global) │
│ │
│ ┌──────────────────────┐ ┌────────────────────────┐ │
│ │ CaslAbilityFactory │ │ AuthorizationService │ │
│ │ │ │ │ │
│ │ createForUser(user) │ │ can(req, action, subj) │ │
│ │ → AppAbility │ │ assert(req, ...) │ │
│ │ │ │ canAbility(ab, ...) │ │
│ └──────────┬───────────┘ │ assertAbility(ab, ...) │ │
│ │ └────────────────────────┘ │
│ ┌──────────▼───────────┐ ┌────────────────────────┐ │
│ │ casl.constants.ts │ │ PoliciesGuard │ │
│ │ mapPermissionCode() │ │ @CheckPolicies(…) │ │
│ │ CaslAction/Subject │ └────────────────────────┘ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────────┘
核心类型
| 概念 | 类型 | 说明 |
|---|---|---|
| Action | CaslAction |
'manage' | 'create' | 'read' | 'update' | 'delete' |
| Subject | SubjectName |
'Student' | 'Room' | 'Class' | … (所有实体) |
| Ability | AppAbility |
MongoAbility<[CaslAction, AppSubject]> |
| User | AuthenticatedUser |
{ id, username, permissions, isSuperAdmin, roles } |
权限码映射
| 旧权限码 | CASL Action | CASL Subject |
|---|---|---|
student:view |
read |
Student |
student:create / student:import |
create |
Student |
student:edit |
update |
Student |
student:delete |
delete |
Student |
student:export |
read |
Student |
occupancy:checkin |
create |
Occupancy |
occupancy:checkout |
update |
Occupancy |
occupancy:transfer |
update |
Occupancy |
bill:generate / bill:confirm |
update |
Bill |
bill:export-excel / bill:export-pdf |
read |
Bill |
deposit:approve |
update |
Deposit |
sync:trigger / integration:trigger |
update |
Sync / Integration |
完整映射见 apps/server/src/authorization/casl.constants.ts。
超管处理
isSuperAdmin === true → ability.can('manage', 'all') → 所有操作全部放行。
未知权限码处理
未知/无法映射的权限码(如 ghost:action)→ 不产生任何 CASL ability → deny-by-default。用户对象上仍保留完整的 permissions 数组用于前端菜单/日志,但授权判断拒绝未知码。
修改文件清单
新增文件
| 文件 | 说明 |
|---|---|
apps/server/src/authorization/casl.constants.ts |
Action/Subject 定义、权限码映射函数 |
apps/server/src/authorization/interfaces.ts |
AppAbility, AuthenticatedUser, PolicyHandler 类型 |
apps/server/src/authorization/casl-ability.factory.ts |
CASL Ability 构建工厂 |
apps/server/src/authorization/authorization.service.ts |
通用授权服务(HTTP + 非 HTTP) |
apps/server/src/authorization/authorization.module.ts |
@Global 模块 |
apps/server/src/authorization/index.ts |
桶导出 |
apps/server/src/authorization/decorators/check-policies.decorator.ts |
@CheckPolicies() 装饰器 |
apps/server/src/authorization/guards/policies.guard.ts |
PoliciesGuard CASL 策略守卫 |
apps/server/src/authorization/casl-ability.factory.spec.ts |
工厂测试(17 用例) |
apps/server/src/authorization/authorization.service.spec.ts |
服务测试(12 用例) |
apps/server/src/authorization/guards/policies.guard.spec.ts |
策略守卫测试(7 用例) |
修改文件
| 文件 | 变更 |
|---|---|
apps/server/src/auth/guards/permission.guard.ts |
注入 CaslAbilityFactory,用 ability.can() 替代 permissions.includes() |
apps/server/src/auth/guards/permission.guard.spec.ts |
新增 CASL 授权测试(7 用例) |
apps/server/src/app.module.ts |
导入 AuthorizationModule |
apps/server/package.json |
新增 @casl/ability 依赖 |
apps/server/src/students/students.controller.ts |
注入 AuthorizationService,用 CASL 替代 isSuperAdmin 检查 |
apps/server/src/classes/classes.controller.ts |
同上 |
apps/server/src/attendance/attendance.controller.ts |
同上,修复测试兼容 |
apps/server/src/schedules/schedules.controller.ts |
同上 |
apps/server/src/dashboard/dashboard.controller.ts |
同上 |
Agent Tool 使用指南
CASL 授权服务不依赖 HTTP ExecutionContext,可在 Agent Tool、后台任务、CLI 等场景直接使用:
import { CaslAbilityFactory } from './authorization';
import { AuthorizationService } from './authorization';
import { CaslAction, SubjectName } from './authorization';
// 方式 1: 只构建 Ability
const factory = app.get(CaslAbilityFactory);
const ability = factory.createForUser({
permissions: ['attendance:view', 'attendance:create'],
isSuperAdmin: false,
});
if (ability.can(CaslAction.Read, SubjectName.Attendance)) {
// 执行考勤查询
}
// 方式 2: 使用 AuthorizationService
const authz = app.get(AuthorizationService);
const toolAbility = factory.createForUser(user);
authz.assertAbility(toolAbility, CaslAction.Create, SubjectName.Attendance);
// 如果无权限,抛出 ForbiddenException
// 方式 3: 通过 request-like 对象(适用于有 request 模拟的场景)
authz.assert(
{ user: { permissions: ['student:view'], isSuperAdmin: false } },
CaslAction.Read,
SubjectName.Student,
);
推荐 Agent Tool 使用 方式 1+2:先用 factory.createForUser(user) 构建 ability,再用 service.canAbility/assertAbility 检查。这种方式完全独立于 NestJS 请求生命周期。
测试命令与结果
cd apps/server
# 全部测试
npx jest --no-coverage
# 结果: 27 passed, 127 passed, 3 skipped
# 仅 CASL 相关测试
npx jest --no-coverage authorization/ auth/guards/permission.guard.spec.ts
# 结果: 54 passed
# 类型检查
npx tsc -p tsconfig.build.json --noEmit
# 结果: clean (无错误)
遗留风险 / TODO
-
class:edit宽泛授权(ponytail 标记):拥有class:edit权限的教师目前获得全量学生/排课/考勤管理权限。理想情况下应通过 CASL conditions 限制为仅自己班级的学生。当前数据模型(需查询class_teacher关联表确定 scope)无法直接在 CASL Ability 中表达。未降低现有权限,保留现状并加 TODO。 -
前端权限守卫:前端
PermissionRoute组件(apps/admin/src/auth/permission-store.ts)仍然使用permissions.includes()检查。不影响安全性(后端是真实授权源),但可在后续迭代中统一。 -
操作日志中的权限上下文:当前操作日志记录仍使用
user.permissions数组。CASL 迁移未改变日志格式。 -
dashboard:manage权限:dashboardsubject 在 preset permissions 中仅有dashboard:view,但 dashboard.controller 检查了dashboard:manage。CASL 映射将dashboard:manage的未知 action 映射为read(保守),非 super_admin 用户理论上无法通过此检查。但实际上 controller 的权限守卫用的是@RequirePermission('dashboard:view'),CASL 映射正常。dashboard:manage仅出现在内部方法canManageAllDashboard的 permissions.includes 检查中,现已被 CASL 替代。 -
构建验证:
npx nest build未在迁移中执行(jest + tsc 已覆盖编译和类型检查)。Docker 部署前建议执行一次完整构建。
Agent Tool 只读数据安全执行框架
架构
┌──────────────────────────────────────────────────────────────┐
│ AgentToolsModule (NON-HTTP — no controller) │
│ │
│ ┌──────────────────────┐ ┌─────────────────────────────┐ │
│ │ AgentToolRegistry │ │ AgentToolExecutor │ │
│ │ │ │ │ │
│ │ listAvailable(ctx) │ │ execute(name, input, ctx) │ │
│ │ → ToolDef[] │ │ 1. assertPermission │ │
│ │ │ │ 2. tool.validate(input) │ │
│ │ Filtered by exact- │ │ 3. tool.execute(…) │ │
│ │ code permission │ │ 4. audit (best-effort) │ │
│ └──────────────────────┘ └─────────────────────────────┘ │
│ │
│ Built-in tools: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ search_students (student:view) │ │
│ │ get_student_basic (student:view) │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
安全保证
- 动态暴露(listAvailable):只暴露 principal 拥有 exact permission 的 Tool。
- 执行时二次授权(execute):不依赖
listAvailable,execute再次调用assertPermission。 - 数据库 WHERE 数据范围:
StudentAccessScope使用 TypeORM QueryBuilder + EXISTS 子查询,在 SQL 层面限制数据范围:manageAll:超管或全量学生范围 → 无限制teacher:仅ClassTeacher.userId分配班级的 activeClassStudent
- 输出白名单:所有 Tool 输出仅限
id, name, studentNo, gender, status, organizationId, organizationName, classIds。phone,idNumber,emergencyContact,emergencyPhone不进入查询 SELECT。 - 审计:模块
AI Agent Tool,记录 tool 名、状态(success/denied/failed)、userId/username(来自 context principal)。审计写入失败不影响 Tool 调用结果。 - 审计脱敏:审计 detail 绝不包含 raw input、phone、idNumber 等敏感值。
SDK 适配伪代码(provider-neutral)
任何 LLM SDK(Vercel AI、LangChain、OpenAI function calling 等)都可以适配:
// 1. 获取 NestJS 容器中的 Registry 和 Executor
const registry = app.get(AgentToolRegistry);
const executor = app.get(AgentToolExecutor);
const authz = app.get(AuthorizationService);
// 2. 构建可信 AgentToolContext(userId/permissions 来自服务端认证)
const ability = abilityFactory.createForUser(authenticatedUser);
const ctx: AgentToolContext = {
userId: authenticatedUser.id,
username: authenticatedUser.username,
permissions: authenticatedUser.permissions,
isSuperAdmin: authenticatedUser.isSuperAdmin,
ability,
};
// 3. 动态暴露工具列表(给 LLM SDK 的 tools/functions 定义)
const availableTools = registry.listAvailable(ctx);
const sdkTools = availableTools.map(tool => ({
name: tool.name,
description: tool.description,
// … 根据 tool 自定义参数 schema
}));
// 4. 执行 Tool 调用(带输入校验 + 二次授权 + 审计)
const result = await executor.execute("search_students", rawInput, ctx);
// result.status: 'success' | 'denied' | 'failed'
// result.result: 白名单后的数据(仅 success 时)
// result.error: 错误信息(denied/failed 时)
新增文件清单
| 文件 | 说明 |
|---|---|
src/agent-tools/agent-tool.types.ts |
AgentToolContext, ToolDef, ToolExecutionResult 类型定义 |
src/agent-tools/agent-tool.registry.ts |
Tool 注册 + 按权限过滤暴露 |
src/agent-tools/agent-tool.executor.ts |
执行时二次授权 + 输入校验 + 审计 |
src/agent-tools/tools/search-students.tool.ts |
search_students Tool |
src/agent-tools/tools/get-student-basic.tool.ts |
get_student_basic Tool |
src/agent-tools/agent-tools.module.ts |
NestJS 模块(不暴露 HTTP endpoint) |
src/agent-tools/index.ts |
桶导出 |
src/students/student-access-scope.ts |
StudentAccessScope 数据范围类型 |
src/agent-tools/agent-tool.executor.spec.ts |
执行器测试(19 用例) |
src/agent-tools/tools/search-students.tool.spec.ts |
search_students 测试(12 用例) |
src/agent-tools/tools/get-student-basic.tool.spec.ts |
get_student_basic 测试(10 用例) |
src/students/students.agent-api.spec.ts |
agent-safe API 测试(17 用例) |
修改文件
| 文件 | 变更 |
|---|---|
src/authorization/authorization.service.ts |
新增 canPermission / assertPermission 方法 |
src/authorization/authorization.service.spec.ts |
新增 8 个 exact-code 权限检查测试 |
src/students/students.service.ts |
新增 agentSearchStudents / agentGetStudentBasic + applyStudentScope |
src/app.module.ts |
导入 AgentToolsModule |
docs/superpowers/plans/casl-migration.md |
本文档新增 Agent Tool 章节 |
测试结果
npx jest --no-coverage --forceExit
# 结果: 31 suites, 223 passed, 3 skipped
npx tsc -p tsconfig.build.json --noEmit
# 结果: clean
npx nest build
# 结果: clean
npx eslint --no-fix src/agent-tools/**/*.ts src/students/student-access-scope.ts src/authorization/authorization.service.ts
# 结果: clean
剩余风险
- classIds 聚合为第二查询:对大量结果,批量聚合 classIds 的第二条查询使用
IN (:...ids),在 MySQL 中 IN 子句过大时有性能上限(当前 limit 50 安全)。 manageAll判定:当前manageAll=isSuperAdmin。若未来有非超管的全量学生范围角色,需扩展StudentAccessScope的manageAll判定逻辑。- Tool 扩展:当前仅
student:view的两个 Tool。新增 Tool 只需实现ToolDef并注册到AgentToolsModule,无需修改框架代码。