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
@@ -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';
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user