Use workspace membership as session ACL; keep owners for audit.

Session RPC access checks visible workspaces (and cwd), wraps prompt and related methods with address.sessionId extraction, and gates create paths for non-admins.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-08 14:25:52 +08:00
parent 97dbfc4940
commit bad000d5b6
4 changed files with 185 additions and 63 deletions

View file

@ -39,6 +39,7 @@ originSystemCode: ''
- `POST /uds-auth/api/logout` — 登出
- 用户管理 / 兜底管理员:见 `/uds-auth/api/users*`、`/uds-auth/api/fallback/*`
- **默认兜底账号**(扫码不可用时):用户名 `administrator`,密码 `Admin@123`(首次启动自动启用;可在设置中改密或关闭)
- **ACL**:租户边界以工作区为准(可见工作区下的会话可访问);`session-owners.json` 仅记录工号供导出/分析,不是主鉴权键
## License

View file

@ -344,6 +344,7 @@ export function installDshAcl(ctx, {
getWorkspaceRoot,
rolesStore,
ensureUserWorkspace,
getWorkspaceRegistry,
}) {
const disposers = []
@ -366,6 +367,129 @@ export function installDshAcl(ctx, {
if (!sc || sc.__udsAcl) return
sc.__udsAcl = true
const canSeeAll = (identity) => {
if (!identity) return false
if (identity.permissions?.canViewAllSessions) return true
const role = identity.role || identity.userContext?.role
if (role === 'fallback_admin' || role === 'super_admin') return true
if (String(identity.empNo || identity.userContext?.empNo || '') === 'administrator') return true
return false
}
const empOf = (identity) => identity?.empNo || identity?.userContext?.empNo || null
const extractSessionId = (request) => {
if (!request || typeof request !== 'object') return null
return request.address?.sessionId
?? request.sessionId
?? request.id
?? request.childSessionId
?? null
}
const resolveRegistry = () => {
try {
if (typeof getWorkspaceRegistry === 'function') {
const r = getWorkspaceRegistry()
if (r) return r
}
} catch { /* ignore */ }
try { return ctx.get('workspaceRegistry') } catch { return null }
}
const resolveSessionCwd = (sessionId, rowHint) => {
if (rowHint?.cwd) return String(rowHint.cwd)
try {
const agents = ctx.get('agents')
const agent = agents?.get?.(sessionId)
const cwd = agent?.session?.header?.cwd
if (cwd) return String(cwd)
} catch { /* ignore */ }
return null
}
const workspaceContainsSession = (ws, sessionId) => {
const sid = String(sessionId)
try {
const ids = ws?.sessionIds
if (ids && typeof ids[Symbol.iterator] === 'function') {
for (const id of ids) {
if (String(id) === sid) return true
}
}
} catch { /* ignore */ }
const raw = ws?.record?.sessionIds
if (Array.isArray(raw) && raw.some((id) => String(id) === sid)) return true
return false
}
const isVisibleWorkspace = (identity, ws) => {
if (canSeeAll(identity)) return true
const empNo = empOf(identity)
if (!empNo || !ws) return false
const root = getWorkspaceRoot()
const wid = ws.id ?? ws.workspaceId
const path = ws.path
return userWorkspaces.isUserPath(empNo, path, root)
|| (userWorkspaces.get(empNo)?.workspaceId
&& String(userWorkspaces.get(empNo).workspaceId) === String(wid))
}
/** Workspace-first access: visible workspace membership or cwd under user path. */
const canAccessSession = (sessionId, identity, rowHint) => {
if (!identity || !empOf(identity)) return false
if (canSeeAll(identity)) return true
if (sessionId == null) return false
const empNo = empOf(identity)
const root = getWorkspaceRoot()
const cwd = resolveSessionCwd(sessionId, rowHint)
if (cwd && userWorkspaces.isUserPath(empNo, cwd, root)) return true
const registry = resolveRegistry()
if (!registry || typeof registry.list !== 'function') return false
let workspaces = []
try { workspaces = registry.list() || [] } catch { return false }
for (const ws of workspaces) {
if (!isVisibleWorkspace(identity, ws)) continue
if (workspaceContainsSession(ws, sessionId)) return true
}
return false
}
const assertCanAccess = (request, rowHint) => {
const identity = getUserContext()
if (!empOf(identity)) throwForbidden('登录后才能访问会话')
if (canSeeAll(identity)) return
const sessionId = extractSessionId(request)
if (!canAccessSession(sessionId, identity, rowHint)) {
throwForbidden('无权访问该会话')
}
}
const assertCreateTargetAllowed = async (req, identity) => {
if (canSeeAll(identity) || identity.permissions?.canCreateWorkspace) return
const empNo = empOf(identity)
const root = getWorkspaceRoot()
if (req.workspaceId !== undefined) {
const registry = resolveRegistry()
const ws = registry?.get?.(req.workspaceId)
if (!ws || !isVisibleWorkspace(identity, ws)) {
throwForbidden('只能在自己的工作区创建会话')
}
return
}
if (req.cwd !== undefined) {
if (!userWorkspaces.isUserPath(empNo, req.cwd, root)) {
throwForbidden('只能在自己的工作区创建会话')
}
}
}
const filterItems = (items, identity) => (items || []).filter((row) => {
const id = row?.sessionId ?? row?.id
return id != null && canAccessSession(id, identity, row)
})
// Deeper wrap: ApiSessionList.list (cold summaries)
if (sc.listState && typeof sc.listState.list === 'function' && !sc.listState.__udsAcl) {
sc.listState.__udsAcl = true
@ -373,21 +497,18 @@ export function installDshAcl(ctx, {
sc.listState.list = async (signal) => {
const items = await origStateList(signal)
const identity = getUserContext()
if (!identity?.empNo) return []
if (identity.permissions?.canViewAllSessions) return items
return (items || []).filter((row) => {
const id = row?.sessionId ?? row?.id
return id != null && sessionAcl.canViewSession(id)
})
if (!empOf(identity)) return []
if (canSeeAll(identity)) return items
return filterItems(items, identity)
}
if (typeof sc.listState.search === 'function') {
const origStateSearch = sc.listState.search.bind(sc.listState)
sc.listState.search = async (query, signal) => {
const value = await origStateSearch(query, signal)
const identity = getUserContext()
if (!identity?.empNo) return { items: [], hasMore: false }
if (identity.permissions?.canViewAllSessions) return value
return sessionAcl.filterListValue(value)
if (!empOf(identity)) return { items: [], hasMore: false }
if (canSeeAll(identity)) return value
return sessionAcl.filterListValue(value, (id, row) => canAccessSession(id, identity, row))
}
}
}
@ -396,26 +517,28 @@ export function installDshAcl(ctx, {
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)
if (!empOf(identity)) return { items: [] }
if (canSeeAll(identity)) return value
return sessionAcl.filterListValue(value, (id, row) => canAccessSession(id, identity, row))
}
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)
if (!empOf(identity)) return { items: [], hasMore: false }
if (canSeeAll(identity)) return value
return sessionAcl.filterListValue(value, (id, row) => canAccessSession(id, identity, row))
}
const origCreate = sc.create.bind(sc)
sc.create = async (request) => {
const identity = getUserContext()
if (!identity?.empNo) throwForbidden('登录后才能创建会话')
if (!empOf(identity)) throwForbidden('登录后才能创建会话')
let req = { ...(request || {}) }
await assertCreateTargetAllowed(req, identity)
let req = request || {}
if (req.workspaceId === undefined && req.cwd === undefined) {
const ensured = await ensureUserWorkspace(identity.empNo)
if (ensured?.workspaceId) {
@ -430,52 +553,55 @@ export function installDshAcl(ctx, {
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)
const wrapSessionMethod = (methodName) => {
if (typeof sc[methodName] !== 'function') return
const orig = sc[methodName].bind(sc)
sc[methodName] = (request, signal) => {
assertCanAccess(request)
return orig(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)
wrapSessionMethod('page')
wrapSessionMethod('follow')
wrapSessionMethod('prompt')
wrapSessionMethod('rename')
wrapSessionMethod('cancel')
wrapSessionMethod('updateQueue')
wrapSessionMethod('attachment')
wrapSessionMethod('selectModel')
if (typeof sc.openWorkspacePath === 'function') {
const origOpenPath = sc.openWorkspacePath.bind(sc)
sc.openWorkspacePath = async (request, signal) => {
const identity = getUserContext()
if (!empOf(identity)) throwForbidden('登录后才能访问会话')
if (!canSeeAll(identity) && !identity.permissions?.canCreateWorkspace) {
const path = request?.path
if (!path || !userWorkspaces.isUserPath(empOf(identity), path, getWorkspaceRoot())) {
throwForbidden('只能打开自己的工作区路径')
}
}
return origOpenPath(request, signal)
}
}
if (typeof sc.fork === 'function') {
const origFork = sc.fork.bind(sc)
sc.fork = async (request, signal) => {
assertCanView(request?.sessionId ?? request?.id)
assertCanAccess(request)
const result = await origFork(request, signal)
const identity = getUserContext()
const sid = result?.sessionId ?? result?.id
if (sid && identity?.empNo) sessionAcl.setOwner(sid, identity.empNo)
if (sid && empOf(identity)) sessionAcl.setOwner(sid, empOf(identity))
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) => {
ctx.inject(['workspaceController'], (wctx) => {
const wc = wctx.workspaceController
if (!wc || wc.__udsAcl) return
wc.__udsAcl = true

View file

@ -816,6 +816,7 @@ async function initServices(ctx, config) {
getWorkspaceRoot: () => _currentConfig?.workspaceRoot,
rolesStore: _rolesStore,
ensureUserWorkspace,
getWorkspaceRegistry: () => _workspaceRegistry,
})
ctx.logger?.info?.('[uds-auth] Initialized (ACL + workspaces)')

View file

@ -1,5 +1,8 @@
/**
* Session ownership ACL — sessionId → empNo, persisted beside roles.json.
* Session ownership audit store — sessionId → empNo.
*
* Used for export / analytics. Tenant isolation is workspace-based
* (see dsh-acl.js); missing owner must NOT deny access by itself.
*/
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
@ -67,33 +70,24 @@ export class SessionAclStore {
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)
/** All stamped owners for export / analytics. */
listOwners() {
return Array.from(this._owners.entries()).map(([sessionId, empNo]) => ({ sessionId, empNo }))
}
/**
* Filter session list/search payloads shaped as { items: [...] }.
* @param {{ items?: Array<{ sessionId?: string }> }} value
* Filter list/search payloads with an external access predicate.
* @param {{ items?: Array<{ sessionId?: string, id?: string }> }} value
* @param {(sessionId: string, row: object) => boolean} canAccess
*/
filterListValue(value) {
filterListValue(value, canAccess) {
if (!value || !Array.isArray(value.items)) return value
const pred = typeof canAccess === 'function' ? canAccess : () => false
return {
...value,
items: value.items.filter((row) => {
const id = row?.sessionId ?? row?.id
return id != null && this.canViewSession(id)
return id != null && pred(String(id), row)
}),
}
}