oclaw/uds-auth/lib/roles.js
oliver ad0cc98b38 Make session visibility preference-driven and keep system-channel ACL separate.
Super/fallback can toggle view-all (default on); admin/user stay own-only while still seeing unowned system sessions. Add restore/prune helpers so empty-shell cleanup is explicit and recoverable.
2026-09-14 20:48:15 +08:00

423 lines
13 KiB
JavaScript
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.

/**
* uds-auth 角色存储 + 权限管理
*
* 角色:
* super_admin 所有权限 + 用户管理;默认可见全部会话(可在设置中关闭)
* fallback_admin 等同 super_admin(兜底 administrator)
* admin 无设置齿轮;仅看自己会话(含 @);可见渠道/系统会话
* user 仅看自己会话,无设置
*
* 超管/应急默认全览开启;prefs.viewAllSessions === false 时关闭。
* 设置齿轮仅超管/应急(canAccessSettings)。
* 持久化: 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: false,
}
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
}
}