mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-08 22:20:54 +08:00
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>
This commit is contained in:
parent
9d16301700
commit
92ee170b2e
9 changed files with 752 additions and 31 deletions
2
uds-auth/.gitignore
vendored
2
uds-auth/.gitignore
vendored
|
|
@ -6,3 +6,5 @@ config.runtime.json
|
|||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
session-owners.json
|
||||
user-workspaces.json
|
||||
|
|
|
|||
|
|
@ -12,3 +12,6 @@ loginSystemCode: '100000455558'
|
|||
|
||||
# 登录时传给 UAC 的来源系统 Code (留空则不传)
|
||||
originSystemCode: ''
|
||||
|
||||
# Per-user workspaces under workspaceRoot/<empNo>; empty => $DSH_HOME/user-workspaces
|
||||
workspaceRoot: ""
|
||||
|
|
|
|||
|
|
@ -8,3 +8,4 @@
|
|||
userSearchUrl: https://icenterapi.zte.com.cn/zte-km-icenter-addresearch/user/plain/docs/search
|
||||
loginSystemCode: '100000455558'
|
||||
originSystemCode: ''
|
||||
workspaceRoot: ''
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ window.__ModuleLoader__.load({
|
|||
|
||||
const CSS = [
|
||||
'.uds-auth-host{position:relative;display:inline-flex;align-items:center;height:32px;margin:0;flex-shrink:0;pointer-events:auto}.uds-auth-host.is-rail{justify-content:center;width:100%}[data-uds-auth-foot="row"]{display:flex!important;flex-direction:row!important;align-items:center!important;gap:8px;width:100%}[data-uds-auth-foot="row"]>*:nth-child(1){order:2;flex:none!important;width:auto!important;min-width:0;margin-left:auto!important}[data-uds-auth-foot="row"]>*:nth-child(2){order:1;flex:none!important;width:auto!important;min-width:0}',
|
||||
'html[data-uds-can-settings="0"] [data-uds-auth-foot="row"]>*:not(:has([data-uds-auth-host])){display:none!important}html[data-uds-can-create-ws="0"] button[aria-label="添加工作区"],html[data-uds-can-create-ws="0"] button[aria-label="Add workspace"]{display:none!important}',
|
||||
'.uds-auth-badge{display:inline-flex;align-items:center;justify-content:center;gap:8px;max-width:min(180px,40vw);min-width:0;height:42px;padding:0 10px 0 8px;box-sizing:border-box;border:none;border-radius:12px;background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:14px;font-weight:400;line-height:22px;cursor:pointer;overflow:hidden}',
|
||||
'.uds-auth-badge:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
||||
'.uds-auth-host.is-rail .uds-auth-badge{width:36px;height:36px;padding:0;border-radius:50%;gap:0}',
|
||||
|
|
@ -280,6 +281,7 @@ window.__ModuleLoader__.load({
|
|||
userSearchUrl: '',
|
||||
loginSystemCode: '',
|
||||
originSystemCode: '',
|
||||
workspaceRoot: '',
|
||||
})
|
||||
const [fallbackEnabled, setFallbackEnabled] = useState(false)
|
||||
const [fallbackPwd, setFallbackPwd] = useState('')
|
||||
|
|
@ -360,6 +362,7 @@ window.__ModuleLoader__.load({
|
|||
field('userSearchUrl', '\u7528\u6237\u641c\u7d22 URL\uff08token \u6821\u9a8c\uff09'),
|
||||
field('loginSystemCode', 'loginSystemCode'),
|
||||
field('originSystemCode', 'originSystemCode'),
|
||||
field('workspaceRoot', '工作区根目录(空=$DSH_HOME/user-workspaces)'),
|
||||
h('div', { className: 'uds-auth-settings-actions' },
|
||||
h('button', {
|
||||
type: 'button',
|
||||
|
|
@ -457,6 +460,18 @@ window.__ModuleLoader__.load({
|
|||
const [fbErr, setFbErr] = useState('')
|
||||
const qrRef = useRef({ key: null, value: null, timer: null })
|
||||
|
||||
useEffect(() => {
|
||||
const perms = user?.permissions || {}
|
||||
const canSettings = user ? !!perms.canAccessSettings : false
|
||||
const canCreateWs = user ? !!perms.canCreateWorkspace : false
|
||||
document.documentElement.setAttribute('data-uds-can-settings', canSettings ? '1' : '0')
|
||||
document.documentElement.setAttribute('data-uds-can-create-ws', canCreateWs ? '1' : '0')
|
||||
return () => {
|
||||
document.documentElement.removeAttribute('data-uds-can-settings')
|
||||
document.documentElement.removeAttribute('data-uds-can-create-ws')
|
||||
}
|
||||
}, [user])
|
||||
|
||||
const stopQr = useCallback(() => {
|
||||
if (qrRef.current.timer) { clearInterval(qrRef.current.timer); qrRef.current.timer = null }
|
||||
qrRef.current.key = null
|
||||
|
|
@ -748,6 +763,37 @@ window.__ModuleLoader__.load({
|
|||
document.head.appendChild(tag)
|
||||
return () => tag.remove()
|
||||
}, 'uds-auth: styles')
|
||||
|
||||
ctx.effect(() => {
|
||||
let settingsGateDispose = null
|
||||
const syncSettingsGate = async () => {
|
||||
let canSettings = false
|
||||
try {
|
||||
const me = await fetchJson('/uds-auth/api/me')
|
||||
canSettings = !!(me?.data?.permissions?.canAccessSettings)
|
||||
} catch { canSettings = false }
|
||||
if (canSettings) {
|
||||
if (settingsGateDispose) { try { settingsGateDispose() } catch {} settingsGateDispose = null }
|
||||
return
|
||||
}
|
||||
if (settingsGateDispose) return
|
||||
settingsGateDispose = ctx.slots.register({
|
||||
name: 'sidebar.settings',
|
||||
id: 'uds-auth-settings-gate',
|
||||
order: 9999,
|
||||
}, function UdsAuthSettingsGate() { return null })
|
||||
}
|
||||
syncSettingsGate()
|
||||
const mePoll = setInterval(syncSettingsGate, 15000)
|
||||
const onFocus = () => { syncSettingsGate() }
|
||||
window.addEventListener('focus', onFocus)
|
||||
return () => {
|
||||
clearInterval(mePoll)
|
||||
window.removeEventListener('focus', onFocus)
|
||||
if (settingsGateDispose) { try { settingsGateDispose() } catch {} }
|
||||
}
|
||||
}, 'uds-auth: settings-gate')
|
||||
|
||||
// sidebar.footer.action; layout effect lays footArea as one row: Settings | UDS login
|
||||
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
|
||||
name: 'sidebar.footer.action',
|
||||
|
|
|
|||
352
uds-auth/lib/dsh-acl.js
Normal file
352
uds-auth/lib/dsh-acl.js
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
/**
|
||||
* Bridge UDS cookies → AsyncLocalStorage and wrap DSH session/workspace/settings.
|
||||
*/
|
||||
import { withUserContext, getUserContext } from './context.js'
|
||||
import { computePermissions, ROLES } from './roles.js'
|
||||
|
||||
function parseCookie(header, name) {
|
||||
if (!header || typeof header !== 'string') return null
|
||||
for (const part of header.split(';')) {
|
||||
const idx = part.indexOf('=')
|
||||
if (idx < 0) continue
|
||||
if (part.slice(0, idx).trim() !== name) continue
|
||||
try {
|
||||
return decodeURIComponent(part.slice(idx + 1).trim())
|
||||
} catch {
|
||||
return part.slice(idx + 1).trim()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('node:http').IncomingMessage} req
|
||||
* @param {{ sessionStore: any, rolesStore: any }} deps
|
||||
*/
|
||||
export async function resolveIdentityFromRequest(req, deps) {
|
||||
const cookie = req?.headers?.cookie || ''
|
||||
const empNo = parseCookie(cookie, 'PORTALSSOUser')
|
||||
|| parseCookie(cookie, 'ZTEDPGSSOUser')
|
||||
|| parseCookie(cookie, 'UDS_FALLBACK_USER')
|
||||
if (!empNo) return null
|
||||
|
||||
const { sessionStore, rolesStore } = deps
|
||||
let userContext = null
|
||||
try {
|
||||
userContext = await sessionStore.get(empNo)
|
||||
} catch {
|
||||
userContext = null
|
||||
}
|
||||
if (!userContext) return null
|
||||
|
||||
// bootstrap / resolve role
|
||||
let role = rolesStore.getRole(empNo)
|
||||
if (empNo !== 'administrator' && typeof rolesStore.bootstrapFirstUser === 'function') {
|
||||
try {
|
||||
const r = await rolesStore.bootstrapFirstUser(empNo)
|
||||
role = r.role
|
||||
} catch { /* keep getRole */ }
|
||||
}
|
||||
|
||||
const permissions = computePermissions(role)
|
||||
return {
|
||||
empNo: String(empNo),
|
||||
role,
|
||||
permissions,
|
||||
userContext,
|
||||
kind: empNo === 'administrator' ? 'fallback' : 'uds',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch webServer so every route handler runs inside UDS ALS.
|
||||
* @param {any} server
|
||||
* @param {(req: any) => Promise<object|null>} resolveIdentity
|
||||
*/
|
||||
export function patchWebServerWithIdentity(server, resolveIdentity) {
|
||||
if (!server || server.__udsAuthPatched) return () => {}
|
||||
server.__udsAuthPatched = true
|
||||
|
||||
const wrap = (handler) => {
|
||||
if (typeof handler !== 'function' || handler.__udsWrapped) return handler
|
||||
const wrapped = async (req, res, ...rest) => {
|
||||
const identity = await resolveIdentity(req)
|
||||
return withUserContext(identity, () => handler(req, res, ...rest))
|
||||
}
|
||||
wrapped.__udsWrapped = true
|
||||
return wrapped
|
||||
}
|
||||
|
||||
const patchTable = (table) => {
|
||||
if (!table || typeof table.entries !== 'function') return
|
||||
for (const [path, route] of table.entries()) {
|
||||
if (!route?.handler) continue
|
||||
table.set(path, { ...route, handler: wrap(route.handler) })
|
||||
}
|
||||
}
|
||||
|
||||
patchTable(server.exact)
|
||||
patchTable(server.prefixes)
|
||||
if (typeof server.fallback === 'function') {
|
||||
server.fallback = wrap(server.fallback)
|
||||
}
|
||||
|
||||
const origRegister = server.register.bind(server)
|
||||
server.register = (route) => origRegister({
|
||||
...route,
|
||||
handler: wrap(route.handler),
|
||||
})
|
||||
|
||||
const origFallback = server.registerFallback?.bind(server)
|
||||
if (origFallback) {
|
||||
server.registerFallback = (handler) => origFallback(wrap(handler))
|
||||
}
|
||||
|
||||
return () => {
|
||||
/* leave patched — reload recreates webServer fiber */
|
||||
}
|
||||
}
|
||||
|
||||
function throwForbidden(message) {
|
||||
try {
|
||||
const { RemoteError } = require('@deepseek-ai/dsh-typert-protocol')
|
||||
throw new RemoteError('gateway/forbidden', message || 'forbidden', {})
|
||||
} catch (err) {
|
||||
if (err && (err.name === 'RemoteError' || String(err.code || '').startsWith('gateway/'))) throw err
|
||||
const e = new Error(message || 'forbidden')
|
||||
e.code = 'gateway/forbidden'
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install Host ACL wrappers.
|
||||
*/
|
||||
export function installDshAcl(ctx, {
|
||||
sessionAcl,
|
||||
userWorkspaces,
|
||||
getWorkspaceRoot,
|
||||
rolesStore,
|
||||
ensureUserWorkspace,
|
||||
}) {
|
||||
const disposers = []
|
||||
|
||||
// Stamp owner on session create
|
||||
const offCreated = ctx.on('session/created', (session) => {
|
||||
try {
|
||||
const id = session?.id ?? session?.header?.id
|
||||
const identity = getUserContext()
|
||||
if (id && identity?.empNo) {
|
||||
sessionAcl.setOwner(id, identity.empNo)
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.logger?.warn?.('[uds-auth] session stamp failed: %s', err.message)
|
||||
}
|
||||
})
|
||||
disposers.push(() => offCreated?.())
|
||||
|
||||
ctx.inject(['sessionController'], (sctx) => {
|
||||
const sc = sctx.sessionController
|
||||
if (!sc || sc.__udsAcl) return
|
||||
sc.__udsAcl = true
|
||||
|
||||
const origList = sc.list.bind(sc)
|
||||
sc.list = async (request, signal) => {
|
||||
const value = await origList(request, signal)
|
||||
const identity = getUserContext()
|
||||
if (!identity?.empNo) return { items: [] }
|
||||
if (identity.permissions?.canViewAllSessions) return value
|
||||
return sessionAcl.filterListValue(value)
|
||||
}
|
||||
|
||||
const origSearch = sc.search.bind(sc)
|
||||
sc.search = async (request, signal) => {
|
||||
const value = await origSearch(request, signal)
|
||||
const identity = getUserContext()
|
||||
if (!identity?.empNo) return { items: [], hasMore: false }
|
||||
if (identity.permissions?.canViewAllSessions) return value
|
||||
return sessionAcl.filterListValue(value)
|
||||
}
|
||||
|
||||
const origCreate = sc.create.bind(sc)
|
||||
sc.create = async (request) => {
|
||||
const identity = getUserContext()
|
||||
if (!identity?.empNo) throwForbidden('登录后才能创建会话')
|
||||
|
||||
let req = request || {}
|
||||
if (req.workspaceId === undefined && req.cwd === undefined) {
|
||||
const ensured = await ensureUserWorkspace(identity.empNo)
|
||||
if (ensured?.workspaceId) {
|
||||
req = { ...req, workspaceId: ensured.workspaceId }
|
||||
} else if (ensured?.path) {
|
||||
req = { ...req, cwd: ensured.path }
|
||||
}
|
||||
}
|
||||
|
||||
const result = await origCreate(req)
|
||||
const sid = result?.sessionId ?? result?.id
|
||||
if (sid) sessionAcl.setOwner(sid, identity.empNo)
|
||||
return result
|
||||
}
|
||||
const assertCanView = (sessionId) => {
|
||||
const identity = getUserContext()
|
||||
if (!identity?.empNo) throwForbidden('登录后才能访问会话')
|
||||
if (identity.permissions?.canViewAllSessions) return
|
||||
if (!sessionAcl.canViewSession(sessionId)) throwForbidden('无权访问该会话')
|
||||
}
|
||||
|
||||
if (typeof sc.page === 'function') {
|
||||
const origPage = sc.page.bind(sc)
|
||||
sc.page = async (request, signal) => {
|
||||
assertCanView(request?.sessionId ?? request?.id)
|
||||
return origPage(request, signal)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof sc.follow === 'function') {
|
||||
const origFollow = sc.follow.bind(sc)
|
||||
sc.follow = (request, signal) => {
|
||||
assertCanView(request?.sessionId ?? request?.id)
|
||||
return origFollow(request, signal)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof sc.fork === 'function') {
|
||||
const origFork = sc.fork.bind(sc)
|
||||
sc.fork = async (request, signal) => {
|
||||
assertCanView(request?.sessionId ?? request?.id)
|
||||
const result = await origFork(request, signal)
|
||||
const identity = getUserContext()
|
||||
const sid = result?.sessionId ?? result?.id
|
||||
if (sid && identity?.empNo) sessionAcl.setOwner(sid, identity.empNo)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof sc.rename === 'function') {
|
||||
const origRename = sc.rename.bind(sc)
|
||||
sc.rename = async (request, signal) => {
|
||||
assertCanView(request?.sessionId ?? request?.id)
|
||||
return origRename(request, signal)
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
ctx.inject(['workspaceController'], (wctx) => {
|
||||
const wc = wctx.workspaceController
|
||||
if (!wc || wc.__udsAcl) return
|
||||
wc.__udsAcl = true
|
||||
|
||||
const origCreate = wc.create.bind(wc)
|
||||
wc.create = async (request) => {
|
||||
const identity = getUserContext()
|
||||
if (identity?._internalProvision) return origCreate(request)
|
||||
if (!identity?.permissions?.canCreateWorkspace) {
|
||||
throwForbidden('只有超级管理员可以创建工作区')
|
||||
}
|
||||
return origCreate(request)
|
||||
}
|
||||
|
||||
const filterWorkspace = (ws) => {
|
||||
const identity = getUserContext()
|
||||
if (!identity?.empNo) return false
|
||||
if (identity.permissions?.canViewAllSessions) return true // super sees all workspaces
|
||||
const root = getWorkspaceRoot()
|
||||
return userWorkspaces.isUserPath(identity.empNo, ws?.path, root)
|
||||
|| (userWorkspaces.get(identity.empNo)?.workspaceId
|
||||
&& String(userWorkspaces.get(identity.empNo).workspaceId) === String(ws?.workspaceId))
|
||||
}
|
||||
|
||||
const filterBaseline = (baseline) => {
|
||||
if (!baseline?.items) return baseline
|
||||
const items = baseline.items.filter(filterWorkspace)
|
||||
const allowedIds = new Set(items.map((w) => String(w.workspaceId)))
|
||||
return {
|
||||
...baseline,
|
||||
items,
|
||||
// keep archived as-is; session list ACL still applies
|
||||
}
|
||||
}
|
||||
|
||||
const filterFrame = (frame) => {
|
||||
if (!frame) return frame
|
||||
if (frame.type === 'baseline') {
|
||||
return { ...frame, value: filterBaseline(frame.value) }
|
||||
}
|
||||
if (frame.type === 'upsert') {
|
||||
return filterWorkspace(frame.workspace) ? frame : null
|
||||
}
|
||||
if (frame.type === 'order') {
|
||||
const identity = getUserContext()
|
||||
if (identity?.permissions?.canViewAllSessions) return frame
|
||||
const allowed = new Set(
|
||||
(userWorkspaces.get(identity?.empNo)?.workspaceId
|
||||
? [String(userWorkspaces.get(identity.empNo).workspaceId)]
|
||||
: []),
|
||||
)
|
||||
// Also allow any path-matched ids from current list — order may include others; filter
|
||||
return {
|
||||
...frame,
|
||||
workspaceIds: (frame.workspaceIds || []).filter((id) => {
|
||||
if (allowed.has(String(id))) return true
|
||||
// unknown ids: drop for non-super
|
||||
return false
|
||||
}),
|
||||
}
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
const origFollow = wc.follow.bind(wc)
|
||||
wc.follow = async function* (signal) {
|
||||
for await (const frame of origFollow(signal)) {
|
||||
const next = filterFrame(frame)
|
||||
if (next) yield next
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// settings mutate gate
|
||||
ctx.inject(['settings'], (sctx) => {
|
||||
const settings = sctx.settings
|
||||
if (!settings || settings.__udsAcl) return
|
||||
settings.__udsAcl = true
|
||||
for (const method of ['mutate', 'update', 'replace', 'write']) {
|
||||
if (typeof settings[method] !== 'function') continue
|
||||
const orig = settings[method].bind(settings)
|
||||
settings[method] = async (...args) => {
|
||||
const identity = getUserContext()
|
||||
// Allow uds-auth namespace writes from our own settings section for admins;
|
||||
// users cannot touch any settings.
|
||||
if (!identity?.permissions?.canAccessSettings) {
|
||||
throwForbidden('当前账号无设置权限')
|
||||
}
|
||||
return orig(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// directory picker: block createDirectory for non-super
|
||||
ctx.inject(['directoryPicker'], (dctx) => {
|
||||
const dp = dctx.directoryPicker
|
||||
if (!dp || dp.__udsAcl) return
|
||||
dp.__udsAcl = true
|
||||
if (typeof dp.createDirectory === 'function') {
|
||||
const orig = dp.createDirectory.bind(dp)
|
||||
dp.createDirectory = async (...args) => {
|
||||
const identity = getUserContext()
|
||||
if (!identity?.permissions?.canCreateWorkspace) {
|
||||
throwForbidden('只有超级管理员可以创建目录/工作区')
|
||||
}
|
||||
return orig(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
for (const d of disposers) {
|
||||
try { d() } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -61,6 +61,7 @@ const CONFIG_DEFAULTS = {
|
|||
userSearchUrl: 'https://icenterapi.zte.com.cn/zte-km-icenter-addresearch/user/plain/docs/search',
|
||||
loginSystemCode: '100000455558',
|
||||
originSystemCode: '',
|
||||
workspaceRoot: '',
|
||||
}
|
||||
|
||||
// 内部常量(不暴露给用户,UDS 固定协议)
|
||||
|
|
@ -95,6 +96,7 @@ function loadConfigSchemaAndSettings(ctx) {
|
|||
userSearchUrl: z.string().default(CONFIG_DEFAULTS.userSearchUrl),
|
||||
loginSystemCode: z.string().default(CONFIG_DEFAULTS.loginSystemCode),
|
||||
originSystemCode: z.string().default(CONFIG_DEFAULTS.originSystemCode),
|
||||
workspaceRoot: z.string().default(CONFIG_DEFAULTS.workspaceRoot),
|
||||
})
|
||||
_DshSettings = require('@deepseek-ai/dsh-settings')
|
||||
ctx?.logger?.info?.('[uds-auth] schemastery + dsh-settings loaded')
|
||||
|
|
@ -116,6 +118,9 @@ let _apiHandlers = null
|
|||
let _rolesStore = null
|
||||
let _currentConfig = null
|
||||
let _qrcodeLib = null
|
||||
let _sessionAcl = null
|
||||
let _userWorkspaces = null
|
||||
let _pluginCtx = null
|
||||
let _logger = console
|
||||
|
||||
function buildVerifyUrl(uacBaseUrl, uacQrVerifyPath) {
|
||||
|
|
@ -307,7 +312,8 @@ async function handleConfigGet(req, res) {
|
|||
userSearchUrl: c.userSearchUrl,
|
||||
loginSystemCode: c.loginSystemCode,
|
||||
originSystemCode: c.originSystemCode,
|
||||
},
|
||||
workspaceRoot: c.workspaceRoot,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -440,6 +446,7 @@ async function handleFallbackLogin(req, res) {
|
|||
lastActiveAt: new Date().toISOString(),
|
||||
}
|
||||
await _sessionStore.setex(empNo, Math.floor(INTERNAL.session.cookieMaxAge / 1000), userContext)
|
||||
await ensureUserWorkspace(empNo)
|
||||
|
||||
// 给浏览器设 cookie,让后续请求 auth-middleware 能识别
|
||||
res.setHeader('Set-Cookie', [
|
||||
|
|
@ -465,7 +472,7 @@ const RUNTIME_CONFIG_FILE = resolve(__dirname, '..', 'config.runtime.json')
|
|||
async function saveRuntimeConfig(partial) {
|
||||
if (!_currentConfig) throw new Error('配置未初始化')
|
||||
// 只允许修改 4 个可配置字段
|
||||
const allowed = ['uacBaseUrl', 'userSearchUrl', 'loginSystemCode', 'originSystemCode']
|
||||
const allowed = ['uacBaseUrl', 'userSearchUrl', 'loginSystemCode', 'originSystemCode', 'workspaceRoot']
|
||||
for (const k of allowed) {
|
||||
if (partial[k] !== undefined) {
|
||||
_currentConfig[k] = String(partial[k])
|
||||
|
|
@ -546,6 +553,9 @@ async function handleAllRoutes(req, res) {
|
|||
function handleRequest(req, res) {
|
||||
const ctx2 = { req, res }
|
||||
_authMiddleware(ctx2, async () => {
|
||||
if (ctx2.empNo && ctx2.empNo !== 'administrator') {
|
||||
try { await ensureUserWorkspace(ctx2.empNo) } catch { /* ignore */ }
|
||||
}
|
||||
// bootstrap 已在 auth-middleware.resolveRole 中完成(勿用 !ctx2.role,getRole 恒有值)
|
||||
|
||||
const url = new URL(req.url, 'http://localhost').pathname.replace(API_PREFIX, '') || '/'
|
||||
|
|
@ -662,6 +672,21 @@ function installSettingsSection(ctx, entry, hooks) {
|
|||
})
|
||||
}
|
||||
|
||||
|
||||
async function ensureUserWorkspace(empNo) {
|
||||
if (!_userWorkspaces || !_pluginCtx) return null
|
||||
try {
|
||||
return await _userWorkspaces.ensureUserWorkspace(
|
||||
_pluginCtx,
|
||||
_currentConfig?.workspaceRoot,
|
||||
empNo,
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn('[uds-auth] ensureUserWorkspace failed:', err.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
let source = () => mergeConfig(config)
|
||||
_currentConfig = source()
|
||||
|
|
@ -698,10 +723,13 @@ async function initServices(ctx, config) {
|
|||
const { createAuthMiddleware } = await import('./middleware/auth-middleware.js')
|
||||
const { createApiHandlers } = await import('./api.js')
|
||||
const { RolesStore } = await import('./roles.js')
|
||||
|
||||
const { SessionAclStore } = await import('./session-acl.js')
|
||||
const { UserWorkspaceStore } = await import('./workspace-provision.js')
|
||||
const { patchWebServerWithIdentity, resolveIdentityFromRequest, installDshAcl } = await import('./dsh-acl.js')
|
||||
|
||||
_pluginCtx = ctx
|
||||
_currentConfig = config
|
||||
|
||||
// 加载运行时配置覆盖(如果存在)
|
||||
try {
|
||||
const raw = await readFile(RUNTIME_CONFIG_FILE, 'utf-8')
|
||||
const rt = JSON.parse(raw)
|
||||
|
|
@ -710,29 +738,56 @@ async function initServices(ctx, config) {
|
|||
_currentConfig[k] = rt[k]
|
||||
}
|
||||
}
|
||||
} catch { /* runtime config 不存在是正常的 */ }
|
||||
|
||||
} catch { /* runtime config optional */ }
|
||||
|
||||
_sessionStore = await createSessionStore(INTERNAL.session)
|
||||
|
||||
// RolesStore — 持久化到 roles.json
|
||||
const rolesFile = resolve(__dirname, '..', 'roles.json')
|
||||
_rolesStore = new RolesStore({ rolesFile })
|
||||
await _rolesStore.init()
|
||||
|
||||
|
||||
_sessionAcl = new SessionAclStore({ ownersFile: resolve(__dirname, '..', 'session-owners.json') })
|
||||
await _sessionAcl.init()
|
||||
|
||||
_userWorkspaces = new UserWorkspaceStore({ mapFile: resolve(__dirname, '..', 'user-workspaces.json') })
|
||||
await _userWorkspaces.init()
|
||||
|
||||
_authMiddleware = createAuthMiddleware({
|
||||
userSearchUrl: config.userSearchUrl,
|
||||
userSearchUrl: _currentConfig.userSearchUrl,
|
||||
udsAuth: {
|
||||
baseUrl: config.uacBaseUrl,
|
||||
systemCode: config.loginSystemCode,
|
||||
userSearchUrl: config.userSearchUrl,
|
||||
baseUrl: _currentConfig.uacBaseUrl,
|
||||
systemCode: _currentConfig.loginSystemCode,
|
||||
userSearchUrl: _currentConfig.userSearchUrl,
|
||||
empNoHeader: INTERNAL.empNoHeader,
|
||||
authValueHeader: INTERNAL.authValueHeader,
|
||||
},
|
||||
session: INTERNAL.session,
|
||||
}, _sessionStore, _rolesStore)
|
||||
_apiHandlers = createApiHandlers({ session: INTERNAL.session }, _sessionStore, _rolesStore)
|
||||
|
||||
ctx.logger?.info?.('[uds-auth] Initialized')
|
||||
|
||||
const resolveIdentity = (req) => resolveIdentityFromRequest(req, {
|
||||
sessionStore: _sessionStore,
|
||||
rolesStore: _rolesStore,
|
||||
})
|
||||
|
||||
const patchServer = (server) => {
|
||||
patchWebServerWithIdentity(server, resolveIdentity)
|
||||
}
|
||||
const present = tryGet(ctx, 'webServer')
|
||||
if (present) patchServer(present)
|
||||
ctx.inject(['webServer'], (wctx) => {
|
||||
patchServer(wctx.webServer)
|
||||
})
|
||||
|
||||
installDshAcl(ctx, {
|
||||
sessionAcl: _sessionAcl,
|
||||
userWorkspaces: _userWorkspaces,
|
||||
getWorkspaceRoot: () => _currentConfig?.workspaceRoot,
|
||||
rolesStore: _rolesStore,
|
||||
ensureUserWorkspace,
|
||||
})
|
||||
|
||||
ctx.logger?.info?.('[uds-auth] Initialized (ACL + workspaces)')
|
||||
} catch (err) {
|
||||
ctx.logger?.error?.('[uds-auth] Init failed: ' + (err.message || err))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,13 +30,33 @@ export const ROLE_LABELS = {
|
|||
export function computePermissions(role) {
|
||||
switch (role) {
|
||||
case ROLES.SUPER_ADMIN:
|
||||
return { canManageUsers: true, canAccessSettings: true, canViewAllSessions: true }
|
||||
return {
|
||||
canManageUsers: true,
|
||||
canAccessSettings: true,
|
||||
canViewAllSessions: true,
|
||||
canCreateWorkspace: true,
|
||||
}
|
||||
case ROLES.FALLBACK_ADMIN:
|
||||
return { canManageUsers: true, canAccessSettings: true, canViewAllSessions: true }
|
||||
return {
|
||||
canManageUsers: true,
|
||||
canAccessSettings: true,
|
||||
canViewAllSessions: true,
|
||||
canCreateWorkspace: true,
|
||||
}
|
||||
case ROLES.ADMIN:
|
||||
return { canManageUsers: false, canAccessSettings: true, canViewAllSessions: false }
|
||||
return {
|
||||
canManageUsers: false,
|
||||
canAccessSettings: true,
|
||||
canViewAllSessions: false,
|
||||
canCreateWorkspace: false,
|
||||
}
|
||||
default: // user / undefined
|
||||
return { canManageUsers: false, canAccessSettings: false, canViewAllSessions: false }
|
||||
return {
|
||||
canManageUsers: false,
|
||||
canAccessSettings: false,
|
||||
canViewAllSessions: false,
|
||||
canCreateWorkspace: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -195,16 +215,9 @@ export class RolesStore {
|
|||
* 设置用户角色
|
||||
* 保护性 invariant: 至少保留 1 个 super_admin
|
||||
*/
|
||||
_assertAdmin(currentAdminRole) {
|
||||
if (currentAdminRole !== ROLES.SUPER_ADMIN && currentAdminRole !== ROLES.FALLBACK_ADMIN) {
|
||||
throw new Error('只有超级管理员可以执行此操作')
|
||||
}
|
||||
}
|
||||
|
||||
async setRole(empNo, newRole, currentAdminRole) {
|
||||
this._assertAdmin(currentAdminRole)
|
||||
if (newRole === ROLES.FALLBACK_ADMIN) {
|
||||
throw new Error('不能将普通工号设为兜底管理员(请使用应急账号)')
|
||||
if (currentAdminRole !== ROLES.SUPER_ADMIN) {
|
||||
throw new Error('只有超级管理员可以修改角色')
|
||||
}
|
||||
|
||||
// invariant: 不能让系统变成 0 个 super_admin
|
||||
|
|
@ -223,7 +236,9 @@ export class RolesStore {
|
|||
|
||||
/** 删除用户 */
|
||||
async removeUser(empNo, currentAdminRole) {
|
||||
this._assertAdmin(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)
|
||||
|
|
@ -238,7 +253,9 @@ export class RolesStore {
|
|||
|
||||
/** 确保用户存在 (如果不存在设为 user) */
|
||||
ensureUser(empNo, currentAdminRole) {
|
||||
this._assertAdmin(currentAdminRole)
|
||||
if (currentAdminRole !== ROLES.SUPER_ADMIN) {
|
||||
throw new Error('只有超级管理员可以添加用户')
|
||||
}
|
||||
if (!this._roles.has(empNo)) {
|
||||
this._roles.set(empNo, ROLES.USER)
|
||||
this._markDirty()
|
||||
|
|
@ -249,7 +266,9 @@ export class RolesStore {
|
|||
// === Fallback Administrator ===
|
||||
|
||||
setFallbackPassword(password, currentAdminRole) {
|
||||
this._assertAdmin(currentAdminRole)
|
||||
if (currentAdminRole !== ROLES.SUPER_ADMIN) {
|
||||
throw new Error('只有超级管理员可以设置兜底管理员密码')
|
||||
}
|
||||
if (!password || password.length < 6) {
|
||||
throw new Error('密码至少 6 位')
|
||||
}
|
||||
|
|
@ -259,7 +278,9 @@ export class RolesStore {
|
|||
}
|
||||
|
||||
clearFallbackPassword(currentAdminRole) {
|
||||
this._assertAdmin(currentAdminRole)
|
||||
if (currentAdminRole !== ROLES.SUPER_ADMIN) {
|
||||
throw new Error('只有超级管理员可以清除兜底管理员密码')
|
||||
}
|
||||
this._fallbackPasswordHash = null
|
||||
this._markDirty()
|
||||
return true
|
||||
|
|
|
|||
119
uds-auth/lib/session-acl.js
Normal file
119
uds-auth/lib/session-acl.js
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* Session ownership ACL — sessionId → empNo, persisted beside roles.json.
|
||||
*/
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { getUserContext } from './context.js'
|
||||
import { ROLES, computePermissions } from './roles.js'
|
||||
|
||||
export class SessionAclStore {
|
||||
/**
|
||||
* @param {{ ownersFile?: string }} options
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this._owners = new Map()
|
||||
this._ownersFile = options.ownersFile ? resolve(options.ownersFile) : null
|
||||
this._dirty = false
|
||||
this._saveTimer = null
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this._ownersFile) return
|
||||
try {
|
||||
const raw = await readFile(this._ownersFile, 'utf-8')
|
||||
const data = JSON.parse(raw)
|
||||
for (const [sessionId, empNo] of Object.entries(data.owners || {})) {
|
||||
this._owners.set(String(sessionId), String(empNo))
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
console.warn('[uds-auth:SessionAcl] load failed:', err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_markDirty() {
|
||||
this._dirty = true
|
||||
if (this._saveTimer) return
|
||||
this._saveTimer = setTimeout(() => { void this._save() }, 1500)
|
||||
}
|
||||
|
||||
async _save() {
|
||||
this._saveTimer = null
|
||||
if (!this._dirty || !this._ownersFile) return
|
||||
this._dirty = false
|
||||
try {
|
||||
await mkdir(dirname(this._ownersFile), { recursive: true })
|
||||
await writeFile(
|
||||
this._ownersFile,
|
||||
JSON.stringify({
|
||||
owners: Object.fromEntries(this._owners),
|
||||
savedAt: new Date().toISOString(),
|
||||
}, null, 2),
|
||||
'utf-8',
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn('[uds-auth:SessionAcl] save failed:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
getOwner(sessionId) {
|
||||
return this._owners.get(String(sessionId)) || null
|
||||
}
|
||||
|
||||
setOwner(sessionId, empNo) {
|
||||
if (!sessionId || !empNo) return
|
||||
this._owners.set(String(sessionId), String(empNo))
|
||||
this._markDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current ALS identity may see this session.
|
||||
* @param {string} sessionId
|
||||
*/
|
||||
canViewSession(sessionId) {
|
||||
const ctx = getUserContext()
|
||||
const perms = ctx?.permissions || computePermissions(ctx?.role)
|
||||
if (perms.canViewAllSessions) return true
|
||||
const owner = this.getOwner(sessionId)
|
||||
if (!owner) {
|
||||
// Orphan historical sessions: super_admin only (canViewAllSessions already true above)
|
||||
return false
|
||||
}
|
||||
return !!ctx?.empNo && owner === String(ctx.empNo)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter session list/search payloads shaped as { items: [...] }.
|
||||
* @param {{ items?: Array<{ sessionId?: string }> }} value
|
||||
*/
|
||||
filterListValue(value) {
|
||||
if (!value || !Array.isArray(value.items)) return value
|
||||
return {
|
||||
...value,
|
||||
items: value.items.filter((row) => {
|
||||
const id = row?.sessionId ?? row?.id
|
||||
return id != null && this.canViewSession(id)
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve identity for ACL checks (null = anonymous).
|
||||
* @param {import('./roles.js').RolesStore} rolesStore
|
||||
*/
|
||||
export function identityFromAls(rolesStore) {
|
||||
const ctx = getUserContext()
|
||||
if (!ctx?.empNo) return null
|
||||
const role = ctx.role || rolesStore.getRole(ctx.empNo)
|
||||
return {
|
||||
empNo: ctx.empNo,
|
||||
role,
|
||||
permissions: ctx.permissions || computePermissions(role),
|
||||
}
|
||||
}
|
||||
|
||||
export function isSuperLike(role) {
|
||||
return role === ROLES.SUPER_ADMIN || role === ROLES.FALLBACK_ADMIN
|
||||
}
|
||||
122
uds-auth/lib/workspace-provision.js
Normal file
122
uds-auth/lib/workspace-provision.js
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* Per-user workspace provisioning under workspaceRoot/<empNo>.
|
||||
*/
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { withUserContext, getUserContext } from './context.js'
|
||||
|
||||
function defaultWorkspaceRoot() {
|
||||
const home = process.env.DSH_HOME || process.env.DSH_PROFILE_DIR || join(homedir(), '.dsh')
|
||||
return join(home, 'user-workspaces')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|undefined} configured
|
||||
*/
|
||||
export function resolveWorkspaceRoot(configured) {
|
||||
const raw = (configured && String(configured).trim()) || defaultWorkspaceRoot()
|
||||
return resolve(raw)
|
||||
}
|
||||
|
||||
export class UserWorkspaceStore {
|
||||
/**
|
||||
* @param {{ mapFile?: string }} options
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this._map = new Map() // empNo → { path, workspaceId }
|
||||
this._mapFile = options.mapFile ? resolve(options.mapFile) : null
|
||||
this._dirty = false
|
||||
this._saveTimer = null
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this._mapFile) return
|
||||
try {
|
||||
const raw = await readFile(this._mapFile, 'utf-8')
|
||||
const data = JSON.parse(raw)
|
||||
for (const [empNo, row] of Object.entries(data.users || {})) {
|
||||
if (row?.path) this._map.set(String(empNo), { path: String(row.path), workspaceId: row.workspaceId || null })
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') console.warn('[uds-auth:UserWorkspace] load failed:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
_markDirty() {
|
||||
this._dirty = true
|
||||
if (this._saveTimer) return
|
||||
this._saveTimer = setTimeout(() => { void this._save() }, 1500)
|
||||
}
|
||||
|
||||
async _save() {
|
||||
this._saveTimer = null
|
||||
if (!this._dirty || !this._mapFile) return
|
||||
this._dirty = false
|
||||
try {
|
||||
await mkdir(dirname(this._mapFile), { recursive: true })
|
||||
await writeFile(
|
||||
this._mapFile,
|
||||
JSON.stringify({
|
||||
users: Object.fromEntries(this._map),
|
||||
savedAt: new Date().toISOString(),
|
||||
}, null, 2),
|
||||
'utf-8',
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn('[uds-auth:UserWorkspace] save failed:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
get(empNo) {
|
||||
return this._map.get(String(empNo)) || null
|
||||
}
|
||||
|
||||
set(empNo, row) {
|
||||
this._map.set(String(empNo), row)
|
||||
this._markDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure directory + registry entry for empNo.
|
||||
* @param {object} ctx - cordis context with workspaceRegistry
|
||||
* @param {string} workspaceRoot
|
||||
* @param {string} empNo
|
||||
*/
|
||||
async ensureUserWorkspace(ctx, workspaceRoot, empNo) {
|
||||
if (!empNo || empNo === 'administrator') {
|
||||
// Fallback admin: still get a dedicated folder
|
||||
empNo = empNo || 'administrator'
|
||||
}
|
||||
const root = resolveWorkspaceRoot(workspaceRoot)
|
||||
const userPath = join(root, String(empNo))
|
||||
await mkdir(userPath, { recursive: true })
|
||||
|
||||
const registry = ctx.workspaceRegistry || ctx.get?.('workspaceRegistry')
|
||||
if (!registry || typeof registry.create !== 'function') {
|
||||
console.warn('[uds-auth] workspaceRegistry unavailable; mkdir only:', userPath)
|
||||
this.set(empNo, { path: userPath, workspaceId: null })
|
||||
return { path: userPath, workspaceId: null, workspace: null }
|
||||
}
|
||||
|
||||
const prev = getUserContext()
|
||||
const workspace = await withUserContext(
|
||||
{ ...(prev || {}), empNo, _internalProvision: true },
|
||||
() => registry.create(userPath),
|
||||
)
|
||||
const workspaceId = workspace?.id != null ? String(workspace.id) : null
|
||||
this.set(empNo, { path: userPath, workspaceId })
|
||||
return { path: userPath, workspaceId, workspace }
|
||||
}
|
||||
|
||||
/** Path prefix check: is this path under the user's provisioned dir? */
|
||||
isUserPath(empNo, candidatePath, workspaceRoot) {
|
||||
if (!empNo || !candidatePath) return false
|
||||
const root = resolveWorkspaceRoot(workspaceRoot)
|
||||
const userPath = resolve(join(root, String(empNo)))
|
||||
const cand = resolve(String(candidatePath))
|
||||
return cand === userPath || cand.startsWith(userPath + sep)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue