Restrict @ mention and session query ACL so admin/user only see owned or workspace sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-10 00:30:46 +08:00
parent e48b8bd082
commit e2c2e949ed
5 changed files with 394 additions and 110 deletions

View file

@ -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 时)

View file

@ -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))

View file

@ -2,9 +2,10 @@
* uds-auth 角色存储 + 权限管理
*
* 角色:
* super_admin 所有权限 + 用户管理
* admin 设置权限 + 仅看自己会话
* user 仅看自己会话,无设置
* super_admin 所有权限 + 用户管理 + 可见全部会话(含 @)
* fallback_admin 等同 super_admin(兜底 administrator)
* admin 设置权限 + 仅看自己会话(含 @)
* user 仅看自己会话,无设置
*
* 持久化: roles.json (单实例文件) + MemoryStore 同步
*/

View file

@ -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'

View file

@ -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)
})
})