mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-08 23:33:16 +08:00
Make session visibility preference-driven and keep system-channel ACL separate.
Super/fallback can toggle view-all (default on); admin/user stay own-only while still seeing unowned system sessions. Add restore/prune helpers so empty-shell cleanup is explicit and recoverable.
This commit is contained in:
parent
c26dc68f77
commit
ad0cc98b38
17 changed files with 898 additions and 93 deletions
|
|
@ -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 时)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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))),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
48
uds-auth/scripts/check-session-registry.ps1
Normal file
48
uds-auth/scripts/check-session-registry.ps1
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
14
uds-auth/scripts/count-sessions.ps1
Normal file
14
uds-auth/scripts/count-sessions.ps1
Normal file
|
|
@ -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)
|
||||
}
|
||||
9
uds-auth/scripts/dump-workspaces.ps1
Normal file
9
uds-auth/scripts/dump-workspaces.ps1
Normal file
|
|
@ -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)
|
||||
}
|
||||
44
uds-auth/scripts/merge-sessions-into-workspace.cjs
Normal file
44
uds-auth/scripts/merge-sessions-into-workspace.cjs
Normal file
|
|
@ -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)
|
||||
250
uds-auth/scripts/prune-empty-sessions.cjs
Normal file
250
uds-auth/scripts/prune-empty-sessions.cjs
Normal file
|
|
@ -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)
|
||||
})
|
||||
35
uds-auth/scripts/restore-sessions-from-backup.ps1
Normal file
35
uds-auth/scripts/restore-sessions-from-backup.ps1
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue