/** * uds-auth 角色存储 + 权限管理 * * 角色(身份标签;admin 级权限相同): * super_admin 身份:首位扫码 bootstrap / 显式提权;权限 = admin 级 * fallback_admin 身份:应急账号 administrator;权限 = admin 级 * admin 身份:用户管理中提权;权限 = admin 级 * user 仅看自己会话,无设置 / 不可建工作区 * * admin 级(isAdminClass)权限相同:用户管理、设置、建工作区、默认可看全部会话。 * 超管只是身份;默认可持有该身份的包括 admin,以及首位扫码加入者(bootstrap)。 * prefs.viewAllSessions === false 时关闭全览。 * 持久化: roles.json (roles + prefs + fallbackPasswordHash) */ import { createHash, randomBytes } from 'node:crypto' import { readFile, writeFile, mkdir } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { ROLE_LABELS_ZH } from './i18n.js' function codedError(code) { const err = new Error(code) err.code = code return err } export const ROLES = { SUPER_ADMIN: 'super_admin', ADMIN: 'admin', USER: 'user', FALLBACK_ADMIN: 'fallback_admin', // 特殊,UAC 挂了时用,等同 super_admin 权限 } /** zh labels for list/search; UI should translate via i18n role.* keys. */ export const ROLE_LABELS = ROLE_LABELS_ZH /** admin 级身份:超管 / 应急 / 管理员(权限相同,仅身份标签不同) */ export function isAdminClass(role) { return role === ROLES.SUPER_ADMIN || role === ROLES.FALLBACK_ADMIN || role === ROLES.ADMIN } /** * 计算角色权限 (纯函数) * @param {string} role * @param {{ viewAllSessions?: boolean }} [opts] 个人偏好;admin 级默认可见全部 */ export function computePermissions(role, opts = {}) { const viewAll = !!opts.viewAllSessions if (isAdminClass(role)) { return { canManageUsers: true, canAccessSettings: true, canToggleViewAllSessions: true, canViewAllSessions: viewAll, canViewSystemSessions: true, canCreateWorkspace: true, } } // user / undefined return { canManageUsers: false, canAccessSettings: false, canToggleViewAllSessions: false, canViewAllSessions: false, canViewSystemSessions: false, canCreateWorkspace: false, } } /** 角色是否允许开启「查看全部会话」(admin 级) */ export function canToggleViewAllSessions(role) { return isAdminClass(role) } function hashPassword(password) { return createHash('sha256').update(password).digest('hex') } /** 初始部署默认兜底密码(扫码不可用时用);可在设置里改密或关闭。 */ export const DEFAULT_FALLBACK_PASSWORD = 'Admin@123' export const DEFAULT_FALLBACK_USERNAME = 'administrator' /** * RolesStore — 角色存储 * 内存 Map + 可选 JSON 文件持久化 * * fallback admin: 默认启用,密码 Admin@123;roles.json 显式 null 表示已关闭。 * 登录路径: POST /uds-auth/api/fallback/login { username, password } */ export class RolesStore { constructor(options = {}) { this._roles = new Map() // empNo → role this._prefs = new Map() // empNo → { viewAllSessions?: boolean } this._firstBootLock = Promise.resolve() this._rolesFile = options.rolesFile ? resolve(options.rolesFile) : null this._fallbackPasswordHash = null // SHA-256 hex,null = 未启用 this._fallbackRateLimit = new Map() // ip → { count, resetAt } this._dirty = false this._saveTimer = null } _ensureDefaultFallback() { if (this._fallbackPasswordHash) return this._fallbackPasswordHash = hashPassword(DEFAULT_FALLBACK_PASSWORD) this._markDirty() console.info( '[uds-auth] fallback_admin enabled by default' + ` (user=${DEFAULT_FALLBACK_USERNAME}, change password in settings)`, ) } // === 持久化 === async init() { let loadedHash = undefined // undefined = missing / new file; null = explicitly cleared if (this._rolesFile) { try { const raw = await readFile(this._rolesFile, 'utf-8') const data = JSON.parse(raw) for (const [empNo, role] of Object.entries(data.roles || {})) { this._roles.set(empNo, role) } for (const [empNo, prefs] of Object.entries(data.prefs || {})) { if (prefs && typeof prefs === 'object') { this._prefs.set(String(empNo), { ...prefs }) } } if (Object.prototype.hasOwnProperty.call(data, 'fallbackPasswordHash')) { loadedHash = data.fallbackPasswordHash || null if (loadedHash) this._fallbackPasswordHash = loadedHash } } catch (err) { if (err.code === 'ENOENT') { // 首次启动,文件不存在 — 走默认兜底 } else { console.warn('[uds-auth:RolesStore] Failed to load roles file:', err.message) } } } // 无持久化哈希(新部署或旧文件未写该字段)→ 默认开启;显式 null 表示超管已关闭 if (loadedHash === undefined && !this._fallbackPasswordHash) { this._ensureDefaultFallback() } } _markDirty() { this._dirty = true if (this._saveTimer) return this._saveTimer = setTimeout(() => { void this._save() }, 2000) } /** Flush pending roles/prefs to disk immediately (e.g. view-all toggle). */ async flush() { if (this._saveTimer) { clearTimeout(this._saveTimer) this._saveTimer = null } await this._save() } async _save() { this._saveTimer = null if (!this._dirty || !this._rolesFile) return this._dirty = false try { const data = { roles: Object.fromEntries(this._roles), prefs: Object.fromEntries(this._prefs), fallbackPasswordHash: this._fallbackPasswordHash, savedAt: new Date().toISOString(), } await mkdir(dirname(this._rolesFile), { recursive: true }) await writeFile(this._rolesFile, JSON.stringify(data, null, 2), 'utf-8') } catch (err) { this._dirty = true console.warn('[uds-auth:RolesStore] Failed to save roles file:', err.message) } // Changes during await writeFile — schedule another save. if (this._dirty) this._markDirty() } // === 首次部署 bootstrap === /** * 原子 check-and-set: 第一个登录的用户 = super_admin * 返回 { role, bootstrapped } bootstrapped=true 表示本次是 bootstrap */ async bootstrapFirstUser(empNo) { // 用锁保证原子性 this._firstBootLock = this._firstBootLock.then(async () => { if (this._roles.size === 0) { this._roles.set(empNo, ROLES.SUPER_ADMIN) this._markDirty() return { role: ROLES.SUPER_ADMIN, bootstrapped: true } } if (!this._roles.has(empNo)) { this._roles.set(empNo, ROLES.USER) this._markDirty() return { role: ROLES.USER, bootstrapped: false } } return { role: this._roles.get(empNo), bootstrapped: false } }) return this._firstBootLock } // === 角色查询 === getRole(empNo) { if (empNo === 'administrator') return ROLES.FALLBACK_ADMIN return this._roles.get(empNo) || ROLES.USER } /** * 个人偏好:admin 级默认开启查看全部;显式 false 才关闭。 * user 不会走到这里(resolvePermissions 里 allowToggle=false)。 */ isViewAllSessionsEnabled(empNo) { if (!empNo) return false const prefs = this._prefs.get(String(empNo)) if (prefs && Object.prototype.hasOwnProperty.call(prefs, 'viewAllSessions')) { return !!prefs.viewAllSessions } return true } /** * 设置「查看全部会话」偏好(调用方需校验 canToggleViewAllSessions) * @param {string} empNo * @param {boolean} enabled */ setViewAllSessions(empNo, enabled) { const key = String(empNo || '').trim() if (!key) throw codedError('emp_no_required') const role = this.getRole(key) if (!canToggleViewAllSessions(role)) { throw codedError('forbidden_view_all_sessions') } const cur = { ...(this._prefs.get(key) || {}) } // Persist explicit true/false — deleting the key would fall back to default-on. cur.viewAllSessions = !!enabled this._prefs.set(key, cur) this._markDirty() return true } /** 角色 + 个人偏好 → 有效权限 */ resolvePermissions(empNo, role) { const id = empNo != null ? String(empNo) : '' const r = role || this.getRole(id) const allowToggle = canToggleViewAllSessions(r) return computePermissions(r, { viewAllSessions: allowToggle && this.isViewAllSessionsEnabled(id), }) } hasRole(empNo) { return this._roles.has(empNo) } getAll() { return Array.from(this._roles.entries()).map(([empNo, role]) => ({ empNo, role })) } /** 角色表是否为空(用于 bootstrap) */ isEmpty() { return this._roles.size === 0 } /** * 分页 + 工号模糊搜索(上千用户场景) * @param {{ page?: number, pageSize?: number, q?: string }} opts */ listPage(opts = {}) { const page = Math.max(1, Number(opts.page) || 1) const pageSize = Math.min(200, Math.max(1, Number(opts.pageSize) || 50)) const q = String(opts.q || '').trim().toLowerCase() let rows = Array.from(this._roles.entries()).map(([empNo, role]) => ({ empNo, role })) if (q) { rows = rows.filter((r) => String(r.empNo).toLowerCase().includes(q) || String(ROLE_LABELS[r.role] || r.role).toLowerCase().includes(q)) } rows.sort((a, b) => String(a.empNo).localeCompare(String(b.empNo), 'zh')) const total = rows.length const start = (page - 1) * pageSize const users = rows.slice(start, start + pageSize).map(({ empNo, role }) => ({ empNo, role, roleLabel: ROLE_LABELS[role] || role, })) return { users, total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)), } } async countByRole(role) { let n = 0 for (const r of this._roles.values()) if (r === role) n++ return n } // === 角色管理 (admin 级) === /** * 设置用户角色 * 保护性 invariant: 至少保留 1 个 super_admin 身份(若表中曾有) */ async setRole(empNo, newRole, currentAdminRole) { if (!isAdminClass(currentAdminRole)) { throw codedError('forbidden_set_role') } // invariant: 不能让系统变成 0 个 super_admin(身份仍保留) const currentRole = this._roles.get(empNo) if (currentRole === ROLES.SUPER_ADMIN && newRole !== ROLES.SUPER_ADMIN) { const superAdmins = await this.countByRole(ROLES.SUPER_ADMIN) if (superAdmins <= 1) { throw codedError('last_super_admin_demote') } } this._roles.set(empNo, newRole) this._markDirty() return true } /** 删除用户 */ async removeUser(empNo, currentAdminRole) { if (!isAdminClass(currentAdminRole)) { throw codedError('forbidden_remove_user') } const currentRole = this._roles.get(empNo) if (currentRole === ROLES.SUPER_ADMIN) { const superAdmins = await this.countByRole(ROLES.SUPER_ADMIN) if (superAdmins <= 1) { throw codedError('last_super_admin_delete') } } this._roles.delete(empNo) this._prefs.delete(empNo) this._markDirty() return true } /** 确保用户存在 (如果不存在设为 user) */ ensureUser(empNo, currentAdminRole) { if (!isAdminClass(currentAdminRole)) { throw codedError('forbidden_add_user') } if (!this._roles.has(empNo)) { this._roles.set(empNo, ROLES.USER) this._markDirty() } return true } // === Fallback Administrator === setFallbackPassword(password, currentAdminRole) { if (!isAdminClass(currentAdminRole)) { throw codedError('forbidden_set_fallback') } if (!password || password.length < 6) { throw codedError('password_too_short') } this._fallbackPasswordHash = hashPassword(password) this._markDirty() return true } clearFallbackPassword(currentAdminRole) { if (!isAdminClass(currentAdminRole)) { throw codedError('forbidden_clear_fallback') } this._fallbackPasswordHash = null this._markDirty() return true } isFallbackEnabled() { return this._fallbackPasswordHash !== null } /** * 验证 fallback 密码 + rate limit * @returns {boolean} */ verifyFallback(password, ip) { if (!this._fallbackPasswordHash) return false const now = Date.now() // rate limit: 5 tries per minute per IP let bucket = this._fallbackRateLimit.get(ip) if (!bucket || now > bucket.resetAt) { bucket = { count: 0, resetAt: now + 60_000 } this._fallbackRateLimit.set(ip, bucket) } bucket.count++ if (bucket.count > 5) { return false // rate limited } return hashPassword(password) === this._fallbackPasswordHash } }