diff --git a/uds-auth/README.zh.md b/uds-auth/README.zh.md index 465df69a..680b8c69 100644 --- a/uds-auth/README.zh.md +++ b/uds-auth/README.zh.md @@ -46,7 +46,7 @@ originSystemCode: '' 2. 把输出的 `UDS_AUTH_LOCAL_ADMIN_BOX=...` 设到 **Harness 进程环境**(这是密文,不是口令) 3. 登录面板 →「本机密钥解锁」→ 输入口令;**必须解密成功才有 admin** 仅设置环境变量、不知道口令 → **无法登录**。旧变量 `UDS_AUTH_LOCAL_ADMIN_KEY` 已忽略。 -- **ACL**:`super_admin` / 兜底 `administrator` 可见全部会话(含 `@` 提及);`admin` / `user` 仅可见 **自己拥有的** 或 **自己工作区路径下的** 会话。侧栏、`session/search` 与 `@` 候选共用同一规则 +- **ACL / 侧栏**:未登录与普通用户看不到设置齿轮;**仅超管/应急**可见设置。`admin` 无设置齿轮,但可看渠道/系统会话。`super_admin` / 兜底默认可见全部会话(可关)。布局:设置在左、登录在右。 ## Skill 认证(给他人改造 skill 时) diff --git a/uds-auth/lib/api.js b/uds-auth/lib/api.js index d7284a07..abbda807 100644 --- a/uds-auth/lib/api.js +++ b/uds-auth/lib/api.js @@ -188,6 +188,27 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {}) }) } + async function setViewAllSessions(ctx) { + if (!requirePermission(ctx, 'canToggleViewAllSessions')) { + return fail(ctx, 'forbidden_view_all_sessions', 403) + } + const body = await readBody(ctx.req) + const enabled = !!(body && body.enabled) + try { + rolesStore.setViewAllSessions(ctx.empNo, enabled) + if (typeof rolesStore.flush === 'function') { + await rolesStore.flush() + } + ctx.permissions = rolesStore.resolvePermissions(ctx.empNo, ctx.role) + await ok(ctx, enabled ? 'view_all_sessions_on' : 'view_all_sessions_off', null, { + permissions: ctx.permissions, + viewAllSessions: enabled, + }) + } catch (err) { + await mapThrown(ctx, err, 403) + } + } + return { logout, getCurrentUser, @@ -198,6 +219,7 @@ export function createApiHandlers(config, sessionStore, rolesStore, extra = {}) setFallbackPassword, clearFallbackPassword, fallbackStatus, + setViewAllSessions, } } diff --git a/uds-auth/lib/client.js b/uds-auth/lib/client.js index e7f08856..5aa5b50a 100644 --- a/uds-auth/lib/client.js +++ b/uds-auth/lib/client.js @@ -66,6 +66,11 @@ window.__ModuleLoader__.load({ "ui.nextPage": "下一页", "ui.add": "添加", "ui.department": "部门", + "ui.viewAllSessionsTitle": "查看全部会话", + "ui.viewAllSessionsIntro": "超级管理员默认可见全部会话(含渠道)。关闭后仅看自己的;侧栏与 @ 提及规则相同。", + "ui.viewAllSessionsToggle": "显示所有人的会话", + "ui.viewAllSessionsOn": "已开启:可见全部会话", + "ui.viewAllSessionsOff": "已关闭:仅可见自己的会话", "ui.notLoggedIn": "未登录", "ui.pleaseScan": "请使用 iCenter 扫码登录", "ui.refreshQr": "刷新二维码", @@ -106,6 +111,7 @@ window.__ModuleLoader__.load({ "err.forbidden_remove_user": "只有超级管理员可以删除用户", "err.forbidden_set_fallback": "只有超级管理员可以设置应急密码", "err.forbidden_clear_fallback": "只有超级管理员可以清除应急密码", + "err.forbidden_view_all_sessions": "当前角色不能开启查看全部会话", "err.invalid_role_params": "参数错误: empNo 和 role 必填", "err.emp_no_required": "empNo 必填", "err.username_password_required": "用户名和密码必填", @@ -151,7 +157,9 @@ window.__ModuleLoader__.load({ "ok.fallback_password_set": "应急管理员密码已设置", "ok.fallback_password_cleared": "应急管理员密码已清除", "ok.fallback_login": "应急管理员登录成功", - "ok.local_admin_unlock": "本机密钥解锁成功" + "ok.local_admin_unlock": "本机密钥解锁成功", + "ok.view_all_sessions_on": "已开启查看全部会话", + "ok.view_all_sessions_off": "已关闭查看全部会话" }, "en": { "role.super_admin": "Super admin", @@ -198,6 +206,11 @@ window.__ModuleLoader__.load({ "ui.nextPage": "Next", "ui.add": "Add", "ui.department": "Department", + "ui.viewAllSessionsTitle": "View all sessions", + "ui.viewAllSessionsIntro": "Super admins see all sessions by default (including channels). Turn off to only see your own; sidebar and @ mentions share the same rule.", + "ui.viewAllSessionsToggle": "Show everyone’s sessions", + "ui.viewAllSessionsOn": "On: all sessions visible", + "ui.viewAllSessionsOff": "Off: only your own sessions", "ui.notLoggedIn": "Not signed in", "ui.pleaseScan": "Scan with iCenter to sign in", "ui.refreshQr": "Refresh QR", @@ -238,6 +251,7 @@ window.__ModuleLoader__.load({ "err.forbidden_remove_user": "Only super admins can remove users", "err.forbidden_set_fallback": "Only super admins can set the emergency password", "err.forbidden_clear_fallback": "Only super admins can clear the emergency password", + "err.forbidden_view_all_sessions": "Your role cannot enable view-all sessions", "err.invalid_role_params": "Invalid params: empNo and role required", "err.emp_no_required": "empNo required", "err.username_password_required": "Username and password required", @@ -283,7 +297,9 @@ window.__ModuleLoader__.load({ "ok.fallback_password_set": "Emergency admin password set", "ok.fallback_password_cleared": "Emergency admin password cleared", "ok.fallback_login": "Emergency admin signed in", - "ok.local_admin_unlock": "Local admin unlocked" + "ok.local_admin_unlock": "Local admin unlocked", + "ok.view_all_sessions_on": "View-all sessions enabled", + "ok.view_all_sessions_off": "View-all sessions disabled" } } const UDS_HOST_ARIA = { @@ -396,14 +412,21 @@ window.__ModuleLoader__.load({ const CSS = [ - '.uds-auth-host{position:relative;display:inline-flex;align-items:center;height:32px;margin:0;flex-shrink:0;pointer-events:auto}.uds-auth-host.is-rail{justify-content:center;width:100%}[data-uds-auth-foot="row"]{display:flex!important;flex-direction:row!important;align-items:center!important;gap:8px;width:100%}[data-uds-auth-foot="row"]>*:nth-child(1){order:2;flex:none!important;width:auto!important;min-width:0;margin-left:auto!important}[data-uds-auth-foot="row"]>*:nth-child(2){order:1;flex:none!important;width:auto!important;min-width:0}', - 'html[data-uds-can-settings="0"] [data-uds-auth-foot="row"]>*:not(:has([data-uds-auth-host])){display:none!important}html[data-uds-can-create-ws="0"] button[aria-label="添加工作区"],html[data-uds-can-create-ws="0"] button[aria-label="Add workspace"]{display:none!important}html[data-uds-logged-in="0"] [role="tree"][aria-label="会话"],html[data-uds-logged-in="0"] [role="tree"][aria-label="Sessions"],html[data-uds-logged-in="0"] [class*="WorkspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceBrowser"],html[data-uds-logged-in="0"] .dsh-ct-entry,html[data-uds-logged-in="0"] .dsh-ct-region,html[data-uds-logged-in="0"] .dsh-ct-main,html[data-uds-logged-in="0"] [data-dsh-ct-mode="on"] .dsh-ct-region{display:none!important}html[data-uds-can-create-ws="0"] button[aria-label="选择工作区"],html[data-uds-can-create-ws="0"] button[aria-label="Choose workspace"],html[data-uds-can-create-ws="0"] [aria-label="选择工作区"],html[data-uds-can-create-ws="0"] [aria-label="Choose workspace"]{display:none!important}html[data-uds-logged-in="0"] [class*="cardWorkspaceTrigger"],html[data-uds-logged-in="0"] [data-composer-card][class*="cardWorkspaceTrigger"]{pointer-events:none!important;opacity:.45!important;cursor:not-allowed!important}/* uds-anon-hide-workspaces *//* uds-anon-hide-conversation:removed */html[data-uds-logged-in="0"] [class*="WorkspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceRow"],html[data-uds-logged-in="0"] [class*="WorkspaceRow"]{display:none!important}', + '.uds-auth-host{position:relative;display:inline-flex!important;align-items:center;height:32px;margin:0;flex-shrink:0;pointer-events:auto;visibility:visible!important;opacity:1!important}.uds-auth-host.is-rail{justify-content:center;width:100%}', + /* Foot: Settings left, login right. Marked via data-uds-foot-slot by AuthBadge. */ + '[data-uds-auth-foot="row"]{display:flex!important;flex-direction:row!important;flex-wrap:nowrap!important;align-items:center!important;gap:8px;width:100%}', + '[data-uds-auth-foot="row"]>[data-uds-foot-slot="settings"]{order:1;flex:none!important;width:auto!important;min-width:0}', + '[data-uds-auth-foot="row"]>[data-uds-foot-slot="login"]{order:2;flex:none!important;width:auto!important;min-width:0;margin-left:auto!important}', + /* Hide settings when logged out or no settings permission (super/fallback only). */ + 'html[data-uds-can-settings="0"] [data-uds-foot-slot="settings"],html[data-uds-logged-in="0"] [data-uds-foot-slot="settings"]{display:none!important}', + 'html[data-uds-can-settings="0"] button[aria-label="设置"],html[data-uds-can-settings="0"] button[aria-label="Settings"],html[data-uds-logged-in="0"] button[aria-label="设置"],html[data-uds-logged-in="0"] button[aria-label="Settings"]{display:none!important}', + 'html[data-uds-can-create-ws="0"] button[aria-label="添加工作区"],html[data-uds-can-create-ws="0"] button[aria-label="Add workspace"]{display:none!important}html[data-uds-logged-in="0"] [role="tree"][aria-label="会话"],html[data-uds-logged-in="0"] [role="tree"][aria-label="Sessions"],html[data-uds-logged-in="0"] [class*="WorkspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceBrowser"],html[data-uds-logged-in="0"] .dsh-ct-entry,html[data-uds-logged-in="0"] .dsh-ct-region,html[data-uds-logged-in="0"] .dsh-ct-main,html[data-uds-logged-in="0"] [data-dsh-ct-mode="on"] .dsh-ct-region{display:none!important}html[data-uds-can-create-ws="0"] button[aria-label="选择工作区"],html[data-uds-can-create-ws="0"] button[aria-label="Choose workspace"],html[data-uds-can-create-ws="0"] [aria-label="选择工作区"],html[data-uds-can-create-ws="0"] [aria-label="Choose workspace"]{display:none!important}html[data-uds-logged-in="0"] [class*="cardWorkspaceTrigger"],html[data-uds-logged-in="0"] [data-composer-card][class*="cardWorkspaceTrigger"]{pointer-events:none!important;opacity:.45!important;cursor:not-allowed!important}/* uds-anon-hide-workspaces *//* uds-anon-hide-conversation:removed */html[data-uds-logged-in="0"] [class*="WorkspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceBrowser"],html[data-uds-logged-in="0"] [class*="workspaceRow"],html[data-uds-logged-in="0"] [class*="WorkspaceRow"]{display:none!important}', /* uds-session-only-sidebar */ 'html[data-uds-can-create-ws="0"][data-uds-logged-in="1"] [class*="projectRow"]:not([class*="dsh-ct-project"]),html[data-uds-can-create-ws="0"][data-uds-logged-in="1"] [class*="ProjectRow"]:not([class*="dsh-ct-project"]){display:none!important}', '.uds-auth-badge{display:inline-flex;align-items:center;justify-content:flex-start;gap:0;max-width:min(160px,42vw);min-width:0;height:32px;padding:0 8px;box-sizing:border-box;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:13px;font-weight:400;line-height:20px;cursor:pointer;overflow:hidden}', '.uds-auth-badge:hover{background:var(--dsw-alias-interactive-bg-hover)}', '.uds-auth-host.is-rail .uds-auth-badge{width:auto;max-width:100%;height:32px;padding:0 6px;border-radius:8px}', - '.uds-auth-badge-unauth{color:var(--dsw-alias-label-tertiary,#8f959e)}', + '.uds-auth-badge-unauth{color:var(--dsw-alias-label-primary,#e8eaed)}', '.uds-auth-avatar{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:50%;flex:none;font-size:10px;line-height:1;color:var(--dsw-alias-label-secondary,#646a73);background:transparent;border:none}', '.uds-auth-badge-unauth .uds-auth-avatar{background:var(--dsw-alias-bg-module-platform,rgba(242,243,245,1));color:var(--dsw-alias-label-tertiary,#8f959e)}', '.uds-auth-badge-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', @@ -461,6 +484,10 @@ window.__ModuleLoader__.load({ '.uds-auth-settings-msg.ok{color:var(--dsw-alias-state-success-primary,#20a162)}', '.uds-auth-settings-msg.err{color:var(--dsw-alias-state-error-primary,#d54941)}', '.uds-auth-settings-empty{padding:24px;text-align:center;color:var(--dsw-alias-label-tertiary,#8f959e);font-size:13px}', + '.uds-auth-settings-toggle{display:flex;align-items:flex-start;gap:10px;margin:8px 0 4px;cursor:pointer;user-select:none}', + '.uds-auth-settings-toggle input{margin-top:3px;flex-shrink:0}', + '.uds-auth-settings-toggle span{font-size:13px;line-height:1.4;color:var(--dsw-alias-label-primary,#1f2329)}', + '.uds-auth-settings-toggle-status{margin:4px 0 0;font-size:12px;color:var(--dsw-alias-label-secondary,#646a73)}', ].join('') function getCookie(cookieName) { @@ -844,6 +871,8 @@ function reloadAfterLogin() { const [msg, setMsg] = useState('') const [msgKind, setMsgKind] = useState('') const [busy, setBusy] = useState(false) + const [viewAllBusy, setViewAllBusy] = useState(false) + const [viewAllMsg, setViewAllMsg] = useState('') useEffect(() => { let cancelled = false @@ -869,6 +898,8 @@ function reloadAfterLogin() { const perms = me?.permissions || {} const canManage = !!perms.canManageUsers const canSettings = !!perms.canAccessSettings + const canToggleViewAll = !!perms.canToggleViewAllSessions + const viewAllOn = !!perms.canViewAllSessions const saveConfig = async () => { setBusy(true) @@ -889,6 +920,31 @@ function reloadAfterLogin() { } } + const setViewAllSessions = async (enabled) => { + setViewAllBusy(true) + setViewAllMsg('') + try { + const res = await fetchJson('/uds-auth/api/me/view-all-sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: !!enabled }), + }) + setMe((prev) => prev ? { + ...prev, + permissions: res.permissions || { + ...prev.permissions, + canViewAllSessions: !!enabled, + }, + } : prev) + setViewAllMsg(res.message || (enabled ? t('ui.viewAllSessionsOn') : t('ui.viewAllSessionsOff'))) + try { softReconnectAuth() } catch { /* ignore */ } + } catch (err) { + setViewAllMsg(apiMessage(err) || t('ui.saveFailed')) + } finally { + setViewAllBusy(false) + } + } + const field = (key, label) => h('div', { className: 'uds-auth-settings-field' }, h('label', { htmlFor: 'uds-auth-' + key }, label), h('input', { @@ -905,9 +961,25 @@ function reloadAfterLogin() { h('p', { className: 'uds-auth-settings-intro' }, t('ui.settingsIntro')), ), !me && h('div', { className: 'uds-auth-settings-empty' }, t('ui.loginRequiredPage')), - me && !canSettings && !canManage && h('div', { className: 'uds-auth-settings-empty' }, + me && !canSettings && !canManage && !canToggleViewAll && h('div', { className: 'uds-auth-settings-empty' }, t('ui.roleHint', { role: me.role || 'user' }) ), + canToggleViewAll && h('div', { className: 'uds-auth-settings-card' }, + h('h3', null, t('ui.viewAllSessionsTitle')), + h('p', { className: 'uds-auth-settings-intro' }, t('ui.viewAllSessionsIntro')), + h('label', { className: 'uds-auth-settings-toggle' }, + h('input', { + type: 'checkbox', + checked: viewAllOn, + disabled: viewAllBusy, + onChange: (e) => setViewAllSessions(e.target.checked), + }), + h('span', null, t('ui.viewAllSessionsToggle')), + ), + h('p', { className: 'uds-auth-settings-toggle-status' }, + viewAllMsg || (viewAllOn ? t('ui.viewAllSessionsOn') : t('ui.viewAllSessionsOff')), + ), + ), canSettings && h('div', { className: 'uds-auth-settings-card' }, h('h3', null, t('ui.deployConfig')), field('uacBaseUrl', 'UAC Base URL'), @@ -987,13 +1059,23 @@ function reloadAfterLogin() { let footArea = null for (let el = host.parentElement; el && el !== document.body; el = el.parentElement) { if (el.childElementCount < 2) continue - const mine = [...el.children].some((c) => c.contains(host)) - const other = [...el.children].some((c) => !c.contains(host)) + const mine = [...el.children].some((c) => c.contains(host) || c === host) + const other = [...el.children].some((c) => !(c.contains(host) || c === host)) if (mine && other) { footArea = el; break } } if (!footArea) return undefined footArea.setAttribute('data-uds-auth-foot', 'row') - return () => { footArea.removeAttribute('data-uds-auth-foot') } + const marked = [] + for (const child of footArea.children) { + const isLogin = child === host || child.contains(host) + const slot = isLogin ? 'login' : 'settings' + child.setAttribute('data-uds-foot-slot', slot) + marked.push(child) + } + return () => { + footArea.removeAttribute('data-uds-auth-foot') + for (const child of marked) child.removeAttribute('data-uds-foot-slot') + } }, []) const [open, setOpen] = useState(false) const [anchor, setAnchor] = useState(null) @@ -1517,6 +1599,9 @@ function reloadAfterLogin() { tag.setAttribute('data-plugin', name) tag.textContent = CSS document.head.appendChild(tag) + } else { + // Hot reload / plugin update: refresh rules (e.g. login-host visibility fix). + document.getElementById('uds-auth-client-css').textContent = CSS } // Register login entry before heavy gates / settings section. ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ diff --git a/uds-auth/lib/dsh-acl.js b/uds-auth/lib/dsh-acl.js index ba1435f2..fd7553c2 100644 --- a/uds-auth/lib/dsh-acl.js +++ b/uds-auth/lib/dsh-acl.js @@ -2,12 +2,25 @@ * Bridge UDS cookies → AsyncLocalStorage and wrap DSH session/workspace/settings. */ import { createRequire } from 'node:module' +import { resolve as resolvePath, sep as pathSep } from 'node:path' import { withUserContext, getUserContext, runWithUserContext } from './context.js' -import { computePermissions, ROLES } from './roles.js' import { resolveLocale, t } from './i18n.js' const require = createRequire(import.meta.url) +/** True when path is outside per-user workspace root (channel/bot/harness cwd). */ +export function isOutsideUserWorkspaceRoot(candidatePath, workspaceRoot) { + if (!candidatePath) return true + if (!workspaceRoot) return true + try { + const base = resolvePath(String(workspaceRoot)) + const cand = resolvePath(String(candidatePath)) + return cand !== base && !cand.startsWith(base + pathSep) + } catch { + return true + } +} + function parseCookie(header, name) { if (!header || typeof header !== 'string') return null for (const part of header.split(';')) { @@ -174,7 +187,7 @@ export function resolveIdentityFromRequestSync(req, deps) { return { empNo: String(empNo), role, - permissions: computePermissions(role), + permissions: rolesStore.resolvePermissions(empNo, role), userContext: { empNo: String(empNo), userId: String(empNo), @@ -229,7 +242,7 @@ export async function resolveIdentityFromRequest(req, deps) { } catch { /* keep getRole */ } } - const permissions = computePermissions(role) + const permissions = rolesStore.resolvePermissions(empNo, role) return { empNo: String(empNo), role, @@ -372,7 +385,11 @@ function throwForbidden(code) { /** * Shared session visibility helpers (sidebar + @ mention + query reads). - * Visibility: super_admin / fallback_admin see all; others see owner OR own workspace. + * Visibility: + * - canViewAllSessions (super/fallback toggle): see all + * - else: own owner stamp OR own user-workspace path + * - canViewSystemSessions (admin+): also see system/channel sessions whose cwd + * is outside the per-user workspace root (IM bots, harness cwd, unstamped) */ export function createSessionAccess({ sessionAcl, @@ -380,18 +397,26 @@ export function createSessionAccess({ getWorkspaceRoot, getWorkspaceRegistry, resolveLiveCwd, + rolesStore, }) { + const empOf = (identity) => identity?.empNo || identity?.userContext?.empNo || null + + /** Always re-read prefs from live rolesStore — never trust frozen WS identity.permissions. */ const canSeeAll = (identity) => { if (!identity) return false + const empNo = empOf(identity) + const store = typeof rolesStore === 'function' ? rolesStore() : rolesStore + if (empNo && store && typeof store.resolvePermissions === 'function') { + // Do not pass identity.role — store.getRole(empNo) is source of truth. + return !!store.resolvePermissions(empNo).canViewAllSessions + } if (identity.permissions?.canViewAllSessions) return true + // No store (tests / misconfig): keep legacy super visibility. 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 + return role === 'super_admin' || role === 'fallback_admin' + || String(empNo) === 'administrator' } - const empOf = (identity) => identity?.empNo || identity?.userContext?.empNo || null - const resolveRegistry = () => { try { if (typeof getWorkspaceRegistry === 'function') { @@ -436,14 +461,21 @@ export function createSessionAccess({ const root = getWorkspaceRoot() const wid = ws.id ?? ws.workspaceId const path = ws.path - return userWorkspaces.isUserPath(empNo, path, root) + if (userWorkspaces.isUserPath(empNo, path, root) || (userWorkspaces.get(empNo)?.workspaceId - && String(userWorkspaces.get(empNo).workspaceId) === String(wid)) + && String(userWorkspaces.get(empNo).workspaceId) === String(wid))) { + return true + } + // Channel / bot / shared harness workspaces live outside user-workspaces. + if (identity.permissions?.canViewSystemSessions && isOutsideUserWorkspaceRoot(path, root)) { + return true + } + return false } /** * Owner stamp OR cwd/workspace under the caller's provisioned path. - * Missing owner alone does not deny (legacy sessions rely on workspace/cwd). + * Settings roles also see unowned system/channel sessions (cwd outside user-workspaces). */ const canAccessSession = (sessionId, identity, rowHint) => { if (!identity || !empOf(identity)) return false @@ -457,12 +489,23 @@ export function createSessionAccess({ const cwd = resolveSessionCwd(sessionId, rowHint) if (cwd && userWorkspaces.isUserPath(empNo, cwd, root)) return true + const foreignOwner = !!(owner && String(owner) !== String(empNo)) + + // IM/channel (and other host-internal) sessions: often unstamped + bot cwd. + // Let admin+ see those; never leak another user's stamped private session. + if (!foreignOwner && !owner && identity.permissions?.canViewSystemSessions + && isOutsideUserWorkspaceRoot(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 + // Foreign-owned sessions must not become visible via channel/system workspaces. + if (foreignOwner && isOutsideUserWorkspaceRoot(ws?.path, root)) continue if (workspaceContainsSession(ws, sessionId)) return true } return false @@ -480,47 +523,66 @@ export function createSessionAccess({ /** * Install Host ACL wrappers. + * `getRolesStore` / `rolesStore` is read live on every ACL check so plugin reload + * and preference toggles take effect without re-wrapping Host controllers. */ export function installDshAcl(ctx, { sessionAcl, userWorkspaces, getWorkspaceRoot, rolesStore, + getRolesStore, ensureUserWorkspace, getWorkspaceRegistry, }) { const disposers = [] + const resolveRolesStore = () => { + if (typeof getRolesStore === 'function') { + try { return getRolesStore() } catch { return null } + } + if (typeof rolesStore === 'function') { + try { return rolesStore() } catch { return null } + } + return rolesStore || null + } - const access = createSessionAccess({ - sessionAcl, - userWorkspaces, - getWorkspaceRoot, - getWorkspaceRegistry: () => { - try { - if (typeof getWorkspaceRegistry === 'function') { - const r = getWorkspaceRegistry() - if (r) return r + // Shared live bag: re-install updates this even when Host controllers are already wrapped. + const live = installDshAcl._live || (installDshAcl._live = { access: null }) + const rebuildAccess = () => { + live.access = createSessionAccess({ + sessionAcl, + userWorkspaces, + getWorkspaceRoot, + rolesStore: resolveRolesStore, + 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 } - } 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 + }, + }) + } + rebuildAccess() + + const canSeeAll = (identity) => live.access.canSeeAll(identity) + const empOf = (identity) => live.access.empOf(identity) + const resolveRegistry = () => live.access.resolveRegistry() + const isVisibleWorkspace = (identity, ws) => live.access.isVisibleWorkspace(identity, ws) + const canAccessSession = (sessionId, identity, rowHint) => ( + live.access.canAccessSession(sessionId, identity, rowHint) + ) // Stamp owner on session create const offCreated = ctx.on('session/created', (session) => { @@ -880,12 +942,7 @@ ctx.inject(['workspaceController'], (wctx) => { // the ungrouped bucket). const canSeeAllWorkspaces = (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 - // Fallback cookie user is always administrator - if (String(identity.empNo || identity.userContext?.empNo || '') === 'administrator') return true - return false + return canSeeAll(identity) } const allowWorkspace = (identity, ws) => { @@ -894,9 +951,16 @@ ctx.inject(['workspaceController'], (wctx) => { const empNo = identity.empNo || identity.userContext?.empNo const root = getWorkspaceRoot() const wid = ws?.workspaceId ?? ws?.id - return userWorkspaces.isUserPath(empNo, ws?.path, root) + if (userWorkspaces.isUserPath(empNo, ws?.path, root) || (userWorkspaces.get(empNo)?.workspaceId - && String(userWorkspaces.get(empNo).workspaceId) === String(wid)) + && String(userWorkspaces.get(empNo).workspaceId) === String(wid))) { + return true + } + if (identity.permissions?.canViewSystemSessions + && isOutsideUserWorkspaceRoot(ws?.path, root)) { + return true + } + return false } const filterBaseline = (identity, baseline) => { @@ -921,6 +985,20 @@ ctx.inject(['workspaceController'], (wctx) => { const allowed = new Set() const mapped = userWorkspaces.get(empNo)?.workspaceId if (mapped != null) allowed.add(String(mapped)) + // Keep channel/bot workspaces for admin+ (same rule as allowWorkspace). + if (identity?.permissions?.canViewSystemSessions) { + try { + const root = getWorkspaceRoot() + const registry = resolveRegistry() + const list = typeof registry?.list === 'function' ? (registry.list() || []) : [] + for (const ws of list) { + const id = ws?.id ?? ws?.workspaceId + if (id != null && isOutsideUserWorkspaceRoot(ws?.path, root)) { + allowed.add(String(id)) + } + } + } catch { /* ignore */ } + } return { ...frame, workspaceIds: (frame.workspaceIds || []).filter((id) => allowed.has(String(id))), diff --git a/uds-auth/lib/i18n.js b/uds-auth/lib/i18n.js index cc0a6767..0fc376c4 100644 --- a/uds-auth/lib/i18n.js +++ b/uds-auth/lib/i18n.js @@ -60,6 +60,13 @@ export const MESSAGES = { 'ui.add': '添加', 'ui.department': '部门', + // privacy / session visibility + 'ui.viewAllSessionsTitle': '查看全部会话', + 'ui.viewAllSessionsIntro': '超级管理员默认可见全部会话(含渠道)。关闭后仅看自己的;侧栏与 @ 提及规则相同。', + 'ui.viewAllSessionsToggle': '显示所有人的会话', + 'ui.viewAllSessionsOn': '已开启:可见全部会话', + 'ui.viewAllSessionsOff': '已关闭:仅可见自己的会话', + // login panel 'ui.notLoggedIn': '未登录', 'ui.pleaseScan': '请使用 iCenter 扫码登录', @@ -107,6 +114,7 @@ export const MESSAGES = { 'err.forbidden_remove_user': '只有超级管理员可以删除用户', 'err.forbidden_set_fallback': '只有超级管理员可以设置应急密码', 'err.forbidden_clear_fallback': '只有超级管理员可以清除应急密码', + 'err.forbidden_view_all_sessions': '当前角色不能开启查看全部会话', 'err.invalid_role_params': '参数错误: empNo 和 role 必填', 'err.emp_no_required': 'empNo 必填', 'err.username_password_required': '用户名和密码必填', @@ -155,6 +163,8 @@ export const MESSAGES = { 'ok.fallback_password_cleared': '应急管理员密码已清除', 'ok.fallback_login': '应急管理员登录成功', 'ok.local_admin_unlock': '本机密钥解锁成功', + 'ok.view_all_sessions_on': '已开启查看全部会话', + 'ok.view_all_sessions_off': '已关闭查看全部会话', }, en: { 'role.super_admin': 'Super admin', @@ -203,6 +213,12 @@ export const MESSAGES = { 'ui.add': 'Add', 'ui.department': 'Department', + 'ui.viewAllSessionsTitle': 'View all sessions', + 'ui.viewAllSessionsIntro': 'Super admins see all sessions by default (including channels). Turn off to only see your own; sidebar and @ mentions share the same rule.', + 'ui.viewAllSessionsToggle': 'Show everyone’s sessions', + 'ui.viewAllSessionsOn': 'On: all sessions visible', + 'ui.viewAllSessionsOff': 'Off: only your own sessions', + 'ui.notLoggedIn': 'Not signed in', 'ui.pleaseScan': 'Scan with iCenter to sign in', 'ui.refreshQr': 'Refresh QR', @@ -246,6 +262,7 @@ export const MESSAGES = { 'err.forbidden_remove_user': 'Only super admins can remove users', 'err.forbidden_set_fallback': 'Only super admins can set the emergency password', 'err.forbidden_clear_fallback': 'Only super admins can clear the emergency password', + 'err.forbidden_view_all_sessions': 'Your role cannot enable view-all sessions', 'err.invalid_role_params': 'Invalid params: empNo and role required', 'err.emp_no_required': 'empNo required', 'err.username_password_required': 'Username and password required', @@ -293,6 +310,8 @@ export const MESSAGES = { 'ok.fallback_password_cleared': 'Emergency admin password cleared', 'ok.fallback_login': 'Emergency admin signed in', 'ok.local_admin_unlock': 'Local admin unlocked', + 'ok.view_all_sessions_on': 'View-all sessions enabled', + 'ok.view_all_sessions_off': 'View-all sessions disabled', }, } diff --git a/uds-auth/lib/index.js b/uds-auth/lib/index.js index 96eabe75..34e89f6c 100644 --- a/uds-auth/lib/index.js +++ b/uds-auth/lib/index.js @@ -768,6 +768,9 @@ function handleRequest(req, res) { if (url === '/api/fallback/clear' && method === 'POST') { await _apiHandlers.clearFallbackPassword(ctx2); return } + if (url === '/api/me/view-all-sessions' && method === 'POST') { + await _apiHandlers.setViewAllSessions(ctx2); return + } // 配置端点 (admin / super_admin:canAccessSettings) if (url === '/api/config' && method === 'GET') { @@ -1100,7 +1103,7 @@ async function initServices(ctx, config) { sessionAcl: _sessionAcl, userWorkspaces: _userWorkspaces, getWorkspaceRoot: () => _currentConfig?.workspaceRoot, - rolesStore: _rolesStore, + getRolesStore: () => _rolesStore, ensureUserWorkspace, getWorkspaceRegistry: () => _workspaceRegistry, }) @@ -1141,6 +1144,10 @@ async function initServices(ctx, config) { } }, canViewAllJobs(identity) { + const empNo = identity?.empNo || identity?.userContext?.empNo + if (empNo && _rolesStore?.resolvePermissions) { + return !!_rolesStore.resolvePermissions(empNo, identity?.role).canViewAllSessions + } return !!identity?.permissions?.canViewAllSessions }, getRole(empNo) { @@ -1156,7 +1163,8 @@ async function initServices(ctx, config) { const id = String(empNo || '').trim() if (!id || id.startsWith('__')) return null const role = _rolesStore?.getRole?.(id) || 'user' - const permissions = computePermissions(role) + const permissions = _rolesStore?.resolvePermissions?.(id, role) + || computePermissions(role) const workspacePath = (() => { try { const row = _userWorkspaces?.get(id) diff --git a/uds-auth/lib/local-admin.js b/uds-auth/lib/local-admin.js index 7618cb74..89dcb367 100644 --- a/uds-auth/lib/local-admin.js +++ b/uds-auth/lib/local-admin.js @@ -189,10 +189,13 @@ export function buildLocalAdminUserContext() { export function buildLocalAdminIdentity(rolesStore) { const empNo = DEFAULT_FALLBACK_USERNAME const role = rolesStore?.getRole?.(empNo) || ROLES.FALLBACK_ADMIN + const permissions = typeof rolesStore?.resolvePermissions === 'function' + ? rolesStore.resolvePermissions(empNo, role) + : computePermissions(role) return { empNo, role, - permissions: computePermissions(role), + permissions, userContext: buildLocalAdminUserContext(), kind: 'fallback', } diff --git a/uds-auth/lib/middleware/auth-middleware.js b/uds-auth/lib/middleware/auth-middleware.js index 75976798..3782281c 100644 --- a/uds-auth/lib/middleware/auth-middleware.js +++ b/uds-auth/lib/middleware/auth-middleware.js @@ -1,6 +1,6 @@ import { UdsClient } from '../uds/client.js' import { UdsValidator } from '../uds/validator.js' -import { ROLES, computePermissions } from '../roles.js' +import { ROLES } from '../roles.js' import { searchUserByEmpNoToken } from '../uds/user-search.js' /** @@ -108,7 +108,7 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = { ctx.userContext = userContext ctx.empNo = empNo ctx.role = role - ctx.permissions = computePermissions(role) + ctx.permissions = rolesStore.resolvePermissions(empNo, role) } async function authMiddleware(ctx, next) { @@ -207,7 +207,7 @@ export function createAuthMiddleware(config, sessionStore, rolesStore, hooks = { ctx.userContext = userContext ctx.empNo = profile.empNo ctx.role = role - ctx.permissions = computePermissions(role) + ctx.permissions = rolesStore.resolvePermissions(profile.empNo, role) return next() } diff --git a/uds-auth/lib/roles.js b/uds-auth/lib/roles.js index c0f3582a..5226cd4f 100644 --- a/uds-auth/lib/roles.js +++ b/uds-auth/lib/roles.js @@ -1,13 +1,15 @@ /** * uds-auth 角色存储 + 权限管理 - * + * * 角色: - * super_admin 所有权限 + 用户管理 + 可见全部会话(含 @) + * super_admin 所有权限 + 用户管理;默认可见全部会话(可在设置中关闭) * fallback_admin 等同 super_admin(兜底 administrator) - * admin 设置权限 + 仅看自己会话(含 @) + * admin 无设置齿轮;仅看自己会话(含 @);可见渠道/系统会话 * user 仅看自己会话,无设置 * - * 持久化: roles.json (单实例文件) + MemoryStore 同步 + * 超管/应急默认全览开启;prefs.viewAllSessions === false 时关闭。 + * 设置齿轮仅超管/应急(canAccessSettings)。 + * 持久化: roles.json (roles + prefs + fallbackPasswordHash) */ import { createHash, randomBytes } from 'node:crypto' import { readFile, writeFile, mkdir } from 'node:fs/promises' @@ -30,40 +32,60 @@ export const ROLES = { /** zh labels for list/search; UI should translate via i18n role.* keys. */ export const ROLE_LABELS = ROLE_LABELS_ZH -/** 计算角色权限 (纯函数) */ -export function computePermissions(role) { +/** + * 计算角色权限 (纯函数) + * @param {string} role + * @param {{ viewAllSessions?: boolean }} [opts] 个人偏好;超管默认可见全部 + */ +export function computePermissions(role, opts = {}) { + const viewAll = !!opts.viewAllSessions switch (role) { case ROLES.SUPER_ADMIN: return { canManageUsers: true, canAccessSettings: true, - canViewAllSessions: true, + canToggleViewAllSessions: true, + canViewAllSessions: viewAll, + canViewSystemSessions: true, canCreateWorkspace: true, } case ROLES.FALLBACK_ADMIN: return { canManageUsers: true, canAccessSettings: true, - canViewAllSessions: true, + canToggleViewAllSessions: true, + canViewAllSessions: viewAll, + canViewSystemSessions: true, canCreateWorkspace: true, } case ROLES.ADMIN: return { canManageUsers: false, - canAccessSettings: true, + // 设置齿轮仅超管/应急;admin 仍可看渠道/系统会话 + canAccessSettings: false, + canToggleViewAllSessions: false, canViewAllSessions: false, + canViewSystemSessions: true, canCreateWorkspace: false, } default: // user / undefined return { canManageUsers: false, canAccessSettings: false, + canToggleViewAllSessions: false, canViewAllSessions: false, + canViewSystemSessions: false, canCreateWorkspace: false, } } } +/** 角色是否允许开启「查看全部会话」(仅超管 / 应急) */ +export function canToggleViewAllSessions(role) { + return role === ROLES.SUPER_ADMIN + || role === ROLES.FALLBACK_ADMIN +} + function hashPassword(password) { return createHash('sha256').update(password).digest('hex') } @@ -82,6 +104,7 @@ export const DEFAULT_FALLBACK_USERNAME = 'administrator' export class RolesStore { constructor(options = {}) { this._roles = new Map() // empNo → role + this._prefs = new Map() // empNo → { viewAllSessions?: boolean } this._firstBootLock = Promise.resolve() this._rolesFile = options.rolesFile ? resolve(options.rolesFile) : null this._fallbackPasswordHash = null // SHA-256 hex,null = 未启用 @@ -111,6 +134,11 @@ export class RolesStore { for (const [empNo, role] of Object.entries(data.roles || {})) { this._roles.set(empNo, role) } + for (const [empNo, prefs] of Object.entries(data.prefs || {})) { + if (prefs && typeof prefs === 'object') { + this._prefs.set(String(empNo), { ...prefs }) + } + } if (Object.prototype.hasOwnProperty.call(data, 'fallbackPasswordHash')) { loadedHash = data.fallbackPasswordHash || null if (loadedHash) this._fallbackPasswordHash = loadedHash @@ -132,7 +160,16 @@ export class RolesStore { _markDirty() { this._dirty = true if (this._saveTimer) return - this._saveTimer = setTimeout(() => this._save(), 2000) + this._saveTimer = setTimeout(() => { void this._save() }, 2000) + } + + /** Flush pending roles/prefs to disk immediately (e.g. view-all toggle). */ + async flush() { + if (this._saveTimer) { + clearTimeout(this._saveTimer) + this._saveTimer = null + } + await this._save() } async _save() { @@ -142,14 +179,18 @@ export class RolesStore { try { const data = { roles: Object.fromEntries(this._roles), + prefs: Object.fromEntries(this._prefs), fallbackPasswordHash: this._fallbackPasswordHash, savedAt: new Date().toISOString(), } await mkdir(dirname(this._rolesFile), { recursive: true }) await writeFile(this._rolesFile, JSON.stringify(data, null, 2), 'utf-8') } catch (err) { + this._dirty = true console.warn('[uds-auth:RolesStore] Failed to save roles file:', err.message) } + // Changes during await writeFile — schedule another save. + if (this._dirty) this._markDirty() } // === 首次部署 bootstrap === @@ -183,6 +224,49 @@ export class RolesStore { return this._roles.get(empNo) || ROLES.USER } + /** + * 个人偏好:超管/应急默认开启查看全部;显式 false 才关闭。 + * admin/user 不会走到这里(resolvePermissions 里 allowToggle=false)。 + */ + isViewAllSessionsEnabled(empNo) { + if (!empNo) return false + const prefs = this._prefs.get(String(empNo)) + if (prefs && Object.prototype.hasOwnProperty.call(prefs, 'viewAllSessions')) { + return !!prefs.viewAllSessions + } + return true + } + + /** + * 设置「查看全部会话」偏好(调用方需校验 canToggleViewAllSessions) + * @param {string} empNo + * @param {boolean} enabled + */ + setViewAllSessions(empNo, enabled) { + const key = String(empNo || '').trim() + if (!key) throw codedError('emp_no_required') + const role = this.getRole(key) + if (!canToggleViewAllSessions(role)) { + throw codedError('forbidden_view_all_sessions') + } + const cur = { ...(this._prefs.get(key) || {}) } + // Persist explicit true/false — deleting the key would fall back to default-on. + cur.viewAllSessions = !!enabled + this._prefs.set(key, cur) + this._markDirty() + return true + } + + /** 角色 + 个人偏好 → 有效权限 */ + resolvePermissions(empNo, role) { + const id = empNo != null ? String(empNo) : '' + const r = role || this.getRole(id) + const allowToggle = canToggleViewAllSessions(r) + return computePermissions(r, { + viewAllSessions: allowToggle && this.isViewAllSessionsEnabled(id), + }) + } + hasRole(empNo) { return this._roles.has(empNo) } @@ -271,6 +355,7 @@ export class RolesStore { } } this._roles.delete(empNo) + this._prefs.delete(empNo) this._markDirty() return true } diff --git a/uds-auth/lib/session-acl.js b/uds-auth/lib/session-acl.js index 3ffe630e..0719c55b 100644 --- a/uds-auth/lib/session-acl.js +++ b/uds-auth/lib/session-acl.js @@ -104,7 +104,10 @@ export function identityFromAls(rolesStore) { return { empNo: ctx.empNo, role, - permissions: ctx.permissions || computePermissions(role), + // Prefer live prefs — ALS identity.permissions may be frozen at WS connect. + permissions: typeof rolesStore.resolvePermissions === 'function' + ? rolesStore.resolvePermissions(ctx.empNo, role) + : (ctx.permissions || computePermissions(role)), } } diff --git a/uds-auth/scripts/check-session-registry.ps1 b/uds-auth/scripts/check-session-registry.ps1 new file mode 100644 index 00000000..8647f473 --- /dev/null +++ b/uds-auth/scripts/check-session-registry.ps1 @@ -0,0 +1,48 @@ +$ErrorActionPreference = 'Stop' +$w = Get-Content 'C:\Users\zhout\.dsh\storages\workspace.json' -Raw | ConvertFrom-Json +$archived = [System.Collections.Generic.HashSet[string]]::new([string[]]@($w.global.archivedSessionIds)) +Write-Output ("archived count=" + $archived.Count) + +$liveIds = New-Object System.Collections.Generic.List[string] +Get-ChildItem 'C:\Users\zhout\.dsh\sessions' -Directory | ForEach-Object { + Get-ChildItem $_.FullName -Directory | ForEach-Object { [void]$liveIds.Add($_.Name) } +} +Write-Output ("live session dirs=" + $liveIds.Count) +$inArchived = @($liveIds | Where-Object { $archived.Contains($_) }) +Write-Output ("live dirs that are archived=" + $inArchived.Count) +if ($inArchived.Count -gt 0 -and $inArchived.Count -le 20) { + $inArchived | ForEach-Object { Write-Output (" archived: " + $_) } +} + +Write-Output '--- table keys ---' +foreach ($prop in $w.tables.PSObject.Properties) { + $name = $prop.Name + $val = $prop.Value + if ($null -eq $val) { + Write-Output (" {0}=null" -f $name) + continue + } + if ($val -is [System.Array] -or ($val -is [System.Collections.IList])) { + Write-Output (" {0} list count={1}" -f $name, @($val).Count) + continue + } + if ($val.PSObject -and $val.PSObject.Properties['rows']) { + Write-Output (" {0}.rows={1}" -f $name, @($val.rows).Count) + continue + } + # workspace records often keyed by id + $keys = @($val.PSObject.Properties.Name) + Write-Output (" {0} keys={1} sample={2}" -f $name, $keys.Count, (($keys | Select-Object -First 3) -join ',')) + foreach ($k in ($keys | Select-Object -First 3)) { + $row = $val.$k + if ($row.sessionIds) { + Write-Output (" {0} sessionIds={1}" -f $k, @($row.sessionIds).Count) + } elseif ($row.PSObject.Properties['sessionIds']) { + Write-Output (" {0} sessionIds={1}" -f $k, @($row.sessionIds).Count) + } else { + $rowJson = ($row | ConvertTo-Json -Compress -Depth 3) + if ($rowJson.Length -gt 200) { $rowJson = $rowJson.Substring(0, 200) + '...' } + Write-Output (" {0} => {1}" -f $k, $rowJson) + } + } +} diff --git a/uds-auth/scripts/count-sessions.ps1 b/uds-auth/scripts/count-sessions.ps1 new file mode 100644 index 00000000..1ce9543b --- /dev/null +++ b/uds-auth/scripts/count-sessions.ps1 @@ -0,0 +1,14 @@ +$live = 'C:\Users\zhout\.dsh\sessions' +$bak = 'C:\Users\zhout\.dsh\upgrade-backup-20260914-074633\sessions' + +Write-Output 'LIVE:' +Get-ChildItem $live -Directory -ErrorAction SilentlyContinue | ForEach-Object { + $count = @(Get-ChildItem $_.FullName -Directory -ErrorAction SilentlyContinue).Count + Write-Output (" {0} => {1}" -f $_.Name, $count) +} + +Write-Output 'BACKUP:' +Get-ChildItem $bak -Directory -ErrorAction SilentlyContinue | ForEach-Object { + $count = @(Get-ChildItem $_.FullName -Directory -ErrorAction SilentlyContinue).Count + Write-Output (" {0} => {1}" -f $_.Name, $count) +} diff --git a/uds-auth/scripts/dump-workspaces.ps1 b/uds-auth/scripts/dump-workspaces.ps1 new file mode 100644 index 00000000..393c7e0a --- /dev/null +++ b/uds-auth/scripts/dump-workspaces.ps1 @@ -0,0 +1,9 @@ +$ErrorActionPreference = 'Stop' +$w = Get-Content 'C:\Users\zhout\.dsh\storages\workspace.json' -Raw | ConvertFrom-Json +foreach ($prop in $w.tables.workspaces.PSObject.Properties) { + $row = $prop.Value + Write-Output ("id={0}" -f $prop.Name) + Write-Output (" path={0}" -f $row.path) + Write-Output (" title={0}" -f $row.title) + Write-Output (" sessionIds={0}" -f @($row.sessionIds).Count) +} diff --git a/uds-auth/scripts/merge-sessions-into-workspace.cjs b/uds-auth/scripts/merge-sessions-into-workspace.cjs new file mode 100644 index 00000000..652f2867 --- /dev/null +++ b/uds-auth/scripts/merge-sessions-into-workspace.cjs @@ -0,0 +1,44 @@ +const fs = require('fs') +const path = require('path') + +const workspacePath = 'C:/Users/zhout/.dsh/storages/workspace.json' +const sessionsRoot = 'C:/Users/zhout/.dsh/sessions' + +const map = { + '--C-Users-zhout-.dsh-user-workspaces-administrator--': + 'C:\\Users\\zhout\\.dsh\\user-workspaces\\administrator', + '--D-project-chatgpt--': 'D:\\project\\chatgpt', + '--D-project-harness--': 'D:\\project\\harness', +} + +const w = JSON.parse(fs.readFileSync(workspacePath, 'utf8')) +const pathToId = {} +for (const [id, row] of Object.entries(w.tables.workspaces)) { + pathToId[row.path] = id +} + +let added = 0 +for (const [folder, wsPath] of Object.entries(map)) { + const wsId = pathToId[wsPath] + if (!wsId) { + console.log('skip no workspace', wsPath) + continue + } + const proj = path.join(sessionsRoot, folder) + if (!fs.existsSync(proj)) continue + const disk = fs + .readdirSync(proj, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + const row = w.tables.workspaces[wsId] + const existing = new Set(row.sessionIds || []) + const missing = disk.filter((id) => !existing.has(id)) + added += missing.length + row.sessionIds = [...missing, ...(row.sessionIds || [])] + row.updatedAt = new Date().toISOString() + console.log(wsPath, 'disk', disk.length, 'now', row.sessionIds.length, 'added', missing.length) +} + +fs.copyFileSync(workspacePath, workspacePath + '.bak-before-session-restore') +fs.writeFileSync(workspacePath, JSON.stringify(w, null, 2) + '\n') +console.log('added', added) diff --git a/uds-auth/scripts/prune-empty-sessions.cjs b/uds-auth/scripts/prune-empty-sessions.cjs new file mode 100644 index 00000000..e5b5e205 --- /dev/null +++ b/uds-auth/scripts/prune-empty-sessions.cjs @@ -0,0 +1,250 @@ +/** + * Classify & prune empty/invalid DSH sessions under ~/.dsh/sessions. + * + * empty/shell — log exists but has no user/message and no turn/start + * (only session header + permission/sandbox/approval/end-seed) + * invalid — no log, 0 bytes, corrupt, non-session header + * keep — has at least one conversational event + * + * Usage: + * node prune-empty-sessions.cjs --dry-run + * node prune-empty-sessions.cjs --apply + */ +const fs = require('fs') +const path = require('path') +const { promisify } = require('util') +const { zstdDecompress } = require('zlib') +const zstdDecompressAsync = promisify(zstdDecompress) + +const SESSIONS_ROOT = 'C:/Users/zhout/.dsh/sessions' +const WORKSPACE_PATH = 'C:/Users/zhout/.dsh/storages/workspace.json' +const OWNERS_PATH = 'D:/project/chatgpt/oclaw/uds-auth/session-owners.json' +const PROJCACHE = 'C:/Users/zhout/.dsh/storages/session_projcache/sessions' + +const ZSTD_MAGIC = 0xfd2fb528 +const apply = process.argv.includes('--apply') + +/** Event types that prove the session had real conversation activity. */ +const LIVE_TYPES = new Set([ + 'user/message', + 'turn/start', + 'assistant/message', + 'agent/message', + 'step/start', +]) + +function scanZstdFrames(buffer, maxFrames = Infinity) { + const frames = [] + let offset = 0 + while (offset < buffer.length) { + const start = offset + if (buffer.length - offset < 4) return { frames, tornStart: start } + if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) { + throw new Error(`invalid magic at ${offset}`) + } + offset += 4 + if (offset === buffer.length) return { frames, tornStart: start } + const descriptor = buffer.readUInt8(offset) + offset += 1 + if ((descriptor & 0x18) !== 0) throw new Error('reserved frame-header bit') + const contentSizeFlag = descriptor >>> 6 + const singleSegment = (descriptor & 0x20) !== 0 + const checksum = (descriptor & 0x04) !== 0 + const dictionaryFlag = descriptor & 0x03 + const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag + const contentSizeBytes = + contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag + const remainingHeaderBytes = + (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes + if (buffer.length - offset < remainingHeaderBytes) return { frames, tornStart: start } + offset += remainingHeaderBytes + for (;;) { + if (buffer.length - offset < 3) return { frames, tornStart: start } + const blockHeader = buffer.readUIntLE(offset, 3) + offset += 3 + const lastBlock = (blockHeader & 1) !== 0 + const blockType = (blockHeader >>> 1) & 0x03 + const blockSize = blockHeader >>> 3 + if (blockType === 0x03) throw new Error('reserved block type') + const payloadBytes = blockType === 0x01 ? 1 : blockSize + if (buffer.length - offset < payloadBytes) return { frames, tornStart: start } + offset += payloadBytes + if (lastBlock) break + } + if (checksum) { + if (buffer.length - offset < 4) return { frames, tornStart: start } + offset += 4 + } + frames.push({ start, end: offset }) + if (frames.length >= maxFrames) return { frames } + } + return { frames } +} + +function findLog(dir) { + const names = fs.readdirSync(dir) + const preferred = names + .filter((n) => /^session(\.v\d+)?\.jsonl(\.zstd)?$/.test(n)) + .sort() + return preferred[0] ? path.join(dir, preferred[0]) : null +} + +function collectTypesFromText(text, types) { + for (const line of text.split(/\n/)) { + if (!line.trim()) continue + try { + const o = JSON.parse(line) + if (o && typeof o.type === 'string') types.add(o.type) + } catch { + // ignore bad lines + } + } +} + +async function classify(dir) { + const log = findLog(dir) + if (!log) return { kind: 'invalid', reason: 'no-log' } + const buf = fs.readFileSync(log) + if (buf.length === 0) return { kind: 'invalid', reason: 'zero-bytes' } + + const types = new Set() + try { + if (log.endsWith('.zstd')) { + const { frames, tornStart } = scanZstdFrames(buf) + if (frames.length === 0) { + return { kind: 'invalid', reason: tornStart != null ? 'torn-frame' : 'no-frames' } + } + for (const fr of frames) { + const plain = await zstdDecompressAsync(buf.subarray(fr.start, fr.end)) + collectTypesFromText(plain.toString('utf8'), types) + // Early exit once we know it's live + for (const t of LIVE_TYPES) { + if (types.has(t)) { + return { kind: 'keep', reason: `has:${t}`, types: [...types] } + } + } + } + } else { + collectTypesFromText(buf.toString('utf8'), types) + } + } catch (e) { + return { kind: 'invalid', reason: `decode:${e.message}` } + } + + if (!types.has('session')) { + return { kind: 'invalid', reason: 'non-session-header', types: [...types] } + } + + for (const t of LIVE_TYPES) { + if (types.has(t)) return { kind: 'keep', reason: `has:${t}`, types: [...types] } + } + + return { + kind: 'empty', + reason: 'no-conversation', + types: [...types], + } +} + +async function main() { + const results = { invalid: [], empty: [], keep: [] } + + for (const proj of fs.readdirSync(SESSIONS_ROOT, { withFileTypes: true })) { + if (!proj.isDirectory()) continue + const projDir = path.join(SESSIONS_ROOT, proj.name) + for (const sid of fs.readdirSync(projDir, { withFileTypes: true })) { + if (!sid.isDirectory()) continue + const dir = path.join(projDir, sid.name) + const c = await classify(dir) + results[c.kind].push({ + id: sid.name, + proj: proj.name, + dir, + reason: c.reason, + types: c.types, + }) + } + } + + const byProj = {} + for (const item of [...results.empty, ...results.invalid]) { + byProj[item.proj] = (byProj[item.proj] || 0) + 1 + } + + console.log( + JSON.stringify( + { + mode: apply ? 'apply' : 'dry-run', + counts: { + invalid: results.invalid.length, + empty: results.empty.length, + keep: results.keep.length, + deleteTotal: results.invalid.length + results.empty.length, + }, + deleteByProject: byProj, + sampleEmpty: results.empty.slice(0, 5).map((x) => ({ + id: x.id, + reason: x.reason, + types: x.types, + })), + }, + null, + 2, + ), + ) + + if (!apply) { + console.log('\nRe-run with --apply to delete invalid+empty and update workspace/owners.') + return + } + + const toDelete = [...results.invalid, ...results.empty] + const deleteIds = new Set(toDelete.map((x) => x.id)) + + for (const item of toDelete) { + fs.rmSync(item.dir, { recursive: true, force: true }) + const cache = path.join(PROJCACHE, `${item.id}.json`) + if (fs.existsSync(cache)) fs.rmSync(cache, { force: true }) + } + + if (fs.existsSync(WORKSPACE_PATH)) { + const bak = WORKSPACE_PATH + '.bak-before-prune-empty' + fs.copyFileSync(WORKSPACE_PATH, bak) + const w = JSON.parse(fs.readFileSync(WORKSPACE_PATH, 'utf8')) + if (Array.isArray(w.global?.archivedSessionIds)) { + w.global.archivedSessionIds = w.global.archivedSessionIds.filter((id) => !deleteIds.has(id)) + } + for (const row of Object.values(w.tables?.workspaces || {})) { + if (!Array.isArray(row.sessionIds)) continue + const before = row.sessionIds.length + row.sessionIds = row.sessionIds.filter((id) => !deleteIds.has(id)) + if (row.sessionIds.length !== before) row.updatedAt = new Date().toISOString() + } + fs.writeFileSync(WORKSPACE_PATH, JSON.stringify(w, null, 2) + '\n') + console.log('updated workspace.json; backup', bak) + } + + if (fs.existsSync(OWNERS_PATH)) { + const bak = OWNERS_PATH + '.bak-before-prune-empty' + fs.copyFileSync(OWNERS_PATH, bak) + const o = JSON.parse(fs.readFileSync(OWNERS_PATH, 'utf8')) + let removed = 0 + if (o.owners && typeof o.owners === 'object') { + for (const id of deleteIds) { + if (Object.prototype.hasOwnProperty.call(o.owners, id)) { + delete o.owners[id] + removed++ + } + } + } + fs.writeFileSync(OWNERS_PATH, JSON.stringify(o, null, 2) + '\n') + console.log('updated session-owners.json removed', removed, 'backup', bak) + } + + console.log('deleted dirs', toDelete.length) +} + +main().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/uds-auth/scripts/restore-sessions-from-backup.ps1 b/uds-auth/scripts/restore-sessions-from-backup.ps1 new file mode 100644 index 00000000..f218fd6d --- /dev/null +++ b/uds-auth/scripts/restore-sessions-from-backup.ps1 @@ -0,0 +1,35 @@ +$ErrorActionPreference = 'Stop' +$srcRoot = 'C:\Users\zhout\.dsh\upgrade-backup-20260914-074633\sessions' +$dstRoot = 'C:\Users\zhout\.dsh\sessions' + +if (-not (Test-Path $srcRoot)) { throw "backup missing: $srcRoot" } +if (-not (Test-Path $dstRoot)) { New-Item -ItemType Directory -Path $dstRoot | Out-Null } + +$copied = 0 +$skipped = 0 + +Get-ChildItem $srcRoot -Directory | ForEach-Object { + $proj = $_.Name + $srcProj = $_.FullName + $dstProj = Join-Path $dstRoot $proj + if (-not (Test-Path $dstProj)) { + New-Item -ItemType Directory -Path $dstProj | Out-Null + } + Get-ChildItem $srcProj -Directory | ForEach-Object { + $sid = $_.Name + $dstSid = Join-Path $dstProj $sid + if (Test-Path $dstSid) { + $skipped++ + return + } + Copy-Item -LiteralPath $_.FullName -Destination $dstSid -Recurse -Force + $copied++ + } +} + +Write-Output ("copied={0} skipped_existing={1}" -f $copied, $skipped) +Write-Output 'LIVE after restore:' +Get-ChildItem $dstRoot -Directory | ForEach-Object { + $count = @(Get-ChildItem $_.FullName -Directory -ErrorAction SilentlyContinue).Count + Write-Output (" {0} => {1}" -f $_.Name, $count) +} diff --git a/uds-auth/test/session-access.test.js b/uds-auth/test/session-access.test.js index 1a6c8ecb..75d1474a 100644 --- a/uds-auth/test/session-access.test.js +++ b/uds-auth/test/session-access.test.js @@ -1,6 +1,7 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' import { createSessionAccess } from '../lib/dsh-acl.js' +import { computePermissions, RolesStore, ROLES } from '../lib/roles.js' function makeAccess(owners = {}) { const ownersMap = new Map(Object.entries(owners)) @@ -38,25 +39,28 @@ function makeAccess(owners = {}) { } 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 }, - } + it('super/fallback see all by default via rolesStore prefs (default on)', () => { + const store = new RolesStore() + store._roles.set('boss', ROLES.SUPER_ADMIN) + const { canAccessSession, canSeeAll } = createSessionAccess({ + sessionAcl: { getOwner: () => 'u2' }, + userWorkspaces: { get: () => null, isUserPath: () => false }, + getWorkspaceRoot: () => '/ws', + getWorkspaceRegistry: () => ({ list: () => [] }), + rolesStore: store, + }) + const superAdmin = { empNo: 'boss', role: 'super_admin', permissions: computePermissions('super_admin') } + const fallback = { empNo: 'administrator', role: 'fallback_admin', permissions: computePermissions('fallback_admin') } assert.equal(canSeeAll(superAdmin), true) assert.equal(canSeeAll(fallback), true) assert.equal(canAccessSession('s-other', superAdmin), true) - assert.equal(canAccessSession('s-other', fallback), true) + + store.setViewAllSessions('boss', false) + assert.equal(canSeeAll(superAdmin), false) + assert.equal(canAccessSession('s-other', superAdmin), false) }) - it('admin and user only see owned or own-workspace sessions', () => { + it('admin and user only see owned or own-workspace sessions by default', () => { const { canAccessSession, canSeeAll } = makeAccess({ 's-owned': 'u1', 's-peer': 'u2', @@ -64,15 +68,17 @@ describe('createSessionAccess', () => { const admin = { empNo: 'u1', role: 'admin', - permissions: { canViewAllSessions: false, canAccessSettings: true }, + permissions: computePermissions('admin'), } const user = { empNo: 'u1', role: 'user', - permissions: { canViewAllSessions: false }, + permissions: computePermissions('user'), } assert.equal(canSeeAll(admin), false) assert.equal(canSeeAll(user), false) + assert.equal(admin.permissions.canToggleViewAllSessions, false) + assert.equal(user.permissions.canToggleViewAllSessions, false) assert.equal(canAccessSession('s-owned', admin), true) assert.equal(canAccessSession('s-in-u1', user), true) @@ -84,10 +90,106 @@ describe('createSessionAccess', () => { assert.equal(canAccessSession('s-cwd-peer', user, { cwd: '/ws/u2/x' }), false) }) + it('admin cannot enable view-all even if preference flag is passed', () => { + const { canAccessSession, canSeeAll } = makeAccess({ 's-peer': 'u2' }) + const admin = { + empNo: 'u1', + role: 'admin', + permissions: computePermissions('admin', { viewAllSessions: true }), + } + assert.equal(admin.permissions.canViewAllSessions, false) + assert.equal(admin.permissions.canToggleViewAllSessions, false) + assert.equal(canSeeAll(admin), false) + assert.equal(canAccessSession('s-peer', admin), 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 } } + const user = { empNo: 'u1', role: 'user', permissions: computePermissions('user') } assert.equal(canAccessSession('legacy', user, { cwd: '/ws/u1' }), true) assert.equal(canAccessSession('legacy-other', user, { cwd: '/ws/u2' }), false) }) + + it('admin can see unowned channel/system sessions outside user-workspaces', () => { + const { canAccessSession, isVisibleWorkspace } = makeAccess({}) + const admin = { + empNo: 'u1', + role: 'admin', + permissions: computePermissions('admin'), + } + const user = { + empNo: 'u1', + role: 'user', + permissions: computePermissions('user'), + } + assert.equal(canAccessSession('ch-1', admin, { cwd: '/bots/whatsapp' }), true) + assert.equal(canAccessSession('ch-1', user, { cwd: '/bots/whatsapp' }), false) + assert.equal(canAccessSession('s-peer', admin, { cwd: '/ws/u2/x' }), false) + + const access = makeAccess({ 'ch-owned': 'u2' }) + assert.equal(access.canAccessSession('ch-owned', admin, { cwd: '/bots/wa' }), false) + + assert.equal(isVisibleWorkspace(admin, { id: 'bot-ws', path: '/bots/whatsapp' }), true) + assert.equal(isVisibleWorkspace(user, { id: 'bot-ws', path: '/bots/whatsapp' }), false) + }) + + it('live rolesStore prefs override stale identity.permissions', () => { + const store = new RolesStore() + store._roles.set('boss', ROLES.SUPER_ADMIN) + store.setViewAllSessions('boss', false) + const ownersMap = new Map([['s-peer', 'u2']]) + const access = createSessionAccess({ + sessionAcl: { getOwner: (id) => ownersMap.get(String(id)) || null }, + userWorkspaces: { + get: () => null, + isUserPath: () => false, + }, + getWorkspaceRoot: () => '/ws', + getWorkspaceRegistry: () => ({ list: () => [] }), + rolesStore: store, + }) + const stale = { + empNo: 'boss', + role: 'super_admin', + permissions: computePermissions('super_admin', { viewAllSessions: true }), + } + assert.equal(access.canSeeAll(stale), false) + assert.equal(access.canAccessSession('s-peer', stale), false) + + store.setViewAllSessions('boss', true) + assert.equal(access.canSeeAll(stale), true) + assert.equal(access.canAccessSession('s-peer', stale), true) + }) +}) + +describe('RolesStore view-all prefs', () => { + it('defaults on for super_admin and can be turned off', () => { + const store = new RolesStore() + store._roles.set('boss', ROLES.SUPER_ADMIN) + store._roles.set('op', ROLES.ADMIN) + + assert.equal(store.isViewAllSessionsEnabled('boss'), true) + assert.equal(store.resolvePermissions('boss').canViewAllSessions, true) + assert.equal(store.resolvePermissions('boss').canToggleViewAllSessions, true) + + store.setViewAllSessions('boss', false) + assert.equal(store.isViewAllSessionsEnabled('boss'), false) + assert.equal(store.resolvePermissions('boss').canViewAllSessions, false) + + store.setViewAllSessions('boss', true) + assert.equal(store.resolvePermissions('boss').canViewAllSessions, true) + + assert.throws(() => store.setViewAllSessions('op', true), (err) => err.code === 'forbidden_view_all_sessions') + store._prefs.set('op', { viewAllSessions: true }) + assert.equal(store.resolvePermissions('op').canViewAllSessions, false) + assert.equal(store.resolvePermissions('op').canToggleViewAllSessions, false) + }) + + it('rejects view-all toggle for ordinary users and admins', () => { + const store = new RolesStore() + store._roles.set('u1', ROLES.USER) + store._roles.set('a1', ROLES.ADMIN) + assert.throws(() => store.setViewAllSessions('u1', true), (err) => err.code === 'forbidden_view_all_sessions') + assert.throws(() => store.setViewAllSessions('a1', true), (err) => err.code === 'forbidden_view_all_sessions') + }) })