feat(authz): 重写权限模板分配与班级授权边界
拆分能力、分配范围和业务关系,固定管理员能力并收紧组织级写入口。 补齐内容、课表、设备会话和实时通道授权,兼容旧库分配及历史快照。 重写权限分配预览,按实际可操作范围展示维护入口,合并学生入口并独立学年升级页。 增加授权攻击矩阵及静态门禁,更新隔离回归与开发契约。 验证:授权矩阵 23 段通过,Node 24 下 Web 63/63,类型检查、lint 与文档严格构建通过。
This commit is contained in:
1 parent
682818dd4a
commit
66be3a63b1
115 files changed
+4463
-2747
No files matched your search
@@ -0,0 +1,205 @@
|
||||
/** 只创建独立临时班级;验证任课教师的只读详情不能显示班主任维护入口。 */
|
||||
async function checkClassRelations(win, options, record) {
|
||||
const base = `${new URL(options.url).origin}/api`;
|
||||
async function request(route, token, method = 'GET', body) {
|
||||
const response = await fetch(`${base}${route}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
const result = await response.json();
|
||||
return { status: response.status, data: result.data, message: result.message };
|
||||
}
|
||||
async function data(route, token, method, body) {
|
||||
const result = await request(route, token, method, body);
|
||||
if (result.status >= 400) throw new Error(`${route}: ${result.status} ${result.message}`);
|
||||
return result.data;
|
||||
}
|
||||
async function wait(expression) {
|
||||
const end = Date.now() + 8000;
|
||||
while (Date.now() < end) {
|
||||
if (await win.webContents.executeJavaScript(expression)) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error('班级详情未就绪');
|
||||
}
|
||||
const teacher = await data('/auth/login', null, 'POST', {
|
||||
username: options.username,
|
||||
password: options.password,
|
||||
});
|
||||
const admin = await data('/auth/login', null, 'POST', {
|
||||
username: options.adminUsername,
|
||||
password: options.adminPassword,
|
||||
});
|
||||
let other, student;
|
||||
try {
|
||||
other = await data('/classes', admin.token, 'POST', {
|
||||
grade: '验证',
|
||||
enrollmentYear: 2098,
|
||||
classIndex: 1 + Math.floor(Math.random() * 99),
|
||||
teacherId: admin.user.id,
|
||||
code: `REL${Date.now().toString(36).toUpperCase()}`,
|
||||
});
|
||||
await data(`/classes/${other.id}/subject-teachers`, admin.token, 'PUT', {
|
||||
assignments: [{ subjectName: '数学', teacherId: teacher.user.id }],
|
||||
});
|
||||
student = await data('/students', admin.token, 'POST', {
|
||||
classId: other.id,
|
||||
studentNo: `REL${Date.now()}`,
|
||||
name: '独立关系验证学生',
|
||||
});
|
||||
const detail = await data(`/classes/${other.id}`, teacher.token);
|
||||
const statuses = await Promise.all([
|
||||
request(`/students/${student.id}`, teacher.token, 'PATCH', { name: student.name }),
|
||||
request(`/classes/${other.id}/settings`, teacher.token, 'PATCH', { termWeeks: detail.termWeeks }),
|
||||
request(`/classes/${other.id}/student-grade-query`, teacher.token, 'PATCH', {
|
||||
enabled: detail.studentGradeQueryEnabled,
|
||||
}),
|
||||
request(`/courses/${detail.courses[0].id}`, teacher.token, 'DELETE'),
|
||||
]);
|
||||
record(
|
||||
'任课教师跨班修改学生、学期设置、成绩开关及删除课程均返回 403',
|
||||
statuses.every((result) => result.status === 403),
|
||||
statuses.map((result) => result.status).join('/'),
|
||||
);
|
||||
|
||||
await win.loadURL(`${new URL(options.url).origin}/classes`);
|
||||
await wait(
|
||||
`Array.from(document.querySelectorAll('.el-table__row')).some(row => row.textContent.includes(${JSON.stringify(other.name)}))`,
|
||||
);
|
||||
await win.webContents.executeJavaScript(
|
||||
`Array.from(document.querySelectorAll('.el-table__row')).find(row => row.textContent.includes(${JSON.stringify(other.name)})).querySelector('button').click()`,
|
||||
);
|
||||
await wait(
|
||||
`Boolean(document.querySelector('.el-drawer .el-tabs')) && !document.querySelector('.el-drawer .el-loading-mask')`,
|
||||
);
|
||||
const peer = await win.webContents.executeJavaScript(`(() => {
|
||||
const drawer = document.querySelector('.el-drawer');
|
||||
const pane = name => drawer.querySelector('#pane-' + name);
|
||||
const writes = Array.from(pane('students').querySelectorAll('button')).filter(button => ['添加学生', '移出'].includes(button.textContent.trim()));
|
||||
return { studentsVisible: pane('students').textContent.includes('独立关系验证学生'), studentWrites: writes.length, courseDeletes: Array.from(pane('courses').querySelectorAll('button')).filter(button => button.textContent.trim() === '删除').length, courseCreate: Boolean(pane('courses').querySelector('input')), gradeQueryDisabled: pane('settings').querySelector('.el-switch')?.classList.contains('is-disabled') };
|
||||
})()`);
|
||||
record(
|
||||
'任课教师可查看别班详情,名单/课程写入口隐藏且成绩开关只读',
|
||||
peer.studentsVisible &&
|
||||
peer.studentWrites === 0 &&
|
||||
peer.courseDeletes === 0 &&
|
||||
!peer.courseCreate &&
|
||||
peer.gradeQueryDisabled,
|
||||
JSON.stringify(peer),
|
||||
);
|
||||
|
||||
const classes = await data('/classes', teacher.token);
|
||||
const own = classes.find((row) => row.teacher?.id === teacher.user.id);
|
||||
if (!own) throw new Error('缺少独立的班主任班级');
|
||||
await win.loadURL(`${new URL(options.url).origin}/classes`);
|
||||
await wait(
|
||||
`Array.from(document.querySelectorAll('.el-table__row')).some(row => row.textContent.includes(${JSON.stringify(own.name)}))`,
|
||||
);
|
||||
await win.webContents.executeJavaScript(
|
||||
`Array.from(document.querySelectorAll('.el-table__row')).find(row => row.textContent.includes(${JSON.stringify(own.name)})).querySelector('button').click()`,
|
||||
);
|
||||
await wait(
|
||||
`Boolean(document.querySelector('.el-drawer .el-tabs')) && !document.querySelector('.el-drawer .el-loading-mask')`,
|
||||
);
|
||||
const owner = await win.webContents.executeJavaScript(`(() => {
|
||||
const drawer = document.querySelector('.el-drawer');
|
||||
return { studentWrite: Array.from(drawer.querySelectorAll('#pane-students button')).some(button => button.textContent.trim() === '添加学生'), courseCreate: Boolean(drawer.querySelector('#pane-courses input')), gradeQueryEnabled: !drawer.querySelector('#pane-settings .el-switch')?.classList.contains('is-disabled') };
|
||||
})()`);
|
||||
record(
|
||||
'班主任本人班级仍可维护名单、课程和成绩查询开关',
|
||||
owner.studentWrite && owner.courseCreate && owner.gradeQueryEnabled,
|
||||
JSON.stringify(owner),
|
||||
);
|
||||
const members = await data('/permissions/users', admin.token);
|
||||
const previous = members.find((member) => member.id === teacher.user.id).assignments;
|
||||
const groups = [];
|
||||
try {
|
||||
const allClasses = await data('/classes', admin.token);
|
||||
const unrelatedGrade = allClasses.find(
|
||||
(row) => row.grade !== own.grade && row.teacher?.id !== teacher.user.id,
|
||||
)?.grade;
|
||||
if (!unrelatedGrade) throw new Error('缺少不担任班主任的隔离年级');
|
||||
const reads = await data('/permissions/groups', admin.token, 'POST', {
|
||||
name: `菜单只读${Date.now()}`,
|
||||
scope: 'ALL',
|
||||
keys: [
|
||||
'dashboard:read',
|
||||
'class:read',
|
||||
'roster:read',
|
||||
'schedule:read',
|
||||
'term:read',
|
||||
'homework:read',
|
||||
'notification:read',
|
||||
'grade:read',
|
||||
'courseware:read',
|
||||
'client:read',
|
||||
],
|
||||
});
|
||||
groups.push(reads.id);
|
||||
const limited = await data('/permissions/groups', admin.token, 'POST', {
|
||||
name: `菜单空关系${Date.now()}`,
|
||||
scope: 'GRADE',
|
||||
scopeValue: unrelatedGrade,
|
||||
keys: ['class:settings'],
|
||||
});
|
||||
groups.push(limited.id);
|
||||
await data(`/permissions/users/${teacher.user.id}/groups`, admin.token, 'PUT', {
|
||||
assignments: [
|
||||
{ groupId: reads.id, scope: 'ALL', scopeValue: '' },
|
||||
{ groupId: limited.id, scope: 'GRADE', scopeValue: unrelatedGrade },
|
||||
],
|
||||
});
|
||||
const restricted = await data('/permissions/me', teacher.token);
|
||||
await win.loadURL(`${new URL(options.url).origin}/dashboard`);
|
||||
await wait(
|
||||
`Boolean(document.querySelector('.page-title')) && document.querySelectorAll('.el-menu-item').length > 0`,
|
||||
);
|
||||
const labels = await win.webContents.executeJavaScript(
|
||||
`Array.from(document.querySelectorAll('.el-menu-item')).map(node => node.textContent.trim())`,
|
||||
);
|
||||
const hidden = [
|
||||
'班级管理',
|
||||
'学生管理',
|
||||
'课表档案',
|
||||
'学期周次',
|
||||
'作业发布',
|
||||
'通知发布',
|
||||
'成绩录入',
|
||||
'课件',
|
||||
'客户端',
|
||||
];
|
||||
record(
|
||||
'只有读权限或写键无可操作班级时隐藏维护页面',
|
||||
restricted.permissions.includes('class:settings') &&
|
||||
restricted.actionScopes['class:settings'].length === 0 &&
|
||||
hidden.every((label) => !labels.includes(label)),
|
||||
labels.join(','),
|
||||
);
|
||||
} finally {
|
||||
await data(`/permissions/users/${teacher.user.id}/groups`, admin.token, 'PUT', {
|
||||
assignments: previous.map(({ groupId, scope, scopeValue }) => ({ groupId, scope, scopeValue })),
|
||||
});
|
||||
for (const id of groups) await data(`/permissions/groups/${id}`, admin.token, 'DELETE');
|
||||
}
|
||||
await win.loadURL(`${new URL(options.url).origin}/term-weeks`);
|
||||
await wait(
|
||||
`document.querySelector('.page-title')?.textContent.trim() === '学期周次' && document.querySelector('.page').textContent.includes('当前第') && !document.querySelector('.el-loading-mask')`,
|
||||
);
|
||||
const partial = await win.webContents.executeJavaScript(`(() => ({
|
||||
menu: Array.from(document.querySelectorAll('.el-menu-item')).some(node => node.textContent.trim() === '学期周次'),
|
||||
own: document.querySelector('.page').textContent.includes(${JSON.stringify(own.name)}),
|
||||
editable: Array.from(document.querySelectorAll('.page button')).some(node => node.textContent.trim() === '保存'),
|
||||
dateEnabled: !document.querySelector('.page .el-date-editor input')?.disabled,
|
||||
}))()`);
|
||||
record(
|
||||
'部分班级可维护时保留周次入口,进入后默认选中本人可编辑班级',
|
||||
partial.menu && partial.own && partial.editable && partial.dateEnabled,
|
||||
JSON.stringify(partial),
|
||||
);
|
||||
} finally {
|
||||
if (other && student) await data(`/classes/${other.id}/students/${student.id}`, admin.token, 'DELETE');
|
||||
if (other) await data(`/classes/${other.id}`, admin.token, 'DELETE');
|
||||
}
|
||||
}
|
||||
module.exports = { checkClassRelations };
|
||||
Reference in new issue
Block a user