Files
NextEdu/docs/architecture/002_rbac_refactoring.md
SpecialX f8dfd1dddd docs: 全项目架构审查与文档体系重写
- 全项目逐文件审查: 4 份审计报告(shared/core-business/management/new-modules)
- 重写 004 架构影响地图: 图优先 + 模块依赖图 + 数据流 + 调用链 + 问题分级
- 更新 005 结构化数据: 新增 architectureOverview/moduleDependencyGraph/knownIssues/dbTables 节点
- 更新 006 功能清单: 143 项功能标注实现状态, P0 覆盖率 80%->92%
- 更新 007 差距审计: v2->v3, P0 完成 69%->84%, 新增架构技术债章节
- 更新 001 项目概览: 6 角色/54 权限/26 模块/54 表
- 新增 docs/README.md 文档索引
- 归档 11 份过时文档(002x2/003/designx8) 标注
- 更新 work_log
2026-06-17 21:51:32 +08:00

30 KiB
Raw Blame History

⚠️ 已归档文档 本文档记录的是 2026-06 初的 RBAC 权限体系重构方案,描述的是修复前的安全隐患(如 exams/actions.ts 中的硬编码 getCurrentUser 存根)。 当前已由 004 架构影响地图005 架构数据 取代——所有 Server Action 已接入 requirePermission()/requireAuth()54 个权限点与 6 角色映射已落地。 保留用于历史参考,不再维护。


企业级权限体系重构方案

基于源码逆向分析,覆盖 proxy.ts / auth.ts / 各模块 actions.ts / 前端组件


一、问题诊断

1.1 安全隐患

隐患 A考试模块 Server Action 零鉴权

exams/actions.ts:721-723 中的 getCurrentUser() 是一个硬编码存根:

async function getCurrentUser() {
  return { id: "user_teacher_math", role: "teacher" }
}

后果:任何已登录用户(包括 student/parent都可以调用 deleteExamActionupdateExamActionduplicateExamAction,删除或修改任意考试。攻击路径:

  1. 学生 A 登录系统,获取合法 session
  2. 直接调用 deleteExamAction,传入任意 examId
  3. Server Action 不校验调用者身份,直接执行 db.delete(exams).where(eq(exams.id, examId))
  4. 考试被删除

隐患 B越权修改他人考试

即使修复了 getCurrentUser()updateExamActiondeleteExamAction 仍不检查资源归属:

// updateExamAction — 任何 teacher 可修改任何考试
await db.update(exams).set(updateData).where(eq(exams.id, examId))
// 缺少: AND creatorId = currentUserId

教师 B 可以修改教师 A 创建的考试,只需知道 examId。

隐患 C班级管理部分接口无鉴权

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_headgrade_head)需要改动所有这些位置。

问题 B多角色用户被强制取一个

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前端条件渲染依赖角色字符串

// 当前:硬编码角色判断
{role === "teacher" ? <TeacherView /> : <StudentView />}

// 无法支持:年级主任看到额外的管理入口

二、企业级权限模型设计

2.1 权限模型RBAC + 数据权限

┌─────────────┐     ┌──────────────┐     ┌─────────────┐
│    User      │────→│  UserRole    │←────│    Role      │
│             │  M:N │  (多角色)     │     │  (角色定义)  │
└─────────────┘     └──────────────┘     └──────┬──────┘
                                                 │ M:N
                                          ┌──────┴──────┐
                                          │ RolePermission│
                                          └──────┬──────┘
                                                 │ N:1
                                          ┌──────┴──────┐
                                          │  Permission  │
                                          │  (权限点)     │
                                          └─────────────┘

2.2 权限点定义

权限点采用 resource:action 命名规范:

// 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 角色-权限映射

// 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 层自动注入过滤条件:

// 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 数据库变更

新增一张表(最小变更,不修改现有表结构):

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

// 追加到 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 类型

// 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

// 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() 服务端权限断言

// 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?: any     // 如 exams.creatorId
    classIdField?: any       // 如 classes.id
    gradeIdField?: any       // 如 grades.id
    studentIdField?: any     // 如 classEnrollments.studentId
  }
): any[] {
  const conditions: any[] = []

  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

// src/shared/hooks/use-permission.ts

"use client"

import { useSession } from "next-auth/react"
import type { Permission } from "@/shared/types/permissions"

export function usePermission() {
  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

// 零鉴权getCurrentUser() 返回硬编码值
export async function deleteExamAction(prevState, formData) {
  const { examId } = parsed.data
  await db.delete(exams).where(eq(exams.id, examId))  // 任何人可删任何考试
}

改造后

import { requirePermission, getAuthContext, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"

export async function deleteExamAction(prevState, formData) {
  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 层数据权限过滤

// 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 改造

// 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) {
  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 前端组件改造

改造前

// 硬编码角色判断
{role === "teacher" ? <TeacherView /> : <StudentView />}

改造后

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.tsrequirePermission() / 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

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

export async function createExamAction(prevState, formData) {
  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

export async function deleteExamAction(prevState, formData) {
  const { examId } = parsed.data
  // 无任何鉴权!任何人可删任何考试!
  await db.delete(exams).where(eq(exams.id, examId))
}

改造后 — deleteExamAction

export async function deleteExamAction(prevState, formData) {
  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

export const getExams = cache(async (params: GetExamsParams) => {
  // 任何调用者都能看到所有考试
  const data = await db.query.exams.findMany({
    where: conditions.length ? and(...conditions) : undefined,
  })
})

改造后 — getExams

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

// 仅通过路由守卫保护,组件内无权限判断
<Button onClick={handleDelete}>删除考试</Button>

改造后 — exam-actions.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无需改动业务代码