mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-12 06:50:48 +08:00
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>
337 lines
12 KiB
JavaScript
337 lines
12 KiB
JavaScript
import { ROLES, ROLE_LABELS } from './roles.js'
|
|
import { requirePermission } from './middleware/auth-middleware.js'
|
|
import { apiError, apiOk, resolveLocale, roleLabel } from './i18n.js'
|
|
import { clearBrowserIdentity } from './identity-cache.js'
|
|
import { SESSION_COOKIE, clearSessionCookie } from './session/request-auth.js'
|
|
import { extractSessionBearerAsync } from './session/request-auth.js'
|
|
import { readBodyLimited, DEFAULT_MAX_BODY } from './utils/read-body.js'
|
|
import { audit } from './utils/audit-log.js'
|
|
import { identityFromSessionRecord } from './dsh-acl.js'
|
|
|
|
/**
|
|
* API handlers — permission guards + localized messages via stable error codes.
|
|
*/
|
|
/**
|
|
* @param {object} config
|
|
* @param {import('./session/store.js').SessionStore} sessionStore
|
|
* @param {*} rolesStore
|
|
* @param {{ skillCredentials?: { delete: (empNo: string) => void }, retainSkillCredentialsOnLogout?: () => boolean }} [extra]
|
|
*/
|
|
export function createApiHandlers(config, sessionStore, rolesStore, extra = {}) {
|
|
// Read again inside the RolesStore mutation queue, after a slow body or a
|
|
// concurrent demotion/logout; the role captured at request admission is stale.
|
|
const liveActorRole = ctx => () => {
|
|
if (rolesStore.isDisabled?.(ctx.empNo)) return ROLES.USER
|
|
const sid = ctx.sessionId || ctx.userContext?._sessionId
|
|
if (sid && typeof sessionStore.getBySessionIdSync === 'function') {
|
|
const row = sessionStore.getBySessionIdSync(sid)
|
|
return identityFromSessionRecord(row, rolesStore)?.role || ROLES.USER
|
|
}
|
|
return rolesStore.getRole?.(ctx.empNo) || ctx.role
|
|
}
|
|
async function sendRes(res, code, data) {
|
|
res.statusCode = code >= 200 && code < 300 ? 200 : code
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
res.end(JSON.stringify(data))
|
|
}
|
|
|
|
function locale(ctx) {
|
|
return resolveLocale(ctx.req, ctx.userContext)
|
|
}
|
|
|
|
function fail(ctx, code, status, vars) {
|
|
return sendRes(ctx.res, status, apiError(code, locale(ctx), vars))
|
|
}
|
|
|
|
function ok(ctx, code, vars, extraFields = {}) {
|
|
return sendRes(ctx.res, 200, { ...apiOk(code, locale(ctx), vars), ...extraFields })
|
|
}
|
|
|
|
function mapThrown(ctx, err, status = 400) {
|
|
const code = err?.code || err?.message
|
|
if (code && typeof code === 'string' && !code.includes(' ') && !/[\u4e00-\u9fff]/.test(code)) {
|
|
return fail(ctx, code, status)
|
|
}
|
|
return sendRes(ctx.res, status, {
|
|
error: 'request_failed',
|
|
message: err?.message || String(err),
|
|
})
|
|
}
|
|
|
|
async function logout(ctx) {
|
|
const empNo = ctx.empNo
|
|
const sessionId = ctx.sessionId || ctx.userContext?._sessionId
|
|
// Prefer revoke current device; fall back to all devices for legacy callers.
|
|
if (sessionId && typeof sessionStore.revokeSession === 'function') {
|
|
await sessionStore.revokeSession(sessionId)
|
|
} else if (empNo) {
|
|
await sessionStore.delete(empNo)
|
|
}
|
|
try {
|
|
const extracted = await extractSessionBearerAsync(ctx.req, {
|
|
sessionBridge: extra.sessionBridge,
|
|
isLiveBearer: async (bearer) => !!(await sessionStore.getByBearer?.(bearer)),
|
|
})
|
|
if (extracted?.bearer) {
|
|
extra.sessionBridge?.revokeToken?.(extracted.bearer)
|
|
if (typeof sessionStore.revokeBearer === 'function') {
|
|
await sessionStore.revokeBearer(extracted.bearer)
|
|
}
|
|
} else if (empNo) {
|
|
extra.sessionBridge?.revokeEmpNo?.(empNo)
|
|
}
|
|
} catch { /* ignore */ }
|
|
try { clearBrowserIdentity() } catch { /* ignore */ }
|
|
const retain = extra.retainSkillCredentialsOnLogout
|
|
? extra.retainSkillCredentialsOnLogout() !== false
|
|
: true
|
|
if (empNo && !retain) {
|
|
try { extra.skillCredentials?.delete(empNo) } catch { /* ignore */ }
|
|
}
|
|
// Task capabilities outlive UI logout only if Host minted them for cron;
|
|
// revoke current-user caps when logout is full-account style (no retain path).
|
|
if (empNo && typeof extra.revokeTaskCapabilitiesForEmpNo === 'function' && !retain) {
|
|
try { extra.revokeTaskCapabilitiesForEmpNo(empNo) } catch { /* ignore */ }
|
|
}
|
|
const clear = [
|
|
clearSessionCookie({ secure: false }),
|
|
clearSessionCookie({ secure: true }),
|
|
]
|
|
for (const name of [
|
|
'UDS_FALLBACK_USER',
|
|
'UDS_FALLBACK_UI',
|
|
'UDS_LOCAL_ADMIN',
|
|
'PORTALSSOUser',
|
|
'PORTALSSOCookie',
|
|
'ZTEDPGSSOUser',
|
|
'ZTEDPGSSOCookie',
|
|
]) {
|
|
const httpOnly = name === 'UDS_FALLBACK_USER' || name === 'UDS_LOCAL_ADMIN' || name === SESSION_COOKIE
|
|
const base = httpOnly
|
|
? (name + '=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax')
|
|
: (name + '=; Max-Age=0; Path=/; SameSite=Lax')
|
|
clear.push(base)
|
|
clear.push(base + '; Secure')
|
|
}
|
|
ctx.res.setHeader('Set-Cookie', clear)
|
|
ctx.res.setHeader('Cache-Control', 'no-store')
|
|
audit({
|
|
action: 'logout',
|
|
decision: 'allow',
|
|
actor: empNo || null,
|
|
resource: sessionId || null,
|
|
})
|
|
await ok(ctx, 'logged_out')
|
|
}
|
|
|
|
async function getCurrentUser(ctx) {
|
|
const ws = ctx.provisionedWorkspace || null
|
|
ctx.res.setHeader('Cache-Control', 'no-store')
|
|
// SEC-07: field whitelist — never return upstream token / authValue
|
|
const uc = ctx.userContext
|
|
const safe = uc ? {
|
|
empNo: ctx.empNo,
|
|
userId: uc.userId || ctx.empNo,
|
|
username: uc.username || null,
|
|
displayName: uc.displayName || uc.username || null,
|
|
department: uc.department || null,
|
|
organization: uc.organization || null,
|
|
lang: uc.lang || null,
|
|
isAuthenticated: true,
|
|
authMode: uc.authMode || null,
|
|
role: ctx.role,
|
|
permissions: ctx.permissions,
|
|
workspaceId: ws?.workspaceId || null,
|
|
workspacePath: ws?.path || null,
|
|
authenticatedAt: uc.authenticatedAt || null,
|
|
lastActiveAt: uc.lastActiveAt || null,
|
|
} : null
|
|
await sendRes(ctx.res, 200, {
|
|
data: safe,
|
|
authenticated: !!safe,
|
|
})
|
|
}
|
|
|
|
async function listUsers(ctx) {
|
|
if (!requirePermission(ctx, 'super_admin')) {
|
|
return fail(ctx, 'forbidden_list_users', 403)
|
|
}
|
|
const url = new URL(ctx.req.url, 'http://localhost')
|
|
const page = url.searchParams.get('page')
|
|
const pageSize = url.searchParams.get('pageSize')
|
|
const q = url.searchParams.get('q') || ''
|
|
const result = rolesStore.listPage({ page, pageSize, q })
|
|
const loc = locale(ctx)
|
|
if (Array.isArray(result.users)) {
|
|
result.users = result.users.map((u) => ({
|
|
...u,
|
|
roleLabel: roleLabel(u.role, loc) || ROLE_LABELS[u.role] || u.role,
|
|
}))
|
|
}
|
|
await sendRes(ctx.res, 200, result)
|
|
}
|
|
|
|
async function setUserRole(ctx) {
|
|
if (!requirePermission(ctx, 'super_admin')) {
|
|
return fail(ctx, 'forbidden_set_role', 403)
|
|
}
|
|
const body = await readBody(ctx.req)
|
|
const { empNo, role } = body || {}
|
|
if (!empNo || !role || !Object.values(ROLES).includes(role)) {
|
|
return fail(ctx, 'invalid_role_params', 400)
|
|
}
|
|
try {
|
|
await rolesStore.setRole(empNo, role, liveActorRole(ctx))
|
|
audit({
|
|
action: 'set_role',
|
|
decision: 'allow',
|
|
actor: ctx.empNo,
|
|
target: empNo,
|
|
meta: { role },
|
|
})
|
|
await ok(ctx, 'role_updated', { empNo, role: roleLabel(role, locale(ctx)) })
|
|
} catch (err) {
|
|
await mapThrown(ctx, err)
|
|
}
|
|
}
|
|
|
|
async function addUser(ctx) {
|
|
if (!requirePermission(ctx, 'super_admin')) {
|
|
return fail(ctx, 'forbidden_add_user', 403)
|
|
}
|
|
const body = await readBody(ctx.req)
|
|
const { empNo, role } = body || {}
|
|
if (!empNo) {
|
|
return fail(ctx, 'emp_no_required', 400)
|
|
}
|
|
const targetRole = role && Object.values(ROLES).includes(role) ? role : ROLES.USER
|
|
try {
|
|
await rolesStore.ensureUser(empNo, liveActorRole(ctx))
|
|
if (role) await rolesStore.setRole(empNo, role, liveActorRole(ctx))
|
|
await ok(ctx, 'user_added', { empNo, role: roleLabel(targetRole, locale(ctx)) })
|
|
} catch (err) {
|
|
await mapThrown(ctx, err)
|
|
}
|
|
}
|
|
|
|
async function removeUser(ctx) {
|
|
if (!requirePermission(ctx, 'super_admin')) {
|
|
return fail(ctx, 'forbidden_remove_user', 403)
|
|
}
|
|
const body = await readBody(ctx.req)
|
|
const { empNo } = body || {}
|
|
if (!empNo) {
|
|
return fail(ctx, 'emp_no_required', 400)
|
|
}
|
|
try {
|
|
await rolesStore.removeUser(empNo, liveActorRole(ctx))
|
|
await sessionStore.delete(empNo)
|
|
try { extra.skillCredentials?.delete(empNo) } catch { /* ignore */ }
|
|
// A06: revoke task capabilities when account is disabled/removed.
|
|
try { extra.revokeTaskCapabilitiesForEmpNo?.(empNo) } catch { /* ignore */ }
|
|
await ok(ctx, 'user_removed', { empNo })
|
|
} catch (err) {
|
|
await mapThrown(ctx, err)
|
|
}
|
|
}
|
|
|
|
function isPasswordModeSession(row) {
|
|
const mode = String(row?.userData?.authMode || row?.authMode || row?.kind || '')
|
|
if (mode.includes('local-admin') || mode === 'sealed_box' || mode === 'local_admin') {
|
|
return false
|
|
}
|
|
return mode.includes('fallback') || mode === 'fallback' || row?.kind === 'fallback'
|
|
}
|
|
|
|
async function revokePasswordFallbackSessions() {
|
|
// D04/F02: revoke password-mode only — keep independent sealed-box sessions/bridges.
|
|
const empNo = 'administrator'
|
|
try {
|
|
if (typeof sessionStore.revokeEmpNoMatching === 'function') {
|
|
await sessionStore.revokeEmpNoMatching(empNo, isPasswordModeSession)
|
|
} else if (typeof sessionStore.revokeEmpNo === 'function') {
|
|
// Stores without matching API: do not wipe sealed-box with a blanket delete.
|
|
}
|
|
} catch { /* ignore */ }
|
|
try {
|
|
extra.sessionBridge?.revokeEmpNoKinds?.(empNo, [
|
|
'fallback', 'fallback_password', 'fallback-password', 'fallback-login',
|
|
])
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
async function setFallbackPassword(ctx) {
|
|
if (!requirePermission(ctx, 'super_admin')) {
|
|
return fail(ctx, 'forbidden_set_fallback', 403)
|
|
}
|
|
const body = await readBody(ctx.req)
|
|
const { password } = body || {}
|
|
try {
|
|
await rolesStore.setFallbackPassword(password, liveActorRole(ctx))
|
|
await revokePasswordFallbackSessions()
|
|
await ok(ctx, 'fallback_password_set')
|
|
} catch (err) {
|
|
await mapThrown(ctx, err)
|
|
}
|
|
}
|
|
|
|
async function clearFallbackPassword(ctx) {
|
|
if (!requirePermission(ctx, 'super_admin')) {
|
|
return fail(ctx, 'forbidden_clear_fallback', 403)
|
|
}
|
|
try {
|
|
await rolesStore.clearFallbackPassword(liveActorRole(ctx))
|
|
await revokePasswordFallbackSessions()
|
|
await ok(ctx, 'fallback_password_cleared')
|
|
} catch (err) {
|
|
await mapThrown(ctx, err)
|
|
}
|
|
}
|
|
|
|
async function fallbackStatus(ctx) {
|
|
await sendRes(ctx.res, 200, {
|
|
enabled: rolesStore.isFallbackEnabled(),
|
|
})
|
|
}
|
|
|
|
async function setViewAllSessions(ctx) {
|
|
if (!requirePermission(ctx, 'canToggleViewAllSessions')) {
|
|
return fail(ctx, 'forbidden_view_all_sessions', 403)
|
|
}
|
|
const body = await readBody(ctx.req)
|
|
const enabled = !!(body && body.enabled)
|
|
try {
|
|
await rolesStore.setViewAllSessionsDurable(ctx.empNo, enabled)
|
|
ctx.permissions = rolesStore.resolvePermissions(ctx.empNo, ctx.role)
|
|
await ok(ctx, enabled ? 'view_all_sessions_on' : 'view_all_sessions_off', null, {
|
|
permissions: ctx.permissions,
|
|
viewAllSessions: enabled,
|
|
})
|
|
} catch (err) {
|
|
await mapThrown(ctx, err, 403)
|
|
}
|
|
}
|
|
|
|
return {
|
|
logout,
|
|
getCurrentUser,
|
|
listUsers,
|
|
setUserRole,
|
|
addUser,
|
|
removeUser,
|
|
setFallbackPassword,
|
|
clearFallbackPassword,
|
|
fallbackStatus,
|
|
setViewAllSessions,
|
|
}
|
|
}
|
|
|
|
async function readBody(req) {
|
|
try {
|
|
const raw = await readBodyLimited(req, { maxBytes: DEFAULT_MAX_BODY })
|
|
if (!raw) return null
|
|
return JSON.parse(raw)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|