mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-18 18:23:22 +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
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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue