feat: add CASL authorization and AI configuration

This commit is contained in:
2026-07-11 14:25:34 +08:00
parent 8f0991a51f
commit 1e1c476bc3
59 changed files with 7733 additions and 120 deletions

View File

@@ -0,0 +1,282 @@
# 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 SDKVercel 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. 构建可信 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 章节|
### 测试结果
```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`,无需修改框架代码。