oclaw/uds-auth/lib/roles.js
oliver 91d2515205 Unify admin-class workspace create and restore Add-workspace UI.
Give admin the same permissions as super/fallback, occupy directoryFlow at priority 1 so the button renders without colliding with the native picker, and inject uiWorkspace for pickDirectory.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 22:46:45 +08:00

410 lines
13 KiB
JavaScript
Raw Permalink 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.

/**
* 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
}
}