From e2c2e949eda5c1b297414904410fa3c3df650165 Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 10 Sep 2026 00:30:46 +0800 Subject: [PATCH] Restrict @ mention and session query ACL so admin/user only see owned or workspace sessions. Co-authored-by: Cursor --- uds-auth/README.zh.md | 2 +- uds-auth/lib/dsh-acl.js | 396 ++++++++++++++++++++------- uds-auth/lib/roles.js | 7 +- uds-auth/lib/session-acl.js | 6 +- uds-auth/test/session-access.test.js | 93 +++++++ 5 files changed, 394 insertions(+), 110 deletions(-) create mode 100644 uds-auth/test/session-access.test.js diff --git a/uds-auth/README.zh.md b/uds-auth/README.zh.md index f9736a3f..98a64148 100644 --- a/uds-auth/README.zh.md +++ b/uds-auth/README.zh.md @@ -41,7 +41,7 @@ originSystemCode: '' - `POST /uds-auth/outbound` — **loopback**:白名单出站并注入鉴权头 - 用户管理 / 兜底管理员:见 `/uds-auth/api/users*`、`/uds-auth/api/fallback/*` - **默认兜底账号**(扫码不可用时):用户名 `administrator`,密码 `Admin@123`(首次启动自动启用;可在设置中改密或关闭) -- **ACL**:租户边界以工作区为准(可见工作区下的会话可访问);`session-owners.json` 仅记录工号供导出/分析,不是主鉴权键 +- **ACL**:`super_admin` / 兜底 `administrator` 可见全部会话(含 `@` 提及);`admin` / `user` 仅可见 **自己拥有的** 或 **自己工作区路径下的** 会话。侧栏、`session/search` 与 `@` 候选共用同一规则 ## Skill 认证(给他人改造 skill 时) diff --git a/uds-auth/lib/dsh-acl.js b/uds-auth/lib/dsh-acl.js index a1711879..ba1435f2 100644 --- a/uds-auth/lib/dsh-acl.js +++ b/uds-auth/lib/dsh-acl.js @@ -370,6 +370,114 @@ function throwForbidden(code) { throw err } +/** + * Shared session visibility helpers (sidebar + @ mention + query reads). + * Visibility: super_admin / fallback_admin see all; others see owner OR own workspace. + */ +export function createSessionAccess({ + sessionAcl, + userWorkspaces, + getWorkspaceRoot, + getWorkspaceRegistry, + resolveLiveCwd, +}) { + 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 resolveRegistry = () => { + try { + if (typeof getWorkspaceRegistry === 'function') { + const r = getWorkspaceRegistry() + if (r) return r + } + } catch { /* ignore */ } + return null + } + + const resolveSessionCwd = (sessionId, rowHint) => { + if (rowHint?.cwd) return String(rowHint.cwd) + if (rowHint?.header?.cwd) return String(rowHint.header.cwd) + if (typeof resolveLiveCwd === 'function') { + try { + const cwd = resolveLiveCwd(sessionId) + 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)) + } + + /** + * Owner stamp OR cwd/workspace under the caller's provisioned path. + * Missing owner alone does not deny (legacy sessions rely on workspace/cwd). + */ + 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 owner = sessionAcl?.getOwner?.(sessionId) || null + if (owner && String(owner) === String(empNo)) return true + + 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 + } + + return { + canSeeAll, + empOf, + resolveRegistry, + resolveSessionCwd, + isVisibleWorkspace, + canAccessSession, + } +} + /** * Install Host ACL wrappers. */ @@ -383,6 +491,37 @@ export function installDshAcl(ctx, { }) { const disposers = [] + const access = createSessionAccess({ + sessionAcl, + userWorkspaces, + getWorkspaceRoot, + getWorkspaceRegistry: () => { + try { + if (typeof getWorkspaceRegistry === 'function') { + const r = getWorkspaceRegistry() + if (r) return r + } + } catch { /* ignore */ } + try { return ctx.get('workspaceRegistry') } catch { return null } + }, + resolveLiveCwd: (sessionId) => { + try { + const agents = ctx.get('agents') + const agent = agents?.get?.(sessionId) + return agent?.session?.header?.cwd || null + } catch { + return null + } + }, + }) + const { + canSeeAll, + empOf, + resolveRegistry, + isVisibleWorkspace, + canAccessSession, + } = access + // Stamp owner on session create const offCreated = ctx.on('session/created', (session) => { try { @@ -397,114 +536,161 @@ export function installDshAcl(ctx, { }) disposers.push(() => offCreated?.()) + const extractSessionId = (request) => { + if (!request || typeof request !== 'object') return null + return request.address?.sessionId + ?? request.sessionId + ?? request.id + ?? request.childSessionId + ?? null + } + + // Browser HTTP always enters ALS via withUserContext(null|identity). + // In-process Host callers (WhatsApp/IM, cron fire) never enter ALS → undefined. + // Treat undefined as host-internal and skip UDS ACL (pre-auth behavior). + const assertCanAccess = (request, rowHint) => { + const identity = getUserContext() + if (identity === undefined) return + if (!empOf(identity)) throwForbidden('login_required_session') + if (canSeeAll(identity)) return + const sessionId = extractSessionId(request) + if (!canAccessSession(sessionId, identity, rowHint)) { + throwForbidden('session_forbidden') + } + } + + const assertSessionReadable = (sessionId, rowHint) => { + const identity = getUserContext() + if (identity === undefined) return + if (!empOf(identity)) throwForbidden('login_required_session') + if (canSeeAll(identity)) return + if (!canAccessSession(sessionId, identity, rowHint)) { + throwForbidden('session_forbidden') + } + } + + const filterSessionRecords = (records, identity) => (records || []).filter((row) => { + const id = row?.header?.id ?? row?.sessionId ?? row?.id + return id != null && canAccessSession(id, identity, { + cwd: row?.header?.cwd ?? row?.cwd, + header: row?.header, + }) + }) + + // @ mention discovery (SessionReferenceResolver) lists via sessionQuery.listSessions, + // bypassing sessionController ACL — filter the corpus here. + ctx.inject(['sessionQuery'], (qctx) => { + const sq = qctx.sessionQuery + if (!sq || sq.__udsAcl) return + sq.__udsAcl = true + + if (typeof sq.listSessions === 'function') { + const origList = sq.listSessions.bind(sq) + sq.listSessions = async (signal) => { + const records = await origList(signal) + const identity = getUserContext() + if (identity === undefined) return records + if (!empOf(identity)) return [] + if (canSeeAll(identity)) return records + return filterSessionRecords(records, identity) + } + } + + if (typeof sq.filterSessions === 'function') { + const origFilter = sq.filterSessions.bind(sq) + sq.filterSessions = async (filters, signal) => { + const records = await origFilter(filters, signal) + const identity = getUserContext() + if (identity === undefined) return records + if (!empOf(identity)) return [] + if (canSeeAll(identity)) return records + return filterSessionRecords(records, identity) + } + } + + if (typeof sq.searchSessions === 'function') { + const origSearch = sq.searchSessions.bind(sq) + sq.searchSessions = async (request, exec) => { + const page = await origSearch(request, exec) + const identity = getUserContext() + if (identity === undefined) return page + if (!empOf(identity)) { + return page && typeof page === 'object' + ? { ...page, hits: [], items: [] } + : page + } + if (canSeeAll(identity)) return page + if (!page || typeof page !== 'object') return page + const filterHits = (hits) => (hits || []).filter((hit) => { + const id = hit?.sessionId ?? hit?.header?.id ?? hit?.id + return id != null && canAccessSession(id, identity, { + cwd: hit?.cwd ?? hit?.header?.cwd, + header: hit?.header, + }) + }) + return { + ...page, + ...(Array.isArray(page.hits) ? { hits: filterHits(page.hits) } : {}), + ...(Array.isArray(page.items) ? { items: filterHits(page.items) } : {}), + } + } + } + + for (const method of ['readSession', 'readSurface', 'readTitle', 'readTitleSnapshot', 'listEvents', 'observeSession']) { + if (typeof sq[method] !== 'function') continue + const orig = sq[method].bind(sq) + sq[method] = async (sessionId, ...rest) => { + assertSessionReadable(sessionId) + return orig(sessionId, ...rest) + } + } + }) + + // Belt-and-suspenders: filter @ candidates even if listSessions wrap order changes. + ctx.inject(['sessionReferenceResolver'], (rctx) => { + const resolver = rctx.sessionReferenceResolver + if (!resolver || resolver.__udsAcl) return + resolver.__udsAcl = true + + if (typeof resolver.listCandidates === 'function') { + const origList = resolver.listCandidates.bind(resolver) + resolver.listCandidates = async (agent, query, limit, signal) => { + const candidates = await origList(agent, query, limit, signal) + const identity = getUserContext() + if (identity === undefined) return candidates + if (!empOf(identity)) return [] + if (canSeeAll(identity)) return candidates + return (candidates || []).filter((c) => canAccessSession(c?.sessionId, identity, { + cwd: c?.cwd, + })) + } + } + + if (typeof resolver.prepare === 'function') { + const origPrepare = resolver.prepare.bind(resolver) + resolver.prepare = async (agent, content, references, signal) => { + const identity = getUserContext() + if (identity !== undefined) { + if (!empOf(identity)) throwForbidden('login_required_session') + if (!canSeeAll(identity)) { + for (const ref of references || []) { + const sid = ref?.sessionId + if (sid != null && !canAccessSession(sid, identity)) { + throwForbidden('session_forbidden') + } + } + } + } + return origPrepare(agent, content, references, signal) + } + } + }) + ctx.inject(['sessionController'], (sctx) => { const sc = sctx.sessionController 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 - } - - // Browser HTTP always enters ALS via withUserContext(null|identity). - // In-process Host callers (WhatsApp/IM, cron fire) never enter ALS → undefined. - // Treat undefined as host-internal and skip UDS ACL (pre-auth behavior). - const assertCanAccess = (request, rowHint) => { - const identity = getUserContext() - if (identity === undefined) return - if (!empOf(identity)) throwForbidden('login_required_session') - if (canSeeAll(identity)) return - const sessionId = extractSessionId(request) - if (!canAccessSession(sessionId, identity, rowHint)) { - throwForbidden('session_forbidden') - } - } - const assertCreateTargetAllowed = async (req, identity) => { if (canSeeAll(identity) || identity.permissions?.canCreateWorkspace) return const empNo = empOf(identity) @@ -536,6 +722,7 @@ export function installDshAcl(ctx, { sc.listState.list = async (signal) => { const items = await origStateList(signal) const identity = getUserContext() + if (identity === undefined) return items if (!empOf(identity)) return [] if (canSeeAll(identity)) return items return filterItems(items, identity) @@ -545,6 +732,7 @@ export function installDshAcl(ctx, { sc.listState.search = async (query, signal) => { const value = await origStateSearch(query, signal) const identity = getUserContext() + if (identity === undefined) return value if (!empOf(identity)) return { items: [], hasMore: false } if (canSeeAll(identity)) return value return sessionAcl.filterListValue(value, (id, row) => canAccessSession(id, identity, row)) @@ -556,6 +744,7 @@ export function installDshAcl(ctx, { sc.list = async (request, signal) => { const value = await origList(request, signal) const identity = getUserContext() + if (identity === undefined) return value if (!empOf(identity)) return { items: [] } if (canSeeAll(identity)) return value return sessionAcl.filterListValue(value, (id, row) => canAccessSession(id, identity, row)) @@ -565,6 +754,7 @@ export function installDshAcl(ctx, { sc.search = async (request, signal) => { const value = await origSearch(request, signal) const identity = getUserContext() + if (identity === undefined) return value if (!empOf(identity)) return { items: [], hasMore: false } if (canSeeAll(identity)) return value return sessionAcl.filterListValue(value, (id, row) => canAccessSession(id, identity, row)) diff --git a/uds-auth/lib/roles.js b/uds-auth/lib/roles.js index d9949cab..c0f3582a 100644 --- a/uds-auth/lib/roles.js +++ b/uds-auth/lib/roles.js @@ -2,9 +2,10 @@ * uds-auth 角色存储 + 权限管理 * * 角色: - * super_admin 所有权限 + 用户管理 - * admin 设置权限 + 仅看自己会话 - * user 仅看自己会话,无设置 + * super_admin 所有权限 + 用户管理 + 可见全部会话(含 @) + * fallback_admin 等同 super_admin(兜底 administrator) + * admin 设置权限 + 仅看自己会话(含 @) + * user 仅看自己会话,无设置 * * 持久化: roles.json (单实例文件) + MemoryStore 同步 */ diff --git a/uds-auth/lib/session-acl.js b/uds-auth/lib/session-acl.js index 877c8495..3ffe630e 100644 --- a/uds-auth/lib/session-acl.js +++ b/uds-auth/lib/session-acl.js @@ -1,8 +1,8 @@ /** - * Session ownership audit store — sessionId → empNo. + * Session ownership store — sessionId → empNo. * - * Used for export / analytics. Tenant isolation is workspace-based - * (see dsh-acl.js); missing owner must NOT deny access by itself. + * Used with workspace/cwd checks in dsh-acl.js (owner OR own workspace). + * Missing owner must NOT deny access by itself (legacy sessions). */ import { readFile, writeFile, mkdir } from 'node:fs/promises' import { dirname, resolve } from 'node:path' diff --git a/uds-auth/test/session-access.test.js b/uds-auth/test/session-access.test.js new file mode 100644 index 00000000..1a6c8ecb --- /dev/null +++ b/uds-auth/test/session-access.test.js @@ -0,0 +1,93 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { createSessionAccess } from '../lib/dsh-acl.js' + +function makeAccess(owners = {}) { + const ownersMap = new Map(Object.entries(owners)) + const userPaths = new Map([ + ['u1', { path: '/ws/u1', workspaceId: 'ws-u1' }], + ['u2', { path: '/ws/u2', workspaceId: 'ws-u2' }], + ]) + return createSessionAccess({ + sessionAcl: { + getOwner(id) { + return ownersMap.get(String(id)) || null + }, + }, + userWorkspaces: { + get(empNo) { + return userPaths.get(String(empNo)) || null + }, + isUserPath(empNo, candidate, root) { + const row = userPaths.get(String(empNo)) + if (!row?.path || !candidate) return false + const base = String(row.path).replace(/\\/g, '/').replace(/\/+$/, '') + const path = String(candidate).replace(/\\/g, '/') + return path === base || path.startsWith(base + '/') + }, + }, + getWorkspaceRoot: () => '/ws', + getWorkspaceRegistry: () => ({ + list: () => ([ + { id: 'ws-u1', path: '/ws/u1', sessionIds: ['s-in-u1'] }, + { id: 'ws-u2', path: '/ws/u2', sessionIds: ['s-in-u2'] }, + { id: 'ws-shared', path: '/ws/shared', sessionIds: ['s-shared'] }, + ]), + }), + }) +} + +describe('createSessionAccess', () => { + it('super_admin and administrator see all sessions', () => { + const { canAccessSession, canSeeAll } = makeAccess({ 's-other': 'u2' }) + const superAdmin = { + empNo: 'boss', + role: 'super_admin', + permissions: { canViewAllSessions: true }, + } + const fallback = { + empNo: 'administrator', + role: 'fallback_admin', + permissions: { canViewAllSessions: true }, + } + assert.equal(canSeeAll(superAdmin), true) + assert.equal(canSeeAll(fallback), true) + assert.equal(canAccessSession('s-other', superAdmin), true) + assert.equal(canAccessSession('s-other', fallback), true) + }) + + it('admin and user only see owned or own-workspace sessions', () => { + const { canAccessSession, canSeeAll } = makeAccess({ + 's-owned': 'u1', + 's-peer': 'u2', + }) + const admin = { + empNo: 'u1', + role: 'admin', + permissions: { canViewAllSessions: false, canAccessSettings: true }, + } + const user = { + empNo: 'u1', + role: 'user', + permissions: { canViewAllSessions: false }, + } + assert.equal(canSeeAll(admin), false) + assert.equal(canSeeAll(user), false) + + assert.equal(canAccessSession('s-owned', admin), true) + assert.equal(canAccessSession('s-in-u1', user), true) + assert.equal(canAccessSession('s-cwd', user, { cwd: '/ws/u1/project' }), true) + + assert.equal(canAccessSession('s-peer', admin), false) + assert.equal(canAccessSession('s-in-u2', user), false) + assert.equal(canAccessSession('s-shared', user), false) + assert.equal(canAccessSession('s-cwd-peer', user, { cwd: '/ws/u2/x' }), false) + }) + + it('missing owner does not deny when cwd is under user path', () => { + const { canAccessSession } = makeAccess({}) + const user = { empNo: 'u1', role: 'user', permissions: { canViewAllSessions: false } } + assert.equal(canAccessSession('legacy', user, { cwd: '/ws/u1' }), true) + assert.equal(canAccessSession('legacy-other', user, { cwd: '/ws/u2' }), false) + }) +})