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

+1 -1
View File
@@ -89,7 +89,7 @@ export function getMaxTermWeeks(configured?: unknown): number {
return Number.isInteger(value) && value >= 1 && value <= 60 ? value : 30;
}
export type PermissionScope = 'SELF_CLASS' | 'GRADE' | 'DIVISION' | 'ALL';
export type PermissionScope = 'TEACHING' | 'HEAD' | 'GRADE' | 'DIVISION' | 'ALL';
export interface ReleaseProgressDto {
version: string;
stage: 'idle' | 'downloading' | 'verifying' | 'ready' | 'waiting' | 'installing' | 'failed';
+18
View File
@@ -493,6 +493,22 @@ export const SOCKET_EVENTS = {
connected: 'connected',
} as const;
/** 班级事件域;订阅资格按对应读能力逐域求值。 */
export const SOCKET_CHANNELS = ['core', 'homework', 'notification', 'grade', 'courseware'] as const;
export type SocketChannel = (typeof SOCKET_CHANNELS)[number];
export const SOCKET_EVENT_CHANNELS: Record<string, SocketChannel> = {
'class:updated': 'core',
'schedule:updated': 'core',
'homework:new': 'homework',
'homework:updated': 'homework',
'homework:status': 'homework',
'notification:new': 'notification',
'notification:deleted': 'notification',
'call:new': 'notification',
'grade:updated': 'grade',
'courseware:changed': 'courseware',
};
/** ClassIsland JSON 导入:时间点类型(与 TimeLayoutItem.TimeType 一致) */
export const CLASSISLAND_TIME_TYPES = {
class: 0,
@@ -560,6 +576,8 @@ export function buildCallTitle(studentName: string, teacherName: string): string
/** Socket.IO 房间名生成规则:与后端保持一致,客户端订阅时复用 */
export const SOCKET_ROOMS = {
classChannel: (classId: string, channel: SocketChannel): string => `class:${classId}:${channel}`,
/** @deprecated 仅供旧协议测试识别,生产不再订阅。 */
class: (classId: string): string => `class:${classId}`,
/**
* 会话自身的房间:老师/管理员是账号 id,ClassHelper 班级端是班级 id。
+11 -5
View File
@@ -2,14 +2,13 @@ import type { PermissionScope } from './class-plan.js';
/** 范围解析供保存、查询与展示共用;旧库空岗位范围会由调用方拒绝访问。 */
export function parsePermissionScope(scope: PermissionScope, value = '', knownGrades?: readonly string[]) {
if (scope === 'ALL') return { scope, scopeValue: '', grades: [], scopeLabel: '全校' };
if (scope === 'SELF_CLASS') {
if (value !== '' && value !== 'HEAD') throw new Error('关联班级范围只允许全部关联班级或仅班主任班');
if (scope === 'ALL' || scope === 'TEACHING' || scope === 'HEAD') {
if (value !== '') throw new Error('关联班级或全校范围不接受范围值');
return {
scope,
scopeValue: value,
scopeValue: '',
grades: [],
scopeLabel: value === 'HEAD' ? '仅班主任班' : '班主任与任课班级',
scopeLabel: scope === 'ALL' ? '全校' : scope === 'HEAD' ? '仅班主任班' : '班主任与任课班级',
};
}
if (scope !== 'GRADE' && scope !== 'DIVISION') throw new Error('未知的数据范围');
@@ -50,3 +49,10 @@ export function defaultPromotionPlan(classCount = 1): PromotionPlan {
resetTermWeeks: true,
};
}
/** 仅迁移和旧快照恢复使用;运行时不接受旧枚举。 */
export function migratePermissionScope(scope: string, scopeValue = '') {
return scope === 'SELF_CLASS'
? { scope: scopeValue === 'HEAD' ? 'HEAD' : 'TEACHING', scopeValue: '' }
: { scope, scopeValue };
}
+92 -7
View File
@@ -1,4 +1,5 @@
import type { UserRole } from './types.js';
import type { PermissionScope } from './class-plan.js';
/**
* 班级内的角色(相对"某一个班级"而言)。
@@ -168,6 +169,8 @@ export const PERMISSION_KEYS = [
// 班级与人员
'class:read',
'class:manage',
'class:account',
'class:settings',
'roster:read',
'roster:manage',
'teacher:read',
@@ -243,11 +246,22 @@ export const BUILTIN_TEACHER_GROUPS = [
{
id: 'builtin-head',
name: '班主任',
scope: 'SELF_CLASS',
scopeValue: 'HEAD',
keys: [...TEACHING_KEYS, 'client:read', 'client:manage'],
scope: 'HEAD',
scopeValue: '',
keys: [
...TEACHING_KEYS,
'class:read',
'class:account',
'class:settings',
'roster:manage',
'schedule:write',
'import:students',
'import:schedule',
'client:read',
'client:manage',
],
},
{ id: 'builtin-teacher', name: '教师', scope: 'SELF_CLASS', scopeValue: '', keys: TEACHING_KEYS },
{ id: 'builtin-teacher', name: '教师', scope: 'TEACHING', scopeValue: '', keys: TEACHING_KEYS },
{ id: 'builtin-grade', name: '级长', scope: 'GRADE', scopeValue: '', keys: LEAD_KEYS },
{ id: 'builtin-division', name: '部长', scope: 'DIVISION', scopeValue: '', keys: LEAD_KEYS },
] as const;
@@ -257,7 +271,9 @@ export const PERMISSION_LABELS: Record<PermissionKey, string> = {
'courseware:read': '查看与下载课件',
'courseware:write': '上传、设置可见范围与推送自己的课件',
'class:read': '查看班级与班级列表',
'class:manage': '创建 / 修改 / 删除班级(含班级账号密码)',
'class:manage': '创建 / 修改 / 删除班级',
'class:account': '管理本班班级码与密码(仅班主任)',
'class:settings': '管理本班学期设置(仅班主任)',
'roster:read': '查看学生名单',
'roster:manage': '学生名单管理(资料 / 调班 / 转入转出 / 停用)',
'teacher:read': '查看教师名单',
@@ -305,6 +321,8 @@ export const PERMISSION_CATEGORIES: readonly { title: string; keys: readonly Per
keys: [
'class:read',
'class:manage',
'class:account',
'class:settings',
'roster:read',
'roster:manage',
'teacher:read',
@@ -376,6 +394,8 @@ export const PERMISSION_DEFAULT_ROLES: Record<PermissionKey, readonly UserRole[]
'courseware:write': ['ADMIN', 'TEACHER'],
'class:read': ['ADMIN', 'TEACHER'],
'class:manage': ['ADMIN'],
'class:account': ['ADMIN', 'TEACHER'],
'class:settings': ['ADMIN', 'TEACHER'],
'roster:read': ['ADMIN', 'TEACHER'],
'roster:manage': ['ADMIN', 'TEACHER'],
'teacher:read': ['ADMIN', 'TEACHER'],
@@ -422,7 +442,7 @@ export const ALL_PERMISSION_KEYS: readonly PermissionKey[] = PERMISSION_KEYS;
/**
* 角色默认权限清单(由 {@link PERMISSION_DEFAULT_ROLES} 派生)。
*
* `ADMIN` 是全集(服务端还会强制补上 `permission:manage` / `system:manage`,防止把自己锁死)。
* `ADMIN` 固定为全集,不由模板限制。
*/
export const ROLE_DEFAULT_PERMISSIONS: Record<UserRole, readonly PermissionKey[]> = {
ADMIN: PERMISSION_KEYS,
@@ -460,7 +480,7 @@ export const PERMISSION_MATRIX: readonly {
subject: boolean;
classDevice?: boolean;
}[] = [
{ action: '学年升级(默认管理员,可按权限组授权)', admin: true, head: false, subject: false },
{ action: '学年升级(仅管理员)', admin: true, head: false, subject: false },
{ action: '班级创建 / 修改 / 删除', admin: true, head: false, subject: false },
{ action: '分配班主任与各科科任老师', admin: true, head: false, subject: false },
{ action: '学生名单管理(增删 / 导入 / 调班)', admin: true, head: true, subject: false },
@@ -474,3 +494,68 @@ export const PERMISSION_MATRIX: readonly {
{ action: '按学号查询学生成绩明细', admin: true, head: true, subject: true, classDevice: true },
{ action: '查看 ClassHelper 班级端在线状态', admin: true, head: true, subject: true },
];
/** 能力目录:管理员能力不可下放,关系提示不替代服务层数据校验。 */
export const PERMISSION_SCOPES = ['TEACHING', 'HEAD', 'GRADE', 'DIVISION', 'ALL'] as const;
export interface PermissionMeta {
kind: 'admin' | 'class' | 'org';
risk: 'normal' | 'high';
relation: 'head' | 'subject' | 'publish' | 'creator' | 'none';
allowedScopes: readonly PermissionScope[];
defaultRoles: readonly UserRole[];
label: string;
category: string;
}
const ADMIN_KEYS: readonly PermissionKey[] = [
'class:manage',
'teacher:manage',
'permission:manage',
'system:manage',
'database:manage',
'update:force',
'ai:manage',
'import:teachers',
'import:class-teachers',
'timetable:manage',
'promotion:manage',
'archive:manage',
'term:write',
'client:update',
];
const ORG_KEYS: readonly PermissionKey[] = ['teacher:read', 'update:check'];
const HIGH_KEYS: readonly PermissionKey[] = [
...ADMIN_KEYS,
'class:account',
'client:manage',
'client:update-push',
'remote:control',
'import:students',
'import:schedule',
'grade:import',
];
export const PERMISSION_META: Record<PermissionKey, PermissionMeta> = Object.fromEntries(
PERMISSION_KEYS.map((key) => {
const kind = ADMIN_KEYS.includes(key) ? 'admin' : ORG_KEYS.includes(key) ? 'org' : 'class';
const relation = ['class:account', 'class:settings'].includes(key)
? 'head'
: ['homework:write', 'grade:write', 'grade:import'].includes(key)
? 'subject'
: ['notification:publish', 'call:create'].includes(key)
? 'publish'
: key === 'notification:delete'
? 'creator'
: 'none';
return [
key,
{
kind,
relation,
risk: HIGH_KEYS.includes(key) ? 'high' : 'normal',
allowedScopes: kind === 'class' ? PERMISSION_SCOPES : [],
defaultRoles: PERMISSION_DEFAULT_ROLES[key],
label: PERMISSION_LABELS[key],
category: PERMISSION_CATEGORIES.find((c) => c.keys.includes(key))?.title ?? '',
},
];
}),
) as unknown as Record<PermissionKey, PermissionMeta>;
+51 -13
View File
@@ -12,7 +12,8 @@ import type {
// 只导入类型(PermissionKey 由 permissions.ts 的 PERMISSION_KEYS 推导):
// 两个文件之间是**类型层面**的相互引用,编译后都会被抹掉,运行时不存在循环依赖。
import type { PermissionKey } from './permissions.js';
import type { PermissionKey, PermissionMeta } from './permissions.js';
import type { SocketChannel } from './constants.js';
/**
* 账号角色:管理员 / 教师 / ClassHelper 班级端。
@@ -495,14 +496,12 @@ export interface CreateClassRequest {
}
export interface UpdateClassRequest {
/** 本班学期开始日期(YYYY-MM-DD,第 1 教学周的周一);留空串表示回落到全局配置 */
termStartDate?: string | null;
/** 入学年份(有名无实的旧数据需要补填;填了会重算 name) */
enrollmentYear?: number;
/** 班号(同上) */
classIndex?: number;
/** 本学期教学周数(1~30,仅管理员可调) */
}
export interface UpdateClassSettingsRequest {
termWeeks?: number;
termStartDate?: string | null;
}
/** 班级端成绩查询开关 */
@@ -1205,6 +1204,10 @@ export interface IslandState {
/* ------------------------------------------------------------------ 仪表盘 */
export interface DashboardSummary {
sections: Record<
'classes' | 'students' | 'courses' | 'schedules' | 'homeworks' | 'notifications' | 'grades',
boolean
>;
classCount: number;
studentCount: number;
courseCount: number;
@@ -1325,8 +1328,11 @@ export interface TimeLayoutImportResult {
/** 客户端 -> 服务端事件名 */
export interface ClientToServerEvents {
'class:join': (classId: string, ack?: (result: { ok: boolean; message?: string }) => void) => void;
'class:leave': (classId: string) => void;
'class:join': (
classId: string | { classId: string; channels?: SocketChannel[] },
ack?: (result: { ok: boolean; message?: string }) => void,
) => void;
'class:leave': (classId: string | { classId: string; channels?: SocketChannel[] }) => void;
ping: (ack?: () => void) => void;
}
@@ -1465,6 +1471,12 @@ export interface UpdateInfo {
* **没有任何分组**时回落角色默认模板(逐条镜像权限组上线前各路由的 requireRole)。
*/
export interface PermissionGroupDto {
defaultScope: ContractPermissionScope;
defaultScopeValue: string;
defaultScopeLabel: string;
kind: 'builtin' | 'custom' | 'default';
inactiveKeys: PermissionKey[];
warnings: string[];
scope?: ContractPermissionScope;
scopeValue?: string;
scopeLabel: string;
@@ -1481,14 +1493,32 @@ export interface PermissionGroupDto {
}
/** 权限页里的一个成员(管理员/教师账号) */
export interface PermissionAssignment {
groupId: string;
scope: ContractPermissionScope;
scopeValue: string;
}
export interface EffectivePermissionsDto {
permissions: PermissionKey[];
entries: {
key: PermissionKey;
scopes: string[];
classes: { id: string; name: string }[];
classCount: number;
}[];
conflicts: string[];
headOnlyKeys: PermissionKey[];
}
export interface PermissionMemberDto {
fixed: boolean;
assignments: (PermissionAssignment & { groupName: string; scopeLabel: string })[];
id: string;
username: string;
name: string;
role: UserRole;
/** 所属权限组;空数组 = 走角色默认模板 */
/** 所属模板的派生 id;空数组 = 教师默认模板 */
groupIds: string[];
/** 生效权限数量;管理员在分组并集之外始终保留两个管理入口 */
/** 生效能力数量;管理员固定为全集 */
permissionCount: number;
permissions: PermissionKey[];
scopeLabels: string[];
@@ -1496,6 +1526,7 @@ export interface PermissionMemberDto {
/** `GET /api/permissions/catalog`:权限键目录(与 shared 的常量一一对应,供权限页渲染) */
export interface PermissionCatalogDto {
meta: Record<PermissionKey, PermissionMeta>;
keys: PermissionKey[];
labels: Record<PermissionKey, string>;
categories: { title: string; keys: PermissionKey[] }[];
@@ -1503,7 +1534,7 @@ export interface PermissionCatalogDto {
defaultRoles: Record<PermissionKey, UserRole[]>;
/** 每个角色的默认模板("没有分组时会是什么") */
roleDefaults: Record<UserRole, PermissionKey[]>;
/** 管理员不可被剥夺的键(前端置灰、服务端也会合并回去) */
/** 兼容字段:管理员固定拥有的全部目录键 */
unremovable: PermissionKey[];
}
@@ -1525,7 +1556,8 @@ export interface UpdatePermissionGroupRequest {
/** `PUT /api/permissions/users/:id/groups`:整体替换某账号的权限组 */
export interface AssignUserGroupsRequest {
groupIds: string[];
groupIds?: string[];
assignments?: PermissionAssignment[];
}
/**
@@ -1535,9 +1567,15 @@ export interface AssignUserGroupsRequest {
* 真正的拦截始终在服务端。
*/
export interface MyPermissionsDto {
/** 各能力实际班级集合,供界面选择器与按钮使用;服务端仍须再授权。 */
classScopes?: Partial<Record<PermissionKey, string[]>>;
manageScopes?: Partial<Record<PermissionKey, string[]>>;
/** 结合班主任、任课及发布者关系后的动作范围,用于维护页面入口。 */
actionScopes?: Partial<Record<PermissionKey, string[]>>;
allScopeKeys?: PermissionKey[];
role: UserRole;
permissions: PermissionKey[];
/** true = 权限来自权限组;false = 走角色默认模板(未分配任何组) */
/** true = 教师有显式分配;管理员和班级端恒为 false */
fromGroups: boolean;
}