Files
gongxue-base/docs/superpowers/plans/casl-migration.md

15 KiB
Raw Blame History

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 === trueability.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

  1. class:edit 宽泛授权ponytail 标记):拥有 class:edit 权限的教师目前获得全量学生/排课/考勤管理权限。理想情况下应通过 CASL conditions 限制为仅自己班级的学生。当前数据模型(需查询 class_teacher 关联表确定 scope无法直接在 CASL Ability 中表达。未降低现有权限,保留现状并加 TODO。

  2. 前端权限守卫:前端 PermissionRoute 组件(apps/admin/src/auth/permission-store.ts)仍然使用 permissions.includes() 检查。不影响安全性(后端是真实授权源),但可在后续迭代中统一。

  3. 操作日志中的权限上下文:当前操作日志记录仍使用 user.permissions 数组。CASL 迁移未改变日志格式。

  4. dashboard:manage 权限dashboard subject 在 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 替代。

  5. 构建验证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)                     │    │
│  └──────────────────────────────────────────────────────┘    │
└──────────────────────────────────────────────────────────────┘

安全保证

  1. 动态暴露listAvailable:只暴露 principal 拥有 exact permission 的 Tool。
  2. 执行时二次授权execute:不依赖 listAvailableexecute 再次调用 assertPermission
  3. 数据库 WHERE 数据范围StudentAccessScope 使用 TypeORM QueryBuilder + EXISTS 子查询,在 SQL 层面限制数据范围:
    • manageAll:超管或全量学生范围 → 无限制
    • teacher:仅 ClassTeacher.userId 分配班级的 active ClassStudent
  4. 输出白名单:所有 Tool 输出仅限 id, name, studentNo, gender, status, organizationId, organizationName, classIdsphone, idNumber, emergencyContact, emergencyPhone 不进入查询 SELECT。
  5. 审计:模块 AI Agent Tool,记录 tool 名、状态success/denied/failed、userId/username来自 context principal。审计写入失败不影响 Tool 调用结果。
  6. 审计脱敏:审计 detail 绝不包含 raw input、phone、idNumber 等敏感值。

SDK 适配伪代码provider-neutral

任何 LLM SDKVercel 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. 构建可信 AgentToolContextuserId/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

剩余风险

  1. classIds 聚合为第二查询:对大量结果,批量聚合 classIds 的第二条查询使用 IN (:...ids),在 MySQL 中 IN 子句过大时有性能上限(当前 limit 50 安全)。
  2. manageAll 判定:当前 manageAll = isSuperAdmin。若未来有非超管的全量学生范围角色,需扩展 StudentAccessScopemanageAll 判定逻辑。
  3. Tool 扩展:当前仅 student:view 的两个 Tool。新增 Tool 只需实现 ToolDef 并注册到 AgentToolsModule,无需修改框架代码。