'use strict'; /** * 管理端权限点与「路由 → 权限」映射(R178·C 层唯一真相源) * ============================================================================ * * 为什么需要这层:后端有 63 条 /api/admin/* 路由,原本全部只挂 adminAuth(role='admin')。 * 在本模块之前,「是管理员」等于「有全部权限」—— 无法做到「只给阅卷、不给发布」。 * * 三条设计纪律(改动前必读): * * 1. **默认拒绝**:`resolvePermission()` 匹配不到任何规则时返回 null, * 中间件对 null **直接 403**,而不是放行。 * ⇒ 新增路由若忘了登记规则,表现是「功能不可用」而非「静默成为人人可用的后门」。 * * 2. **单一真相源**:路由的权限归属只在本文件声明,⛔ 不得在 router.js 逐条写权限参数 * (那会让 63 条路由各带一份权限字符串,与本表必然漂移,且无人能核对全集)。 * 全集对齐由门禁 `scripts/verify_admin_rbac.py` 双向断言: * 「router.js 的 /api/admin 路由全集」== 「ROUTE_RULES 覆盖集」,两个方向差集都必须为空。 * * 3. **多权限其一(anyOf)**:部分接口被两个菜单共用(如成绩复核页复用成绩列表), * 用 `perms: ['scores','review']` 表达「具备任一即可」。这不会降低安全性 —— * 任何一条都需被显式授予,只是避免「勾了复核却被 403」的误伤。 * * 高危权限点单独成项(score.publish / data.reset / admins.manage), * 目的是把「能看成绩」与「能发布成绩」分开 —— 这两件事的责任人通常不同。 */ const MODULE = 'module'; const HIGH_RISK = 'highRisk'; /** * 全部权限点。顺序即前端展示顺序。 * ⚠️ 新增权限点必须同步:① 本数组;② ROUTE_RULES 至少一条规则;③ 前端 `can()` 使用处。 */ const PERMISSIONS = [ { key: 'dashboard', label: '数据总览', group: MODULE, desc: '查看各组账号数 / 提交数 / 激活率' }, { key: 'accounts', label: '选手管理', group: MODULE, desc: '查看选手、导入报名表、编辑选手信息、标记测试数据' }, { key: 'questions', label: '试卷管理', group: MODULE, desc: '客观题与综合大题题库维护、Word 模板' }, { key: 'materials', label: '备赛资料', group: MODULE, desc: '上传 / 删除备赛资料' }, { key: 'submissions', label: '作品管理', group: MODULE, desc: '查看作品与答题记录、下载作品文件' }, { key: 'times', label: '时间设置', group: MODULE, desc: '考试起止时间、抽题数量、节点备份状态' }, { key: 'scores', label: '成绩管理', group: MODULE, desc: '阅卷打分、导出成绩、生成公示表(不含发布)' }, { key: 'review', label: '成绩复核', group: MODULE, desc: '按选手查验分项得分' }, { key: 'news', label: '新闻管理', group: MODULE, desc: '新闻增删改、置顶、附件上传、发布 / 撤回' }, { key: 'carousel', label: '轮播管理', group: MODULE, desc: '首页轮播图增删改与排序' }, { key: 'score.publish', label: '发布 / 撤回成绩', group: HIGH_RISK, desc: '控制考生端能否看到成绩,并自动生成公示草稿' }, { key: 'data.reset', label: '重置测试数据 / 删除选手', group: HIGH_RISK, desc: '会真实删除数据的操作,仅授予负责人' }, { key: 'admins.manage', label: '管理员账号管理', group: HIGH_RISK, desc: '新增 / 删除管理员、分配权限、重置口令、禁用' }, ]; const PERMISSION_KEYS = PERMISSIONS.map(p => p.key); const PERMISSION_KEY_SET = new Set(PERMISSION_KEYS); /** * 路径模式 → 正则。`:xxx` 段匹配任意单段(不含 `/`)。 * 其余字符按字面转义,避免 `/api/admin/big-questions/:group/:id` 里的 `-` `.` 被当元字符。 */ function toRegExp(pattern) { const src = pattern .split('/') .map((seg) => { if (seg.startsWith(':')) return '[^/]+'; return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }) .join('/'); return new RegExp('^' + src + '$'); } /** * 路由规则表。**顺序敏感**:更具体的路径必须排在带 `:id` 的通配路径之前 * (如 `/news/order` 必须在 `/news/:id` 之前),否则会被后者抢先匹配。 * * ⚠️ 每条规则的 perms 至少一个;`perms` 语义 = 「具备其中之一即可」。 */ const ROUTE_TABLE = [ // ---------- 自助信息(登录即可,无需业务权限) ---------- // 前端用它得知「我有哪些权限」以渲染菜单,并感知权限是否被他人改动。 // ⚠️ perms 为空数组是本表唯一允许的「显式放行」写法,门禁会断言只有本行如此。 ['GET', '/api/admin/me', []], // ---------- 管理员账号管理(高危) ---------- ['GET', '/api/admin/admin-users', ['admins.manage']], ['POST', '/api/admin/admin-users', ['admins.manage']], ['PUT', '/api/admin/admin-users/:id/permissions', ['admins.manage']], ['PUT', '/api/admin/admin-users/:id/password', ['admins.manage']], ['PUT', '/api/admin/admin-users/:id/status', ['admins.manage']], ['DELETE', '/api/admin/admin-users/:id', ['admins.manage']], // ---------- 数据总览 ---------- ['GET', '/api/admin/stats', ['dashboard']], // ---------- 选手管理 ---------- ['GET', '/api/admin/accounts', ['accounts']], ['GET', '/api/admin/teams', ['accounts']], ['POST', '/api/admin/import-excel', ['accounts']], ['POST', '/api/admin/contestants/backfill-teacher', ['accounts']], ['PUT', '/api/admin/contestants/:id', ['accounts']], ['POST', '/api/admin/toggle-test-flag', ['accounts']], // 重置测试数据是真实删除,单列高危(不给「只做选手维护」的人) ['POST', '/api/admin/reset-test-data', ['data.reset']], // ---------- 作品管理 ---------- // 成绩管理页的「考生答题记录」也读 submissions 接口 ⇒ 允许 scores 其一 ['GET', '/api/admin/submissions/:group', ['submissions', 'scores']], ['GET', '/api/admin/submission-file/:group/:id', ['submissions']], ['GET', '/api/admin/submissions/:group/:id/word', ['submissions']], ['GET', '/api/admin/works/:group', ['submissions']], ['GET', '/api/admin/batch-download/:group', ['submissions']], // ---------- 成绩管理(阅卷/导出/公示表) ---------- ['GET', '/api/admin/scores/:group', ['scores', 'review']], ['PUT', '/api/admin/scores/:group', ['scores']], ['GET', '/api/admin/export-csv/:group', ['scores']], ['GET', '/api/admin/export-excel/:group', ['scores']], ['GET', '/api/admin/export-publicity/:group', ['scores']], ['GET', '/api/admin/competition-item', ['scores']], ['PUT', '/api/admin/competition-item', ['scores']], ['GET', '/api/admin/big-answers/:group', ['scores', 'review']], ['GET', '/api/admin/big-answers/:group/export', ['scores']], ['GET', '/api/admin/big-answers/:group/import-template', ['scores']], ['POST', '/api/admin/big-answers/:group/import-preview', ['scores']], ['POST', '/api/admin/big-answers/:group/import-scores', ['scores']], ['PUT', '/api/admin/big-answers/:group/:uid/scores', ['scores']], ['GET', '/api/admin/publish-scores', ['scores', 'review']], // 发布 / 撤回 = 高危:能看成绩 ≠ 能公布成绩 ['PUT', '/api/admin/publish-scores', ['score.publish']], // ---------- 时间设置 ---------- ['GET', '/api/admin/times', ['times']], ['PUT', '/api/admin/times', ['times']], ['GET', '/api/admin/node-backup/status', ['times']], // ---------- 试卷管理 ---------- ['GET', '/api/admin/exam/:group/questions', ['questions']], ['PUT', '/api/admin/exam/:group/questions', ['questions']], ['POST', '/api/admin/exam/:group/questions/import-excel', ['questions']], ['GET', '/api/admin/exam/:group/questions/template', ['questions']], ['PUT', '/api/admin/exam/:group/word-template', ['questions']], ['GET', '/api/admin/exam/:group/word-template', ['questions']], ['DELETE', '/api/admin/exam/:group/word-template', ['questions']], ['GET', '/api/admin/big-questions/:group', ['questions']], ['POST', '/api/admin/big-questions/:group', ['questions']], ['GET', '/api/admin/big-questions/:group/export', ['questions']], ['GET', '/api/admin/big-questions/:group/template', ['questions']], ['POST', '/api/admin/big-questions/:group/import-excel', ['questions']], ['PUT', '/api/admin/big-questions/:group/:id', ['questions']], ['DELETE', '/api/admin/big-questions/:group/:id', ['questions']], // ---------- 备赛资料 ---------- ['GET', '/api/admin/materials/:group', ['materials']], ['POST', '/api/admin/materials/:group', ['materials']], ['DELETE', '/api/admin/materials/:group/:fileName', ['materials']], // ---------- 新闻管理 ---------- // ⚠️ /news/order 必须排在 /news/:id 之前 ['PUT', '/api/admin/news/order', ['news']], ['GET', '/api/admin/news', ['news']], ['POST', '/api/admin/news', ['news']], ['PUT', '/api/admin/news/:id', ['news']], ['DELETE', '/api/admin/news/:id', ['news']], ['POST', '/api/admin/upload/news-image', ['news']], ['POST', '/api/admin/upload/news-file', ['news']], // ---------- 轮播管理 ---------- // ⚠️ /carousel/order 必须排在 /carousel/:id 之前 ['PUT', '/api/admin/carousel/order', ['carousel']], ['GET', '/api/admin/carousel', ['carousel']], ['POST', '/api/admin/carousel', ['carousel']], ['PUT', '/api/admin/carousel/:id', ['carousel']], ['DELETE', '/api/admin/carousel/:id', ['carousel']], ['POST', '/api/admin/upload/image', ['carousel']], // ---------- 成绩复核 ---------- ['GET', '/api/admin/review/search', ['review']], ['GET', '/api/admin/review/score/:group/:id', ['review']], ].map(([method, pattern, perms]) => ({ method, pattern, perms, re: toRegExp(pattern) })); /** * 解析请求需要哪些权限(任一即可)。 * * @param {string} method - HTTP 方法(大小写不敏感) * @param {string} path - 请求路径(不含 query) * @returns {string[]|null} 权限点数组;null = 未登记(调用方必须拒绝) */ function resolvePermission(method, path) { const m = String(method || '').toUpperCase(); const p = String(path || '').split('?')[0]; for (const rule of ROUTE_TABLE) { if (rule.method !== m) continue; if (rule.re.test(p)) return rule.perms; } return null; } /** * 解析数据库里的 permissions 列(JSON 数组文本)。 * 容错策略:解析失败 / 非数组 ⇒ 返回空数组(= 无权限),⛔ 绝不回落到「全权」。 * * @param {string|null|undefined} raw * @returns {string[]} */ function parsePermissions(raw) { if (raw === null || raw === undefined || raw === '') return []; let arr = raw; if (typeof raw === 'string') { try { arr = JSON.parse(raw); } catch (e) { return []; } } if (!Array.isArray(arr)) return []; return arr.filter(k => typeof k === 'string' && PERMISSION_KEY_SET.has(k)); } /** * 规整待写入的权限点:只保留已登记的 key,去重,按 PERMISSIONS 顺序排列。 * ⛔ 必须过滤未知值 —— 否则前端传错一个字符串就会被存进库, * 将来某个新权限点恰好同名时会「凭空获得权限」。 * * @param {string[]} list * @returns {string[]} */ function sanitizePermissions(list) { if (!Array.isArray(list)) return []; const set = new Set(list.filter(k => PERMISSION_KEY_SET.has(k))); return PERMISSION_KEYS.filter(k => set.has(k)); } /** * 判断某管理员是否满足给定权限(任一命中即可)。 * * ⚠️ 三种返回语义必须严格区分: * required === null ⇒ 未登记路由(由 resolvePermission 返回,调用方必须拒绝) * required === [] ⇒ **显式声明**「登录即可,无需业务权限」(如 /api/admin/me) * required === ['a','b'] ⇒ 具备任一即可 * * @param {{is_super?:number|boolean, permissions?:string[]|string}} admin * @param {string[]|null} required * @returns {boolean} */ function canAccess(admin, required) { if (!admin) return false; // 🔴 顺序不可换(R178 运行期判据 P3-2 抓到):**未登记路由对任何人都是拒绝**, // 包括最高管理员。若把 is_super 判在前面,`canAccess(super, null)` 会返回 true —— // 中间件虽然另有 `required === null ⇒ 403` 的前置分支,但那样「默认拒绝」就只活在 // 一处调用约定里:任何新调用方(例如前端按钮显隐的服务端预判)漏判即成后门。 // 此处作为第二道兜底,保证 null 永远不构成授权。 if (!Array.isArray(required)) return false; if (Number(admin.is_super) === 1) return true; // 空数组 = 显式放行(仅限登记为「登录即可」的接口) if (required.length === 0) return true; const owned = Array.isArray(admin.permissions) ? admin.permissions : parsePermissions(admin.permissions); return required.some(k => owned.includes(k)); } /** * 账号是否可被管理(改权限 / 重置口令 / 禁用 / 删除)。 * 最高管理员一律不可变动 —— 这是用户明确裁定的硬约束,集中在单点判断, * ⛔ 不得在各接口里各写一遍 `id === 1`。 * * @param {{is_super?:number|boolean}} admin * @returns {boolean} */ function isManageable(admin) { return !!admin && Number(admin.is_super) !== 1; } module.exports = { PERMISSIONS, PERMISSION_KEYS, PERMISSION_KEY_SET, ROUTE_TABLE, resolvePermission, parsePermissions, sanitizePermissions, canAccess, isManageable, MODULE, HIGH_RISK, };