# 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 等场景直接使用: ```typescript 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 请求生命周期。 ## 测试命令与结果 ```bash 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)**:不依赖 `listAvailable`,`execute` 再次调用 `assertPermission`。 3. **数据库 WHERE 数据范围**:`StudentAccessScope` 使用 TypeORM QueryBuilder + EXISTS 子查询,在 SQL 层面限制数据范围: - `manageAll`:超管或全量学生范围 → 无限制 - `teacher`:仅 `ClassTeacher.userId` 分配班级的 active `ClassStudent` 4. **输出白名单**:所有 Tool 输出仅限 `id, name, studentNo, gender, status, organizationId, organizationName, classIds`。`phone`, `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 SDK(Vercel AI、LangChain、OpenAI function calling 等)都可以适配: ```typescript // 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 章节| ### 测试结果 ```bash 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`。若未来有非超管的全量学生范围角色,需扩展 `StudentAccessScope` 的 `manageAll` 判定逻辑。 3. **Tool 扩展**:当前仅 `student:view` 的两个 Tool。新增 Tool 只需实现 `ToolDef` 并注册到 `AgentToolsModule`,无需修改框架代码。