From 7d27179e5112f619bf2110c76fa3c326a0b0e4ec Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 8 Sep 2026 15:49:54 +0800 Subject: [PATCH] Isolate scheduled tasks by empNo, aligned with uds-auth workspaces. Super/fallback admins see all jobs grouped by user folder; admin/user only see their own. Stamp ownership on create, filter HTTP/tools, and prefer the owner's provisioned cwd on fire. Co-authored-by: Cursor --- README.md | 11 ++ docs/PRD.md | 15 +++ lib/client.js | 219 ++++++++++++++++++++++----------- lib/fire.js | 2 + lib/host.js | 267 +++++++++++++++++++++++++++++++++-------- lib/index.d.ts | 9 +- lib/index.js | 11 ++ lib/ownership.js | 112 +++++++++++++++++ lib/store.js | 9 ++ lib/tools.js | 5 + test/host.test.js | 192 ++++++++++++++++++++++++++--- test/ownership.test.js | 74 ++++++++++++ 12 files changed, 785 insertions(+), 141 deletions(-) create mode 100644 lib/ownership.js create mode 100644 test/ownership.test.js diff --git a/README.md b/README.md index 3c2f14f..e2b5af3 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,17 @@ dsh plugin --profile web add -w "github:hansjone/dsh-ops-cron" # restart dsh web ``` +## Multi-user isolation (with uds-auth) + +Requires **uds-auth** in the same profile. Jobs carry `ownerEmpNo`; visibility matches workspace ACL: + +| Role | Sees | +|------|------| +| `super_admin` / `fallback_admin` | All users’ jobs (sidebar groups by empNo folder) and their run sessions | +| `admin` / `user` | Only own jobs and runs | + +Create stamps the logged-in empNo. Legacy jobs without owner migrate to `__unassigned__` (super-only). Fire cwd prefers the owner’s provisioned workspace under `user-workspaces/`. + ## Delivery defaults | Created from | Default delivery | Effective-session mirror | diff --git a/docs/PRD.md b/docs/PRD.md index 687ecde..ed5865f 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -180,3 +180,18 @@ - **入口位置**:无官方「新会话下方」slot;注入必须紧挨该按钮。仅 footer 不满足需求。 - **Host 必须在跑**。写进本 PRD 与 README,避免被当成 bug。 - 提示词按不可信内容包裹,降低提示注入把定时通道变成越权入口的风险。 + +## 10. 多用户隔离(对齐 uds-auth 工作区) + +与侧栏工作区同一套角色: + +| 角色 | 定时任务 / 任务会话 | +|------|---------------------| +| `super_admin` / `fallback_admin` | 按用户文件夹看到全部;可改派 `ownerEmpNo` | +| `admin` / `user` | 仅 `ownerEmpNo === 自己` | + +- Job 字段:`ownerEmpNo`、`ownerDisplayName`(创建时服务端盖章,客户端不可伪造)。 +- 存量无归属:IM origin → `__unassigned__`;Web 可尝试 `sessionAcl` 推断;否则 `__unassigned__`(仅超管可见)。 +- 依赖 Cordis 服务 `udsAuth`;未就绪时 HTTP API 返回 503,不回退为全员可见。 +- 调度器仍扫描全库 enabled jobs;归属只约束可见与写权限。 +- oclaw `scheduled_job`(管理后台)为另一套系统,本 PRD 不覆盖。 diff --git a/lib/client.js b/lib/client.js index b7d463d..4396ff8 100644 --- a/lib/client.js +++ b/lib/client.js @@ -191,6 +191,7 @@ window.__ModuleLoader__.load({ catalog: { groups: [], current: null }, presets: { items: [], current: null }, imCatalog: { available: true, options: [], loading: false }, + viewer: null, } function jobStamp(rows) { @@ -332,7 +333,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba newJob: '新建任务', name: '名称', prompt: '提示词', kind: '日程', cron: 'Cron(循环)', at: '一次性时间', expr: 'Cron 表达式', atTime: '时间', create: '创建', pause: '暂停', resume: '恢复', runNow: '立即运行', remove: '删除', - emptyJobs: '还没有定时任务。点右上角 + 新建。', next: '下次', last: '上次', + emptyJobs: '还没有定时任务。点右上角 + 新建。', unassignedOwner: '未归属', ownerClaim: '改派归属', next: '下次', last: '上次', expandRuns: '展开记录', collapseRuns: '收起记录', search: '搜索', searchPlaceholder: '搜索任务', searchClear: '清除搜索', searchEmpty: '没有匹配的任务。', paused: '已暂停', @@ -373,7 +374,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba newJob: 'New job', name: 'Name', prompt: 'Prompt', kind: 'Schedule', cron: 'Cron (recurring)', at: 'One-shot time', expr: 'Cron expression', atTime: 'Time', create: 'Create', pause: 'Pause', resume: 'Resume', runNow: 'Run now', remove: 'Delete', - emptyJobs: 'No jobs yet. Use + to create one.', next: 'Next', last: 'Last', + emptyJobs: 'No jobs yet. Use + to create one.', unassignedOwner: 'Unassigned', ownerClaim: 'Reassign owner', next: 'Next', last: 'Last', expandRuns: 'Show runs', collapseRuns: 'Hide runs', search: 'Search', searchPlaceholder: 'Search jobs', searchClear: 'Clear search', searchEmpty: 'No matching jobs.', paused: 'Paused', @@ -696,12 +697,13 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba return '' } - function JobList({ t, jobs, runs, selection, expanded, onSelectJob, onSelectRun, onNew, onRun, onToggle, onRemove, onToggleGroup }) { + function JobList({ t, jobs, runs, selection, expanded, viewer, onSelectJob, onSelectRun, onNew, onRun, onToggle, onRemove, onToggleGroup }) { const skin = workspaceSkin() const [query, setQuery] = useState('') const [searchOn, setSearchOn] = useState(false) const searchRef = useRef(null) const needle = query.trim().toLowerCase() + const canViewAll = !!viewer?.canViewAll const visibleJobs = !needle ? jobs : jobs.filter((job) => { const hay = [ job.name, @@ -710,10 +712,140 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba job.schedule?.at, job.provider, job.model, + job.ownerEmpNo, + job.ownerDisplayName, ...(runs.filter((run) => run.jobId === job.id).map((run) => run.summary || run.status)), ].join(' ').toLowerCase() return hay.includes(needle) }) + + function ownerKey(job) { + return String(job?.ownerEmpNo || '__unassigned__') + } + + function ownerLabel(job) { + const key = ownerKey(job) + if (key === '__unassigned__') return t('unassignedOwner') + const name = String(job?.ownerDisplayName || '').trim() + return name && name !== key ? `${key} · ${name}` : key + } + + function renderJobGroup(job) { + const open = expanded[job.id] === true || !!needle + const paused = job.enabled === false + const jobRuns = runs.filter((run) => run.jobId === job.id) + const selectedJob = selection?.type === 'job' && selection.jobId === job.id + const containsCurrent = selectedJob || (selection?.type === 'run' && selection.jobId === job.id) + return h('div', { className: skin.group, key: job.id }, + h('div', { + className: skin.project, + role: 'treeitem', + 'aria-expanded': open, + 'data-on': selectedJob ? 'true' : 'false', + 'data-paused': paused ? 'true' : 'false', + onClick: () => onSelectJob(job.id), + }, + h('span', { + className: joinClass(skin.slot, skin.folder, open && containsCurrent ? skin.folderOn : ''), + 'data-on': open && containsCurrent ? 'true' : 'false', + }, folderIcon(open)), + h('button', { + type: 'button', + className: joinClass(skin.slot, skin.chevron, 'dsh-ct-chevronHit'), + title: open ? t('collapseRuns') : t('expandRuns'), + onClick: (e) => { e.stopPropagation(); onToggleGroup(job.id) }, + }, + h('span', { className: joinClass(skin.arrow, open ? skin.arrowOpen : '') }, + chevronIcon() || '▸')), + h('span', { className: skin.projectText }, + h('span', { className: skin.title }, paused + ? `${job.name} · ${t('paused')}` + : `${job.name} · ${jobDeliveryLabel(job, t)}${jobModelLabel(job) ? ` · ${jobModelLabel(job)}` : ''}`)), + h('span', { className: skin.rowActs, onClick: (e) => e.stopPropagation() }, + h('button', { type: 'button', className: skin.rowIcon, title: t('runNow'), onClick: () => onRun(job.id) }, + playIcon() || '▶'), + h('button', { + type: 'button', + className: skin.rowIcon, + title: paused ? t('resume') : t('pause'), + onClick: () => onToggle(job), + }, pauseIcon() || (paused ? '▶' : '⏸')), + h('button', { type: 'button', className: skin.rowIcon, title: t('remove'), onClick: () => onRemove(job.id) }, + trashIcon() || '×'), + ), + ), + h('div', { className: 'dsh-ct-runs', 'data-open': open ? 'true' : 'false' }, + h('div', { className: 'dsh-ct-runsInner' }, + jobRuns.map((run) => { + const selectedRun = selection?.type === 'run' && selection.runId === run.id + return h('div', { + key: run.id, + className: skin.session, + role: 'treeitem', + 'data-on': selectedRun ? 'true' : 'false', + onClick: () => onSelectRun(job.id, run), + }, + h('span', { className: skin.slot }, sessionIcon() || '·'), + h('span', { className: skin.title }, (run.summary || run.status || '').split('\n')[0] || run.status), + h('span', { className: skin.time }, formatTime(run.actualAt || run.scheduledAt, job.schedule?.timezone)), + ) + }), + ), + ), + ) + } + + function renderGrouped() { + const groups = new Map() + for (const job of visibleJobs) { + const key = ownerKey(job) + if (!groups.has(key)) groups.set(key, { key, label: ownerLabel(job), jobs: [] }) + groups.get(key).jobs.push(job) + } + const ordered = [...groups.values()].sort((a, b) => { + if (a.key === '__unassigned__') return 1 + if (b.key === '__unassigned__') return -1 + return String(a.label).localeCompare(String(b.label), 'zh') + }) + return ordered.map((group) => { + const folderId = `owner:${group.key}` + const open = expanded[folderId] !== false || !!needle + const containsCurrent = group.jobs.some((job) => ( + (selection?.type === 'job' && selection.jobId === job.id) + || (selection?.type === 'run' && selection.jobId === job.id) + )) + return h('div', { className: skin.group, key: folderId }, + h('div', { + className: skin.project, + role: 'treeitem', + 'aria-expanded': open, + 'data-on': containsCurrent ? 'true' : 'false', + onClick: () => onToggleGroup(folderId), + }, + h('span', { + className: joinClass(skin.slot, skin.folder, open && containsCurrent ? skin.folderOn : ''), + 'data-on': open && containsCurrent ? 'true' : 'false', + }, folderIcon(open)), + h('button', { + type: 'button', + className: joinClass(skin.slot, skin.chevron, 'dsh-ct-chevronHit'), + title: open ? t('collapseRuns') : t('expandRuns'), + onClick: (e) => { e.stopPropagation(); onToggleGroup(folderId) }, + }, + h('span', { className: joinClass(skin.arrow, open ? skin.arrowOpen : '') }, + chevronIcon() || '▸')), + h('span', { className: skin.projectText }, + h('span', { className: skin.title }, `${group.label} · ${group.jobs.length}`)), + ), + h('div', { className: 'dsh-ct-runs', 'data-open': open ? 'true' : 'false' }, + h('div', { className: 'dsh-ct-runsInner' }, + group.jobs.map((job) => renderJobGroup(job)), + ), + ), + ) + }) + } + return h(React.Fragment, null, h('div', { className: skin.sectionHeader }, h('span', { className: joinClass(skin.sectionLabel, searchOn ? skin.sectionLabelHidden : '') }, t('title')), @@ -768,71 +900,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba h('div', { className: skin.list }, jobs.length === 0 ? h('p', { className: skin.empty }, t('emptyJobs')) : visibleJobs.length === 0 ? h('p', { className: skin.empty }, t('searchEmpty')) - : visibleJobs.map((job) => { - const open = expanded[job.id] === true || !!needle - const paused = job.enabled === false - const jobRuns = runs.filter((run) => run.jobId === job.id) - const selectedJob = selection?.type === 'job' && selection.jobId === job.id - const containsCurrent = selectedJob || (selection?.type === 'run' && selection.jobId === job.id) - return h('div', { className: skin.group, key: job.id }, - h('div', { - className: skin.project, - role: 'treeitem', - 'aria-expanded': open, - 'data-on': selectedJob ? 'true' : 'false', - 'data-paused': paused ? 'true' : 'false', - onClick: () => onSelectJob(job.id), - }, - h('span', { - className: joinClass(skin.slot, skin.folder, open && containsCurrent ? skin.folderOn : ''), - 'data-on': open && containsCurrent ? 'true' : 'false', - }, folderIcon(open)), - h('button', { - type: 'button', - className: joinClass(skin.slot, skin.chevron, 'dsh-ct-chevronHit'), - title: open ? t('collapseRuns') : t('expandRuns'), - onClick: (e) => { e.stopPropagation(); onToggleGroup(job.id) }, - }, - h('span', { className: joinClass(skin.arrow, open ? skin.arrowOpen : '') }, - chevronIcon() || '▸')), - h('span', { className: skin.projectText }, - h('span', { className: skin.title }, paused - ? `${job.name} · ${t('paused')}` - : `${job.name} · ${jobDeliveryLabel(job, t)}${jobModelLabel(job) ? ` · ${jobModelLabel(job)}` : ''}`)), - h('span', { className: skin.rowActs, onClick: (e) => e.stopPropagation() }, - h('button', { type: 'button', className: skin.rowIcon, title: t('runNow'), onClick: () => onRun(job.id) }, - playIcon() || '▶'), - h('button', { - type: 'button', - className: skin.rowIcon, - title: paused ? t('resume') : t('pause'), - onClick: () => onToggle(job), - }, pauseIcon() || (paused ? '▶' : '⏸')), - h('button', { type: 'button', className: skin.rowIcon, title: t('remove'), onClick: () => onRemove(job.id) }, - trashIcon() || '×'), - ), - ), - h('div', { className: 'dsh-ct-runs', 'data-open': open ? 'true' : 'false' }, - h('div', { className: 'dsh-ct-runsInner' }, - jobRuns.map((run) => { - const selectedRun = selection?.type === 'run' && selection.runId === run.id - return h('div', { - role: 'treeitem', - 'aria-selected': selectedRun, - key: run.id, - className: joinClass(skin.session, selectedRun ? skin.sessionOn : ''), - 'data-on': selectedRun ? 'true' : 'false', - onClick: () => onSelectRun(job.id, run), - }, - h('span', { className: skin.slot }), - h('span', { className: skin.title }, (run.summary || run.status || '').split('\n')[0] || run.status), - h('span', { className: skin.time }, formatTime(run.actualAt || run.scheduledAt, job.schedule?.timezone)), - ) - }), - ), - ), - ) - }), + : (canViewAll ? renderGrouped() : visibleJobs.map((job) => renderJobGroup(job))), ), ) } @@ -851,9 +919,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba return '__custom__' } - function CwdField({ t, form, setForm, workspaces }) { + function CwdField({ t, form, setForm, workspaces, viewer }) { const rows = Array.isArray(workspaces) ? workspaces : [] const mode = cwdSelectValue(form.cwd, rows) + const allowCustom = !!viewer?.canViewAll return h('label', null, t('cwd'), h('select', { value: mode, @@ -869,9 +938,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba }, h('option', { value: '' }, t('cwdRecent')), ...rows.map((row) => h('option', { value: row.path, key: row.id || row.path }, `${row.title} — ${row.path}`)), - h('option', { value: '__custom__' }, t('cwdCustom')), + ...(allowCustom ? [h('option', { value: '__custom__' }, t('cwdCustom'))] : []), ), - mode === '__custom__' + allowCustom && mode === '__custom__' ? h('input', { placeholder: t('cwdPlaceholder'), value: form.cwd, @@ -1041,7 +1110,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba h('label', null, t('scheduleTz'), h('input', { value: form.timezone, onChange: (e) => setForm({ ...form, timezone: e.target.value }) })), h('label', null, t('timeout'), h('input', { type: 'number', min: 1, max: 240, value: form.timeoutMinutes, onChange: (e) => setForm({ ...form, timeoutMinutes: Number(e.target.value) }) })), ), - h(CwdField, { t, form, setForm, workspaces }), + h(CwdField, { t, form, setForm, workspaces, viewer }), h(ModelField, { t, form, setForm, catalog }), h(PresetField, { t, form, setForm, presets }), h('label', null, t('delivery'), @@ -1072,6 +1141,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba function CronApp({ t, faces }) { const [jobs, setJobs] = useState(() => listSnapshot.jobs) + const [viewer, setViewer] = useState(() => listSnapshot.viewer) const [runs, setRuns] = useState(() => listSnapshot.runs) const [selection, setSelection] = useState({ type: 'new' }) const [expanded, setExpanded] = useState({}) @@ -1140,11 +1210,14 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba const nextJobs = jobBody.jobs || [] const nextRuns = histBody.runs || [] const nextWorkspaces = wsBody.workspaces || [] + const nextViewer = jobBody.viewer || wsBody.viewer || histBody.viewer || null const nextCatalog = { groups: modelBody.groups || [], current: modelBody.current || null } const nextPresets = { items: presetBody.items || [], current: presetBody.current || null } if (jobStamp(listSnapshot.jobs) !== jobStamp(nextJobs)) { listSnapshot.jobs = nextJobs + listSnapshot.viewer = nextViewer setJobs(nextJobs) + setViewer(nextViewer) } if (runStamp(listSnapshot.runs) !== runStamp(nextRuns)) { listSnapshot.runs = nextRuns @@ -1348,7 +1421,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba return h(React.Fragment, null, h(JobList, { - t, jobs, runs, selection, expanded, + t, jobs, runs, selection, viewer, expanded, onSelectJob: selectJob, onSelectRun: selectRun, onNew: selectNew, diff --git a/lib/fire.js b/lib/fire.js index 624b654..625e2ad 100644 --- a/lib/fire.js +++ b/lib/fire.js @@ -56,6 +56,8 @@ export function publicJob(job) { model: job.model || '', reasoningEffort: job.reasoningEffort || '', agentPreset: job.agentPreset || '', + ownerEmpNo: job.ownerEmpNo || '', + ownerDisplayName: job.ownerDisplayName || '', delivery: job.delivery && job.delivery.kind === 'im' ? { kind: 'im', diff --git a/lib/host.js b/lib/host.js index 26efc8d..d27169a 100644 --- a/lib/host.js +++ b/lib/host.js @@ -23,6 +23,16 @@ import { storePath, upsertJob, } from './store.js' +import { + assertCanAccessJob, + canViewAllJobs, + filterJobsForIdentity, + filterRunsForJobs, + migrateJobOwners, + normalizeOwnerEmpNo, + UNASSIGNED_OWNER, + viewerPayload, +} from './ownership.js' export const PLUGIN_NAME = 'dsh-ops-cron' export const API_PREFIX = '/dsh-ops-cron' @@ -68,24 +78,68 @@ function parseCookieHeader(header, name) { return null } -/** Browser UDS / fallback login cookies (validated more strictly by uds-auth when present). */ + function browserEmpNo(request) { const cookie = request?.headers?.cookie || '' return parseCookieHeader(cookie, 'PORTALSSOUser') || parseCookieHeader(cookie, 'ZTEDPGSSOUser') || parseCookieHeader(cookie, 'UDS_FALLBACK_USER') + || parseCookieHeader(cookie, 'UDS_FALLBACK_UI') } -function requireBrowserLogin(request, write) { +/** + * Resolve browser identity via uds-auth. Returns null when missing/unavailable. + * @param {object} request + * @param {() => object|undefined} getUdsAuth + */ +async function resolveBrowserIdentity(request, getUdsAuth) { + const uds = typeof getUdsAuth === 'function' ? getUdsAuth() : null + if (!uds || typeof uds.resolveRequestIdentity !== 'function') return null + try { + const identity = await uds.resolveRequestIdentity(request) + if (!identity?.empNo) return null + const workspacePath = typeof uds.getProvisionedWorkspacePath === 'function' + ? uds.getProvisionedWorkspacePath(identity.empNo) + : null + return { ...identity, workspacePath: workspacePath || identity.workspacePath || null } + } catch { + return null + } +} + +/** + * @returns {Promise} identity or null after writing error response + */ +async function requireIdentity(request, write, getUdsAuth) { if (!isTrustedApiRequest(request)) { write(403, { ok: false, error: 'forbidden' }) - return false + return null } - if (!browserEmpNo(request)) { + const uds = typeof getUdsAuth === 'function' ? getUdsAuth() : null + if (!uds || typeof uds.resolveRequestIdentity !== 'function') { + write(503, { ok: false, error: 'auth_unavailable', message: 'uds-auth 未就绪,无法使用定时任务' }) + return null + } + const identity = await resolveBrowserIdentity(request, getUdsAuth) + if (!identity?.empNo) { write(401, { ok: false, error: 'login_required', message: '登录后才能使用定时任务' }) - return false + return null } - return true + return identity +} + +function sanitizeJobCwd(cwd, identity, getUdsAuth, { forOwnerEmpNo } = {}) { + const raw = typeof cwd === 'string' ? cwd.trim() : '' + const uds = typeof getUdsAuth === 'function' ? getUdsAuth() : null + const owner = forOwnerEmpNo || identity?.empNo + const ownerPath = owner && uds?.getProvisionedWorkspacePath + ? uds.getProvisionedWorkspacePath(owner) + : null + if (!raw) return ownerPath || '' + if (canViewAllJobs(identity)) return raw + if (owner && uds?.isUserPath?.(owner, raw)) return raw + if (ownerPath) return ownerPath + return '' } function isTrustedApiRequest(request) { @@ -162,6 +216,7 @@ export function createHostService(options = {}) { const sessionPort = options.sessionPort || null const getDshIm = typeof options.getDshIm === 'function' ? options.getDshIm : () => undefined const getAgents = typeof options.getAgents === 'function' ? options.getAgents : () => undefined + const getUdsAuth = typeof options.getUdsAuth === 'function' ? options.getUdsAuth : () => undefined const logger = options.logger || console const tickIntervalMs = Number(options.tickIntervalMs) > 0 ? Number(options.tickIntervalMs) : 15_000 let timer = null @@ -215,10 +270,19 @@ export function createHostService(options = {}) { return { job: jobView(job), run: runView(run), decision: claimedDecision } } - async function createJob(input) { + async function createJob(input, identity = null) { const t = now() // Ignore client-supplied ids on create — otherwise POST/tools can overwrite. - const { id: _ignoredId, ...safeInput } = input && typeof input === 'object' ? input : {} + const { id: _ignoredId, ownerEmpNo: _ignoreOwner, ...safeInput } = input && typeof input === 'object' ? input : {} + if (identity?.empNo) { + safeInput.ownerEmpNo = identity.empNo + safeInput.ownerDisplayName = identity.displayName || identity.empNo + safeInput.cwd = sanitizeJobCwd(safeInput.cwd, identity, getUdsAuth, { forOwnerEmpNo: identity.empNo }) + } else if (safeInput.ownerEmpNo) { + safeInput.ownerEmpNo = normalizeOwnerEmpNo(safeInput.ownerEmpNo) + } else { + safeInput.ownerEmpNo = UNASSIGNED_OWNER + } let created await withState((current) => { created = createJobRecord(safeInput, current, t) @@ -227,7 +291,7 @@ export function createHostService(options = {}) { return jobView(created) } - async function updateJob(jobId, patch) { + async function updateJob(jobId, patch, identity = null) { const t = now() const state = await withState((current) => { const job = getJob(current, jobId) @@ -236,6 +300,7 @@ export function createHostService(options = {}) { error.code = 'NOT_FOUND' throw error } + if (identity) patch = { ...patch, _identity: identity } const nextInput = { id: job.id, name: patch.name !== undefined ? patch.name : job.name, @@ -254,6 +319,26 @@ export function createHostService(options = {}) { origin: patch.origin !== undefined ? (normalizeOrigin(patch.origin) || undefined) : job.origin, + ownerEmpNo: job.ownerEmpNo || UNASSIGNED_OWNER, + ownerDisplayName: job.ownerDisplayName || '', + } + if (patch._identity) { + nextInput.cwd = sanitizeJobCwd( + nextInput.cwd, + patch._identity, + getUdsAuth, + { forOwnerEmpNo: nextInput.ownerEmpNo }, + ) + } + if (patch.ownerEmpNo !== undefined && canViewAllJobs(patch._identity)) { + nextInput.ownerEmpNo = normalizeOwnerEmpNo(patch.ownerEmpNo) + if (patch.ownerDisplayName !== undefined) { + nextInput.ownerDisplayName = String(patch.ownerDisplayName || '').trim().slice(0, 80) + } else if (nextInput.ownerEmpNo !== job.ownerEmpNo) { + nextInput.ownerDisplayName = nextInput.ownerEmpNo === UNASSIGNED_OWNER + ? '' + : (patch.ownerDisplayName || nextInput.ownerEmpNo) + } } const record = createJobRecord(nextInput, current, t) record.createdAt = job.createdAt @@ -268,8 +353,8 @@ export function createHostService(options = {}) { return jobView(getJob(state, jobId)) } - async function pauseJob(jobId, enabled) { - return updateJob(jobId, { enabled }) + async function pauseJob(jobId, enabled, identity = null) { + return updateJob(jobId, { enabled }, identity) } async function deleteJob(jobId) { @@ -388,8 +473,20 @@ export function createHostService(options = {}) { return hidden } + + async function migrateOwnersIfNeeded() { + const uds = getUdsAuth() + await withState((current) => { + const { state, changed } = migrateJobOwners(current, { + getSessionOwner: (sessionId) => uds?.getSessionOwner?.(sessionId) || null, + }) + return changed ? state : current + }) + } + async function recover() { await mkdir(join(dshHome(), 'ops-cron'), { recursive: true }) + await migrateOwnersIfNeeded() const t = now() await withState((current) => { let next = interruptActiveRuns(current, t) @@ -430,14 +527,16 @@ export function createHostService(options = {}) { } if (path === `${API_PREFIX}/settings` && method === 'GET') { - if (!requireBrowserLogin(req, write)) return + const identity = await requireIdentity(req, write, getUdsAuth) + if (!identity) return const state = await snapshot() write(200, { ok: true, settings: state.settings }) return } if (path === `${API_PREFIX}/settings` && method === 'PUT') { - if (!requireBrowserLogin(req, write)) return + const identity = await requireIdentity(req, write, getUdsAuth) + if (!identity) return const body = await readJsonBody(req) const settings = await updateSettings(body) write(200, { ok: true, settings }) @@ -445,7 +544,8 @@ export function createHostService(options = {}) { } if (path === `${API_PREFIX}/models` && method === 'GET') { - if (!requireBrowserLogin(req, write)) return + const identity = await requireIdentity(req, write, getUdsAuth) + if (!identity) return const catalog = typeof sessionPort?.listModels === 'function' ? await sessionPort.listModels() : { groups: [], current: null } @@ -454,7 +554,8 @@ export function createHostService(options = {}) { } if (path === `${API_PREFIX}/presets` && method === 'GET') { - if (!requireBrowserLogin(req, write)) return + const identity = await requireIdentity(req, write, getUdsAuth) + if (!identity) return const catalog = typeof sessionPort?.listPresets === 'function' ? await sessionPort.listPresets() : { items: [], current: null } @@ -463,16 +564,27 @@ export function createHostService(options = {}) { } if (path === `${API_PREFIX}/workspaces` && method === 'GET') { - if (!requireBrowserLogin(req, write)) return - const workspaces = typeof sessionPort?.listWorkspaces === 'function' + const identity = await requireIdentity(req, write, getUdsAuth) + if (!identity) return + let workspaces = typeof sessionPort?.listWorkspaces === 'function' ? await sessionPort.listWorkspaces() : [] - write(200, { ok: true, workspaces: Array.isArray(workspaces) ? workspaces : [] }) + if (!Array.isArray(workspaces)) workspaces = [] + const uds = getUdsAuth() + if (!canViewAllJobs(identity) && uds?.isUserPath) { + workspaces = workspaces.filter((row) => uds.isUserPath(identity.empNo, row?.path)) + } + if (!canViewAllJobs(identity) && workspaces.length === 0) { + const pathOnly = identity.workspacePath || uds?.getProvisionedWorkspacePath?.(identity.empNo) + if (pathOnly) workspaces = [{ id: null, path: pathOnly, name: identity.displayName || identity.empNo }] + } + write(200, { ok: true, workspaces, viewer: viewerPayload(identity) }) return } if (path === `${API_PREFIX}/im-catalog` && method === 'GET') { - if (!requireBrowserLogin(req, write)) return + const identity = await requireIdentity(req, write, getUdsAuth) + if (!identity) return const dshIm = getDshIm() if (!dshIm || typeof dshIm.listDeliveryCatalog !== 'function') { write(200, { @@ -502,16 +614,23 @@ export function createHostService(options = {}) { } if (path === `${API_PREFIX}/jobs` && method === 'GET') { - if (!requireBrowserLogin(req, write)) return + const identity = await requireIdentity(req, write, getUdsAuth) + if (!identity) return const state = await snapshot() - write(200, { ok: true, jobs: listJobs(state).map(jobView) }) + const jobs = filterJobsForIdentity(listJobs(state), identity).map(jobView) + write(200, { + ok: true, + jobs, + viewer: viewerPayload(identity), + }) return } if (path === `${API_PREFIX}/jobs` && method === 'POST') { - if (!requireBrowserLogin(req, write)) return + const identity = await requireIdentity(req, write, getUdsAuth) + if (!identity) return const body = await readJsonBody(req) - const job = await createJob(body) + const job = await createJob(body, identity) write(200, { ok: true, job }) return } @@ -520,18 +639,18 @@ export function createHostService(options = {}) { if (jobMatch) { const jobId = decodeURIComponent(jobMatch[1]) const rest = jobMatch[2] || '' + const identity = await requireIdentity(req, write, getUdsAuth) + if (!identity) return + const state = await snapshot() + const existing = getJob(state, jobId) + assertCanAccessJob(existing, identity) if (method === 'GET' && !rest) { - if (!requireBrowserLogin(req, write)) return - const state = await snapshot() - const job = getJob(state, jobId) - if (!job) return write(404, { ok: false, error: 'job not found' }) - write(200, { ok: true, job: jobView(job) }) + write(200, { ok: true, job: jobView(existing) }) return } - if (!requireBrowserLogin(req, write)) return if (method === 'PATCH' && !rest) { const body = await readJsonBody(req) - const job = await updateJob(jobId, body) + const job = await updateJob(jobId, body, identity) write(200, { ok: true, job }) return } @@ -546,12 +665,12 @@ export function createHostService(options = {}) { return } if (method === 'POST' && rest === '/pause') { - const job = await pauseJob(jobId, false) + const job = await pauseJob(jobId, false, identity) write(200, { ok: true, job }) return } if (method === 'POST' && rest === '/resume') { - const job = await pauseJob(jobId, true) + const job = await pauseJob(jobId, true, identity) write(200, { ok: true, job }) return } @@ -559,11 +678,13 @@ export function createHostService(options = {}) { const openMatch = path.match(new RegExp(`^${API_PREFIX}/runs/([^/]+)/open$`)) if (openMatch && method === 'POST') { - if (!requireBrowserLogin(req, write)) return + const identity = await requireIdentity(req, write, getUdsAuth) + if (!identity) return const runId = decodeURIComponent(openMatch[1]) const state = await snapshot() const run = (state.runs || []).find((row) => row.id === runId) if (!run?.sessionId) return write(404, { ok: false, error: 'run not found' }) + assertCanAccessJob(getJob(state, run.jobId), identity) let unarchived = false if (typeof sessionPort?.revealSession === 'function') { unarchived = await sessionPort.revealSession(run.sessionId) @@ -574,7 +695,8 @@ export function createHostService(options = {}) { const adoptMatch = path.match(new RegExp(`^${API_PREFIX}/sessions/([^/]+)/adopt$`)) if (adoptMatch && method === 'POST') { - if (!requireBrowserLogin(req, write)) return + const identity = await requireIdentity(req, write, getUdsAuth) + if (!identity) return const sessionId = decodeURIComponent(adoptMatch[1]) if (!sessionId) return write(400, { ok: false, error: 'sessionId required' }) if (typeof sessionPort?.adoptSession !== 'function') { @@ -586,22 +708,30 @@ export function createHostService(options = {}) { } if (path === `${API_PREFIX}/conceal` && method === 'POST') { - if (!requireBrowserLogin(req, write)) return + const identity = await requireIdentity(req, write, getUdsAuth) + if (!identity) return const hidden = await concealKnownSessions() write(200, { ok: true, hidden }) return } if (path === `${API_PREFIX}/history` && method === 'GET') { - if (!requireBrowserLogin(req, write)) return + const identity = await requireIdentity(req, write, getUdsAuth) + if (!identity) return const state = await snapshot() const jobId = url.searchParams.get('jobId') || undefined - write(200, { ok: true, runs: listHistory(state, jobId).map(runView) }) + if (jobId) { + assertCanAccessJob(getJob(state, jobId), identity) + } + const visibleJobs = filterJobsForIdentity(listJobs(state), identity) + const runs = filterRunsForJobs(listHistory(state, jobId), visibleJobs).map(runView) + write(200, { ok: true, runs, viewer: viewerPayload(identity) }) return } if (path === `${API_PREFIX}/preview` && method === 'POST') { - if (!requireBrowserLogin(req, write)) return + const identity = await requireIdentity(req, write, getUdsAuth) + if (!identity) return const body = await readJsonBody(req) const settings = (await snapshot()).settings const schedule = validateSchedule(body.schedule || body, body.timezone || settings.timezone) @@ -611,7 +741,8 @@ export function createHostService(options = {}) { } if (path === `${API_PREFIX}/workspace-visible` && method === 'GET') { - if (!requireBrowserLogin(req, write)) return + const identity = await requireIdentity(req, write, getUdsAuth) + if (!identity) return const state = await snapshot() const listed = url.searchParams.getAll('id') write(200, { @@ -626,7 +757,8 @@ export function createHostService(options = {}) { } catch (error) { const code = error && error.code if (code === 'NOT_FOUND') return write(404, { ok: false, error: error.message }) - if (code === 'INVALID_CRON' || code === 'INVALID_AT' || code === 'INVALID_SCHEDULE' || code === 'INVALID_JOB' || code === 'INVALID_TIMEZONE') { + if (code === 'LOGIN_REQUIRED') return write(401, { ok: false, error: 'login_required', message: error.message }) + if (code === 'INVALID_CRON' || code === 'INVALID_AT' || code === 'INVALID_SCHEDULE' || code === 'INVALID_JOB' || code === 'INVALID_TIMEZONE' || code === 'INVALID_CWD') { return write(400, { ok: false, error: error.message, code }) } if (code === 'PAYLOAD_TOO_LARGE') return write(413, { ok: false, error: 'payload too large' }) @@ -649,15 +781,25 @@ export function createHostService(options = {}) { stopTimer, handleRequest, snapshot, - async listJobs() { - return listJobs(await snapshot()).map(jobView) + getUdsAuth, + async listJobs(identity = null) { + const jobs = listJobs(await snapshot()) + const filtered = identity ? filterJobsForIdentity(jobs, identity) : jobs + return filtered.map(jobView) }, - async getJob(jobId) { + async getJob(jobId, identity = null) { const job = getJob(await snapshot(), jobId) - return job ? jobView(job) : null + if (!job) return null + if (identity) assertCanAccessJob(job, identity) + return jobView(job) }, - async listHistory(jobId) { - return listHistory(await snapshot(), jobId).map(runView) + async listHistory(jobId, identity = null) { + const state = await snapshot() + if (jobId && identity) assertCanAccessJob(getJob(state, jobId), identity) + const runs = listHistory(state, jobId) + if (!identity) return runs.map(runView) + const visible = filterJobsForIdentity(listJobs(state), identity) + return filterRunsForJobs(runs, visible).map(runView) }, workspaceVisibleIds(allIds) { const hidden = store.snapshot().hiddenSessionIds @@ -748,15 +890,27 @@ function sessionCwdOf(ctx, sessionId) { } /** - * Prefer the user's most recently ordered Workspace so a scheduled session - * (and a later native fork) can occupy a real sidebar group. Official - * attachSession requires header.cwd === workspace.path. + * Prefer the job owner's provisioned workspace (uds-auth), then explicit cwd, + * then recent workspace, then shared ops-cron fallback. + * Official attachSession requires header.cwd === workspace.path. */ -export function resolveSessionPlacement(ctx, job = {}) { - const requested = String(job?.cwd || '').trim() +export function resolveSessionPlacement(ctx, job = {}, deps = {}) { const workspaces = listWorkspaces(ctx) const match = (path) => (path && workspaces.find((row) => workspacePathOf(row) === path)) || null + const uds = deps.udsAuth || (typeof deps.getUdsAuth === 'function' ? deps.getUdsAuth() : tryGet(ctx, 'udsAuth')) + const ownerEmpNo = String(job?.ownerEmpNo || '').trim() + const ownerPath = ownerEmpNo && !ownerEmpNo.startsWith('__') && uds?.getProvisionedWorkspacePath + ? uds.getProvisionedWorkspacePath(ownerEmpNo) + : null + + let requested = String(job?.cwd || '').trim() + if (requested && ownerEmpNo && !ownerEmpNo.startsWith('__') && uds?.isUserPath) { + if (!uds.isUserPath(ownerEmpNo, requested) && ownerPath) { + requested = ownerPath + } + } if (requested) return { cwd: requested, workspace: match(requested) } + if (ownerPath) return { cwd: ownerPath, workspace: match(ownerPath) } const recent = workspaces[0] const recentPath = workspacePathOf(recent) if (recentPath) return { cwd: recentPath, workspace: recent } @@ -1026,7 +1180,8 @@ export function makeLiveSessionPort(ctx) { throw new Error('ctx.agents.create is unavailable') } const sessionId = randomUUID() - const placement = resolveSessionPlacement(ctx, job) + const udsAuth = tryGet(ctx, 'udsAuth') + const placement = resolveSessionPlacement(ctx, job, { udsAuth }) const cwd = placement.cwd || defaultCwd() await mkdir(cwd, { recursive: true }) const selection = await resolveJobModel(ctx, job) @@ -1062,6 +1217,14 @@ export function makeLiveSessionPort(ctx) { } catch { // Forks of unattached runs stay loose; listing hide is separate. } + try { + const owner = String(job?.ownerEmpNo || '').trim() + if (owner && !owner.startsWith('__')) { + udsAuth?.stampSessionOwner?.(sessionId, owner) + } + } catch { + // Ownership stamp is best-effort. + } try { if (typeof agent.session?.append === 'function') { agent.session.append('session/title', { diff --git a/lib/index.d.ts b/lib/index.d.ts index 3c3abfb..42a607a 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -15,7 +15,14 @@ export interface Config { export function apply(ctx: Context, config?: Config): void export function createHostService(options?: object): object export function makeLiveSessionPort(ctx: object): object -export function resolveSessionPlacement(ctx: object, job?: object): { cwd: string, workspace: object | null } +export function resolveSessionPlacement(ctx: object, job?: object, deps?: object): { cwd: string, workspace: object | null } +export const UNASSIGNED_OWNER: '__unassigned__' +export function jobVisibleToIdentity(job: object, identity: object): boolean +export function canViewAllJobs(identity: object): boolean +export function assertCanAccessJob(job: object, identity: object): object +export function filterJobsForIdentity(jobs: object[], identity: object): object[] +export function migrateJobOwners(state: object, deps?: object): { state: object, changed: boolean } +export function viewerPayload(identity: object | null): object | null export function listWorkspaceChoices(ctx: object): Array<{ id: string, title: string, path: string }> export function listModelChoices(ctx: object): Promise<{ groups: Array<{ provider: string, displayName: string, models: Array<{ id: string, name: string }> }>, current: { provider: string, model: string, reasoningEffort?: string } | null }> export function resolveJobModel(ctx: object, job?: object): Promise<{ provider: string, model: string, reasoningEffort?: string }> diff --git a/lib/index.js b/lib/index.js index 75312a3..babf87c 100644 --- a/lib/index.js +++ b/lib/index.js @@ -48,6 +48,15 @@ export { } from './isolation.js' export { wrapScheduledPrompt } from './prompt.js' export { normalizeJobModel } from './store.js' +export { + assertCanAccessJob, + canViewAllJobs, + filterJobsForIdentity, + jobVisibleToIdentity, + migrateJobOwners, + UNASSIGNED_OWNER, + viewerPayload, +} from './ownership.js' export { claimOccurrence, executeClaimedRun, extractAssistantText, TITLE_PREFIX } from './fire.js' export { deliverRunToIm, @@ -140,10 +149,12 @@ export function apply(ctx, config = {}) { const getDshIm = () => tryGet(ctx, 'dshIm') const getAgents = () => tryGet(ctx, 'agents') const getAgentPresets = () => tryGet(ctx, 'agentPresets') + const getUdsAuth = () => tryGet(ctx, 'udsAuth') const service = createHostService({ sessionPort: makeLiveSessionPort(ctx), getDshIm, getAgents, + getUdsAuth, logger: ctx.logger, }) diff --git a/lib/ownership.js b/lib/ownership.js new file mode 100644 index 0000000..45a0f8f --- /dev/null +++ b/lib/ownership.js @@ -0,0 +1,112 @@ +/** + * Job ownership helpers for per-user cron isolation (aligns with uds-auth roles). + */ + +export const UNASSIGNED_OWNER = '__unassigned__' + +export function normalizeOwnerEmpNo(value) { + const empNo = String(value || '').trim() + return empNo || UNASSIGNED_OWNER +} + +export function canViewAllJobs(identity) { + return !!identity?.permissions?.canViewAllSessions +} + +export function jobVisibleToIdentity(job, identity) { + if (!identity?.empNo) return false + if (canViewAllJobs(identity)) return true + const owner = normalizeOwnerEmpNo(job?.ownerEmpNo) + if (owner === UNASSIGNED_OWNER) return false + return owner === String(identity.empNo) +} + +export function assertCanAccessJob(job, identity) { + if (!job) { + const error = new Error('job not found') + error.code = 'NOT_FOUND' + throw error + } + if (!identity?.empNo) { + const error = new Error('login_required') + error.code = 'LOGIN_REQUIRED' + throw error + } + if (!jobVisibleToIdentity(job, identity)) { + const error = new Error('job not found') + error.code = 'NOT_FOUND' + throw error + } + return job +} + +export function filterJobsForIdentity(jobs, identity) { + const list = Array.isArray(jobs) ? jobs : [] + if (!identity?.empNo) return [] + if (canViewAllJobs(identity)) return [...list] + return list.filter((job) => jobVisibleToIdentity(job, identity)) +} + +export function filterRunsForJobs(runs, jobs) { + const allowed = new Set((jobs || []).map((job) => job.id)) + return (Array.isArray(runs) ? runs : []).filter((run) => allowed.has(run.jobId)) +} + +/** + * Infer owner for legacy jobs missing ownerEmpNo. + * @param {object} job + * @param {{ getSessionOwner?: (sessionId: string) => string|null }} [deps] + */ +export function inferOwnerEmpNo(job, deps = {}) { + if (job?.ownerEmpNo) return normalizeOwnerEmpNo(job.ownerEmpNo) + const origin = job?.origin + if (origin?.kind === 'im') return UNASSIGNED_OWNER + const sessionId = origin?.kind === 'web' && typeof origin.sessionId === 'string' + ? origin.sessionId.trim() + : '' + if (sessionId && typeof deps.getSessionOwner === 'function') { + const owner = deps.getSessionOwner(sessionId) + if (owner) return normalizeOwnerEmpNo(owner) + } + return UNASSIGNED_OWNER +} + +/** + * Migrate jobs in-place; returns { state, changed }. + */ +export function migrateJobOwners(state, deps = {}) { + const jobs = Array.isArray(state?.jobs) ? state.jobs : [] + let changed = false + const nextJobs = jobs.map((job) => { + if (!job || typeof job !== 'object') return job + if (job.ownerEmpNo) { + const normalized = normalizeOwnerEmpNo(job.ownerEmpNo) + if (normalized === job.ownerEmpNo && job.ownerDisplayName !== undefined) return job + changed = true + return { + ...job, + ownerEmpNo: normalized, + ownerDisplayName: job.ownerDisplayName || '', + } + } + changed = true + return { + ...job, + ownerEmpNo: inferOwnerEmpNo(job, deps), + ownerDisplayName: job.ownerDisplayName || '', + } + }) + if (!changed) return { state, changed: false } + return { state: { ...state, jobs: nextJobs }, changed: true } +} + +export function viewerPayload(identity) { + if (!identity?.empNo) return null + return { + empNo: identity.empNo, + role: identity.role || 'user', + displayName: identity.displayName || identity.empNo, + canViewAll: canViewAllJobs(identity), + workspacePath: identity.workspacePath || null, + } +} diff --git a/lib/store.js b/lib/store.js index b82d7c2..5e97ac2 100644 --- a/lib/store.js +++ b/lib/store.js @@ -10,6 +10,9 @@ import { applyRunIsolation, recordHiddenSession } from './isolation.js' import { nextFire, validateSchedule } from './scheduler.js' import { normalizeDelivery, normalizeOrigin } from './delivery.js' import { normalizeAgentPresetId } from './preset.js' +import { normalizeOwnerEmpNo, UNASSIGNED_OWNER } from './ownership.js' + +export { UNASSIGNED_OWNER } export const STORE_VERSION = 1 @@ -97,6 +100,10 @@ export function createJobRecord(input, state, now) { const delivery = normalizeDelivery(input.delivery) const origin = normalizeOrigin(input.origin) const agentPreset = normalizeAgentPresetId(input.agentPreset ?? input.agent_preset) + const ownerEmpNo = normalizeOwnerEmpNo(input.ownerEmpNo) + const ownerDisplayName = typeof input.ownerDisplayName === 'string' + ? input.ownerDisplayName.trim().slice(0, 80) + : '' const job = { id: String(input.id || newId()), name, @@ -111,6 +118,8 @@ export function createJobRecord(input, state, now) { reasoningEffort: model.reasoningEffort, agentPreset, delivery, + ownerEmpNo, + ownerDisplayName, ...origin ? { origin } : {}, schedule: schedule.kind === 'cron' ? { kind: 'cron', expr: schedule.expr, timezone: schedule.timezone } diff --git a/lib/tools.js b/lib/tools.js index 79a8b29..2268ffd 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -12,6 +12,11 @@ import { resolveCreateOrigin, } from './delivery.js' import { resolveCreateAgentPreset } from './preset.js' +import { + assertCanAccessJob, + filterJobsForIdentity, + UNASSIGNED_OWNER, +} from './ownership.js' const JOB_SCHEMA = { type: 'object', diff --git a/test/host.test.js b/test/host.test.js index 6c4f613..e23f216 100644 --- a/test/host.test.js +++ b/test/host.test.js @@ -74,6 +74,10 @@ async function listen(service) { } async function jsonRequest(base, path, options = {}) { + const empNo = options.empNo || 'tester' + const cookie = options.anonymous + ? '' + : (options.cookie || `PORTALSSOUser=${encodeURIComponent(empNo)}`) const response = await fetch(`${base}${path}`, { ...options, headers: { @@ -81,7 +85,7 @@ async function jsonRequest(base, path, options = {}) { origin: base, host: new URL(base).host, 'sec-fetch-site': 'same-origin', - cookie: options.anonymous ? '' : 'PORTALSSOUser=tester', + cookie, ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers, }, @@ -90,6 +94,69 @@ async function jsonRequest(base, path, options = {}) { return { status: response.status, body } } +function mockUdsAuth(options = {}) { + const owners = new Map() + const users = { + tester: { role: 'user', canViewAll: false, path: '/tmp/user-workspaces/tester', displayName: 'Tester' }, + peer: { role: 'user', canViewAll: false, path: '/tmp/user-workspaces/peer', displayName: 'Peer' }, + admin1: { role: 'super_admin', canViewAll: true, path: '/tmp/user-workspaces/admin1', displayName: 'Admin' }, + administrator: { role: 'fallback_admin', canViewAll: true, path: '/tmp/user-workspaces/administrator', displayName: 'Fallback' }, + ...(options.users || {}), + } + return { + async resolveRequestIdentity(req) { + const cookie = req?.headers?.cookie || '' + const match = /(?:PORTALSSOUser|UDS_FALLBACK_USER|UDS_FALLBACK_UI)=([^;]+)/.exec(cookie) + if (!match) return null + const empNo = decodeURIComponent(match[1].trim()) + const row = users[empNo] || { + role: 'user', + canViewAll: false, + path: `/tmp/user-workspaces/${empNo}`, + displayName: empNo, + } + return { + empNo, + role: row.role, + displayName: row.displayName || empNo, + permissions: { canViewAllSessions: !!row.canViewAll }, + workspacePath: row.path, + } + }, + canViewAllJobs(identity) { + return !!identity?.permissions?.canViewAllSessions + }, + getProvisionedWorkspacePath(empNo) { + return users[empNo]?.path || `/tmp/user-workspaces/${empNo}` + }, + isUserPath(empNo, candidatePath) { + if (!candidatePath) return false + if (options.strictPath) { + const root = this.getProvisionedWorkspacePath(empNo) + const cand = String(candidatePath) + return cand === root || cand.startsWith(`${root}/`) || cand.startsWith(`${root}\\`) + } + return true + }, + stampSessionOwner(sessionId, empNo) { + if (sessionId && empNo) owners.set(String(sessionId), String(empNo)) + }, + getSessionOwner(sessionId) { + return owners.get(String(sessionId)) || null + }, + } +} + +function createTestHost(options = {}) { + const { udsAuthOptions, udsAuth, ...rest } = options + const auth = udsAuth || mockUdsAuth(udsAuthOptions) + return createHostService({ + ...rest, + getUdsAuth: () => auth, + }) +} + + test('host service: create, list, run-now, history, and workspace isolation', async (t) => { const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) @@ -97,7 +164,7 @@ test('host service: create, list, run-now, history, and workspace isolation', as const archived = [] let clock = Date.parse('2026-08-24T01:00:00.000Z') let sessionSeq = 0 - const service = createHostService({ + const service = createTestHost({ filePath: join(dir, 'store.json'), now: () => clock, sessionPort: { @@ -169,7 +236,7 @@ test('overlap skip writes a skipped history row instead of a second session', as t.after(() => rm(dir, { recursive: true, force: true })) let clock = Date.parse('2026-08-24T01:00:00.000Z') let inflight = 0 - const service = createHostService({ + const service = createTestHost({ filePath: join(dir, 'store.json'), now: () => clock, sessionPort: { @@ -197,7 +264,7 @@ test('overlap skip writes a skipped history row instead of a second session', as test('http api: anonymous list/run-now is rejected', async (t) => { const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) - const service = createHostService({ + const service = createTestHost({ filePath: join(dir, 'store.json'), now: () => Date.parse('2026-08-24T01:00:00.000Z'), sessionPort: { @@ -209,7 +276,7 @@ test('http api: anonymous list/run-now is rejected', async (t) => { name: 'secret job', prompt: 'do not leak', schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'UTC' }, - }) + }, { empNo: 'tester', displayName: 'Tester', permissions: { canViewAllSessions: false } }) const http = await listen(service) t.after(() => http.close()) const listed = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { anonymous: true }) @@ -217,6 +284,7 @@ test('http api: anonymous list/run-now is rejected', async (t) => { assert.equal(listed.body.error, 'login_required') const jobs = await jsonRequest(http.url, '/dsh-ops-cron/jobs') assert.equal(jobs.status, 200) + assert.equal(jobs.body.jobs.length, 1) const jobId = jobs.body.jobs[0].id const ran = await jsonRequest(http.url, `/dsh-ops-cron/jobs/${jobId}/run`, { method: 'POST', @@ -229,7 +297,7 @@ test('shipped HTTP handler: create, list, run-now, history', async (t) => { const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) const archived = [] - const service = createHostService({ + const service = createTestHost({ filePath: join(dir, 'store.json'), now: () => Date.parse('2026-08-24T01:00:00.000Z'), sessionPort: { @@ -274,7 +342,7 @@ test('shipped HTTP handler: create, list, run-now, history', async (t) => { test('createJob ignores client-supplied id to prevent overwrite', async (t) => { const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) - const service = createHostService({ + const service = createTestHost({ filePath: join(dir, 'store.json'), now: () => Date.parse('2026-08-24T01:00:00.000Z'), }) @@ -299,7 +367,7 @@ test('POST /runs/:id/open reveals the run session id', async (t) => { const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) const revealed = [] - const service = createHostService({ + const service = createTestHost({ filePath: join(dir, 'store.json'), now: () => Date.parse('2026-08-24T01:00:00.000Z'), sessionPort: { @@ -391,7 +459,7 @@ test('POST /conceal archives every run session id', async (t) => { const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) const archived = [] - const service = createHostService({ + const service = createTestHost({ filePath: join(dir, 'store.json'), now: () => Date.parse('2026-08-24T01:00:00.000Z'), sessionPort: { @@ -424,7 +492,7 @@ test('one-shot at job fires once then later ticks wait instead of retriggering', const at = Date.parse('2026-08-23T14:57:00.000Z') let clock = at - 60_000 let sessions = 0 - const service = createHostService({ + const service = createTestHost({ filePath: join(dir, 'store.json'), now: () => clock, sessionPort: { @@ -568,7 +636,7 @@ test('listModelChoices maps llm providers and the current default', async () => test('GET /models lists session-port catalog', async (t) => { const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) - const service = createHostService({ + const service = createTestHost({ filePath: join(dir, 'store.json'), sessionPort: { async listModels() { @@ -607,7 +675,7 @@ test('listWorkspaceChoices maps registry entries to title and path', () => { test('GET /workspaces lists session-port choices', async (t) => { const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) - const service = createHostService({ + const service = createTestHost({ filePath: join(dir, 'store.json'), sessionPort: { async listWorkspaces() { @@ -626,7 +694,7 @@ test('POST /sessions/:id/adopt uses the session port', async (t) => { const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true })) const adopted = [] - const service = createHostService({ + const service = createTestHost({ filePath: join(dir, 'store.json'), sessionPort: { async adoptSession(sessionId) { @@ -660,7 +728,7 @@ test('after one live-shaped dispatch, the next run-now still fires', async (t) = t.after(() => rm(dir, { recursive: true, force: true })) const archived = [] const sessionPort = makeLiveSessionPort(fakeLiveCtx(archived)) - const service = createHostService({ + const service = createTestHost({ filePath: join(dir, 'store.json'), now: () => Date.parse('2026-08-24T01:00:00.000Z'), sessionPort, @@ -695,7 +763,7 @@ test('after a live-shaped due tick settles, the next due occurrence still fires' const archived = [] let clock = Date.parse('2026-08-24T01:00:00.000Z') const sessionPort = makeLiveSessionPort(fakeLiveCtx(archived)) - const service = createHostService({ + const service = createTestHost({ filePath: join(dir, 'store.json'), now: () => clock, sessionPort, @@ -720,3 +788,97 @@ test('after a live-shaped due tick settles, the next due occurrence still fires' assert.notEqual(secondTick[0].run.sessionId, firstTick[0].run.sessionId) assert.notEqual(secondTick[0].run.status, 'skipped') }) + + +test('host HTTP: per-user job isolation and super sees all', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-acl-')) + t.after(() => rm(dir, { recursive: true, force: true })) + + const service = createTestHost({ + filePath: join(dir, 'store.json'), + now: () => Date.parse('2026-08-24T01:00:00.000Z'), + sessionPort: { + async createAndPrompt({ job }) { + return { sessionId: `sess-${job.id}`, status: 'succeeded', summary: 'ok' } + }, + async archiveSession() { return true }, + }, + }) + const http = await listen(service) + t.after(() => http.close()) + + const noAuth = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { anonymous: true }) + assert.equal(noAuth.status, 401) + + const createdA = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { + method: 'POST', + empNo: 'tester', + body: JSON.stringify({ + name: 'tester-job', + prompt: 'do a', + schedule: { kind: 'at', at: '2099-01-01T00:00:00.000Z', timezone: 'UTC' }, + }), + }) + assert.equal(createdA.status, 200) + assert.equal(createdA.body.job.ownerEmpNo, 'tester') + assert.ok(createdA.body.job.ownerDisplayName) + + const createdB = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { + method: 'POST', + empNo: 'peer', + body: JSON.stringify({ + name: 'peer-job', + prompt: 'do b', + schedule: { kind: 'at', at: '2099-01-01T00:00:00.000Z', timezone: 'UTC' }, + }), + }) + assert.equal(createdB.status, 200) + assert.equal(createdB.body.job.ownerEmpNo, 'peer') + + const listTester = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { empNo: 'tester' }) + assert.equal(listTester.status, 200) + assert.equal(listTester.body.jobs.length, 1) + assert.equal(listTester.body.jobs[0].name, 'tester-job') + assert.equal(listTester.body.viewer.canViewAll, false) + + const listPeer = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { empNo: 'peer' }) + assert.equal(listPeer.body.jobs.length, 1) + assert.equal(listPeer.body.jobs[0].name, 'peer-job') + + const forbidden = await jsonRequest(http.url, `/dsh-ops-cron/jobs/${createdB.body.job.id}`, { empNo: 'tester' }) + assert.equal(forbidden.status, 404) + + const listAdmin = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { empNo: 'admin1' }) + assert.equal(listAdmin.status, 200) + assert.equal(listAdmin.body.viewer.canViewAll, true) + assert.equal(listAdmin.body.jobs.length, 2) + + const reassigned = await jsonRequest(http.url, `/dsh-ops-cron/jobs/${createdB.body.job.id}`, { + method: 'PATCH', + empNo: 'admin1', + body: JSON.stringify({ ownerEmpNo: 'tester', ownerDisplayName: 'Tester' }), + }) + assert.equal(reassigned.status, 200) + assert.equal(reassigned.body.job.ownerEmpNo, 'tester') + + const listTester2 = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { empNo: 'tester' }) + assert.equal(listTester2.body.jobs.length, 2) +}) + +test('migrateJobOwners assigns unassigned for legacy jobs', async () => { + const { migrateJobOwners, UNASSIGNED_OWNER } = await import('../lib/ownership.js') + const state = { + jobs: [ + { id: '1', name: 'a', origin: { kind: 'im', peer: { botId: 'b' } } }, + { id: '2', name: 'b', origin: { kind: 'web', sessionId: 's1' } }, + { id: '3', name: 'c', ownerEmpNo: 'u1' }, + ], + } + const { state: next, changed } = migrateJobOwners(state, { + getSessionOwner: (id) => (id === 's1' ? 'from-session' : null), + }) + assert.equal(changed, true) + assert.equal(next.jobs[0].ownerEmpNo, UNASSIGNED_OWNER) + assert.equal(next.jobs[1].ownerEmpNo, 'from-session') + assert.equal(next.jobs[2].ownerEmpNo, 'u1') +}) diff --git a/test/ownership.test.js b/test/ownership.test.js new file mode 100644 index 0000000..a983228 --- /dev/null +++ b/test/ownership.test.js @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + assertCanAccessJob, + canViewAllJobs, + filterJobsForIdentity, + filterRunsForJobs, + inferOwnerEmpNo, + jobVisibleToIdentity, + migrateJobOwners, + UNASSIGNED_OWNER, + viewerPayload, +} from '../lib/ownership.js' + +test('jobVisibleToIdentity respects canViewAll and owner', () => { + const user = { empNo: 'u1', permissions: { canViewAllSessions: false } } + const admin = { empNo: 'a1', permissions: { canViewAllSessions: true } } + assert.equal(jobVisibleToIdentity({ ownerEmpNo: 'u1' }, user), true) + assert.equal(jobVisibleToIdentity({ ownerEmpNo: 'u2' }, user), false) + assert.equal(jobVisibleToIdentity({ ownerEmpNo: UNASSIGNED_OWNER }, user), false) + assert.equal(jobVisibleToIdentity({ ownerEmpNo: UNASSIGNED_OWNER }, admin), true) + assert.equal(canViewAllJobs(admin), true) +}) + +test('assertCanAccessJob throws NOT_FOUND for foreigners', () => { + const user = { empNo: 'u1', permissions: { canViewAllSessions: false } } + assert.throws( + () => assertCanAccessJob({ id: 'j', ownerEmpNo: 'u2' }, user), + (err) => err && err.code === 'NOT_FOUND', + ) + assert.equal(assertCanAccessJob({ id: 'j', ownerEmpNo: 'u1' }, user).id, 'j') +}) + +test('filterJobsForIdentity and filterRunsForJobs', () => { + const user = { empNo: 'u1', permissions: { canViewAllSessions: false } } + const jobs = [ + { id: '1', ownerEmpNo: 'u1' }, + { id: '2', ownerEmpNo: 'u2' }, + ] + const visible = filterJobsForIdentity(jobs, user) + assert.deepEqual(visible.map((j) => j.id), ['1']) + const runs = filterRunsForJobs([ + { id: 'r1', jobId: '1' }, + { id: 'r2', jobId: '2' }, + ], visible) + assert.deepEqual(runs.map((r) => r.id), ['r1']) +}) + +test('inferOwnerEmpNo and migrateJobOwners', () => { + assert.equal(inferOwnerEmpNo({ origin: { kind: 'im' } }), UNASSIGNED_OWNER) + assert.equal( + inferOwnerEmpNo({ origin: { kind: 'web', sessionId: 's' } }, { getSessionOwner: () => 'own' }), + 'own', + ) + const { changed, state } = migrateJobOwners({ + jobs: [{ id: 'x', name: 'n' }], + }) + assert.equal(changed, true) + assert.equal(state.jobs[0].ownerEmpNo, UNASSIGNED_OWNER) +}) + +test('viewerPayload', () => { + assert.equal(viewerPayload(null), null) + const v = viewerPayload({ + empNo: 'u1', + role: 'user', + displayName: 'U', + permissions: { canViewAllSessions: false }, + workspacePath: '/w/u1', + }) + assert.equal(v.empNo, 'u1') + assert.equal(v.canViewAll, false) + assert.equal(v.workspacePath, '/w/u1') +})