oclaw/uds-auth/lib/roles.js
oliver 92ee170b2e Enforce multi-user ACL for sessions, settings, and workspaces.
Bridge UDS identity into DSH RPC via ALS, stamp/filter sessions, auto-provision per-empNo workspaces, and hide Settings/Add-workspace for roles that lack those permissions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 04:29:48 +08:00

314 lines
9 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 所有权限 + 用户管理
* admin 设置权限 + 仅看自己会话
* user 仅看自己会话,无设置
*
* 持久化: roles.json (单实例文件) + MemoryStore 同步
*/
import { createHash, randomBytes } from 'node:crypto'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
export const ROLES = {
SUPER_ADMIN: 'super_admin',
ADMIN: 'admin',
USER: 'user',
FALLBACK_ADMIN: 'fallback_admin', // 特殊,UAC 挂了时用,等同 super_admin 权限
}
export const ROLE_LABELS = {
super_admin: '超级管理员',
admin: '管理员',
user: '普通用户',
fallback_admin: '兜底管理员',
}
/** 计算角色权限 (纯函数) */
export function computePermissions(role) {
switch (role) {
case ROLES.SUPER_ADMIN:
return {
canManageUsers: true,
canAccessSettings: true,
canViewAllSessions: true,
canCreateWorkspace: true,
}
case ROLES.FALLBACK_ADMIN:
return {
canManageUsers: true,
canAccessSettings: true,
canViewAllSessions: true,
canCreateWorkspace: true,
}
case ROLES.ADMIN:
return {
canManageUsers: false,
canAccessSettings: true,
canViewAllSessions: false,
canCreateWorkspace: false,
}
default: // user / undefined
return {
canManageUsers: false,
canAccessSettings: false,
canViewAllSessions: false,
canCreateWorkspace: false,
}
}
}
function hashPassword(password) {
return createHash('sha256').update(password).digest('hex')
}
/**
* RolesStore — 角色存储
* 内存 Map + 可选 JSON 文件持久化
*
* fallback admin: 默认不启用,需在 DSH 设置里配置密码哈希
* 登录路径: POST /uds-auth/admin-login { username, password }
*/
export class RolesStore {
constructor(options = {}) {
this._roles = new Map() // empNo → role
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
}
// === 持久化 ===
async init() {
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)
}
if (data.fallbackPasswordHash) {
this._fallbackPasswordHash = data.fallbackPasswordHash
}
} catch (err) {
if (err.code === 'ENOENT') {
// 首次启动,文件不存在,正常
} else {
console.warn('[uds-auth:RolesStore] Failed to load roles file:', err.message)
}
}
}
}
_markDirty() {
this._dirty = true
if (this._saveTimer) return
this._saveTimer = setTimeout(() => this._save(), 2000)
}
async _save() {
this._saveTimer = null
if (!this._dirty || !this._rolesFile) return
this._dirty = false
try {
const data = {
roles: Object.fromEntries(this._roles),
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) {
console.warn('[uds-auth:RolesStore] Failed to save roles file:', err.message)
}
}
// === 首次部署 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
}
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 new Error('只有超级管理员可以修改角色')
}
// 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 new Error('系统至少需要 1 个超级管理员,不能降级最后一个')
}
}
this._roles.set(empNo, newRole)
this._markDirty()
return true
}
/** 删除用户 */
async removeUser(empNo, currentAdminRole) {
if (currentAdminRole !== ROLES.SUPER_ADMIN) {
throw new Error('只有超级管理员可以删除用户')
}
const currentRole = this._roles.get(empNo)
if (currentRole === ROLES.SUPER_ADMIN) {
const superAdmins = await this.countByRole(ROLES.SUPER_ADMIN)
if (superAdmins <= 1) {
throw new Error('系统至少需要 1 个超级管理员,不能删除最后一个')
}
}
this._roles.delete(empNo)
this._markDirty()
return true
}
/** 确保用户存在 (如果不存在设为 user) */
ensureUser(empNo, currentAdminRole) {
if (currentAdminRole !== ROLES.SUPER_ADMIN) {
throw new Error('只有超级管理员可以添加用户')
}
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) {
throw new Error('只有超级管理员可以设置兜底管理员密码')
}
if (!password || password.length < 6) {
throw new Error('密码至少 6 位')
}
this._fallbackPasswordHash = hashPassword(password)
this._markDirty()
return true
}
clearFallbackPassword(currentAdminRole) {
if (currentAdminRole !== ROLES.SUPER_ADMIN) {
throw new Error('只有超级管理员可以清除兜底管理员密码')
}
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
}
}