mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 03:30:48 +08:00
super_admin/fallback_admin already could; admin now has canCreateWorkspace, and Host-side creates without browser ALS (IM) are no longer blocked as forbidden. Co-authored-by: Cursor <cursoragent@cursor.com>
424 lines
13 KiB
JavaScript
424 lines
13 KiB
JavaScript
/**
|
||
* uds-auth 角色存储 + 权限管理
|
||
*
|
||
* 角色:
|
||
* super_admin 所有权限 + 用户管理;默认可见全部会话(可在设置中关闭)
|
||
* fallback_admin 等同 super_admin(兜底 administrator)
|
||
* admin 无设置齿轮;仅看自己会话(含 @);可见渠道/系统会话;可创建工作区
|
||
* user 仅看自己会话,无设置
|
||
*
|
||
* 超管/应急默认全览开启;prefs.viewAllSessions === false 时关闭。
|
||
* 设置齿轮仅超管/应急(canAccessSettings)。
|
||
* 创建工作区:super_admin / fallback_admin / admin(扫码不可用时现场通常只有 admin)。
|
||
* 持久化: 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
|
||
|
||
/**
|
||
* 计算角色权限 (纯函数)
|
||
* @param {string} role
|
||
* @param {{ viewAllSessions?: boolean }} [opts] 个人偏好;超管默认可见全部
|
||
*/
|
||
export function computePermissions(role, opts = {}) {
|
||
const viewAll = !!opts.viewAllSessions
|
||
switch (role) {
|
||
case ROLES.SUPER_ADMIN:
|
||
return {
|
||
canManageUsers: true,
|
||
canAccessSettings: true,
|
||
canToggleViewAllSessions: true,
|
||
canViewAllSessions: viewAll,
|
||
canViewSystemSessions: true,
|
||
canCreateWorkspace: true,
|
||
}
|
||
case ROLES.FALLBACK_ADMIN:
|
||
return {
|
||
canManageUsers: true,
|
||
canAccessSettings: true,
|
||
canToggleViewAllSessions: true,
|
||
canViewAllSessions: viewAll,
|
||
canViewSystemSessions: true,
|
||
canCreateWorkspace: true,
|
||
}
|
||
case ROLES.ADMIN:
|
||
return {
|
||
canManageUsers: false,
|
||
// 设置齿轮仅超管/应急;admin 仍可看渠道/系统会话,并可创建工作区
|
||
canAccessSettings: false,
|
||
canToggleViewAllSessions: false,
|
||
canViewAllSessions: false,
|
||
canViewSystemSessions: true,
|
||
canCreateWorkspace: true,
|
||
}
|
||
default: // user / undefined
|
||
return {
|
||
canManageUsers: false,
|
||
canAccessSettings: false,
|
||
canToggleViewAllSessions: false,
|
||
canViewAllSessions: false,
|
||
canViewSystemSessions: false,
|
||
canCreateWorkspace: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 角色是否允许开启「查看全部会话」(仅超管 / 应急) */
|
||
export function canToggleViewAllSessions(role) {
|
||
return role === ROLES.SUPER_ADMIN
|
||
|| role === ROLES.FALLBACK_ADMIN
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
/**
|
||
* 个人偏好:超管/应急默认开启查看全部;显式 false 才关闭。
|
||
* admin/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
|
||
}
|
||
|
||
// === 角色管理 (super_admin only) ===
|
||
|
||
/**
|
||
* 设置用户角色
|
||
* 保护性 invariant: 至少保留 1 个 super_admin
|
||
*/
|
||
async setRole(empNo, newRole, currentAdminRole) {
|
||
if (currentAdminRole !== ROLES.SUPER_ADMIN) {
|
||
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 (currentAdminRole !== ROLES.SUPER_ADMIN) {
|
||
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 (currentAdminRole !== ROLES.SUPER_ADMIN) {
|
||
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 (currentAdminRole !== ROLES.SUPER_ADMIN && currentAdminRole !== ROLES.FALLBACK_ADMIN) {
|
||
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 (currentAdminRole !== ROLES.SUPER_ADMIN && currentAdminRole !== ROLES.FALLBACK_ADMIN) {
|
||
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
|
||
}
|
||
}
|