diff --git a/README.md b/README.md index e2b5af3..71fa138 100644 --- a/README.md +++ b/README.md @@ -17,16 +17,25 @@ dsh plugin --profile web add -w "github:hansjone/dsh-ops-cron" # restart dsh web ``` -## Multi-user isolation (with uds-auth) +## Auth modes (uds-auth is optional) -Requires **uds-auth** in the same profile. Jobs carry `ownerEmpNo`; visibility matches workspace ACL: +`uds-auth` is a soft dependency. Cron and IM plugins work without it. + +| Mode | When | Behavior | +|------|------|----------| +| **standalone / local** | `udsAuth` not provided | Trusted local/same-origin HTTP works with synthetic `__local__` identity; all jobs visible; flat sidebar (no user folders); create does not force an empNo | +| **multi-user** | `uds-auth` installed and providing `ctx.udsAuth` | Jobs carry `ownerEmpNo`; Web login required; isolation matches workspace ACL | + +### Multi-user isolation (with uds-auth) | 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/`. +Create stamps the logged-in empNo. Legacy / unclaimed jobs stay `__unassigned__` (super-only until claimed by session/cwd evidence). Fire cwd prefers the owner’s provisioned workspace under `user-workspaces/`. + +**Channel / IM sessions are not a UDS account.** New WhatsApp/IM chats use the bot workspace (`workspaces.json`); they are not mapped to `administrator` or any empNo by default. IM cron jobs stay peer-scoped (or unassigned for Web ACL). ## Delivery defaults diff --git a/lib/client.js b/lib/client.js index a2f4b06..89214fc 100644 --- a/lib/client.js +++ b/lib/client.js @@ -705,6 +705,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba const searchRef = useRef(null) const needle = query.trim().toLowerCase() const canViewAll = !!viewer?.canViewAll + const multiUser = viewer?.mode === 'multi' const visibleJobs = !needle ? jobs : jobs.filter((job) => { const hay = [ job.name, @@ -910,7 +911,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 }, error || t('emptyJobs')) : visibleJobs.length === 0 ? h('p', { className: skin.empty }, t('searchEmpty')) - : (canViewAll ? renderGrouped() : visibleJobs.map((job) => renderJobGroup(job))), + : (multiUser && canViewAll ? renderGrouped() : visibleJobs.map((job) => renderJobGroup(job))), ), ) } @@ -932,7 +933,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba function CwdField({ t, form, setForm, workspaces, viewer }) { const rows = Array.isArray(workspaces) ? workspaces : [] const mode = cwdSelectValue(form.cwd, rows) - const allowCustom = !!viewer?.canViewAll + const allowCustom = viewer?.mode === 'multi' ? !!viewer?.canViewAll : true return h('label', null, t('cwd'), h('select', { value: mode, diff --git a/lib/host.js b/lib/host.js index 2620f94..7af3b50 100644 --- a/lib/host.js +++ b/lib/host.js @@ -29,6 +29,8 @@ import { claimUnassignedForViewer, filterJobsForIdentity, filterRunsForJobs, + isMultiUserIdentity, + localIdentity, migrateJobOwners, normalizeOwnerEmpNo, UNASSIGNED_OWNER, @@ -151,16 +153,16 @@ async function requireIdentity(request, write, getUdsAuth) { return null } const uds = typeof getUdsAuth === 'function' ? getUdsAuth() : null + // Soft dep: without uds-auth, run in standalone single-tenant mode. if (!uds || typeof uds.resolveRequestIdentity !== 'function') { - write(503, { ok: false, error: 'auth_unavailable', message: 'uds-auth 未就绪,无法使用定时任务' }) - return null + return localIdentity() } const identity = await resolveBrowserIdentity(request, getUdsAuth) if (!identity?.empNo) { write(401, { ok: false, error: 'login_required', message: '登录后才能使用定时任务' }) return null } - return identity + return { ...identity, mode: 'multi' } } function sanitizeJobCwd(cwd, identity, getUdsAuth, { forOwnerEmpNo } = {}) { @@ -311,14 +313,17 @@ export function createHostService(options = {}) { const t = now() // Ignore client-supplied ids on create — otherwise POST/tools can overwrite. const { id: _ignoredId, ownerEmpNo: _ignoreOwner, ...safeInput } = input && typeof input === 'object' ? input : {} - let ownerIdentity = identity?.empNo ? identity : inferIdentityFromJobInput(safeInput, getUdsAuth) - if (ownerIdentity?.empNo) { + let ownerIdentity = isMultiUserIdentity(identity) + ? identity + : inferIdentityFromJobInput(safeInput, getUdsAuth) + if (isMultiUserIdentity(ownerIdentity)) { safeInput.ownerEmpNo = ownerIdentity.empNo safeInput.ownerDisplayName = ownerIdentity.displayName || ownerIdentity.empNo safeInput.cwd = sanitizeJobCwd(safeInput.cwd, ownerIdentity, getUdsAuth, { forOwnerEmpNo: ownerIdentity.empNo }) } else if (safeInput.ownerEmpNo) { safeInput.ownerEmpNo = normalizeOwnerEmpNo(safeInput.ownerEmpNo) } else { + // Standalone / no inferred owner: leave unassigned (visible to everyone in local mode). safeInput.ownerEmpNo = UNASSIGNED_OWNER } let created @@ -803,7 +808,7 @@ 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 === 'LOGIN_REQUIRED') return write(401, { ok: false, error: 'login_required', message: error.message }) + write(401, { ok: false, error: 'login_required', 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 }) } diff --git a/lib/index.d.ts b/lib/index.d.ts index 42a607a..7f9b7ff 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -17,6 +17,10 @@ export function createHostService(options?: object): object export function makeLiveSessionPort(ctx: object): object export function resolveSessionPlacement(ctx: object, job?: object, deps?: object): { cwd: string, workspace: object | null } export const UNASSIGNED_OWNER: '__unassigned__' +export const LOCAL_EMP_NO: '__local__' +export function isMultiUserIdentity(identity: object | null | undefined): boolean +export function localIdentity(overrides?: object): object +export function claimUnassignedForViewer(state: object, identity: object, deps?: object): { state: object, changed: boolean } export function jobVisibleToIdentity(job: object, identity: object): boolean export function canViewAllJobs(identity: object): boolean export function assertCanAccessJob(job: object, identity: object): object diff --git a/lib/index.js b/lib/index.js index babf87c..2a67684 100644 --- a/lib/index.js +++ b/lib/index.js @@ -51,8 +51,12 @@ export { normalizeJobModel } from './store.js' export { assertCanAccessJob, canViewAllJobs, + claimUnassignedForViewer, filterJobsForIdentity, + isMultiUserIdentity, jobVisibleToIdentity, + LOCAL_EMP_NO, + localIdentity, migrateJobOwners, UNASSIGNED_OWNER, viewerPayload, diff --git a/lib/ownership.js b/lib/ownership.js index e5baf1b..b29edc7 100644 --- a/lib/ownership.js +++ b/lib/ownership.js @@ -1,29 +1,48 @@ /** * Job ownership helpers for per-user cron isolation (aligns with uds-auth roles). + * + * Two modes: + * - standalone (no uds-auth): identity empNo is LOCAL_EMP_NO → everyone sees all jobs + * - multi-user (uds-auth present): filter/claim by real empNo */ export const UNASSIGNED_OWNER = '__unassigned__' +export const LOCAL_EMP_NO = '__local__' export function normalizeOwnerEmpNo(value) { const empNo = String(value || '').trim() return empNo || UNASSIGNED_OWNER } -export function canViewAllJobs(identity) { - return !!identity?.permissions?.canViewAllSessions +/** True when uds-auth supplied a real user identity (not standalone local). */ +export function isMultiUserIdentity(identity) { + const empNo = String(identity?.empNo || '').trim() + return !!empNo && empNo !== LOCAL_EMP_NO } -function empNoFromUserWorkspacePath(cwd) { - const norm = String(cwd || '').replace(/\\/g, '/') - const match = norm.match(/\/user-workspaces\/([^/]+)(?:\/|$)/) - return match ? decodeURIComponent(match[1]) : null +export function localIdentity(overrides = {}) { + return { + empNo: LOCAL_EMP_NO, + role: 'local', + displayName: 'local', + permissions: { canViewAllSessions: true }, + workspacePath: null, + mode: 'local', + ...overrides, + } +} + +export function canViewAllJobs(identity) { + if (!isMultiUserIdentity(identity)) return true + return !!identity?.permissions?.canViewAllSessions } export function jobVisibleToIdentity(job, identity) { if (!identity?.empNo) return false - if (canViewAllJobs(identity)) return true + // Standalone / local: no empNo isolation. + if (!isMultiUserIdentity(identity) || canViewAllJobs(identity)) return true const owner = normalizeOwnerEmpNo(job?.ownerEmpNo) - // Unclaimed jobs are admin-only (super_admin / fallback_admin via canViewAll). + // Unclaimed jobs are admin-only in multi-user mode. if (owner === UNASSIGNED_OWNER) return false return owner === String(identity.empNo) } @@ -50,7 +69,7 @@ export function assertCanAccessJob(job, identity) { export function filterJobsForIdentity(jobs, identity) { const list = Array.isArray(jobs) ? jobs : [] if (!identity?.empNo) return [] - if (canViewAllJobs(identity)) return [...list] + if (!isMultiUserIdentity(identity) || canViewAllJobs(identity)) return [...list] return list.filter((job) => jobVisibleToIdentity(job, identity)) } @@ -109,21 +128,23 @@ export function migrateJobOwners(state, deps = {}) { export function viewerPayload(identity) { if (!identity?.empNo) return null + const multi = isMultiUserIdentity(identity) return { empNo: identity.empNo, - role: identity.role || 'user', + role: identity.role || (multi ? 'user' : 'local'), displayName: identity.displayName || identity.empNo, canViewAll: canViewAllJobs(identity), workspacePath: identity.workspacePath || null, + mode: multi ? 'multi' : 'local', } } /** * Claim unassigned jobs that clearly belong to the viewer (origin session / cwd). - * Returns { state, changed }. + * No-op in standalone / canViewAll. Returns { state, changed }. */ export function claimUnassignedForViewer(state, identity, deps = {}) { - if (!identity?.empNo || canViewAllJobs(identity)) { + if (!isMultiUserIdentity(identity) || canViewAllJobs(identity)) { return { state, changed: false } } const jobs = Array.isArray(state?.jobs) ? state.jobs : [] @@ -134,14 +155,14 @@ export function claimUnassignedForViewer(state, identity, deps = {}) { ? job.origin.sessionId.trim() : '' let mine = false - // Only claim when evidence ties the job to this viewer. Remaining unassigned - // jobs stay admin-only until a super/fallback admin reassigns them. + // Only claim when evidence ties the job to this viewer. if (sessionId && typeof deps.getSessionOwner === 'function') { mine = deps.getSessionOwner(sessionId) === identity.empNo } if (!mine && job.cwd) { - const pathOwner = empNoFromUserWorkspacePath(job.cwd) - if (pathOwner === identity.empNo) mine = true + const norm = String(job.cwd).replace(/\\/g, '/') + const match = norm.match(/\/user-workspaces\/([^/]+)(?:\/|$)/) + mine = !!(match && decodeURIComponent(match[1]) === identity.empNo) } if (!mine) return job changed = true diff --git a/test/host.test.js b/test/host.test.js index b9e9138..8d5af11 100644 --- a/test/host.test.js +++ b/test/host.test.js @@ -916,6 +916,47 @@ test('GET /jobs claims unassigned jobs that match viewer cwd', async (t) => { assert.equal(listed.body.jobs[0].ownerEmpNo, 'tester') }) + +test('HTTP works standalone without uds-auth (local mode)', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-local-')) + t.after(() => rm(dir, { recursive: true, force: true })) + const service = createHostService({ + filePath: join(dir, 'store.json'), + now: () => Date.parse('2026-08-24T01:00:00.000Z'), + sessionPort: { + async createAndPrompt() { + return { sessionId: 'local-sess', status: 'succeeded', summary: 'ok' } + }, + async archiveSession() {}, + }, + getUdsAuth: () => undefined, + }) + const http = await listen(service) + t.after(() => http.close()) + + const created = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { + method: 'POST', + body: JSON.stringify({ + name: 'local job', + prompt: 'ping', + schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'UTC' }, + }), + }) + assert.equal(created.status, 200) + assert.equal(created.body.job.name, 'local job') + assert.ok(!created.body.job.ownerEmpNo || created.body.job.ownerEmpNo === '__unassigned__') + + const listed = await jsonRequest(http.url, '/dsh-ops-cron/jobs') + assert.equal(listed.status, 200) + assert.equal(listed.body.viewer.mode, 'local') + assert.equal(listed.body.viewer.canViewAll, true) + assert.equal(listed.body.jobs.length, 1) + + const ran = await jsonRequest(http.url, `/dsh-ops-cron/jobs/${created.body.job.id}/run`, { method: 'POST' }) + assert.equal(ran.status, 200) + assert.equal(ran.body.run.sessionId, 'local-sess') +}) + test('migrateJobOwners assigns unassigned for legacy jobs', async () => { const { migrateJobOwners, UNASSIGNED_OWNER } = await import('../lib/ownership.js') const state = { diff --git a/test/ownership.test.js b/test/ownership.test.js index 990fb17..fbc6af3 100644 --- a/test/ownership.test.js +++ b/test/ownership.test.js @@ -7,7 +7,10 @@ import { filterJobsForIdentity, filterRunsForJobs, inferOwnerEmpNo, + isMultiUserIdentity, jobVisibleToIdentity, + LOCAL_EMP_NO, + localIdentity, migrateJobOwners, UNASSIGNED_OWNER, viewerPayload, @@ -73,6 +76,23 @@ test('viewerPayload', () => { assert.equal(v.empNo, 'u1') assert.equal(v.canViewAll, false) assert.equal(v.workspacePath, '/w/u1') + assert.equal(v.mode, 'multi') +}) + +test('standalone local identity sees all jobs including unassigned', () => { + const local = localIdentity() + assert.equal(isMultiUserIdentity(local), false) + assert.equal(local.empNo, LOCAL_EMP_NO) + assert.equal(canViewAllJobs(local), true) + assert.equal(jobVisibleToIdentity({ ownerEmpNo: UNASSIGNED_OWNER }, local), true) + assert.equal(jobVisibleToIdentity({ ownerEmpNo: 'u2' }, local), true) + const payload = viewerPayload(local) + assert.equal(payload.mode, 'local') + assert.equal(payload.canViewAll, true) + assert.deepEqual( + filterJobsForIdentity([{ id: '1', ownerEmpNo: 'u1' }, { id: '2', ownerEmpNo: UNASSIGNED_OWNER }], local).map((j) => j.id), + ['1', '2'], + ) }) test('claimUnassignedForViewer claims by session owner or user-workspaces cwd', () => {