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
@@ -1,3 +1,5 @@
|
||||
import type { PermissionKey } from '@classhelper/shared';
|
||||
import { assertAdmin, assertPermission, classIdsFor } from '../../lib/authz/index.js';
|
||||
import { activeStudentWhere } from '../../lib/students.js';
|
||||
import {
|
||||
dayKeyLocal,
|
||||
@@ -84,6 +86,7 @@ export async function updateAiSettings(
|
||||
user: TokenPayload,
|
||||
input: UpdateAiSettingsInput,
|
||||
): Promise<AiSettingsDto> {
|
||||
assertAdmin(user);
|
||||
if (input.enabled !== undefined) await setSetting(SETTING_KEYS.aiEnabled, input.enabled ? 'true' : 'false');
|
||||
if (input.provider !== undefined) await setSetting(SETTING_KEYS.aiProvider, input.provider);
|
||||
if (input.baseUrl !== undefined) await setSetting(SETTING_KEYS.aiBaseUrl, input.baseUrl.trim());
|
||||
@@ -105,17 +108,33 @@ interface AssistantContext {
|
||||
}
|
||||
|
||||
async function resolveContext(user: TokenPayload, classId?: string): Promise<AssistantContext> {
|
||||
if (classId) await assertClassAccess(user, classId);
|
||||
const classIds = await getAccessibleClassIds(user);
|
||||
if (classId) await assertClassAccess(user, classId, 'ai:use');
|
||||
await assertPermission(user, 'ai:use');
|
||||
const candidates = new Set(
|
||||
(
|
||||
await Promise.all(
|
||||
['notification:publish', 'homework:write', 'call:create'].map((key) =>
|
||||
classIdsFor(user, key as PermissionKey),
|
||||
),
|
||||
)
|
||||
).flat(),
|
||||
);
|
||||
const classIds = (await getAccessibleClassIds(user, 'ai:use')).filter((id) => candidates.has(id));
|
||||
const classes = await prisma.class.findMany({
|
||||
where: { id: { in: classIds } },
|
||||
where: {
|
||||
id: { in: classIds },
|
||||
...(user.role !== 'ADMIN'
|
||||
? { OR: [{ teacherId: user.sub }, { courses: { some: { teacherId: user.sub } } }] }
|
||||
: {}),
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' },
|
||||
take: 80,
|
||||
});
|
||||
const courses = classId
|
||||
? await prisma.course.findMany({ where: { classId }, select: { id: true, name: true } })
|
||||
: [];
|
||||
const courses =
|
||||
classId && classIds.includes(classId)
|
||||
? await prisma.course.findMany({ where: { classId }, select: { id: true, name: true } })
|
||||
: [];
|
||||
return { classes, courses };
|
||||
}
|
||||
|
||||
@@ -345,7 +364,7 @@ export async function interpret(user: TokenPayload, input: AiInterpretInput): Pr
|
||||
}
|
||||
|
||||
if (typeof params.classId === 'string') {
|
||||
await assertClassAccess(user, params.classId);
|
||||
await assertClassAccess(user, params.classId, 'ai:use');
|
||||
if (typeof params.courseId === 'string') {
|
||||
const course = await prisma.course.findFirst({
|
||||
where: { id: params.courseId, classId: params.classId },
|
||||
|
||||
Reference in new issue
Block a user