oclaw/uds-auth/test/phase2-acl.test.js
oliver df97c5e042
Some checks are pending
ci / test (push) Waiting to run
ci / test-postgresql (push) Waiting to run
fix(uds-auth): emergency login workspaces, Desktop key prompts, persistent sessions
Emergency (fallback / sealed-box) admins lost the workspace sidebar and saw
"sign-in failed / add API key" on DSH Desktop because the remote.mux opens
anonymously at boot and every early call was rejected terminally.

- gateway: anonymous workspace/follow reaches dsh-acl's empty baseline;
  anonymous streams are parked until the carrier aborts instead of failing;
  anonymous workspace/initializeDefault answers "nothing created"
- request-auth: a dead UDS_SESSION cookie no longer shadows a valid WS ticket
  or bridge (sync + async variants used by all callers)
- dsh-acl: /api/* waits (bounded 15s) for the gateway ACL during Host startup
  instead of 403, so the Desktop welcome read no longer fails
- desktop-bootstrap: key/account projection is best-effort per reference
- client: emit connection/reset after login so boot-time caches (settings
  describe mirror -> Settings > Models) re-read under the real principal
- sessions: absolute lifetime fixed at 7 days (was min(ttl, 7d) = 30 min);
  bridge expiry slides with its session; MemoryStore persisted to
  sessions.json (bearer hash only, userData AES-256-GCM sealed)
- README: session lifetime/persistence and DSH compatibility notes

Also includes previously uncommitted uds-auth 0.3.x work in this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:11:09 +08:00

104 lines
4.3 KiB
JavaScript

/**
* Phase-2 ACL tests (SEC-10/12/13/14/15/26 fragments).
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { createSessionAccess } from '../lib/dsh-acl.js'
import { computePermissions, RolesStore, ROLES } from '../lib/roles.js'
import { SessionAclStore } from '../lib/session-acl.js'
import { UserWorkspaceStore, resolveWorkspaceRoot } from '../lib/workspace-provision.js'
import { sanitizeUserId, resolveUserDir } from '../lib/utils/safe-userid.js'
import { join } from 'node:path'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
test('SEC-13: foreign owner is not overridden by own cwd', () => {
const ownersMap = new Map([['s-foreign', 'u2']])
const { canAccessSession } = createSessionAccess({
sessionAcl: { getOwner: (id) => ownersMap.get(String(id)) || null },
userWorkspaces: {
get: (empNo) => (empNo === 'u1' ? { path: '/ws/u1', workspaceId: 'ws-u1' } : null),
isUserPath: (empNo, candidate) => {
if (empNo !== 'u1' || !candidate) return false
const path = String(candidate).replace(/\\/g, '/')
return path === '/ws/u1' || path.startsWith('/ws/u1/')
},
},
getWorkspaceRoot: () => '/ws',
getWorkspaceRegistry: () => ({ list: () => [] }),
})
const user = { empNo: 'u1', role: 'user', permissions: computePermissions('user') }
assert.equal(canAccessSession('s-foreign', user, { cwd: '/ws/u1/project' }), false)
assert.equal(canAccessSession('s-foreign', user, { cwd: '/ws/u2' }), false)
})
test('SEC-13: setOwner refuses overwrite of foreign owner without force', () => {
const store = new SessionAclStore({})
assert.equal(store.setOwner('s1', 'A'), true)
assert.equal(store.setOwner('s1', 'B'), false)
assert.equal(store.getOwner('s1'), 'A')
assert.equal(store.setOwner('s1', 'B', { force: true }), true)
assert.equal(store.getOwner('s1'), 'B')
})
test('SEC-14: sanitizeUserId rejects path escape segments', () => {
assert.equal(sanitizeUserId('../escape'), null)
assert.equal(sanitizeUserId('..\\escape'), null)
assert.equal(sanitizeUserId('a/b'), null)
assert.equal(sanitizeUserId('a\\b'), null)
assert.equal(sanitizeUserId('C:'), null)
assert.equal(sanitizeUserId('con'), null)
assert.equal(sanitizeUserId('10001'), '10001')
assert.equal(sanitizeUserId('user.name'), 'user.name')
})
test('SEC-14: resolveUserDir / isUserPath block ../ empNo', async () => {
const dir = await mkdtemp(join(tmpdir(), 'uds-ws-'))
try {
const root = resolveWorkspaceRoot(dir)
assert.equal(resolveUserDir(root, '../escape'), null)
assert.equal(resolveUserDir(root, '10001')?.endsWith('10001') || resolveUserDir(root, '10001')?.includes('10001'), true)
const store = new UserWorkspaceStore({})
assert.equal(store.isUserPath('../escape', join(root, 'x'), root), false)
assert.equal(store.isUserPath('10001', join(root, '10001', 'a'), root), true)
// Outside user dir
assert.equal(store.isUserPath('10001', join(root, '10002'), root), false)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('SEC-26: setRole cannot assign fallback_admin', async () => {
const store = new RolesStore({})
store._roles.set('admin1', ROLES.ADMIN)
await assert.rejects(
() => store.setRole('u1', ROLES.FALLBACK_ADMIN, ROLES.ADMIN),
(err) => err.code === 'invalid_role_params',
)
await store.setRole('u1', ROLES.USER, ROLES.ADMIN)
assert.equal(store.getRole('u1'), ROLES.USER)
})
test('SEC-10: live rolesStore demotion removes view-all', () => {
const store = new RolesStore()
store._roles.set('boss', ROLES.SUPER_ADMIN)
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 identity = {
empNo: 'boss',
role: 'super_admin',
permissions: computePermissions('super_admin', { viewAllSessions: true }),
}
assert.equal(access.canAccessSession('s-peer', identity), true)
// Demote in store; frozen identity.permissions still look admin — live check wins.
store._roles.set('boss', ROLES.USER)
assert.equal(access.canSeeAll(identity), false)
assert.equal(access.canAccessSession('s-peer', identity), false)
})