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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-08 15:49:54 +08:00
parent 0ffc48b43a
commit 7d27179e51
12 changed files with 785 additions and 141 deletions

View file

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

74
test/ownership.test.js Normal file
View file

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