Files
NextEdu/docs/architecture/002_rbac_refactoring.md

990 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
> ⚠️ **已归档文档**
> 本文档记录的是 2026-06 初的 RBAC 权限体系重构方案,描述的是修复前的安全隐患(如 exams/actions.ts 中的硬编码 getCurrentUser 存根)。
> 当前已由 [004 架构影响地图](./004_architecture_impact_map.md) 与 [005 架构数据](./005_architecture_data.json) 取代——所有 Server Action 已接入 `requirePermission()`/`requireAuth()`54 个权限点与 6 角色映射已落地。
> 保留用于历史参考,不再维护。
---
# 企业级权限体系重构方案
> 基于源码逆向分析,覆盖 proxy.ts / auth.ts / 各模块 actions.ts / 前端组件
---
## 一、问题诊断
### 1.1 安全隐患
#### 隐患 A考试模块 Server Action 零鉴权
[exams/actions.ts:721-723](file:///e:/Desktop/CICD/src/modules/exams/actions.ts#L721-L723) 中的 `getCurrentUser()` 是一个硬编码存根:
```typescript
async function getCurrentUser() {
return { id: "user_teacher_math", role: "teacher" }
}
```
**后果**:任何已登录用户(包括 student/parent都可以调用 `deleteExamAction``updateExamAction``duplicateExamAction`,删除或修改任意考试。攻击路径:
1. 学生 A 登录系统,获取合法 session
2. 直接调用 `deleteExamAction`,传入任意 `examId`
3. Server Action 不校验调用者身份,直接执行 `db.delete(exams).where(eq(exams.id, examId))`
4. 考试被删除
#### 隐患 B越权修改他人考试
即使修复了 `getCurrentUser()``updateExamAction``deleteExamAction` 仍不检查资源归属:
```typescript
// updateExamAction — 任何 teacher 可修改任何考试
await db.update(exams).set(updateData).where(eq(exams.id, examId))
// 缺少: AND creatorId = currentUserId
```
教师 B 可以修改教师 A 创建的考试,只需知道 examId。
#### 隐患 C班级管理部分接口无鉴权
[classes/actions.ts](file:///e:/Desktop/CICD/src/modules/classes/actions.ts) 中:
- `enrollStudentByEmailAction` — 无任何 auth 检查
- `setStudentEnrollmentStatusAction` — 无任何 auth 检查
- `createClassScheduleItemAction` — 无任何 auth 检查
- `deleteClassScheduleItemAction` — 无任何 auth 检查
- `ensureClassInvitationCodeAction` — 无任何 auth 检查
- `regenerateClassInvitationCodeAction` — 无任何 auth 检查
任何已登录用户都可以操作任意班级的学生和课表。
#### 隐患 D作业批改无资源归属校验
`gradeHomeworkSubmissionAction` 只调用 `ensureTeacher()`,确认调用者是教师身份,但不检查该教师是否有权批改此作业。任何教师可批改任何班级的作业。
### 1.2 扩展性问题
#### 问题 A角色硬编码散布全栈
前端 14 处 `role === "xxx"` 硬编码,后端各模块各自实现鉴权逻辑:
| 位置 | 模式 |
|------|------|
| `proxy.ts` | `if (role === "admin")` |
| `onboarding-gate.tsx` | `if (role === "admin")` / `role === "teacher"` / `role === "student"` |
| `dashboard/page.tsx` | `if (role === "admin") redirect(...)` |
| `settings/page.tsx` | `if (role === "admin") return <AdminSettingsView>` |
| `homework/actions.ts` | `ensureTeacher()` / `ensureStudent()` |
| `classes/actions.ts` | `session.user.role !== "admin"` |
新增角色(如 `teaching_head``grade_head`)需要改动所有这些位置。
#### 问题 B多角色用户被强制取一个
[auth.ts](file:///e:/Desktop/CICD/src/auth.ts) 中多角色解析逻辑取优先级最高的一个角色,存入 JWT 的单一 `role` 字段。一个同时是 `teacher` + `grade_head` 的用户,在 JWT 中只保留 `teacher`,导致年级管理功能不可用。
#### 问题 C数据权限无统一抽象
`homework/actions.ts` 手动实现了"教师只能为自己班级的作业发布任务"的逻辑30+ 行),`classes/actions.ts` 手动实现了"年级主任只能管理自己年级"的逻辑20+ 行)。每个模块各自实现,无法复用。
### 1.3 可维护性问题
#### 问题 A鉴权逻辑与业务逻辑耦合
每个 Server Action 的前 10-30 行都是鉴权代码,与业务逻辑混杂,难以测试和复用。
#### 问题 B前端条件渲染依赖角色字符串
```tsx
// 当前:硬编码角色判断
{role === "teacher" ? <TeacherView /> : <StudentView />}
// 无法支持:年级主任看到额外的管理入口
```
---
## 二、企业级权限模型设计
### 2.1 权限模型RBAC + 数据权限
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ User │────→│ UserRole │←────│ Role │
│ │ M:N │ (多角色) │ │ (角色定义) │
└─────────────┘ └──────────────┘ └──────┬──────┘
│ M:N
┌──────┴──────┐
│ RolePermission│
└──────┬──────┘
│ N:1
┌──────┴──────┐
│ Permission │
│ (权限点) │
└─────────────┘
```
### 2.2 权限点定义
权限点采用 `resource:action` 命名规范:
```typescript
// src/shared/types/permissions.ts
export const Permissions = {
// 考试
EXAM_CREATE: "exam:create",
EXAM_READ: "exam:read",
EXAM_UPDATE: "exam:update",
EXAM_DELETE: "exam:delete",
EXAM_DUPLICATE: "exam:duplicate",
EXAM_PUBLISH: "exam:publish",
EXAM_AI_GENERATE: "exam:ai_generate",
// 作业
HOMEWORK_CREATE: "homework:create",
HOMEWORK_GRADE: "homework:grade",
HOMEWORK_SUBMIT: "homework:submit",
// 题库
QUESTION_CREATE: "question:create",
QUESTION_READ: "question:read",
QUESTION_UPDATE: "question:update",
QUESTION_DELETE: "question:delete",
// 教材
TEXTBOOK_CREATE: "textbook:create",
TEXTBOOK_READ: "textbook:read",
TEXTBOOK_UPDATE: "textbook:update",
TEXTBOOK_DELETE: "textbook:delete",
// 班级
CLASS_CREATE: "class:create",
CLASS_READ: "class:read",
CLASS_UPDATE: "class:update",
CLASS_DELETE: "class:delete",
CLASS_ENROLL: "class:enroll",
CLASS_SCHEDULE: "class:schedule",
// 学校管理
SCHOOL_MANAGE: "school:manage",
GRADE_MANAGE: "grade:manage",
USER_MANAGE: "user:manage",
// AI
AI_CHAT: "ai:chat",
AI_CONFIGURE: "ai:configure",
// 设置
SETTINGS_ADMIN: "settings:admin",
} as const
export type Permission = typeof Permissions[keyof typeof Permissions]
```
### 2.3 角色-权限映射
```typescript
// src/shared/lib/permissions.ts
import { Permissions, type Permission } from "@/shared/types/permissions"
export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
admin: [
Permissions.EXAM_CREATE, Permissions.EXAM_READ, Permissions.EXAM_UPDATE,
Permissions.EXAM_DELETE, Permissions.EXAM_DUPLICATE, Permissions.EXAM_PUBLISH,
Permissions.EXAM_AI_GENERATE,
Permissions.HOMEWORK_CREATE, Permissions.HOMEWORK_GRADE,
Permissions.QUESTION_CREATE, Permissions.QUESTION_READ,
Permissions.QUESTION_UPDATE, Permissions.QUESTION_DELETE,
Permissions.TEXTBOOK_CREATE, Permissions.TEXTBOOK_READ,
Permissions.TEXTBOOK_UPDATE, Permissions.TEXTBOOK_DELETE,
Permissions.CLASS_CREATE, Permissions.CLASS_READ,
Permissions.CLASS_UPDATE, Permissions.CLASS_DELETE,
Permissions.CLASS_ENROLL, Permissions.CLASS_SCHEDULE,
Permissions.SCHOOL_MANAGE, Permissions.GRADE_MANAGE,
Permissions.USER_MANAGE,
Permissions.AI_CHAT, Permissions.AI_CONFIGURE,
Permissions.SETTINGS_ADMIN,
],
teacher: [
Permissions.EXAM_CREATE, Permissions.EXAM_READ, Permissions.EXAM_UPDATE,
Permissions.EXAM_DELETE, Permissions.EXAM_DUPLICATE, Permissions.EXAM_PUBLISH,
Permissions.EXAM_AI_GENERATE,
Permissions.HOMEWORK_CREATE, Permissions.HOMEWORK_GRADE,
Permissions.QUESTION_CREATE, Permissions.QUESTION_READ,
Permissions.QUESTION_UPDATE, Permissions.QUESTION_DELETE,
Permissions.TEXTBOOK_CREATE, Permissions.TEXTBOOK_READ,
Permissions.TEXTBOOK_UPDATE,
Permissions.CLASS_READ, Permissions.CLASS_ENROLL,
Permissions.CLASS_SCHEDULE,
Permissions.AI_CHAT,
],
student: [
Permissions.EXAM_READ,
Permissions.HOMEWORK_SUBMIT,
Permissions.QUESTION_READ,
Permissions.TEXTBOOK_READ,
Permissions.CLASS_READ,
Permissions.AI_CHAT,
],
parent: [
Permissions.EXAM_READ,
Permissions.TEXTBOOK_READ,
Permissions.CLASS_READ,
],
// 可扩展:年级主任、教研组长等
grade_head: [
Permissions.EXAM_CREATE, Permissions.EXAM_READ, Permissions.EXAM_UPDATE,
Permissions.EXAM_DELETE, Permissions.EXAM_DUPLICATE, Permissions.EXAM_PUBLISH,
Permissions.EXAM_AI_GENERATE,
Permissions.HOMEWORK_CREATE, Permissions.HOMEWORK_GRADE,
Permissions.QUESTION_CREATE, Permissions.QUESTION_READ,
Permissions.QUESTION_UPDATE, Permissions.QUESTION_DELETE,
Permissions.TEXTBOOK_CREATE, Permissions.TEXTBOOK_READ,
Permissions.TEXTBOOK_UPDATE,
Permissions.CLASS_CREATE, Permissions.CLASS_READ,
Permissions.CLASS_UPDATE, Permissions.CLASS_ENROLL,
Permissions.CLASS_SCHEDULE,
Permissions.GRADE_MANAGE,
Permissions.AI_CHAT,
],
}
```
### 2.4 数据权限策略
数据权限通过 `DataScope` 定义,在 data-access 层自动注入过滤条件:
```typescript
// src/shared/types/permissions.ts
export type DataScope =
| { type: "all" } // admin: 全量
| { type: "owned" } // 仅自己创建的
| { type: "class_members" } // 所在班级的成员数据
| { type: "grade_managed"; gradeIds: string[] } // 管理的年级
| { type: "class_taught"; classIds: string[]; subjectIds?: string[] } // 教学的班级(可限定科目)
| { type: "children"; childrenIds: string[] } // 家长:子女数据
export interface AuthContext {
userId: string
roles: string[] // 所有角色,不再只取一个
permissions: Permission[] // 合并所有角色的权限(去重)
dataScope: DataScope // 数据权限范围
}
```
### 2.5 数据库变更
**新增一张表**(最小变更,不修改现有表结构):
```sql
CREATE TABLE role_permissions (
role_id VARCHAR(128) NOT NULL,
permission VARCHAR(100) NOT NULL,
PRIMARY KEY (role_id, permission),
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
INDEX idx_role_permissions_role (role_id)
);
```
Drizzle schema
```typescript
// 追加到 src/shared/db/schema.ts
export const rolePermissions = mysqlTable("role_permissions", {
roleId: varchar("role_id", { length: 128 }).notNull()
.references(() => roles.id, { onDelete: "cascade" }),
permission: varchar("permission", { length: 100 }).notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.roleId, table.permission] }),
roleIdIdx: index("role_permissions_role_idx").on(table.roleId),
}))
```
**不修改现有表**`ROLE_PERMISSIONS` 常量作为初始 seed 数据,运行时优先从数据库读取(支持动态调整),数据库无记录时 fallback 到常量。
---
## 三、实现方案
### 3.1 NextAuth Session 携带权限信息
#### 修改 JWT 和 Session 类型
```typescript
// src/next-auth.d.ts
declare module "next-auth" {
interface Session {
user: DefaultSession["user"] & {
id: string
roles: string[] // 所有角色
permissions: string[] // 合并后的权限列表
}
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string
roles: string[]
permissions: string[]
}
}
```
#### 修改 auth.ts callbacks
```typescript
// src/auth.ts — 关键改动
import { ROLE_PERMISSIONS } from "@/shared/lib/permissions"
function resolvePermissions(roleNames: string[]): string[] {
const set = new Set<string>()
for (const name of roleNames) {
const perms = ROLE_PERMISSIONS[name] ?? []
for (const p of perms) set.add(p)
}
return Array.from(set)
}
export const authConfig = {
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
// 查询用户所有角色
const userRoles = await db
.select({ name: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, user.id))
const roleNames = userRoles.map(r => r.name)
token.roles = roleNames
token.permissions = resolvePermissions(roleNames)
}
return token
},
async session({ session, token }) {
session.user.id = token.id
session.user.roles = token.roles
session.user.permissions = token.permissions
return session
},
},
}
```
### 3.2 `requirePermission()` 服务端权限断言
```typescript
// src/shared/lib/auth-guard.ts
import { auth } from "@/auth"
import { ROLE_PERMISSIONS } from "@/shared/lib/permissions"
import type { Permission, DataScope, AuthContext } from "@/shared/types/permissions"
import { db } from "@/shared/db"
import { users, usersToRoles, roles, classes, classEnrollments, classSubjectTeachers, grades } from "@/shared/db/schema"
import { eq, and, inArray } from "drizzle-orm"
class PermissionDeniedError extends Error {
constructor(permission: string) {
super(`Permission denied: ${permission}`)
this.name = "PermissionDeniedError"
}
}
/**
* 获取当前用户的完整认证上下文
*/
export async function getAuthContext(): Promise<AuthContext> {
const session = await auth()
const userId = session?.user?.id
if (!userId) throw new PermissionDeniedError("auth_required")
const roles = session.user.roles ?? []
const permissions = session.user.permissions ?? []
const dataScope = await resolveDataScope(userId, roles)
return { userId, roles, permissions, dataScope }
}
/**
* 断言当前用户拥有指定权限
*/
export async function requirePermission(permission: Permission): Promise<AuthContext> {
const ctx = await getAuthContext()
if (!ctx.permissions.includes(permission)) {
throw new PermissionDeniedError(permission)
}
return ctx
}
/**
* 断言当前用户拥有指定权限,并返回上下文(不抛异常版本)
*/
export async function checkPermission(permission: Permission): Promise<{ allowed: boolean; ctx: AuthContext }> {
const ctx = await getAuthContext()
return { allowed: ctx.permissions.includes(permission), ctx }
}
/**
* 根据角色解析数据权限范围
*/
async function resolveDataScope(userId: string, roleNames: string[]): Promise<DataScope> {
if (roleNames.includes("admin")) {
return { type: "all" }
}
// 年级主任
if (roleNames.includes("grade_head")) {
const managedGrades = await db
.select({ id: grades.id })
.from(grades)
.where(
and(
eq(grades.gradeHeadId, userId),
// 或 teachingHeadId
)
)
if (managedGrades.length > 0) {
return { type: "grade_managed", gradeIds: managedGrades.map(g => g.id) }
}
}
// 教师
if (roleNames.includes("teacher")) {
const taughtClasses = await db
.select({ classId: classes.id, subjectId: classSubjectTeachers.subjectId })
.from(classes)
.leftJoin(classSubjectTeachers, eq(classSubjectTeachers.classId, classes.id))
.where(eq(classes.teacherId, userId))
const classIds = [...new Set(taughtClasses.map(c => c.classId))]
const subjectIds = taughtClasses
.map(c => c.subjectId)
.filter((s): s is string => s !== null)
return { type: "class_taught", classIds, subjectIds: subjectIds.length > 0 ? subjectIds : undefined }
}
// 学生
if (roleNames.includes("student")) {
return { type: "class_members" }
}
// 家长
if (roleNames.includes("parent")) {
// TODO: 查询关联子女
return { type: "children", childrenIds: [] }
}
return { type: "owned" }
}
/**
* 在 data-access 层使用:根据 DataScope 生成查询过滤条件
*/
export function applyDataScope(
scope: DataScope,
options: {
creatorIdField?: unknown // 如 exams.creatorId
classIdField?: unknown // 如 classes.id
gradeIdField?: unknown // 如 grades.id
studentIdField?: unknown // 如 classEnrollments.studentId
}
): unknown[] {
const conditions: unknown[] = []
switch (scope.type) {
case "all":
break // 无过滤
case "owned":
if (options.creatorIdField) {
conditions.push(eq(options.creatorIdField, /* currentUserId */))
}
break
case "class_taught":
if (options.classIdField && scope.classIds.length > 0) {
conditions.push(inArray(options.classIdField, scope.classIds))
}
break
case "grade_managed":
if (options.gradeIdField && scope.gradeIds.length > 0) {
conditions.push(inArray(options.gradeIdField, scope.gradeIds))
}
break
case "class_members":
// 需要子查询:学生所在班级
break
}
return conditions
}
export { PermissionDeniedError }
```
### 3.3 `usePermission()` 前端 Hook
```typescript
// src/shared/hooks/use-permission.ts
"use client"
import { useSession } from "next-auth/react"
import type { Permission } from "@/shared/types/permissions"
export function usePermission(): {
permissions: Permission[]
roles: string[]
hasPermission: (permission: Permission) => boolean
hasAnyPermission: (...perms: Permission[]) => boolean
hasAllPermissions: (...perms: Permission[]) => boolean
hasRole: (role: string) => boolean
} {
const { data: session } = useSession()
const permissions = session?.user?.permissions ?? []
const roles = session?.user?.roles ?? []
const hasPermission = (permission: Permission): boolean => {
return permissions.includes(permission)
}
const hasAnyPermission = (...perms: Permission[]): boolean => {
return perms.some(p => permissions.includes(p))
}
const hasAllPermissions = (...perms: Permission[]): boolean => {
return perms.every(p => permissions.includes(p))
}
const hasRole = (role: string): boolean => {
return roles.includes(role)
}
return { permissions, roles, hasPermission, hasAnyPermission, hasAllPermissions, hasRole }
}
```
### 3.4 Server Action 集成模式
#### 改造前exams/actions.ts
```typescript
// 零鉴权getCurrentUser() 返回硬编码值
export async function deleteExamAction(prevState, formData) {
const { examId } = parsed.data
await db.delete(exams).where(eq(exams.id, examId)) // 任何人可删任何考试
}
```
#### 改造后
```typescript
import { requirePermission, getAuthContext, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
export async function deleteExamAction(prevState, formData): Promise<ActionState> {
try {
const ctx = await requirePermission(Permissions.EXAM_DELETE)
const { examId } = parsed.data
// 数据权限:非 admin 只能删自己的考试
if (ctx.dataScope.type !== "all") {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
columns: { creatorId: true },
})
if (!exam || exam.creatorId !== ctx.userId) {
return failState("You can only delete your own exams")
}
}
await db.delete(exams).where(eq(exams.id, examId))
} catch (e) {
if (e instanceof PermissionDeniedError) return failState(e.message)
throw e
}
}
```
### 3.5 data-access 层数据权限过滤
```typescript
// src/modules/exams/data-access.ts — 改造后
import { applyDataScope } from "@/shared/lib/auth-guard"
import type { DataScope } from "@/shared/types/permissions"
export const getExams = cache(async (params: GetExamsParams & { scope: DataScope }) => {
const conditions = []
// 原有筛选条件
if (params.q) { /* ... */ }
if (params.status && params.status !== "all") { /* ... */ }
// 数据权限过滤
const scopeConditions = applyDataScope(params.scope, {
creatorIdField: exams.creatorId,
gradeIdField: exams.gradeId,
})
conditions.push(...scopeConditions)
const data = await db.query.exams.findMany({
where: conditions.length ? and(...conditions) : undefined,
// ...
})
})
```
### 3.6 Middleware 改造
```typescript
// src/proxy.ts — 改造后
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import { getToken } from "next-auth/jwt"
// 路由 → 所需权限映射
const ROUTE_PERMISSIONS: Record<string, string> = {
"/admin": "school:manage",
"/teacher": "exam:read", // teacher 区域最低权限
"/student": "exam:read", // student 区域最低权限
"/parent": "exam:read", // parent 区域最低权限
}
// API 路由 → 所需权限映射
const API_PERMISSIONS: Record<string, string> = {
"/api/ai/chat": "ai:chat",
"/api/onboarding": "auth_required", // 特殊标记:仅需登录
}
export async function middleware(request: NextRequest): Promise<NextResponse> {
const token = await getToken({ req: request })
if (!token) {
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("callbackUrl", request.url)
return NextResponse.redirect(loginUrl)
}
const { pathname } = request.nextUrl
const permissions: string[] = (token.permissions as string[]) ?? []
const roles: string[] = (token.roles as string[]) ?? []
// 检查 API 路由权限
for (const [prefix, requiredPerm] of Object.entries(API_PERMISSIONS)) {
if (pathname.startsWith(prefix)) {
if (requiredPerm === "auth_required") break // 仅需登录
if (!permissions.includes(requiredPerm)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
break
}
}
// 检查页面路由权限
for (const [prefix, requiredPerm] of Object.entries(ROUTE_PERMISSIONS)) {
if (pathname.startsWith(prefix)) {
if (!permissions.includes(requiredPerm)) {
// 无权限 → 重定向到角色首页
const defaultPath = resolveDefaultPath(roles)
return NextResponse.redirect(new URL(defaultPath, request.url))
}
break
}
}
return NextResponse.next()
}
function resolveDefaultPath(roles: string[]): string {
if (roles.includes("admin")) return "/admin/dashboard"
if (roles.includes("teacher")) return "/teacher/dashboard"
if (roles.includes("student")) return "/student/dashboard"
if (roles.includes("parent")) return "/parent/dashboard"
return "/dashboard"
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
}
```
### 3.7 前端组件改造
#### 改造前
```tsx
// 硬编码角色判断
{role === "teacher" ? <TeacherView /> : <StudentView />}
```
#### 改造后
```tsx
import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
function DashboardPage() {
const { hasPermission, hasRole } = usePermission()
return (
<>
{hasPermission(Permissions.HOMEWORK_GRADE) && <GradingWidget />}
{hasPermission(Permissions.EXAM_CREATE) && <CreateExamButton />}
{hasPermission(Permissions.SCHOOL_MANAGE) && <AdminPanel />}
</>
)
}
```
---
## 四、渐进式重构路线图
### Phase 1基础设施1-2 天)
1. 创建 `src/shared/types/permissions.ts` — 权限点定义
2. 创建 `src/shared/lib/permissions.ts` — 角色-权限映射
3. 创建 `src/shared/lib/auth-guard.ts``requirePermission()` / `getAuthContext()`
4. 创建 `src/shared/hooks/use-permission.ts` — 前端 Hook
5. 修改 `src/next-auth.d.ts` — Session/JWT 类型扩展
6. 修改 `src/auth.ts` — JWT/Session callbacks 注入权限
7. 新增 `role_permissions` 数据库表 + seed 脚本
### Phase 2修复高危模块2-3 天)
优先修复零鉴权和越权漏洞:
1. **exams/actions.ts** — 替换 `getCurrentUser()` 存根,所有 Action 加 `requirePermission()`
2. **classes/actions.ts** — 无鉴权接口补全,统一使用 `requirePermission()`
3. **homework/actions.ts** — 用 `requirePermission()` 替换 `ensureTeacher()`/`ensureStudent()`
4. **questions/actions.ts** — 补全鉴权
5. **textbooks/actions.ts** — 补全鉴权
### Phase 3数据权限集成2-3 天)
1. 各模块 `data-access.ts` 接受 `DataScope` 参数
2. `getExams()` / `getHomeworkAssignments()` 等查询函数自动过滤
3. 页面级 Server Component 传入 `AuthContext`
### Phase 4Middleware 升级1 天)
1. `proxy.ts` 从角色路由匹配 → 权限点路由匹配
2. API 路由增加权限检查
### Phase 5前端改造2-3 天)
1. 逐步替换 `role === "xxx"``hasPermission()`
2. 侧边栏导航从 `NAV_CONFIG[role]``NAV_CONFIG.filter(item => hasPermission(item.permission))`
3. 条件渲染统一使用 `usePermission()`
---
## 五、考试模块改造对比
### 5.1 Server Actions 改造
#### 改造前 — createExamAction
```typescript
export async function createExamAction(prevState, formData) {
// ... 解析表单 ...
try {
const user = await getCurrentUser() // 硬编码存根!
await persistExamDraft({
examId: context.examId,
creatorId: user?.id ?? "user_teacher_math", // 硬编码 fallback
// ...
})
} catch (error) {
return failState("Database error")
}
}
```
#### 改造后 — createExamAction
```typescript
export async function createExamAction(prevState, formData): Promise<ActionState> {
let ctx: AuthContext
try {
ctx = await requirePermission(Permissions.EXAM_CREATE)
} catch (e) {
if (e instanceof PermissionDeniedError) return failState(e.message)
throw e
}
// ... 解析表单 ...
try {
await persistExamDraft({
examId: context.examId,
creatorId: ctx.userId, // 真实用户 ID
// ...
})
} catch (error) {
return failState("Database error")
}
}
```
#### 改造前 — deleteExamAction
```typescript
export async function deleteExamAction(prevState, formData) {
const { examId } = parsed.data
// 无任何鉴权!任何人可删任何考试!
await db.delete(exams).where(eq(exams.id, examId))
}
```
#### 改造后 — deleteExamAction
```typescript
export async function deleteExamAction(prevState, formData): Promise<ActionState> {
let ctx: AuthContext
try {
ctx = await requirePermission(Permissions.EXAM_DELETE)
} catch (e) {
if (e instanceof PermissionDeniedError) return failState(e.message)
throw e
}
const { examId } = parsed.data
// 数据权限:非 admin 只能删自己创建的考试
if (ctx.dataScope.type !== "all") {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
columns: { creatorId: true },
})
if (!exam) return failState("Exam not found")
if (exam.creatorId !== ctx.userId) {
return failState("You can only delete your own exams")
}
}
await db.delete(exams).where(eq(exams.id, examId))
revalidatePath("/teacher/exams/all")
return successState(examId, "Exam deleted")
}
```
### 5.2 Data-Access 层改造
#### 改造前 — getExams
```typescript
export const getExams = cache(async (params: GetExamsParams) => {
// 任何调用者都能看到所有考试
const data = await db.query.exams.findMany({
where: conditions.length ? and(...conditions) : undefined,
})
})
```
#### 改造后 — getExams
```typescript
export const getExams = cache(async (params: GetExamsParams & { scope: DataScope }) => {
const conditions = []
// 原有筛选
if (params.q) conditions.push(or(like(exams.title, search), like(exams.description, search)))
if (params.status && params.status !== "all") conditions.push(eq(exams.status, params.status))
// 数据权限自动过滤
switch (params.scope.type) {
case "all":
break
case "owned":
conditions.push(eq(exams.creatorId, /* userId from caller */))
break
case "class_taught": {
// 教师能看到自己班级关联的年级的考试
if (params.scope.classIds.length > 0) {
// 通过 class → grade 关联查询
const teacherGradeIds = await db
.selectDistinct({ gradeId: classes.gradeId })
.from(classes)
.where(inArray(classes.id, params.scope.classIds))
if (teacherGradeIds.length > 0) {
conditions.push(inArray(exams.gradeId, teacherGradeIds.map(g => g.gradeId)))
}
}
break
}
case "grade_managed":
if (params.scope.gradeIds.length > 0) {
conditions.push(inArray(exams.gradeId, params.scope.gradeIds))
}
break
case "class_members": {
// 学生看到自己年级的已发布考试
// 需要子查询获取学生所在班级的年级
break
}
}
const data = await db.query.exams.findMany({
where: conditions.length ? and(...conditions) : undefined,
})
})
```
### 5.3 前端改造
#### 改造前 — exam-actions.tsx
```tsx
// 仅通过路由守卫保护,组件内无权限判断
<Button onClick={handleDelete}></Button>
```
#### 改造后 — exam-actions.tsx
```tsx
import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
function ExamActions({ examId }) {
const { hasPermission } = usePermission()
return (
<>
{hasPermission(Permissions.EXAM_UPDATE) && (
<Button onClick={handleEdit}></Button>
)}
{hasPermission(Permissions.EXAM_DELETE) && (
<Button onClick={handleDelete}></Button>
)}
{hasPermission(Permissions.EXAM_DUPLICATE) && (
<Button onClick={handleDuplicate}></Button>
)}
</>
)
}
```
---
## 六、验收标准
- [ ] 所有 Server Action 都有 `requirePermission()` 调用
- [ ] `getCurrentUser()` 硬编码存根已移除
- [ ] 前端零 `role === "xxx"` 硬编码
- [ ] `exams/actions.ts` 的 CRUD 操作有资源归属校验
- [ ] `classes/actions.ts` 所有接口有鉴权
- [ ] `homework/actions.ts` 批改操作有资源归属校验
- [ ] Middleware 基于权限点而非角色字符串拦截
- [ ] 多角色用户可同时使用所有角色的功能
- [ ] `role_permissions` 表有 seed 数据
- [ ] 新增角色只需修改 `ROLE_PERMISSIONS` 映射 + seed无需改动业务代码