From bad000d5b659809bbb88e0d7ab74bdc7d4dd8105 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 8 Sep 2026 14:25:52 +0800 Subject: [PATCH] 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 --- uds-auth/README.zh.md | 1 + uds-auth/lib/dsh-acl.js | 214 ++++++++++++++++++++++++++++-------- uds-auth/lib/index.js | 1 + uds-auth/lib/session-acl.js | 32 +++--- 4 files changed, 185 insertions(+), 63 deletions(-) diff --git a/uds-auth/README.zh.md b/uds-auth/README.zh.md index e86e9caf..f8ee0acc 100644 --- a/uds-auth/README.zh.md +++ b/uds-auth/README.zh.md @@ -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 diff --git a/uds-auth/lib/dsh-acl.js b/uds-auth/lib/dsh-acl.js index e532cf48..5f8264cc 100644 --- a/uds-auth/lib/dsh-acl.js +++ b/uds-auth/lib/dsh-acl.js @@ -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 diff --git a/uds-auth/lib/index.js b/uds-auth/lib/index.js index e8fc9e53..7ec06156 100644 --- a/uds-auth/lib/index.js +++ b/uds-auth/lib/index.js @@ -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)') diff --git a/uds-auth/lib/session-acl.js b/uds-auth/lib/session-acl.js index e818ef0c..877c8495 100644 --- a/uds-auth/lib/session-acl.js +++ b/uds-auth/lib/session-acl.js @@ -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) }), } }