/** * 路由权限配置表(对齐 CICD 项目 route-permissions.ts) * * 4 张表按优先级顺序匹配(精确 > 前缀 > 仪表盘 > API): * 1. EXACT_ROUTE_PERMISSIONS:精确路由(如 /shell/admin/users) * 2. PREFIX_ROUTE_PERMISSIONS:前缀路由(如 /shell/admin/*) * 3. DASHBOARD_ROUTE_PERMISSIONS:仪表盘路由(按角色分发) * 4. API_ROUTE_PERMISSIONS:Next.js API Route(/api/*) * * 三层安全边界(portal-shell README v2.0 §3.3): * - L1 角色门禁:requiredRoles(4 角色之一) * - L2 权限点门禁:requiredPermissions(AND 语义,必须全部满足) * - L3 数据范围:运行时由插件/page 内 usePermission 校验 * * 使用方式(middleware / page / layout): * ```ts * import { checkRoutePermission } from "@/shared/lib/route-permissions"; * * const result = checkRoutePermission(pathname, userBitmap, userRole); * if (!result.allowed) redirect("/shell/forbidden"); * ``` * * 关联:portal-shell README v2.0 §3.3、project_rules §3.1(禁止 role === "xxx" 硬编码) */ import type { Role } from "@edu/shared-ts/contracts"; import { hasAllPermissionsInBitmap, hasAnyPermissionInBitmap, isValidPermission, } from "@edu/shared-ts/permission-bitmap"; /** * 路由权限配置项 */ export interface RoutePermissionConfig { /** 所需角色(任一满足即可;空数组表示不限制角色) */ requiredRoles?: Role[]; /** * 所需权限点(AND 语义,必须全部满足) * 权限点必须来自 PERMISSION_BITMAP_ORDER */ requiredPermissions?: string[]; /** * 所需权限点(OR 语义,任一满足即可) * 与 requiredPermissions 同时存在时,先 AND 再 OR */ anyOfPermissions?: string[]; } /** * 路由权限检查结果 */ export interface RoutePermissionResult { /** 是否允许访问 */ allowed: boolean; /** 拒绝原因(allowed=false 时填充) */ reason?: "missing_role" | "missing_permission" | "no_config"; /** 匹配到的配置(用于调试) */ matchedPath?: string; /** 缺失的权限点(allowed=false 且 reason=missing_permission 时填充) */ missingPermissions?: string[]; } /** * 1. 精确路由权限表 * * 高优先级,pathname 完全匹配时生效。 * 适用于功能明确、URL 固定的页面(用户管理、RBAC、审计日志等)。 */ export const EXACT_ROUTE_PERMISSIONS: Record = { // ── admin 专属 ──────────────────────────────────────────── "/shell/admin/users": { requiredRoles: ["admin"], requiredPermissions: ["USER_MANAGE"], }, "/shell/admin/roles": { requiredRoles: ["admin"], requiredPermissions: ["ROLE_MANAGE"], }, "/shell/admin/permissions": { requiredRoles: ["admin"], requiredPermissions: ["PERMISSION_MANAGE"], }, "/shell/admin/audit-logs": { requiredRoles: ["admin"], requiredPermissions: ["AUDIT_LOG_READ"], }, "/shell/admin/school": { requiredRoles: ["admin"], requiredPermissions: ["SCHOOL_MANAGE"], }, "/shell/admin/plugins": { requiredRoles: ["admin"], requiredPermissions: ["PLUGIN_REGISTRY_MANAGE"], }, "/shell/admin/invitation-codes": { requiredRoles: ["admin"], anyOfPermissions: ["INVITATION_CODE_MANAGE", "INVITATION_CODE_CREATE"], }, // P1-1:列表页根路由(无尾斜杠)需在 EXACT 表登记, // 否则 middleware checkRoutePermission 落入 /shell/** catch-all 拒绝 "/shell/admin/announcements": { requiredRoles: ["admin"], requiredPermissions: ["ANNOUNCEMENT_MANAGE"], }, "/shell/admin/classes": { requiredRoles: ["admin"], anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"], }, // ── teacher 专属 ────────────────────────────────────────── "/shell/teacher/lesson-plans": { requiredRoles: ["teacher"], anyOfPermissions: [ "LESSON_PLAN_READ", "LESSON_PLAN_CREATE", "LESSON_PLAN_UPDATE", ], }, "/shell/teacher/question-bank": { requiredRoles: ["teacher"], anyOfPermissions: ["QUESTION_READ", "QUESTION_CREATE"], }, "/shell/teacher/textbooks": { requiredRoles: ["teacher", "admin"], requiredPermissions: ["TEXTBOOK_READ"], }, "/shell/teacher/scheduling-rules": { requiredRoles: ["teacher", "admin"], anyOfPermissions: ["SCHEDULE_AUTO", "SCHEDULE_ADJUST", "SCHEDULE_MANAGE"], }, // P1-1:列表页根路由(无尾斜杠)需在 EXACT 表登记, // 与 PREFIX 表(带尾斜杠,匹配子路由如 /shell/teacher/exams/123)互补 "/shell/teacher/exams": { requiredRoles: ["teacher", "admin"], anyOfPermissions: ["EXAM_READ", "EXAM_CREATE", "EXAM_UPDATE", "EXAM_GRADE"], }, "/shell/teacher/homework": { requiredRoles: ["teacher", "admin"], anyOfPermissions: ["HOMEWORK_READ", "HOMEWORK_CREATE", "HOMEWORK_GRADE"], }, "/shell/teacher/grades": { requiredRoles: ["teacher", "admin"], anyOfPermissions: ["GRADE_RECORD_MANAGE", "GRADE_RECORD_READ"], }, "/shell/teacher/attendance": { requiredRoles: ["teacher", "admin"], anyOfPermissions: ["ATTENDANCE_READ", "ATTENDANCE_MANAGE"], }, "/shell/teacher/diagnostics": { requiredRoles: ["teacher", "admin"], anyOfPermissions: ["DIAGNOSTIC_READ", "DIAGNOSTIC_MANAGE"], }, // ── student 专属 ────────────────────────────────────────── "/shell/student/error-book": { requiredRoles: ["student"], requiredPermissions: ["ERROR_BOOK_READ"], }, "/shell/student/learning-path": { requiredRoles: ["student"], requiredPermissions: ["LEARNING_PATH_READ"], }, "/shell/student/electives": { requiredRoles: ["student"], anyOfPermissions: ["ELECTIVE_READ", "ELECTIVE_SELECT"], }, "/shell/student/ai-tutor": { requiredRoles: ["student"], requiredPermissions: ["AI_TUTOR_USE"], }, // ── parent 专属 ─────────────────────────────────────────── "/shell/parent/children": { requiredRoles: ["parent"], requiredPermissions: ["GRADE_READ_CHILD"], }, "/shell/parent/leave-approval": { requiredRoles: ["parent"], requiredPermissions: ["LEAVE_APPROVAL_MANAGE"], }, }; /** * 2. 前缀路由权限表 * * 中优先级,pathname 以指定前缀开头时生效。 * 适用于功能集合下的所有子路由(/shell/admin/* /shell/teacher/exams/* 等)。 * * 注意:前缀必须以 / 结尾,避免误匹配(如 /shell/admin 不能匹配 /shell/admin-users)。 */ export const PREFIX_ROUTE_PERMISSIONS: Array<{ prefix: string; config: RoutePermissionConfig; }> = [ // admin 区所有子路由默认要求 admin 角色 { prefix: "/shell/admin/", config: { requiredRoles: ["admin"] }, }, // 考试管理 { prefix: "/shell/teacher/exams/", config: { requiredRoles: ["teacher", "admin"], anyOfPermissions: [ "EXAM_READ", "EXAM_CREATE", "EXAM_UPDATE", "EXAM_GRADE", ], }, }, // 作业管理 { prefix: "/shell/teacher/homework/", config: { requiredRoles: ["teacher", "admin"], anyOfPermissions: ["HOMEWORK_READ", "HOMEWORK_CREATE", "HOMEWORK_GRADE"], }, }, // 教案管理(P2 迁移) { prefix: "/shell/teacher/lesson-plans/", config: { requiredRoles: ["teacher", "admin"], anyOfPermissions: [ "LESSON_PLAN_READ", "LESSON_PLAN_CREATE", "LESSON_PLAN_UPDATE", ], }, }, // 成绩录入 { prefix: "/shell/teacher/grades/", config: { requiredRoles: ["teacher", "admin"], anyOfPermissions: ["GRADE_RECORD_MANAGE", "GRADE_RECORD_READ"], }, }, // 考勤 { prefix: "/shell/teacher/attendance/", config: { requiredRoles: ["teacher", "admin"], anyOfPermissions: ["ATTENDANCE_READ", "ATTENDANCE_MANAGE"], }, }, // 班级管理 { prefix: "/shell/admin/classes/", config: { requiredRoles: ["admin"], anyOfPermissions: ["CLASS_READ", "CLASS_MANAGE"], }, }, // 学情诊断 { prefix: "/shell/teacher/diagnostics/", config: { requiredRoles: ["teacher", "admin"], anyOfPermissions: ["DIAGNOSTIC_READ", "DIAGNOSTIC_MANAGE"], }, }, // 公告管理 { prefix: "/shell/admin/announcements/", config: { requiredRoles: ["admin"], requiredPermissions: ["ANNOUNCEMENT_MANAGE"], }, }, // P1-3:dev 模板预览页(仅登录可访问,运行时由 notFound() 拒绝生产访问) // ARCHITECTURE.md §10 P1-3:/shell/dev/templates/* 仅 dev 可见 // 注:Next.js 私有文件夹以 _ 开头会被排除路由,故使用 `dev` 而非 `_dev` { prefix: "/shell/dev/", config: {}, // 空 config = 仅校验登录身份 }, ]; /** * 3. 仪表盘路由权限表 * * 低优先级,按角色分发的根仪表盘。 * 当 pathname 不匹配前两张表时,检查是否为角色仪表盘根路径。 */ export const DASHBOARD_ROUTE_PERMISSIONS: Record< string, RoutePermissionConfig > = { "/shell/admin": { requiredRoles: ["admin"], requiredPermissions: ["DASHBOARD_ADMIN_READ"], }, "/shell/teacher": { requiredRoles: ["teacher"], requiredPermissions: ["DASHBOARD_TEACHER_READ"], }, "/shell/student": { requiredRoles: ["student"], requiredPermissions: ["DASHBOARD_STUDENT_READ"], }, "/shell/parent": { requiredRoles: ["parent"], requiredPermissions: ["DASHBOARD_PARENT_READ"], }, // 通用仪表盘 "/shell": { requiredPermissions: ["DASHBOARD_READ"], }, }; /** * 4. Next.js API Route 权限表 * * 用于 /api/* 路径的权限校验。 * 注意:API Route 通常需要更严格的权限校验,因为它们直接操作数据。 * * P0-2(ARCHITECTURE.md §6.2 §3.4 V3-A3):新增 auth/graphql 公开端点。 */ export const API_ROUTE_PERMISSIONS: Record = { // 错误上报端点:所有登录用户可访问(空 config 表示登录即可) "/api/log": {}, // 健康检查:公开 "/api/healthz": {}, "/api/health": {}, "/api/ready": {}, // 认证端点:公开(未登录也要能调登录接口) "/api/auth/login": {}, "/api/auth/logout": {}, // GraphQL 同域代理:登录即可(细粒度由后端 resolver 把关) "/api/graphql": {}, }; /** * 5. 公共路由白名单(P0-2,ARCHITECTURE.md §6.2 §3.4 V3-A3 / §11.7 红线 #5) * * 这些路由允许匿名访问(登录前/无身份也能访问)。 * middleware 对白名单路由跳过身份校验,直接放行。 * * 注意:白名单外的路由,未登录访问 → middleware 重定向到 /login。 */ export const PUBLIC_ROUTES: readonly string[] = [ "/", "/login", "/shell/forbidden", "/api/health", "/api/healthz", "/api/ready", "/api/log", "/api/auth/login", "/api/auth/logout", "/api/graphql", ]; /** * 校验权限配置的合法性(开发时辅助) * * 检查所有声明的权限点是否在 PERMISSION_BITMAP_ORDER 中。 * 在 dev 模式下打 warning,生产构建时可阻断。 * * @returns 非法权限点列表(空数组表示全部合法) */ export function validateRoutePermissionConfigs(): string[] { const invalid: string[] = []; const allConfigs: Array<{ source: string; config: RoutePermissionConfig }> = [ ...Object.entries(EXACT_ROUTE_PERMISSIONS).map(([path, config]) => ({ source: `EXACT:${path}`, config, })), ...PREFIX_ROUTE_PERMISSIONS.map(({ prefix, config }) => ({ source: `PREFIX:${prefix}`, config, })), ...Object.entries(DASHBOARD_ROUTE_PERMISSIONS).map(([path, config]) => ({ source: `DASHBOARD:${path}`, config, })), ...Object.entries(API_ROUTE_PERMISSIONS).map(([path, config]) => ({ source: `API:${path}`, config, })), ]; for (const { source, config } of allConfigs) { for (const perm of config.requiredPermissions ?? []) { if (!isValidPermission(perm)) { invalid.push(`${source}:requiredPermissions:${perm}`); } } for (const perm of config.anyOfPermissions ?? []) { if (!isValidPermission(perm)) { invalid.push(`${source}:anyOfPermissions:${perm}`); } } } return invalid; } /** * 路由权限检查主函数 * * 按优先级顺序匹配 4 张表,返回检查结果。 * * @param pathname 当前路径(如 /shell/admin/users) * @param userBitmap 用户权限位图(base36 字符串,从 JWT cookie 解析) * @param userRole 用户角色 * @returns 检查结果,allowed=true 表示放行 * * @example * ```ts * const result = checkRoutePermission("/shell/admin/users", "abc123", "admin"); * if (!result.allowed) { * redirect("/shell/forbidden"); * } * ``` */ export function checkRoutePermission( pathname: string, userBitmap: string, userRole: Role, ): RoutePermissionResult { // 0. 公共路由白名单优先(含 /shell/forbidden 自身,避免循环重定向) if (PUBLIC_ROUTES.includes(pathname)) { return { allowed: true, matchedPath: "PUBLIC" }; } // 1. 匹配精确路由 const exactConfig = EXACT_ROUTE_PERMISSIONS[pathname]; if (exactConfig) { return evaluateConfig(exactConfig, userBitmap, userRole, pathname); } // 2. 匹配前缀路由 for (const { prefix, config } of PREFIX_ROUTE_PERMISSIONS) { if (pathname.startsWith(prefix)) { return evaluateConfig(config, userBitmap, userRole, prefix); } } // 3. 匹配仪表盘路由 const dashboardConfig = DASHBOARD_ROUTE_PERMISSIONS[pathname]; if (dashboardConfig) { return evaluateConfig(dashboardConfig, userBitmap, userRole, pathname); } // 4. 匹配 API 路由 if (pathname.startsWith("/api/")) { const apiConfig = API_ROUTE_PERMISSIONS[pathname]; if (apiConfig) { return evaluateConfig(apiConfig, userBitmap, userRole, pathname); } // 未配置的 API 路由默认拒绝(fail-closed,§11.7 红线 #5) return { allowed: false, reason: "no_config", }; } // 5. /shell/** 下未登记路由 → 默认拒绝(fail-closed,§3.4 V3-A3 / §11.7 红线 #5) // 防止"幽灵路由"绕过门禁;新增路由必须显式登记到 EXACT/PREFIX/DASHBOARD 表 if (pathname.startsWith("/shell/") || pathname === "/shell") { return { allowed: false, reason: "no_config", }; } // 6. 其他未匹配路由(如 /favicon.ico / 静态资源)默认放行 return { allowed: true }; } /** * 评估单个权限配置 */ function evaluateConfig( config: RoutePermissionConfig, userBitmap: string, userRole: Role, matchedPath: string, ): RoutePermissionResult { // L1 角色门禁 if (config.requiredRoles && config.requiredRoles.length > 0) { if (!config.requiredRoles.includes(userRole)) { return { allowed: false, reason: "missing_role", matchedPath, }; } } // L2 权限点门禁 - AND 语义 const missingPermissions: string[] = []; if (config.requiredPermissions && config.requiredPermissions.length > 0) { for (const perm of config.requiredPermissions) { // 使用 hasAllPermissionsInBitmap 不合适(它返回 boolean 不告知哪些缺失) // 这里手动遍历以便收集缺失项 const bit = hasPermissionInBitmapSimple(userBitmap, perm); if (!bit) { missingPermissions.push(perm); } } if (missingPermissions.length > 0) { return { allowed: false, reason: "missing_permission", matchedPath, missingPermissions, }; } } // L2 权限点门禁 - OR 语义 if (config.anyOfPermissions && config.anyOfPermissions.length > 0) { if (!hasAnyPermissionInBitmap(userBitmap, config.anyOfPermissions)) { return { allowed: false, reason: "missing_permission", matchedPath, missingPermissions: config.anyOfPermissions, }; } } return { allowed: true, matchedPath }; } /** * 简化版单权限检查(避免循环依赖 hasAllPermissionsInBitmap) * * 直接调用 hasAllPermissionsInBitmap 检查单个权限点 */ function hasPermissionInBitmapSimple( bitmap: string, permission: string, ): boolean { return hasAllPermissionsInBitmap(bitmap, [permission]); } /** * 批量检查用户是否拥有所有指定路由的访问权限 * * 用于侧边栏导航项过滤:一次性检查多个路由,避免重复调用。 * * @param paths 路径列表 * @param userBitmap 用户权限位图 * @param userRole 用户角色 * @returns 路径 → 是否允许 的映射 */ export function batchCheckRoutePermission( paths: readonly string[], userBitmap: string, userRole: Role, ): Record { const result: Record = {}; for (const path of paths) { result[path] = checkRoutePermission(path, userBitmap, userRole).allowed; } return result; }