Files
Edu/packages/shared-ts/src/permission-bitmap.ts
SpecialX cfb7b005fd feat(portal-shell): v2.1 P0 auth + middleware + login + graphql proxy
- 新增 ARCHITECTURE.md v3.0:portal-shell 架构权威文档
  涵盖 §3.4 V3-A2/A3 认证链、§4 GraphQL 联邦、§5 安全、
  §6 部署、§10 P0-P3 验收清单
- 新增 middleware.ts:认证 + 路由门禁
  httpOnly cookie edu_session(JWT)读取
  DEV_MODE 合成 dev-user/teacher 身份(NODE_ENV!=production &&
  NEXT_PUBLIC_DEV_MODE=true)
  生产模式 jose JWKS RS256 验签(iss/aud 校验)
  路由权限位图注入 x-user-id/x-user-role/x-user-permissions 头
  /shell/** 强制 checkRoutePermission,拒绝跳 /shell/forbidden
- 新增 instrumentation.ts:生产环境 DEV_MODE 强制 false
  防止生产环境误开 DEV_MODE 合成身份
- 新增 app/api/auth/login/route.ts + logout/route.ts
  登录走 api-gateway /v1/iam/login
  设置 httpOnly + Secure + SameSite=Strict cookie
- 新增 app/api/graphql/route.ts:同域 GraphQL 代理
  转发到 apollo-router,注入 router-authorization 头
- 新增 app/login/page.tsx + login-form.tsx
  zod 表单校验,next 参数支持
- 新增 app/shell/forbidden/page.tsx:403 页面
- 更新 route-permissions.ts:补全 P0 路由权限映射
- 更新 permission-bitmap.ts(shared-ts):位图编码/解码
- 更新 apollo-client.ts:DEV_MODE APQ 关闭,错误处理
- 更新 config-fetcher.ts:config-service 直连降级
- 更新 ApolloProvider.tsx:SSR/RSC 兼容
- 更新 eslint.config.js:design-tokens/no-hardcoded-fonts
  白名单调整
2026-07-22 11:35:36 +08:00

279 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 权限位图编解码(对齐 CICD 项目 permission-bitmap.ts
*
* 核心价值:将 N 个权限点压缩为 base36 字符串JWT cookie 体积减少 ~99%。
* - 67 权限点数组JSON ~1.1KB)→ base36 字符串(~14 字符)
* - Edge Runtime 单点检查 hasPermissionInBitmap 无需完整解码
*
* 关键约束(不可破坏):
* - PERMISSION_BITMAP_ORDER 顺序一经确定不可变,新增权限只能追加末尾
* - 因 Number 仅支持 53 bit 精度,使用 BigInt 实现
* - 未知权限静默忽略,无效字符返回空数组
*
* 编码原理:
* - 每个权限点对应一个 bit 位(按 ORDER 数组下标)
* - permissions 数组 → BigInt每个有权限的 bit 置 1→ base36 字符串
* - 解码base36 → BigInt → 遍历 ORDERbit 为 1 的权限加入结果
*
* 关联project_rules §3.1(前端禁止 role === "xxx" 硬编码)、
* portal-shell README v2.0 §3.3 三层安全边界
*/
/**
* 权限点位图顺序(一经确定不可变,新增只能追加末尾)
*
* 命名规范:`<RESOURCE>_<ACTION>`F7 裁决)
* 数据范围后缀:`_OWN`/`_CHILD`(如 `GRADE_READ_CHILD`
*
* 注意:顺序变更会破坏所有已签发的 JWT cookie只能在版本升级时追加。
*/
export const PERMISSION_BITMAP_ORDER = [
// ── 仪表盘5─────────────────────────────────────────
"DASHBOARD_ADMIN_READ",
"DASHBOARD_TEACHER_READ",
"DASHBOARD_STUDENT_READ",
"DASHBOARD_PARENT_READ",
"DASHBOARD_READ",
// ── 用户管理4──────────────────────────────────────
"USER_MANAGE",
"USER_CREATE",
"USER_UPDATE",
"USER_DELETE",
// ── 角色权限4──────────────────────────────────────
"ROLE_READ",
"ROLE_MANAGE",
"PERMISSION_READ",
"PERMISSION_MANAGE",
// ── 审计与邀请3────────────────────────────────────
"AUDIT_LOG_READ",
"INVITATION_CODE_MANAGE",
"INVITATION_CODE_CREATE",
// ── 学校设置2──────────────────────────────────────
"SCHOOL_READ",
"SCHOOL_MANAGE",
// ── 班级与年级4────────────────────────────────────
"CLASS_READ",
"CLASS_MANAGE",
"GRADE_READ",
"GRADE_MANAGE",
// ── 考试5──────────────────────────────────────────
"EXAM_READ",
"EXAM_CREATE",
"EXAM_UPDATE",
"EXAM_DELETE",
"EXAM_GRADE",
// ── 作业4──────────────────────────────────────────
"HOMEWORK_READ",
"HOMEWORK_CREATE",
"HOMEWORK_SUBMIT",
"HOMEWORK_GRADE",
// ── 成绩4 + 1 占位)─────────────────────────────────
// GRADE_READ 曾在此重复定义ARCHITECTURE.md §3.4 V3-A3 P0-6 去重)。
// 索引 31 标记为 _RESERVED_31 永不复用,避免破坏已签发 JWT 的 bit 位语义。
"_RESERVED_31",
"GRADE_READ_OWN",
"GRADE_READ_CHILD",
"GRADE_RECORD_MANAGE",
"GRADE_RECORD_READ",
// ── 考勤3──────────────────────────────────────────
"ATTENDANCE_READ",
"ATTENDANCE_MANAGE",
"ATTENDANCE_RECORD",
// ── 课表与排课4────────────────────────────────────
"SCHEDULE_READ",
"SCHEDULE_AUTO",
"SCHEDULE_ADJUST",
"SCHEDULE_MANAGE",
// ── 备课与教材5────────────────────────────────────
"LESSON_PLAN_READ",
"LESSON_PLAN_CREATE",
"LESSON_PLAN_UPDATE",
"LESSON_PLAN_DELETE",
"TEXTBOOK_READ",
// ── 题库4──────────────────────────────────────────
"QUESTION_READ",
"QUESTION_CREATE",
"QUESTION_UPDATE",
"QUESTION_DELETE",
// ── 学情诊断2──────────────────────────────────────
"DIAGNOSTIC_READ",
"DIAGNOSTIC_MANAGE",
// ── 选修课3────────────────────────────────────────
"ELECTIVE_READ",
"ELECTIVE_MANAGE",
"ELECTIVE_SELECT",
// ── 错题本与学习路径2──────────────────────────────
"ERROR_BOOK_READ",
"LEARNING_PATH_READ",
// ── AI 辅导2───────────────────────────────────────
"AI_CHAT",
"AI_TUTOR_USE",
// ── 公告与消息4────────────────────────────────────
"ANNOUNCEMENT_READ",
"ANNOUNCEMENT_MANAGE",
"MESSAGE_READ",
"MESSAGE_SEND",
// ── 请假2──────────────────────────────────────────
"LEAVE_REQUEST_CREATE",
"LEAVE_APPROVAL_MANAGE",
// ── 插件与布局4────────────────────────────────────
"PLUGIN_REGISTRY_READ",
"PLUGIN_REGISTRY_MANAGE",
"LAYOUT_TEMPLATE_MANAGE",
"ROLE_LAYOUT_MANAGE",
] as const;
/** 权限点类型(从 ORDER 数组推导) */
export type Permission = (typeof PERMISSION_BITMAP_ORDER)[number];
/** 权限点 → bit 位映射表(启动时构建一次) */
const PERMISSION_BIT_INDEX: ReadonlyMap<string, bigint> = new Map(
PERMISSION_BITMAP_ORDER.map((perm, idx) => [perm, 1n << BigInt(idx)]),
);
/**
* 将权限点数组编码为 base36 字符串
*
* @example
* encodePermissionsBitmap(["USER_MANAGE", "ROLE_READ"])
* // => "j" (前 14 个权限点中 USER_MANAGE=bit5, ROLE_READ=bit9 → 0b10100100000 → base36="j"
*/
export function encodePermissionsBitmap(
permissions: readonly string[],
): string {
let bits = 0n;
for (const perm of permissions) {
const bit = PERMISSION_BIT_INDEX.get(perm);
if (bit !== undefined) {
bits |= bit;
}
// 未知权限静默忽略
}
return bits.toString(36);
}
/**
* 将 base36 字符串解码为权限点数组
*
* 容错:无效字符返回空数组,未知 bit 位静默忽略
*/
export function decodePermissionsBitmap(bitmap: string): Permission[] {
if (!bitmap || !/^[0-9a-z]+$/.test(bitmap)) {
return [];
}
const bits = parseBase36BigInt(bitmap);
if (bits === null) {
return [];
}
const result: Permission[] = [];
for (let i = 0; i < PERMISSION_BITMAP_ORDER.length; i++) {
const bit = 1n << BigInt(i);
if ((bits & bit) !== 0n) {
result.push(PERMISSION_BITMAP_ORDER[i]!);
}
}
return result;
}
/**
* 单点权限检查(不解码整个位图,性能优)
*
* 适用场景Edge Runtime / middleware / proxy 等高频检查
*
* @example
* hasPermissionInBitmap("j", "USER_MANAGE") // true
* hasPermissionInBitmap("j", "EXAM_CREATE") // false
*/
export function hasPermissionInBitmap(
bitmap: string,
permission: string,
): boolean {
const bit = PERMISSION_BIT_INDEX.get(permission);
if (bit === undefined) {
return false; // 未知权限点
}
const bits = parseBase36BigInt(bitmap);
if (bits === null) {
return false;
}
return (bits & bit) !== 0n;
}
/**
* 批量权限检查(任一满足即 true
*/
export function hasAnyPermissionInBitmap(
bitmap: string,
permissions: readonly string[],
): boolean {
return permissions.some((p) => hasPermissionInBitmap(bitmap, p));
}
/**
* 批量权限检查(全部满足才 true
*/
export function hasAllPermissionsInBitmap(
bitmap: string,
permissions: readonly string[],
): boolean {
return permissions.every((p) => hasPermissionInBitmap(bitmap, p));
}
/**
* 解析 base36 字符串为 BigInt
*
* base36 字符集0-9, a-z小写
* 实现原理:从高位到低位逐字符累加
*/
function parseBase36BigInt(str: string): bigint | null {
if (!str) return 0n;
let result = 0n;
const base = 36n;
for (let i = 0; i < str.length; i++) {
const ch = str[i]!;
let digit: number;
if (ch >= "0" && ch <= "9") {
digit = ch.charCodeAt(0) - 48; // '0' = 48
} else if (ch >= "a" && ch <= "z") {
digit = ch.charCodeAt(0) - 87; // 'a' = 97, 97-10=87
} else if (ch >= "A" && ch <= "Z") {
digit = ch.charCodeAt(0) - 55; // 'A' = 65, 65-10=55
} else {
return null; // 非法字符
}
result = result * base + BigInt(digit);
}
return result;
}
/**
* 校验权限点是否在位图顺序表中(且非 _RESERVED_* 占位)
*
* 用于开发时校验 manifest 声明的权限点是否合法。
* _RESERVED_* 占位保留 bit 位语义但不视为合法权限点(防误用)。
*/
export function isValidPermission(permission: string): boolean {
if (!PERMISSION_BIT_INDEX.has(permission)) return false;
if (permission.startsWith("_RESERVED_")) return false;
return true;
}