Files
ClassHelper/docs/dev/permissions.md
T

13 KiB
Raw Blame History

title, description
title description
角色与权限 权限矩阵、科目维度的收口规则,以及"前端隐藏 ≠ 权限"这条纪律。

角色与权限

矩阵在哪

唯一一份在 packages/shared/src/permissions.ts:

  • 后端 lib/access.ts 复用它做断言;
  • 两端前端用它隐藏入口;
  • 验收脚本按它逐条断言。

所以不存在"前端一套后端一套"。改权限就改这一个文件(改完记得 pnpm build:shared)。

角色判定

角色只有三种,没有 STUDENT —— 学生不是账号(见数据模型)。 班级内的角色由「账号角色 + 与该班级的关系」共同决定:

角色 判定方式 可见范围
ADMIN 管理员 User.role = ADMIN 全部班级
班主任 HEAD Class.teacherId === 当前用户 本班
科任老师 SUBJECT 该班存在 Course.teacherId === 当前用户 被任课的班级
CLASS_DEVICE 班级端 班级码 + 班级密码登录(classSession) 本班(代全班读写个人数据)
NONE 与这个班没有任何关系 什么都看不到

判定函数是 resolveClassRole()。「科任」的判定源是 Course(班级 + 科目 + 教师), 不是那张已经没有的 ClassTeacher 协作表。

权限矩阵

下表描述角色与班级关系允许的范围;员工还必须拥有对应功能权限,管理员也不例外。 名单读取使用 roster:read / teacher:read,管理使用独立的 roster:manage / teacher:manage。 日志与客户端开发者模式不再单列无效权限键,服务端系统设置由 system:manage 控制。

操作 管理员 班主任 科任老师 班级端
班级创建 / 修改 / 删除 ✅ ❌ ❌ ❌
分配班主任与各科任课老师 ✅ ❌ ❌ ❌
学生名单管理(增删 / 导入 / 调班 / 转出) ✅ ✅(仅本班) ❌ ❌
教师录入(新建 / 导入名单 / 修改密码) ✅ ❌ ❌ ❌
毕业归档 / 学年升级 / 学期周次 ✅ ❌ ❌ ❌
数据库管理(备份 / 导入导出 / 一键切换) ✅ ❌ ❌ ❌
课表管理(增删改 / 时间配置导入) ✅ ✅(仅本班) ❌ ❌
布置作业(自己任教的科目) ✅ ✅(本班) ✅ ✅
叫人 / 发布通知 ✅ ✅ ✅ ✅
成绩录入 / 修改 / 导入(自己任教的科目) ✅ ✅(本班) ✅ ❌
查看本班成绩汇总 ✅ ✅ ✅ ✅
按学号查询学生成绩明细 ✅ ✅ ✅ 需班级开关
查看 ClassHelper 班级端在线状态 ✅ ✅ ✅ ❌
班级密码 / 班级码维护(PATCH /classes/:id/class-account) ✅ ❌ ❌ ❌

科目维度的收口(本次重构的核心规则)

作业与成绩不是"凡是能进这个班就能改",而是再按科目收一道:

情形 谁可以写
管理员 全部
挂了科目(courseId 有值) 该科的任课老师(Course.teacherId)
没挂科目(courseId 为空) 本班班主任
ClassHelper 班级端 只有作业可以(老师在教室随手录一条);成绩不给

于是:班主任若不教这一科,就改不了这一科的作业与成绩 —— 这正是需求 「班主任不自动拥有所有科目作业成绩编辑权,只有同时是该科科任老师时才有该科权限」。

实现在 lib/access.ts 的 assertCanManageSubjectContent(user, classId, courseId, action, { allowClassDevice }), 作业 / 成绩 / 叫人三处写入统一走它。

权限键与权限组(2026-10 起:权限组驱动,角色只是默认模板)

从权限组重构开始,功能级准入不再看角色,而看权限键:键目录同样是 packages/shared/src/permissions.ts 的 PERMISSION_KEYS(37 个,形如 domain:action, 中文名在 PERMISSION_LABELS、分组在 PERMISSION_CATEGORIES)。路由上挂的是

requirePermission('notification:publish'); // 需要全部列出的键
requireAnyPermission('grade:write', 'grade:import'); // 有任意一个即可

生效规则(四句话):

账号 有效权限
ADMIN 按权限组计算;其中 permission:manage / system:manage 不可被剥夺
CLASS_DEVICE(班级端) 空集,不受任何分组影响(它的能力来自班级维度断言)
被分配了权限分组 分组键的并集(可以比角色默认更少 —— 这就是"能收权")
没有被分配任何分组 使用可编辑的角色默认权限组

默认模板逐条镜像重构前的 requireRole,所以"不动分组"= 行为与以前完全一致。 实现见 packages/server/src/lib/permissions.ts 的 loadEffectivePermissions():它每个请求实时查库 (不缓存),因此管理员改完分组立刻生效,不需要等令牌过期 —— 与下面「鉴权中间件会回查数据库」是同一个理由。

管理接口(packages/server/src/modules/permissions/):

方法与路径 权限要求 说明
GET /api/permissions/me 登录即可 当前账号的有效权限与来源(前端据此显示/隐藏入口)
GET /api/permissions/catalog permission:manage 全部权限键(含中文名、分类、默认角色、不可移除标记)
GET/POST /api/permissions/groups、PATCH/DELETE /groups/:id permission:manage 分组 CRUD;内置分组(builtin=true)可改不可删
GET /api/permissions/users、PUT /api/permissions/users/:id/groups permission:manage 成员列表与「整组替换」式分配;管理员与教师可分配;班级端不参与

一条不能松的边界:权限组只管功能/路由级准入;班级维度的科目规则不可被权限组覆盖。 换句话说,给某人授了 homework:write 只代表"他能进布置作业这条路由", 具体到哪个班、哪个科目仍然由 assertCanManageSubjectContent 判(下一节)。 否则「班主任能布置别人科目的作业」这个老 bug 会从权限组这条新路重新长出来。

实现要点

路由级只做权限过滤:requirePermission('…')(需要列出的键全有)或 requireAnyPermission('…')(任意一个即可)。改造前这里是 requireRole('ADMIN','TEACHER'), 按键目录里的 PERMISSION_DEFAULT_ROLES 逐条映射过来,因此默认行为没有变化。 403 的文案会直接点名缺哪个键:「当前账号缺少「X」权限,请联系管理员在「权限组」里分配」。

班级维度的判断在服务层:src/lib/access.ts 提供

assertCanManageClasses    / assertCanAssignTeachers / assertCanManageRoster
assertCanManageSchedule   / assertCanPublishNotification
assertCanManageGrades     / assertCanManageSubjectContent

列表数据用 resolveClassScope() 收敛(classId in [...]),单条接口直接断言; 越权一律 403。

鉴权中间件会回查数据库(middleware/auth.ts):JWT 校验之后重新读一次账号, 所以改了角色 / 班级关系会立即生效,不用等令牌过期。班级端会话则回查 Class 表。

班级端的成绩查询开关:Class.studentGradeQueryEnabled(默认开启)。 关闭后班级端调 GET /grades/student/:studentNo 一律 403,教师端不受影响 —— 这就是需求「是否允许学生通过班级端查看本人明细,由教师或管理员开关控制」。

前端隐藏 ≠ 权限

前端的隐藏只是"别让人看见入口":

  • 教师登录时侧边栏不显示「班级管理 / 学生管理 / 教师管理 / 毕业归档 / 学期周次 / 数据库管理 / 权限组 / 客户端设备」;
  • 课表页的增删改按钮只对「管理员或本班班主任」显示;
  • 成绩页的录入 / 删除按钮对教师显示(科任也能用,具体到某一条由后端按科目判定)。

门控怎么写的:路由 meta.roles 做粗筛(角色),meta.permission 做细筛(权限键)—— packages/web-admin/src/router/index.ts 的 beforeEach 会 await auth.ensurePermissions() 再问 auth.can(key)(管理员同样按生效集合判断,仅保留权限和系统管理入口),不通过就 fail-closed 回仪表盘; 侧栏菜单项同理带 permission 字段(AdminLayout.vue 的 visibleMenuItems)。 auth.ensurePermissions() 读的就是 GET /api/permissions/me。

但这些页面直接输网址也进不去(路由 meta.roles 会把非管理员挡回仪表盘), 而且所有写入请求服务端都会再断言一次。绕过菜单、绕过前端,一样 403。

新增视图时别忘了两处:路由的 meta.roles + 服务层对应 service 里的 assertCanXxx。 只做其中一个都等于没做。

学生没有账号的两个反向约束

  1. 学生不能登录,而且根本不存在可以登录的学生账号。User 表里没有学生行, 拿学号当账号登录只会得到 401("工号或密码错误"),连"这个学号存在吗"都探测不出来。
  2. 学生没有任何密码接口。POST /students/:id/reset-password 已删除(调用返回 404), 新建学生也不设密码 —— Student 表里压根没有那一列。

默认权限组与客户端维护(2026-10-16)

管理员和教师各有一个默认权限组;未分配其他组时使用对应默认组,编辑后立即生效。 默认组可以改名、增减权限,但不能删除。分配多个组时取并集。 管理员始终保留 permission:manage 和 system:manage,防止失去管理入口。 班级端不参与账号权限组,班级与科目范围的检查保持生效。

「客户端」页统一显示设备心跳、版本、是否接受自动更新、最近指令结果和安装时机。 更新先从本服务器下载并校验 SHA256,再按所选时机安装:

  • 立即更新:下载完成后安装并重启客户端,会中断当前客户端界面。
  • 放学后更新:按服务器当天日期,等待所选最早时间与本班最后一节课结束时间的较晚者;上课中继续等待。
  • 空闲时更新:不上课且主窗口隐藏满五分钟后安装。

离线设备上线后补领更新;取消下发后,已下载的安装包不再自动安装。 客户端关闭自动更新时,任何下发策略都不会强制安装。 远控关机、重启、注销和锁屏仍需管理端确认及本机确认,取消或系统执行失败都会回执。

联动插件及相关接口已下线;升级迁移会删除旧联动设备和推送记录,保留业务课表、通知及离线 JSON 导入。 服务端兼容旧客户端的心跳字段读取,旧设备可领取修复版;第一次升级仍遵循旧版空闲安装条件,新策略需修复版客户端。