feat(authz): 重写权限模板分配与班级授权边界

拆分能力、分配范围和业务关系,固定管理员能力并收紧组织级写入口。
补齐内容、课表、设备会话和实时通道授权,兼容旧库分配及历史快照。
重写权限分配预览,按实际可操作范围展示维护入口,合并学生入口并独立学年升级页。
增加授权攻击矩阵及静态门禁,更新隔离回归与开发契约。

验证:授权矩阵 23 段通过,Node 24 下 Web 63/63,类型检查、lint 与文档严格构建通过。
This commit is contained in:
LoveChengke committed 2026-10-31 09:40:23 +08:00
1 parent 682818dd4a
commit 66be3a63b1
115 files changed
+4463 -2747

No files matched your search

+20 -19
View File
@@ -28,12 +28,12 @@ const groups = (await api('/permissions/groups', admin)).data;
const teacherDefault = groups.find((g) => g.defaultRole === 'TEACHER');
const adminDefault = groups.find((g) => g.defaultRole === 'ADMIN');
const key = `api-regression-${Date.now()}`;
let deviceId, scheduleId, staffDeviceId;
let deviceId, scheduleId, staffDeviceId, foreignClassId;
const version = `99.0.0-regression-${Date.now()}`;
const releaseDir = path.join(root, 'packages/data/client-releases', version);
const releaseName = `ClassHelper-${version}-x64-client-setup.exe`;
try {
check('角色默认组已创建且可以识别对应角色', teacherDefault && adminDefault);
check('只有教师默认组,管理员没有可编辑默认组', teacherDefault && !adminDefault);
await api(`/permissions/groups/${teacherDefault.id}`, admin, { name: '回归教师默认组', keys: [] }, 'PATCH');
let mine = await api('/permissions/me', teacher);
check('未分组教师使用数据库默认组,空组真正收回权限', mine.data.permissions.length === 0);
@@ -47,15 +47,11 @@ try {
mine = await api('/permissions/me', teacher);
check('默认组改名后继续生效,可赋予远控与推送权限', mine.data.permissions.includes('remote:control'));
check('拥有推送权限的教师可读取安装包列表', (await api('/update/client/releases', teacher)).status === 200);
await api(`/permissions/groups/${adminDefault.id}`, admin, { keys: [] }, 'PATCH');
mine = await api('/permissions/me', admin);
check(
'管理员默认组可收权,保留两个防锁死入口',
mine.data.permissions.length === 2 &&
mine.data.permissions.includes('permission:manage') &&
mine.data.permissions.includes('system:manage'),
'管理员固定全集,不受教师模板修改影响',
mine.data.permissions.includes('teacher:manage') && mine.data.permissions.includes('client:update'),
);
await api(`/permissions/groups/${adminDefault.id}`, admin, { keys: adminDefault.keys }, 'PATCH');
const beat = await api('/clients/heartbeat', classToken, {
agentProtocol: 2,
@@ -112,13 +108,21 @@ try {
fs.writeFileSync(path.join(releaseDir, releaseName), '用于下载校验的测试载荷,不是安装器');
const registered = await api('/update/client/releases', admin, { version, fileName: releaseName });
check('本地缓存安装包可登记', registered.status === 201);
staffDeviceId = (await api('/clients/heartbeat', admin, { agentProtocol: 2, deviceKey: `${key}-staff` }))
.data.deviceId;
const deniedCommand = await api('/clients/commands', teacher, { deviceIds: [staffDeviceId], kind: 'lock' });
const foreignClass = (await api('/classes', admin, { grade: '高一', enrollmentYear: 2035, classIndex: 99 }))
.data;
foreignClassId = foreignClass.id;
const otherClassToken = (
await api('/auth/class-login', null, { code: foreignClass.code, password: '123456' })
).data.token;
staffDeviceId = (
await api('/clients/heartbeat', otherClassToken, { agentProtocol: 2, deviceKey: `${key}-other-class` })
).data.deviceId;
check(
'教师获远控权限后仍不能操作班级范围外设备',
deniedCommand.data.failed === 1 && deniedCommand.data.succeeded === 0,
'管理员个人会话不能冒充设备心跳',
(await api('/clients/heartbeat', admin, { agentProtocol: 2, deviceKey: `${key}-staff` })).status === 403,
);
const deniedCommand = await api('/clients/commands', teacher, { deviceIds: [staffDeviceId], kind: 'lock' });
check('教师获远控权限后仍不能操作班级范围外设备', deniedCommand.status === 403);
check(
'教师获更新权限后不能越过班级范围推送',
(await api('/clients/updates', teacher, { deviceIds: [staffDeviceId], version, mode: 'immediate' }))
@@ -212,12 +216,6 @@ try {
(await api(`/classes/${classLogin.data.user.classId}/notification-channel`, classToken)).status === 404,
);
} finally {
await api(
`/permissions/groups/${adminDefault.id}`,
admin,
{ name: adminDefault.name, keys: adminDefault.keys },
'PATCH',
);
await api(
`/permissions/groups/${teacherDefault.id}`,
admin,
@@ -226,8 +224,11 @@ try {
);
if (deviceId) await api(`/clients/devices/${deviceId}`, admin, undefined, 'DELETE');
if (staffDeviceId) await api(`/clients/devices/${staffDeviceId}`, admin, undefined, 'DELETE');
if (foreignClassId) await api(`/classes/${foreignClassId}`, admin, undefined, 'DELETE');
if (scheduleId) await api(`/schedules/${scheduleId}`, admin, undefined, 'DELETE');
await api(`/update/client/releases/${version}`, admin, undefined, 'DELETE');
if (!path.resolve(releaseDir).startsWith(path.resolve(root, 'packages/data/client-releases') + path.sep))
throw new Error('拒绝清理交付目录外的路径');
fs.rmSync(releaseDir, { recursive: true, force: true });
}
console.log(`客户端 API 回归:${passed}/${passed} 项通过`);