From 3f6a25d8f15d3eda555ce904a9ea2bde9d919e55 Mon Sep 17 00:00:00 2001 From: ekko Date: Sat, 23 May 2026 18:44:53 +0800 Subject: [PATCH] Add user-scoped Hermes profile access --- packages/client/src/App.vue | 2 + packages/client/src/api/auth.ts | 80 +++++ packages/client/src/api/client.ts | 77 +++- packages/client/src/api/hermes/kanban.ts | 10 + packages/client/src/api/hermes/profiles.ts | 4 + packages/client/src/api/hermes/sessions.ts | 16 +- .../src/components/auth/AuthEventListener.vue | 35 ++ .../hermes/profiles/ProfileCard.vue | 12 +- .../hermes/settings/AccountSettings.vue | 95 +---- .../settings/UserManagementSettings.vue | 302 ++++++++++++++++ .../src/components/layout/AppSidebar.vue | 6 +- packages/client/src/i18n/locales/de.ts | 49 ++- packages/client/src/i18n/locales/en.ts | 49 ++- packages/client/src/i18n/locales/es.ts | 49 ++- packages/client/src/i18n/locales/fr.ts | 49 ++- packages/client/src/i18n/locales/ja.ts | 49 ++- packages/client/src/i18n/locales/ko.ts | 49 ++- packages/client/src/i18n/locales/pt.ts | 49 ++- packages/client/src/i18n/locales/zh-TW.ts | 51 ++- packages/client/src/i18n/locales/zh.ts | 49 ++- packages/client/src/router/index.ts | 9 +- packages/client/src/stores/hermes/profiles.ts | 86 +++-- packages/client/src/views/LoginView.vue | 142 +------- .../client/src/views/hermes/HistoryView.vue | 33 +- .../client/src/views/hermes/ProfilesView.vue | 2 +- .../client/src/views/hermes/SettingsView.vue | 6 + packages/server/src/controllers/auth.ts | 337 +++++++++++++++--- .../server/src/controllers/hermes/kanban.ts | 102 +++++- .../server/src/controllers/hermes/models.ts | 21 +- .../server/src/controllers/hermes/profiles.ts | 88 ++--- .../server/src/controllers/hermes/sessions.ts | 103 ++++-- packages/server/src/db/hermes/schemas.ts | 39 ++ packages/server/src/db/hermes/users-store.ts | 300 ++++++++++++++++ packages/server/src/index.ts | 10 +- packages/server/src/middleware/user-auth.ts | 215 +++++++++++ packages/server/src/routes/auth.ts | 6 + .../server/src/routes/hermes/kanban-events.ts | 19 +- .../src/routes/hermes/performance-monitor.ts | 3 +- packages/server/src/routes/hermes/profiles.ts | 3 +- packages/server/src/routes/hermes/terminal.ts | 7 +- packages/server/src/routes/index.ts | 4 +- .../src/services/hermes/conversations.ts | 1 + .../src/services/hermes/group-chat/index.ts | 18 +- .../src/services/hermes/run-chat/index.ts | 62 +++- tests/client/api.test.ts | 71 +++- tests/client/kanban-api.test.ts | 5 +- tests/client/login-view.test.ts | 35 +- tests/client/profiles-store.test.ts | 12 +- tests/server/hermes-schemas.test.ts | 15 +- tests/server/kanban-controller.test.ts | 48 +++ .../model-visibility-controller.test.ts | 50 ++- .../performance-monitor-controller.test.ts | 18 + tests/server/sessions-controller.test.ts | 32 +- tests/server/user-auth.test.ts | 264 ++++++++++++++ 54 files changed, 2656 insertions(+), 592 deletions(-) create mode 100644 packages/client/src/components/auth/AuthEventListener.vue create mode 100644 packages/client/src/components/hermes/settings/UserManagementSettings.vue create mode 100644 packages/server/src/db/hermes/users-store.ts create mode 100644 packages/server/src/middleware/user-auth.ts create mode 100644 tests/server/user-auth.test.ts diff --git a/packages/client/src/App.vue b/packages/client/src/App.vue index 6c79e3f..7324e72 100644 --- a/packages/client/src/App.vue +++ b/packages/client/src/App.vue @@ -9,6 +9,7 @@ import AppSidebar from '@/components/layout/AppSidebar.vue' import { useKeyboard } from '@/composables/useKeyboard' import { useAppStore } from '@/stores/hermes/app' import SessionSearchModal from '@/components/hermes/chat/SessionSearchModal.vue' +import AuthEventListener from '@/components/auth/AuthEventListener.vue' const { isDark, isComic } = useTheme() const { t } = useI18n() @@ -55,6 +56,7 @@ useKeyboard() @@ -347,8 +356,8 @@ async function handleDeleteSession(id: string) { :can-delete="true" :streaming="false" :show-profile="false" - @select="handleSessionClick(s.id)" - @delete="handleDeleteSession(s.id)" + @select="handleSessionClick(s.id, s.profile)" + @delete="handleDeleteSession(s.id, s.profile)" /> diff --git a/packages/client/src/views/hermes/ProfilesView.vue b/packages/client/src/views/hermes/ProfilesView.vue index c8338c5..ae01e36 100644 --- a/packages/client/src/views/hermes/ProfilesView.vue +++ b/packages/client/src/views/hermes/ProfilesView.vue @@ -16,7 +16,7 @@ const showImportModal = ref(false) const renamingProfile = ref(null) onMounted(() => { - profilesStore.fetchProfiles() + profilesStore.fetchHermesProfiles() }) function handleCreated() { diff --git a/packages/client/src/views/hermes/SettingsView.vue b/packages/client/src/views/hermes/SettingsView.vue index f4d3a85..cd9c79a 100644 --- a/packages/client/src/views/hermes/SettingsView.vue +++ b/packages/client/src/views/hermes/SettingsView.vue @@ -15,10 +15,13 @@ import SessionSettings from "@/components/hermes/settings/SessionSettings.vue"; import PrivacySettings from "@/components/hermes/settings/PrivacySettings.vue"; import ModelSettings from "@/components/hermes/settings/ModelSettings.vue"; import AccountSettings from "@/components/hermes/settings/AccountSettings.vue"; +import UserManagementSettings from "@/components/hermes/settings/UserManagementSettings.vue"; import VoiceSettings from "@/components/hermes/settings/VoiceSettings.vue"; +import { isStoredSuperAdmin } from "@/api/client"; const settingsStore = useSettingsStore(); const { t } = useI18n(); +const canManageUsers = isStoredSuperAdmin(); onMounted(() => { settingsStore.fetchSettings(); @@ -41,6 +44,9 @@ onMounted(() => { + + + diff --git a/packages/server/src/controllers/auth.ts b/packages/server/src/controllers/auth.ts index d4ab546..0b70d9c 100644 --- a/packages/server/src/controllers/auth.ts +++ b/packages/server/src/controllers/auth.ts @@ -1,24 +1,68 @@ import type { Context } from 'koa' -import { getCredentials, setCredentials, verifyCredentials, deleteCredentials } from '../services/credentials' -import { getToken } from '../services/auth' import { checkPassword, recordPasswordFailure, recordPasswordSuccess, extractIp, getLockedIps, unlockIp, unlockAll } from '../services/login-limiter' +import { + DEFAULT_USERNAME, + bootstrapDefaultSuperAdmin, + countActiveSuperAdmins, + countUsers, + createUser, + deleteUser, + findFirstUser, + findUserById, + findUserByUsername, + listUsers, + updateUser, + updateUsername, + updateUserPassword, + verifyPassword, + type UserRole, + type UserStatus, +} from '../db/hermes/users-store' +import { issueUserJwt } from '../middleware/user-auth' +import { listProfileNamesFromDisk } from '../services/hermes/hermes-profile' /** * GET /api/auth/status * Check if username/password login is configured (public). */ export async function authStatus(ctx: Context) { - const cred = await getCredentials() + const firstUser = findFirstUser() ctx.body = { - hasPasswordLogin: !!cred, - username: cred?.username || null, + hasPasswordLogin: true, + username: firstUser?.username || DEFAULT_USERNAME, + hasUsers: countUsers() > 0, + } +} + +/** + * GET /api/auth/me + * Return the authenticated account. + */ +export async function currentUser(ctx: Context) { + const userId = ctx.state.user?.id + const user = userId ? findUserById(userId) : null + if (!user) { + ctx.status = 404 + ctx.body = { error: 'User not found' } + return + } + ctx.body = { + user: { + id: user.id, + username: user.username, + role: user.role, + status: user.status, + created_at: user.created_at, + updated_at: user.updated_at, + last_login_at: user.last_login_at, + }, } } /** * POST /api/auth/login * Authenticate with username/password (public). - * Returns the static token on success. + * Returns a user-scoped JWT on success. */ export async function login(ctx: Context) { const { username, password } = ctx.request.body as { username?: string; password?: string } @@ -36,18 +80,24 @@ export async function login(ctx: Context) { return } - const valid = await verifyCredentials(username, password) - if (!valid) { + const existingUserCount = countUsers() + const user = existingUserCount === 0 + ? bootstrapDefaultSuperAdmin(username, password) + : findUserByUsername(username) + + if (!user || user.status !== 'active' || (existingUserCount > 0 && !verifyPassword(password, user.password_hash))) { recordPasswordFailure(ip) ctx.status = 401 ctx.body = { error: 'Invalid username or password' } return } - const token = await getToken() - if (!token) { + let token: string + try { + token = await issueUserJwt(user) + } catch (err: any) { ctx.status = 500 - ctx.body = { error: 'Auth is disabled on this server' } + ctx.body = { error: err?.message || 'Auth is disabled on this server' } return } @@ -60,25 +110,8 @@ export async function login(ctx: Context) { * Set up username/password (protected). */ export async function setupPassword(ctx: Context) { - const { username, password } = ctx.request.body as { username?: string; password?: string } - if (!username || !password) { - ctx.status = 400 - ctx.body = { error: 'Username and password are required' } - return - } - if (username.length < 2) { - ctx.status = 400 - ctx.body = { error: 'Username must be at least 2 characters' } - return - } - if (password.length < 6) { - ctx.status = 400 - ctx.body = { error: 'Password must be at least 6 characters' } - return - } - - await setCredentials(username, password) - ctx.body = { success: true } + ctx.status = 400 + ctx.body = { error: 'Password login is managed by user accounts' } } /** @@ -98,22 +131,15 @@ export async function changePassword(ctx: Context) { return } - const cred = await getCredentials() - if (!cred) { - ctx.status = 400 - ctx.body = { error: 'Password login not configured' } - return - } - - // Verify current password — use the username from stored credentials - const valid = await verifyCredentials(cred.username, currentPassword) - if (!valid) { + const userId = ctx.state.user?.id + const user = userId ? findUserById(userId) : null + if (!user || !verifyPassword(currentPassword, user.password_hash)) { ctx.status = 400 ctx.body = { error: 'Current password is incorrect' } return } - await setCredentials(cred.username, newPassword) + updateUserPassword(user.id, newPassword) ctx.body = { success: true } } @@ -134,22 +160,22 @@ export async function changeUsername(ctx: Context) { return } - const cred = await getCredentials() - if (!cred) { - ctx.status = 400 - ctx.body = { error: 'Password login not configured' } - return - } - - const valid = await verifyCredentials(cred.username, currentPassword) - if (!valid) { + const userId = ctx.state.user?.id + const user = userId ? findUserById(userId) : null + if (!user || !verifyPassword(currentPassword, user.password_hash)) { ctx.status = 400 ctx.body = { error: 'Current password is incorrect' } return } - // Update username, keep the same password - await setCredentials(newUsername, currentPassword) + const existing = findUserByUsername(newUsername) + if (existing && existing.id !== user.id) { + ctx.status = 409 + ctx.body = { error: 'Username already exists' } + return + } + + updateUsername(user.id, newUsername) ctx.body = { success: true } } @@ -158,8 +184,211 @@ export async function changeUsername(ctx: Context) { * Remove username/password login (protected). */ export async function removePassword(ctx: Context) { - await deleteCredentials() - ctx.body = { success: true } + ctx.status = 400 + ctx.body = { error: 'Password login cannot be removed for user accounts' } +} + +function normalizeRole(value: unknown): UserRole | null { + return value === 'super_admin' || value === 'admin' ? value : null +} + +function normalizeStatus(value: unknown): UserStatus | null { + return value === 'active' || value === 'disabled' ? value : null +} + +function normalizeProfiles(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return [...new Set(value.map(item => String(item || '').trim()).filter(Boolean))] +} + +function validateProfiles(profiles: string[]): string | null { + const available = new Set(listProfileNamesFromDisk()) + const missing = profiles.find(profile => !available.has(profile)) + return missing || null +} + +/** + * GET /api/auth/users + * Super admin user management list. + */ +export async function listManagedUsers(ctx: Context) { + ctx.body = { + users: listUsers(), + profiles: listProfileNamesFromDisk(), + } +} + +/** + * POST /api/auth/users + * Create a user account. Super admin only. + */ +export async function createManagedUser(ctx: Context) { + const body = ctx.request.body as { + username?: string + password?: string + role?: unknown + status?: unknown + profiles?: unknown + defaultProfile?: string | null + } + const username = String(body.username || '').trim() + const password = String(body.password || '') + const role = normalizeRole(body.role || 'admin') + const status = normalizeStatus(body.status || 'active') + const profiles = normalizeProfiles(body.profiles) + + if (username.length < 2) { + ctx.status = 400 + ctx.body = { error: 'Username must be at least 2 characters' } + return + } + if (password.length < 6) { + ctx.status = 400 + ctx.body = { error: 'Password must be at least 6 characters' } + return + } + if (!role || !status) { + ctx.status = 400 + ctx.body = { error: 'Invalid role or status' } + return + } + if (findUserByUsername(username)) { + ctx.status = 409 + ctx.body = { error: 'Username already exists' } + return + } + + const missingProfile = validateProfiles(profiles) + if (missingProfile) { + ctx.status = 400 + ctx.body = { error: `Profile "${missingProfile}" does not exist` } + return + } + + const user = createUser({ + username, + password, + role, + status, + profiles: role === 'super_admin' ? [] : profiles, + defaultProfile: body.defaultProfile, + }) + ctx.status = 201 + ctx.body = { user, users: listUsers() } +} + +/** + * PUT /api/auth/users/:id + * Update user account metadata, password, and profile bindings. + */ +export async function updateManagedUser(ctx: Context) { + const id = Number(ctx.params.id) + const user = Number.isInteger(id) ? findUserById(id) : null + if (!user) { + ctx.status = 404 + ctx.body = { error: 'User not found' } + return + } + + const body = ctx.request.body as { + username?: string + password?: string + role?: unknown + status?: unknown + profiles?: unknown + defaultProfile?: string | null + } + const username = body.username == null ? undefined : String(body.username).trim() + const password = body.password == null ? undefined : String(body.password) + const role = body.role == null ? undefined : normalizeRole(body.role) + const status = body.status == null ? undefined : normalizeStatus(body.status) + const profiles = body.profiles == null ? undefined : normalizeProfiles(body.profiles) + + if (username !== undefined && username.length < 2) { + ctx.status = 400 + ctx.body = { error: 'Username must be at least 2 characters' } + return + } + if (password !== undefined && password.length > 0 && password.length < 6) { + ctx.status = 400 + ctx.body = { error: 'Password must be at least 6 characters' } + return + } + if (body.role != null && !role || body.status != null && !status) { + ctx.status = 400 + ctx.body = { error: 'Invalid role or status' } + return + } + if (username && username !== user.username) { + const existing = findUserByUsername(username) + if (existing && existing.id !== user.id) { + ctx.status = 409 + ctx.body = { error: 'Username already exists' } + return + } + } + + const nextRole = role || user.role + const nextStatus = status || user.status + const currentUserId = ctx.state.user?.id + if (user.id === currentUserId && nextStatus !== 'active') { + ctx.status = 400 + ctx.body = { error: 'You cannot disable your own account' } + return + } + if (user.role === 'super_admin' && user.status === 'active' && (nextRole !== 'super_admin' || nextStatus !== 'active') && countActiveSuperAdmins(user.id) === 0) { + ctx.status = 400 + ctx.body = { error: 'At least one active super administrator is required' } + return + } + + if (profiles) { + const missingProfile = validateProfiles(profiles) + if (missingProfile) { + ctx.status = 400 + ctx.body = { error: `Profile "${missingProfile}" does not exist` } + return + } + } + + updateUser({ + userId: user.id, + username, + password: password || undefined, + role: role || undefined, + status: status || undefined, + profiles: nextRole === 'super_admin' ? [] : profiles, + defaultProfile: body.defaultProfile, + }) + ctx.body = { user: findUserById(user.id), users: listUsers() } +} + +/** + * DELETE /api/auth/users/:id + * Delete a user account. Super admin only. + */ +export async function deleteManagedUser(ctx: Context) { + const id = Number(ctx.params.id) + const user = Number.isInteger(id) ? findUserById(id) : null + if (!user) { + ctx.status = 404 + ctx.body = { error: 'User not found' } + return + } + + if (ctx.state.user?.id === user.id) { + ctx.status = 400 + ctx.body = { error: 'You cannot delete your own account' } + return + } + if (user.role === 'super_admin' && user.status === 'active' && countActiveSuperAdmins(user.id) === 0) { + ctx.status = 400 + ctx.body = { error: 'At least one active super administrator is required' } + return + } + + deleteUser(user.id) + ctx.body = { success: true, users: listUsers() } } /** diff --git a/packages/server/src/controllers/hermes/kanban.ts b/packages/server/src/controllers/hermes/kanban.ts index 5edff0b..fede9ab 100644 --- a/packages/server/src/controllers/hermes/kanban.ts +++ b/packages/server/src/controllers/hermes/kanban.ts @@ -10,6 +10,85 @@ import { getExactSessionDetailFromDbWithProfile, findLatestExactSessionIdWithProfile, } from '../../db/hermes/sessions-db' +import { listUserProfiles } from '../../db/hermes/users-store' + +const DEFAULT_PROFILE = 'default' + +function profileName(value: string | null | undefined): string { + return value?.trim() || DEFAULT_PROFILE +} + +function requestedProfile(ctx: Context): string | null { + return ctx.state?.profile?.name || null +} + +function allowedProfileSet(ctx: Context): Set | null { + const user = ctx.state?.user + if (!user || user.role === 'super_admin') return null + return new Set(listUserProfiles(user.id).map(profile => profile.profile_name)) +} + +function visibleProfileSet(ctx: Context): Set | null { + const profile = requestedProfile(ctx) + if (profile) return new Set([profile]) + return allowedProfileSet(ctx) +} + +function canUseProfile(ctx: Context, profile: string | null | undefined): boolean { + const allowed = allowedProfileSet(ctx) + return !allowed || allowed.has(profileName(profile)) +} + +function denyProfileAccess(ctx: Context, profile: string | null | undefined): boolean { + if (canUseProfile(ctx, profile)) return false + ctx.status = 403 + ctx.body = { error: `Profile "${profileName(profile)}" is not available for this user` } + return true +} + +function taskAssigneeProfile(task: { assignee: string | null }): string { + return profileName(task.assignee) +} + +function filterTasksByVisibleProfiles(ctx: Context, tasks: kanbanCli.KanbanTask[]): kanbanCli.KanbanTask[] { + const visible = visibleProfileSet(ctx) + if (!visible) return tasks + return tasks.filter(task => visible.has(taskAssigneeProfile(task))) +} + +function statsForTasks(tasks: kanbanCli.KanbanTask[]): kanbanCli.KanbanStats { + const by_status: Record = {} + const by_assignee: Record = {} + for (const task of tasks) { + by_status[task.status] = (by_status[task.status] || 0) + 1 + const assignee = taskAssigneeProfile(task) + by_assignee[assignee] = (by_assignee[assignee] || 0) + 1 + } + return { by_status, by_assignee, total: tasks.length } +} + +function filterAssigneesByVisibleProfiles(ctx: Context, assignees: kanbanCli.KanbanAssignee[]): kanbanCli.KanbanAssignee[] { + const visible = visibleProfileSet(ctx) + if (!visible) return assignees + return assignees.filter(assignee => visible.has(profileName(assignee.name))) +} + +async function getVisibleTasksForBoard(ctx: Context, board: string, opts: { + status?: string + assignee?: string + tenant?: string + includeArchived?: boolean +} = {}): Promise { + if (opts.assignee && denyProfileAccess(ctx, opts.assignee)) return [] + const tasks = await kanbanCli.listTasks({ + board, + status: opts.status, + assignee: opts.assignee, + tenant: opts.tenant, + includeArchived: opts.includeArchived, + }) + return filterTasksByVisibleProfiles(ctx, tasks) +} function getLatestRunProfile(detail: { runs: Array<{ profile: string | null }> }): string | null { return [...detail.runs].reverse().find(run => run.profile)?.profile || null @@ -211,7 +290,8 @@ export async function list(ctx: Context) { const board = requestBoard(ctx) if (!board) return try { - const tasks = await kanbanCli.listTasks({ board, status, assignee, tenant, includeArchived }) + const tasks = await getVisibleTasksForBoard(ctx, board, { status, assignee, tenant, includeArchived }) + if (ctx.status === 403) return ctx.body = { tasks } } catch (err: any) { ctx.status = 500 @@ -229,6 +309,11 @@ export async function get(ctx: Context) { ctx.body = { error: 'Task not found' } return } + if (!filterTasksByVisibleProfiles(ctx, [detail.task]).length) { + ctx.status = 404 + ctx.body = { error: 'Task not found' } + return + } // For terminal tasks, find related session from the worker's profile DB. // Archived tasks can still carry the worker result/session users need to inspect. @@ -291,10 +376,12 @@ export async function create(ctx: Context) { const priority = optionalInteger(payload.priority, 'priority') const tenant = optionalString(payload.tenant, 'tenant') if (rejectBadRequest(ctx, title.error || body.error || assignee.error || priority.error || tenant.error)) return + const targetAssignee = assignee.value || requestedProfile(ctx) || undefined + if (targetAssignee && denyProfileAccess(ctx, targetAssignee)) return const board = requestBoard(ctx) if (!board) return try { - const task = await kanbanCli.createTask(title.value!, { board, body: body.value, assignee: assignee.value, priority: priority.value, tenant: tenant.value }) + const task = await kanbanCli.createTask(title.value!, { board, body: body.value, assignee: targetAssignee, priority: priority.value, tenant: tenant.value }) ctx.body = { task } } catch (err: any) { ctx.status = 500 @@ -357,6 +444,7 @@ export async function assign(ctx: Context) { if (rejectBadRequest(ctx, bodyResult.error)) return const profile = requiredNonEmptyString(bodyResult.body.profile, 'profile') if (rejectBadRequest(ctx, profile.error)) return + if (denyProfileAccess(ctx, profile.value)) return const board = requestBoard(ctx) if (!board) return try { @@ -426,6 +514,7 @@ export async function bulkUpdateTasks(ctx: Context) { const summary = optionalString(body.summary, 'summary') const reason = optionalString(body.reason, 'reason') if (rejectBadRequest(ctx, ids.error || status.error || assignee.error || archive.error || summary.error || reason.error)) return + if (assignee.value && denyProfileAccess(ctx, assignee.value)) return if (!archive.value && status.value === undefined && !hasOwn(body, 'assignee')) { ctx.status = 400 ctx.body = { error: 'at least one bulk action is required' } @@ -516,6 +605,7 @@ export async function reassign(ctx: Context) { const reclaim = optionalBoolean(body.reclaim, 'reclaim') const reason = optionalString(body.reason, 'reason') if (rejectBadRequest(ctx, profile.error || reclaim.error || reason.error)) return + if (denyProfileAccess(ctx, profile.value)) return const board = requestBoard(ctx) if (!board) return try { @@ -566,7 +656,10 @@ export async function stats(ctx: Context) { const board = requestBoard(ctx) if (!board) return try { - const stats = await kanbanCli.getStats({ board }) + const visible = visibleProfileSet(ctx) + const stats = visible + ? statsForTasks(await getVisibleTasksForBoard(ctx, board, { includeArchived: true })) + : await kanbanCli.getStats({ board }) ctx.body = { stats } } catch (err: any) { ctx.status = 500 @@ -578,7 +671,7 @@ export async function assignees(ctx: Context) { const board = requestBoard(ctx) if (!board) return try { - const assignees = await kanbanCli.getAssignees({ board }) + const assignees = filterAssigneesByVisibleProfiles(ctx, await kanbanCli.getAssignees({ board })) ctx.body = { assignees } } catch (err: any) { ctx.status = 500 @@ -628,6 +721,7 @@ export async function searchSessions(ctx: Context) { ctx.body = { error: 'task_id and profile are required' } return } + if (denyProfileAccess(ctx, profile)) return try { if (!q) { const exactSessionId = await findLatestExactSessionIdWithProfile(task_id, profile) diff --git a/packages/server/src/controllers/hermes/models.ts b/packages/server/src/controllers/hermes/models.ts index 65b49f5..070d15f 100644 --- a/packages/server/src/controllers/hermes/models.ts +++ b/packages/server/src/controllers/hermes/models.ts @@ -8,6 +8,7 @@ import { getCopilotModelsDetailed, resolveCopilotOAuthToken, type CopilotModelMe import { readAppConfig, writeAppConfig, type ModelVisibilityRule } from '../../services/app-config' import { getDb } from '../../db' import { MODEL_CONTEXT_TABLE } from '../../db/hermes/schemas' +import { listUserProfiles } from '../../db/hermes/users-store' const PROVIDER_MODEL_CATALOG = buildProviderModelMap() @@ -194,6 +195,19 @@ function mergeAvailableGroups(groups: AvailableGroup[]): AvailableGroup[] { type ProviderFetchCache = Map> +function requestedProfileName(ctx: any): string { + const queryProfile = ctx.query?.profile + return typeof queryProfile === 'string' && queryProfile.trim() ? queryProfile.trim() : '' +} + +function visibleProfileNamesForUser(ctx: any): string[] { + const diskProfiles = listProfileNamesFromDisk() + const user = ctx.state?.user + if (!user || user.role === 'super_admin') return diskProfiles + const allowed = new Set(listUserProfiles(user.id).map(profile => profile.profile_name)) + return diskProfiles.filter(profile => allowed.has(profile)) +} + function cachedProviderModels( cache: ProviderFetchCache, baseUrl: string, @@ -379,17 +393,16 @@ async function buildAvailableForProfile( export async function getAvailable(ctx: any) { try { - const requestedProfile = typeof ctx.query.profile === 'string' && ctx.query.profile.trim() - ? ctx.query.profile.trim() - : '' + const requestedProfile = requestedProfileName(ctx) if (!requestedProfile) { const appConfig = await readAppConfig() const modelAliases = normalizeAliases(appConfig.modelAliases) const modelVisibility = normalizeModelVisibility(appConfig.modelVisibility) const customModels = normalizeCustomModels(appConfig.customModels) const fetchCache: ProviderFetchCache = new Map() + const visibleProfiles = visibleProfileNamesForUser(ctx) const profileResults = await Promise.all( - listProfileNamesFromDisk().map(profile => buildAvailableForProfile(profile, fetchCache, appConfig)), + visibleProfiles.map(profile => buildAvailableForProfile(profile, fetchCache, appConfig)), ) const mergedGroups = mergeAvailableGroups(profileResults.flatMap(result => result.groups)) const groupsWithAliases = applyModelAliases(mergedGroups, modelAliases) diff --git a/packages/server/src/controllers/hermes/profiles.ts b/packages/server/src/controllers/hermes/profiles.ts index 5657d36..70009db 100644 --- a/packages/server/src/controllers/hermes/profiles.ts +++ b/packages/server/src/controllers/hermes/profiles.ts @@ -16,6 +16,7 @@ import { detectHermesRootHome } from '../../services/hermes/hermes-path' import { getActiveProfileName } from '../../services/hermes/hermes-profile' import { HermesSkillInjector } from '../../services/hermes/skill-injector' import type { HermesProfile } from '../../services/hermes/hermes-cli' +import { listUserProfiles } from '../../db/hermes/users-store' const bridgeCleanupClient = () => new AgentBridgeClient({ connectRetryMs: 0, timeoutMs: 5000 }) @@ -127,6 +128,30 @@ function filterVisibleProfiles(profiles: HermesProfile[]): HermesProfile[] { return profiles.filter(profile => !isForbiddenProfileName(profile.name)) } +function requestedProfileName(ctx: any): string { + return ctx.state?.profile?.name || ctx.get?.('x-hermes-profile') || getActiveProfileName() +} + +function filterProfilesForUser(ctx: any, profiles: HermesProfile[]): HermesProfile[] { + const user = ctx.state?.user + if (!user || user.role === 'super_admin') return profiles + const allowed = new Set(listUserProfiles(user.id).map(profile => profile.profile_name)) + return profiles.filter(profile => allowed.has(profile.name)) +} + +function canAccessProfile(ctx: any, profileName: string): boolean { + const user = ctx.state?.user + if (!user || user.role === 'super_admin') return true + return listUserProfiles(user.id).some(profile => profile.profile_name === profileName) +} + +function denyProfile(ctx: any, profileName: string): boolean { + if (canAccessProfile(ctx, profileName)) return false + ctx.status = 403 + ctx.body = { error: `Profile "${profileName}" is not available for this user` } + return true +} + function profileMetadataRoot(): string { return join(getWebUiHome(), 'profile-metadata') } @@ -299,21 +324,12 @@ export async function list(ctx: any) { profiles = listProfilesFromDisk('default') } - // Override active flag from the authoritative source (active_profile file) - // CLI output may be stale, but the file is written by hermes profile use - const { getActiveProfileName } = await import('../../services/hermes/hermes-profile') - const activeProfileName = getActiveProfileName() + const activeProfileName = requestedProfileName(ctx) profiles = filterVisibleProfiles(profiles) + profiles = filterProfilesForUser(ctx, profiles) - // Check if CLI's active flag matches the file (warn if inconsistent) - const cliActive = profiles.find(p => p.active) - if (cliActive?.name !== activeProfileName) { - logger.warn('[listProfiles] CLI active flag (%s) differs from active_profile file (%s) - using file as authoritative source', - cliActive?.name || 'none', activeProfileName) - } - - // Fix the active flag based on the actual active_profile file + // Web UI active profile is request-scoped and comes from X-Hermes-Profile. profiles.forEach(p => { p.active = (p.name === activeProfileName) }) @@ -388,8 +404,10 @@ export async function create(ctx: any) { } export async function get(ctx: any) { + const name = String(ctx.params.name || '').trim() || 'default' + if (denyProfile(ctx, name)) return try { - const profile = await hermesCli.getProfile(ctx.params.name) + const profile = await hermesCli.getProfile(name) ctx.body = { profile: { ...profile, avatar: readProfileAvatar(profile.name) } } } catch (err: any) { ctx.status = err.message.includes('not found') ? 404 : 500 @@ -399,6 +417,7 @@ export async function get(ctx: any) { export async function updateAvatar(ctx: any) { const name = String(ctx.params.name || '').trim() || 'default' + if (denyProfile(ctx, name)) return if (isForbiddenProfileName(name)) { ctx.status = 400 ctx.body = { error: `Profile name '${name}' is reserved` } @@ -438,6 +457,7 @@ export async function updateAvatar(ctx: any) { export async function deleteAvatar(ctx: any) { const name = String(ctx.params.name || '').trim() || 'default' + if (denyProfile(ctx, name)) return try { removeProfileMetadata(name) ctx.body = { success: true } @@ -449,6 +469,7 @@ export async function deleteAvatar(ctx: any) { export async function runtimeStatus(ctx: any) { const name = String(ctx.params.name || '').trim() || 'default' + if (denyProfile(ctx, name)) return if (isForbiddenProfileName(name)) { ctx.status = 400 ctx.body = { error: `Profile name '${name}' is reserved` } @@ -465,7 +486,7 @@ export async function runtimeStatus(ctx: any) { export async function runtimeStatuses(ctx: any) { try { - const profiles = await listProfilesForStatus() + const profiles = filterProfilesForUser(ctx, await listProfilesForStatus()) const bridge = await readBridgeWorkers() const statuses = await Promise.all(profiles.map(profile => buildRuntimeStatus(profile, bridge))) ctx.body = { profiles: statuses } @@ -487,6 +508,7 @@ async function listProfilesForStatus(): Promise { export async function restartGatewayForProfile(ctx: any) { const name = String(ctx.params.name || '').trim() || 'default' + if (denyProfile(ctx, name)) return if (isForbiddenProfileName(name)) { ctx.status = 400 ctx.body = { error: `Profile name '${name}' is reserved` } @@ -509,6 +531,7 @@ export async function restartGatewayForProfile(ctx: any) { export async function restartProfileRuntime(ctx: any) { const name = String(ctx.params.name || '').trim() || 'default' + if (denyProfile(ctx, name)) return if (isForbiddenProfileName(name)) { ctx.status = 400 ctx.body = { error: `Profile name '${name}' is reserved` } @@ -532,6 +555,7 @@ export async function restartProfileRuntime(ctx: any) { export async function remove(ctx: any) { const { name } = ctx.params + if (denyProfile(ctx, name)) return if (name === 'default') { ctx.status = 400 ctx.body = { error: 'Cannot delete the default profile' } @@ -562,6 +586,7 @@ export async function remove(ctx: any) { } export async function rename(ctx: any) { + if (denyProfile(ctx, ctx.params.name)) return const { new_name } = ctx.request.body as { new_name?: string } if (!new_name) { ctx.status = 400 @@ -596,32 +621,20 @@ export async function switchProfile(ctx: any) { return } try { + if (denyProfile(ctx, name)) return + const output = await useProfileWithFallback(name) - // Verify the active_profile file immediately (Hermes CLI writes synchronously) - // Quick verification with 2 retries to handle edge cases (filesystem delays, concurrency) - const { getActiveProfileName } = await import('../../services/hermes/hermes-profile') - let actualActive = getActiveProfileName() - - // Quick retry (max 2 times, 100ms delay each) - for (let i = 0; i < 2; i++) { - if (actualActive === name) break - logger.debug('[switchProfile] Quick retry %d: current=%s, expected=%s', i + 1, actualActive, name) - await new Promise(r => setTimeout(r, 100)) - actualActive = getActiveProfileName() - } - + const actualActive = getActiveProfileName() if (actualActive !== name) { - logger.error('[switchProfile] Verification failed: active_profile is %s (expected %s)', actualActive, name) ctx.status = 500 ctx.body = { error: `Profile switch verification failed - active profile is ${actualActive}` } return } - // Destroy all bridge sessions so they get recreated with the new profile config try { - await bridgeCleanupClient().destroyAll() - logger.info('[switchProfile] destroyed all bridge sessions for profile "%s"', name) + const result = await bridgeCleanupClient().destroyAll() + logger.info('[switchProfile] destroyed all bridge sessions for Hermes profile "%s" destroyed=%s', name, result.destroyed) } catch (err: any) { logger.warn(err, '[switchProfile] failed to destroy bridge sessions') } @@ -630,7 +643,6 @@ export async function switchProfile(ctx: any) { const detail = await hermesCli.getProfile(name) logger.debug('Profile detail.path = %s', detail.path) - // 确保配置文件存在,但不调用 setupReset()(会重置端口配置) const profileConfig = join(detail.path, 'config.yaml') if (!existsSync(profileConfig)) { writeFileSync(profileConfig, '# Hermes Agent Configuration\n', 'utf-8') @@ -647,20 +659,13 @@ export async function switchProfile(ctx: any) { } await injectBundledSkillsForProfile(name) - - // TODO: re-enable pending session delete drain after confirming safety - // const drainResult = await SessionDeleter.getInstance().drain(name) SessionDeleter.getInstance().switchProfile(name) - logger.info('[switchProfile] switched session deleter to profile "%s"', name) - // if (drainResult.failed.length > 0) { - // logger.warn({ profile: name, failed: drainResult.failed }, 'Failed to drain some pending session deletes after profile switch') - // } + logger.info('[switchProfile] switched session deleter to Hermes profile "%s"', name) ctx.body = { success: true, message: output.trim(), - // drained_session_deletes: drainResult.deleted.length, - // failed_session_deletes: drainResult.failed.length, + active: name, } } catch (err: any) { ctx.status = 500 @@ -670,6 +675,7 @@ export async function switchProfile(ctx: any) { export async function exportProfile(ctx: any) { const { name } = ctx.params + if (denyProfile(ctx, name)) return const outputPath = join(tmpdir(), `hermes-profile-${name}.tar.gz`) try { await hermesCli.exportProfile(name, outputPath) diff --git a/packages/server/src/controllers/hermes/sessions.ts b/packages/server/src/controllers/hermes/sessions.ts index a07b05d..17759c7 100644 --- a/packages/server/src/controllers/hermes/sessions.ts +++ b/packages/server/src/controllers/hermes/sessions.ts @@ -1,5 +1,5 @@ import * as hermesCli from '../../services/hermes/hermes-cli' -import { listSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb, getExactSessionDetailFromDbWithProfile } from '../../db/hermes/sessions-db' +import { listSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb, getSessionDetailFromDbWithProfile, getExactSessionDetailFromDbWithProfile } from '../../db/hermes/sessions-db' import { listSessions as localListSessions, searchSessions as localSearchSessions, @@ -17,6 +17,7 @@ import { isPathWithin } from '../../services/hermes/hermes-path' import { getGroupChatServer } from '../../routes/hermes/group-chat' import { logger } from '../../services/logger' import type { ConversationSummary } from '../../services/hermes/conversations' +import { listUserProfiles } from '../../db/hermes/users-store' function getPendingDeletedSessionIds(): Set { return getGroupChatServer()?.getStorage().getPendingDeletedSessionIds() || new Set() @@ -32,6 +33,35 @@ function filterPendingDeletedConversationSummaries(items: ConversationSummary[]) return filterPendingDeletedSessions(items) } +function requestedProfile(ctx: any): string | undefined { + const value = ctx.state?.profile?.name || (typeof ctx.query?.profile === 'string' ? ctx.query.profile.trim() : '') + return value || undefined +} + +function allowedProfileSet(ctx: any): Set | null { + const user = ctx.state?.user + if (!user || user.role === 'super_admin') return null + return new Set(listUserProfiles(user.id).map(profile => profile.profile_name)) +} + +function canAccessProfile(ctx: any, profile: string | null | undefined): boolean { + const allowed = allowedProfileSet(ctx) + return !allowed || allowed.has(profile || 'default') +} + +function filterByAllowedProfiles(ctx: any, items: T[]): T[] { + const allowed = allowedProfileSet(ctx) + if (!allowed) return items + return items.filter(item => allowed.has(((item as any).profile as string | null | undefined) || 'default')) +} + +function denySessionAccess(ctx: any, session: any | null | undefined): boolean { + if (!session || canAccessProfile(ctx, session.profile)) return false + ctx.status = 403 + ctx.body = { error: `Profile "${session.profile || 'default'}" is not available for this user` } + return true +} + interface HermesDeleteResult { attempted: boolean deleted: boolean @@ -73,10 +103,11 @@ export async function listConversations(ctx: any) { const source = (ctx.query.source as string) || undefined const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined - const profile = getActiveProfileName() + const profile = requestedProfile(ctx) const sessions = localListSessions(profile, source, limit && limit > 0 ? limit : 200) const summaries: ConversationSummary[] = sessions.map(s => ({ id: s.id, + profile: s.profile || null, source: s.source, model: s.model, provider: s.provider, @@ -100,7 +131,7 @@ export async function listConversations(ctx: any) { is_active: s.ended_at == null && (Date.now() / 1000 - s.last_active) <= 300, thread_session_count: 1, })) - ctx.body = { sessions: filterPendingDeletedConversationSummaries(summaries) } + ctx.body = { sessions: filterPendingDeletedConversationSummaries(filterByAllowedProfiles(ctx, summaries)) } } export async function getConversationMessages(ctx: any) { @@ -112,6 +143,7 @@ export async function getConversationMessages(ctx: any) { ctx.body = { error: 'Conversation not found' } return } + if (denySessionAccess(ctx, detail)) return const messages = detail.messages .filter(m => { if (humanOnly && m.role !== 'user' && m.role !== 'assistant') return false @@ -136,15 +168,13 @@ export async function getConversationMessages(ctx: any) { export async function list(ctx: any) { const source = (ctx.query.source as string) || undefined const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined - const profile = typeof ctx.query.profile === 'string' && ctx.query.profile.trim() - ? ctx.query.profile.trim() - : undefined + const profile = requestedProfile(ctx) const effectiveLimit = limit && limit > 0 ? limit : 2000 const allSessions = localListSessions(profile, source, effectiveLimit) const knownProfiles = profile ? null : new Set(listProfileNamesFromDisk()) ctx.body = { - sessions: filterPendingDeletedSessions(allSessions.filter(s => + sessions: filterPendingDeletedSessions(filterByAllowedProfiles(ctx, allSessions).filter(s => (s.source === 'api_server' || s.source === 'cli') && (!knownProfiles || knownProfiles.has(s.profile || 'default')), )), @@ -158,23 +188,22 @@ export async function list(ctx: any) { export async function listHermesSessions(ctx: any) { const source = (ctx.query.source as string) || undefined const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined - const profile = getActiveProfileName() + const profile = requestedProfile(ctx) const effectiveLimit = limit && limit > 0 ? limit : 2000 - const allSessions = await listSessionSummaries(source, effectiveLimit, profile) - ctx.body = { sessions: filterPendingDeletedSessions(allSessions.filter(s => s.source !== 'api_server')) } + const allSessions = (await listSessionSummaries(source, effectiveLimit, profile)) + .map(session => profile ? { ...session, profile } : session) + ctx.body = { sessions: filterPendingDeletedSessions(filterByAllowedProfiles(ctx, allSessions).filter(s => s.source !== 'api_server')) } } export async function search(ctx: any) { const q = typeof ctx.query.q === 'string' ? ctx.query.q : '' const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined - const profile = typeof ctx.query.profile === 'string' && ctx.query.profile.trim() - ? ctx.query.profile.trim() - : undefined + const profile = requestedProfile(ctx) const results = localSearchSessions(profile, q, limit && limit > 0 ? limit : 20) const knownProfiles = profile ? null : new Set(listProfileNamesFromDisk()) ctx.body = { - results: filterPendingDeletedSessions(results.filter(s => + results: filterPendingDeletedSessions(filterByAllowedProfiles(ctx, results).filter(s => !knownProfiles || knownProfiles.has(s.profile || 'default'), )), } @@ -187,6 +216,7 @@ export async function get(ctx: any) { ctx.body = { error: 'Session not found' } return } + if (denySessionAccess(ctx, session)) return ctx.body = { session } } @@ -195,20 +225,28 @@ export async function get(ctx: any) { * GET /api/hermes/sessions/hermes/:id */ export async function getHermesSession(ctx: any) { + const profile = requestedProfile(ctx) + // Prefer the Web UI local session store. Hermes state.db can lag behind or // miss messages for Bridge-backed runs, while the local store is the source // used by chat rendering and compression. const localSession = localGetSessionDetail(ctx.params.id) - if (localSession && localSession.source !== 'api_server') { + const localSessionProfile = (localSession?.profile || 'default') as string + if (localSession && localSession.source !== 'api_server' && (!profile || localSessionProfile === profile)) { + if (denySessionAccess(ctx, localSession)) return ctx.body = { session: localSession } return } // Try Hermes state.db next (consistent with listHermesSessions) try { - const session = await getSessionDetailFromDb(ctx.params.id) + const session = profile + ? await getSessionDetailFromDbWithProfile(ctx.params.id, profile) + : await getSessionDetailFromDb(ctx.params.id) if (session && session.source !== 'api_server') { - ctx.body = { session } + const sessionWithProfile = profile ? { ...session, profile } : session + if (denySessionAccess(ctx, sessionWithProfile)) return + ctx.body = { session: sessionWithProfile } return } } catch (err) { @@ -228,13 +266,15 @@ export async function getHermesSession(ctx: any) { ctx.body = { error: 'Session not found' } return } + if (denySessionAccess(ctx, session)) return ctx.body = { session } } export async function remove(ctx: any) { const sessionId = ctx.params.id const existing = localGetSession(sessionId) - const hermesProfile = existing?.profile || getActiveProfileName() + if (denySessionAccess(ctx, existing)) return + const hermesProfile = requestedProfile(ctx) || existing?.profile || getActiveProfileName() const hermes = await deleteHermesSessionIfPresent(sessionId, hermesProfile) const localDeleted = existing ? localDeleteSession(sessionId) : true if (!localDeleted) { @@ -272,6 +312,11 @@ export async function batchRemove(ctx: any) { for (const id of validIds) { const existing = localGetSession(id) + if (existing && !canAccessProfile(ctx, existing.profile)) { + results.failed++ + results.errors.push({ id, error: `Profile "${existing.profile || 'default'}" is not available for this user` }) + continue + } const hermes = await deleteHermesSessionIfPresent(id, existing?.profile) if (hermes.deleted) { results.hermesDeleted++ @@ -304,6 +349,8 @@ export async function usageBatch(ctx: any) { } export async function usageSingle(ctx: any) { + const session = localGetSession(ctx.params.id) + if (denySessionAccess(ctx, session)) return const result = getUsage(ctx.params.id) if (!result) { ctx.body = { input_tokens: 0, output_tokens: 0 } @@ -319,6 +366,8 @@ export async function rename(ctx: any) { ctx.body = { error: 'title is required' } return } + const existing = localGetSession(ctx.params.id) + if (denySessionAccess(ctx, existing)) return const ok = localRenameSession(ctx.params.id, title.trim()) if (!ok) { ctx.status = 500 @@ -336,10 +385,11 @@ export async function setWorkspace(ctx: any) { return } const { updateSession, getSession, createSession } = await import('../../db/hermes/session-store') - const { getActiveProfileName } = await import('../../services/hermes/hermes-profile') const id = ctx.params.id - if (!getSession(id)) { - createSession({ id, profile: getActiveProfileName(), title: '' }) + const existing = getSession(id) + if (denySessionAccess(ctx, existing)) return + if (!existing) { + createSession({ id, profile: requestedProfile(ctx) || 'default', title: '' }) } updateSession(id, { workspace: workspace || null } as any) ctx.body = { ok: true } @@ -358,17 +408,18 @@ export async function setModel(ctx: any) { return } const { updateSession, getSession, createSession } = await import('../../db/hermes/session-store') - const { getActiveProfileName } = await import('../../services/hermes/hermes-profile') const id = ctx.params.id - if (!getSession(id)) { - createSession({ id, profile: getActiveProfileName(), title: '' }) + const existing = getSession(id) + if (denySessionAccess(ctx, existing)) return + if (!existing) { + createSession({ id, profile: requestedProfile(ctx) || 'default', title: '' }) } updateSession(id, { model: model.trim(), provider: (provider || '').trim() } as any) ctx.body = { ok: true } } export async function contextLength(ctx: any) { - const profile = (ctx.query.profile as string) || undefined + const profile = requestedProfile(ctx) const model = typeof ctx.query.model === 'string' ? ctx.query.model : undefined const provider = typeof ctx.query.provider === 'string' ? ctx.query.provider : undefined ctx.body = { context_length: getModelContextLength({ profile, model, provider }) } @@ -484,6 +535,7 @@ export async function exportSession(ctx: any) { ctx.body = { error: 'Session not found' } return } + if (denySessionAccess(ctx, session)) return const mode = (ctx.query.mode as string) || 'full' const ext = (ctx.query.ext as string) || (mode === 'compressed' ? 'txt' : 'json') @@ -560,6 +612,7 @@ export async function getConversationMessagesPaginated(ctx: any) { ctx.body = { error: 'Conversation not found' } return } + if (denySessionAccess(ctx, result.session)) return ctx.body = { session: { diff --git a/packages/server/src/db/hermes/schemas.ts b/packages/server/src/db/hermes/schemas.ts index d390318..87b46f3 100644 --- a/packages/server/src/db/hermes/schemas.ts +++ b/packages/server/src/db/hermes/schemas.ts @@ -104,6 +104,38 @@ export const MODEL_CONTEXT_SCHEMA: Record = { export const MODEL_CONTEXT_INDEX = 'CREATE UNIQUE INDEX IF NOT EXISTS idx_model_context_provider_model ON model_context(provider, model)' +// ============================================================================ +// Users and Profile Access +// ============================================================================ + +export const USERS_TABLE = 'users' + +export const USERS_SCHEMA: Record = { + id: 'INTEGER PRIMARY KEY AUTOINCREMENT', + username: 'TEXT NOT NULL UNIQUE', + password_hash: 'TEXT NOT NULL', + role: "TEXT NOT NULL DEFAULT 'admin'", + status: "TEXT NOT NULL DEFAULT 'active'", + created_at: 'INTEGER NOT NULL', + updated_at: 'INTEGER NOT NULL', + last_login_at: 'INTEGER', +} + +export const USER_PROFILES_TABLE = 'user_profiles' + +export const USER_PROFILES_SCHEMA: Record = { + user_id: 'INTEGER NOT NULL', + profile_name: "TEXT NOT NULL DEFAULT 'default'", + is_default: 'INTEGER NOT NULL DEFAULT 0', + created_at: 'INTEGER NOT NULL', +} + +export const USER_PROFILES_INDEXES = { + idx_user_profiles_user: 'CREATE INDEX IF NOT EXISTS idx_user_profiles_user ON user_profiles(user_id)', + idx_user_profiles_profile: 'CREATE INDEX IF NOT EXISTS idx_user_profiles_profile ON user_profiles(profile_name)', + idx_user_profiles_default: 'CREATE UNIQUE INDEX IF NOT EXISTS idx_user_profiles_default ON user_profiles(user_id) WHERE is_default = 1', +} + // ============================================================================ // Group Chat (services/hermes/group-chat/index.ts) // ============================================================================ @@ -329,6 +361,13 @@ export function initAllHermesTables(): void { } }) + // Users and profile access + syncTable(USERS_TABLE, USERS_SCHEMA) + syncTable(USER_PROFILES_TABLE, USER_PROFILES_SCHEMA, { + primaryKey: 'user_id, profile_name', + indexes: USER_PROFILES_INDEXES, + }) + // Group chat - basic tables syncTable(GC_ROOMS_TABLE, GC_ROOMS_SCHEMA) syncTable(GC_MESSAGES_TABLE, GC_MESSAGES_SCHEMA) diff --git a/packages/server/src/db/hermes/users-store.ts b/packages/server/src/db/hermes/users-store.ts new file mode 100644 index 0000000..705e21b --- /dev/null +++ b/packages/server/src/db/hermes/users-store.ts @@ -0,0 +1,300 @@ +import { randomBytes, scryptSync, timingSafeEqual } from 'crypto' +import { getDb } from '../index' +import { USER_PROFILES_TABLE, USERS_TABLE } from './schemas' + +export type UserRole = 'super_admin' | 'admin' +export type UserStatus = 'active' | 'disabled' +export type UserId = number | string + +export interface UserRecord { + id: number + username: string + password_hash: string + role: UserRole + status: UserStatus + created_at: number + updated_at: number + last_login_at: number | null +} + +export interface UserProfileRecord { + user_id: number + profile_name: string + is_default: number + created_at: number +} + +export interface UserSummary { + id: number + username: string + role: UserRole + status: UserStatus + profiles: string[] + default_profile: string | null + created_at: number + updated_at: number + last_login_at: number | null +} + +export const DEFAULT_USERNAME = 'admin' +export const DEFAULT_PASSWORD = '123456' +export const DEFAULT_PROFILE_NAME = 'default' + +const SCRYPT_KEY_LEN = 64 + +function normalizeUserId(id: UserId): number | null { + const userId = typeof id === 'number' ? id : Number(id) + return Number.isInteger(userId) && userId > 0 ? userId : null +} + +export function hashPassword(password: string): string { + const salt = randomBytes(16).toString('hex') + const hash = scryptSync(password, salt, SCRYPT_KEY_LEN).toString('hex') + return `scrypt:${salt}:${hash}` +} + +export function verifyPassword(password: string, passwordHash: string): boolean { + const [scheme, salt, expectedHex] = passwordHash.split(':') + if (scheme !== 'scrypt' || !salt || !expectedHex) return false + try { + const expected = Buffer.from(expectedHex, 'hex') + const actual = scryptSync(password, salt, expected.length) + return actual.length === expected.length && timingSafeEqual(actual, expected) + } catch { + return false + } +} + +export function findUserById(id: UserId): UserRecord | null { + const db = getDb() + if (!db) return null + const userId = normalizeUserId(id) + if (!userId) return null + const row = db.prepare(`SELECT * FROM ${USERS_TABLE} WHERE id = ?`).get(userId) as UserRecord | undefined + return row || null +} + +export function findUserByUsername(username: string): UserRecord | null { + const db = getDb() + if (!db) return null + const row = db.prepare(`SELECT * FROM ${USERS_TABLE} WHERE username = ?`).get(username) as UserRecord | undefined + return row || null +} + +export function findFirstUser(): UserRecord | null { + const db = getDb() + if (!db) return null + const row = db.prepare(`SELECT * FROM ${USERS_TABLE} ORDER BY id ASC LIMIT 1`).get() as UserRecord | undefined + return row || null +} + +export function listUsers(): UserSummary[] { + const db = getDb() + if (!db) return [] + const users = db.prepare( + `SELECT id, username, role, status, created_at, updated_at, last_login_at FROM ${USERS_TABLE} ORDER BY id ASC` + ).all() as Array> + return users.map(user => { + const profiles = listUserProfiles(user.id) + return { + ...user, + profiles: profiles.map(profile => profile.profile_name), + default_profile: profiles.find(profile => profile.is_default === 1)?.profile_name || null, + } + }) +} + +export function listUserProfiles(userId: UserId): UserProfileRecord[] { + const db = getDb() + if (!db) return [] + const id = normalizeUserId(userId) + if (!id) return [] + return db.prepare( + `SELECT * FROM ${USER_PROFILES_TABLE} WHERE user_id = ? ORDER BY is_default DESC, profile_name ASC` + ).all(id) as unknown as UserProfileRecord[] +} + +export function userCanAccessProfile(userId: UserId, profileName: string): boolean { + const db = getDb() + if (!db) return false + const id = normalizeUserId(userId) + if (!id) return false + const row = db.prepare( + `SELECT 1 FROM ${USER_PROFILES_TABLE} WHERE user_id = ? AND profile_name = ?` + ).get(id, profileName) + return !!row +} + +export function getDefaultProfileForUser(userId: UserId): string { + const db = getDb() + if (!db) return DEFAULT_PROFILE_NAME + const id = normalizeUserId(userId) + if (!id) return DEFAULT_PROFILE_NAME + const row = db.prepare( + `SELECT profile_name FROM ${USER_PROFILES_TABLE} WHERE user_id = ? AND is_default = 1 LIMIT 1` + ).get(id) as { profile_name?: string } | undefined + return row?.profile_name || DEFAULT_PROFILE_NAME +} + +export function countUsers(): number { + const db = getDb() + if (!db) return 0 + const row = db.prepare(`SELECT COUNT(*) as count FROM ${USERS_TABLE}`).get() as { count?: number } | undefined + return Number(row?.count || 0) +} + +export function countActiveSuperAdmins(excludeUserId?: UserId): number { + const db = getDb() + if (!db) return 0 + const exclude = excludeUserId == null ? null : normalizeUserId(excludeUserId) + const row = exclude + ? db.prepare(`SELECT COUNT(*) as count FROM ${USERS_TABLE} WHERE role = 'super_admin' AND status = 'active' AND id != ?`).get(exclude) + : db.prepare(`SELECT COUNT(*) as count FROM ${USERS_TABLE} WHERE role = 'super_admin' AND status = 'active'`).get() + return Number((row as { count?: number } | undefined)?.count || 0) +} + +export function touchUserLogin(userId: UserId, at = Date.now()): void { + const db = getDb() + if (!db) return + const id = normalizeUserId(userId) + if (!id) return + db.prepare(`UPDATE ${USERS_TABLE} SET last_login_at = ?, updated_at = ? WHERE id = ?`).run(at, at, id) +} + +export function updateUserPassword(userId: UserId, password: string): boolean { + const db = getDb() + if (!db) return false + const id = normalizeUserId(userId) + if (!id) return false + const result = db.prepare(`UPDATE ${USERS_TABLE} SET password_hash = ?, updated_at = ? WHERE id = ?`) + .run(hashPassword(password), Date.now(), id) + return result.changes > 0 +} + +export function updateUsername(userId: UserId, username: string): boolean { + const db = getDb() + if (!db) return false + const id = normalizeUserId(userId) + if (!id) return false + const result = db.prepare(`UPDATE ${USERS_TABLE} SET username = ?, updated_at = ? WHERE id = ?`) + .run(username, Date.now(), id) + return result.changes > 0 +} + +export function createUser(input: { + username: string + password: string + role?: UserRole + status?: UserStatus + profiles?: string[] + defaultProfile?: string | null +}): UserRecord | null { + const db = getDb() + if (!db) return null + const now = Date.now() + const role = input.role || 'admin' + const status = input.status || 'active' + db.prepare( + `INSERT INTO ${USERS_TABLE} (username, password_hash, role, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(input.username, hashPassword(input.password), role, status, now, now) + + const user = findUserByUsername(input.username) + if (user) replaceUserProfiles(user.id, input.profiles || [], input.defaultProfile) + return user +} + +export function updateUser(input: { + userId: UserId + username?: string + role?: UserRole + status?: UserStatus + password?: string + profiles?: string[] + defaultProfile?: string | null +}): UserRecord | null { + const db = getDb() + if (!db) return null + const id = normalizeUserId(input.userId) + if (!id) return null + + const current = findUserById(id) + if (!current) return null + + const nextUsername = input.username ?? current.username + const nextRole = input.role ?? current.role + const nextStatus = input.status ?? current.status + const nextPasswordHash = input.password ? hashPassword(input.password) : current.password_hash + const now = Date.now() + + db.prepare( + `UPDATE ${USERS_TABLE} + SET username = ?, password_hash = ?, role = ?, status = ?, updated_at = ? + WHERE id = ?` + ).run(nextUsername, nextPasswordHash, nextRole, nextStatus, now, id) + + if (input.profiles) replaceUserProfiles(id, input.profiles, input.defaultProfile) + return findUserById(id) +} + +export function deleteUser(userId: UserId): boolean { + const db = getDb() + if (!db) return false + const id = normalizeUserId(userId) + if (!id) return false + db.exec('BEGIN') + try { + db.prepare(`DELETE FROM ${USER_PROFILES_TABLE} WHERE user_id = ?`).run(id) + const result = db.prepare(`DELETE FROM ${USERS_TABLE} WHERE id = ?`).run(id) + db.exec('COMMIT') + return result.changes > 0 + } catch (err) { + db.exec('ROLLBACK') + throw err + } +} + +export function replaceUserProfiles(userId: UserId, profiles: string[], defaultProfile?: string | null): void { + const db = getDb() + if (!db) return + const id = normalizeUserId(userId) + if (!id) return + + const uniqueProfiles = [...new Set(profiles.map(profile => profile.trim()).filter(Boolean))] + const defaultName = defaultProfile && uniqueProfiles.includes(defaultProfile) ? defaultProfile : uniqueProfiles[0] || null + const now = Date.now() + + db.exec('BEGIN') + try { + db.prepare(`DELETE FROM ${USER_PROFILES_TABLE} WHERE user_id = ?`).run(id) + const stmt = db.prepare( + `INSERT INTO ${USER_PROFILES_TABLE} (user_id, profile_name, is_default, created_at) VALUES (?, ?, ?, ?)` + ) + uniqueProfiles.forEach(profile => { + stmt.run(id, profile, profile === defaultName ? 1 : 0, now) + }) + db.exec('COMMIT') + } catch (err) { + db.exec('ROLLBACK') + throw err + } +} + +export function createDefaultSuperAdmin(): UserRecord | null { + const db = getDb() + if (!db) return null + + const now = Date.now() + db.prepare( + `INSERT INTO ${USERS_TABLE} (username, password_hash, role, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(DEFAULT_USERNAME, hashPassword(DEFAULT_PASSWORD), 'super_admin', 'active', now, now) + + return findUserByUsername(DEFAULT_USERNAME) +} + +export function bootstrapDefaultSuperAdmin(username: string, password: string): UserRecord | null { + if (countUsers() > 0) return null + if (username !== DEFAULT_USERNAME || password !== DEFAULT_PASSWORD) return null + return createDefaultSuperAdmin() +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index bb985e1..ea076ce 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -8,7 +8,6 @@ import { resolve } from 'path' import { mkdir } from 'fs/promises' import { readFileSync } from 'fs' import { config } from './config' -import { getToken, requireAuth } from './services/auth' import { initLoginLimiter } from './services/login-limiter' import { bindShutdown } from './services/shutdown' import { setupTerminalWebSocket } from './routes/hermes/terminal' @@ -23,6 +22,7 @@ import { startAgentBridgeManager } from './services/hermes/agent-bridge' import { HermesSkillInjector } from './services/hermes/skill-injector' import { ensureProfileGatewaysRunning } from './services/hermes/gateway-autostart' import { logger } from './services/logger' +import { requireUserJwt, resolveUserProfile } from './middleware/user-auth' // Injected by esbuild at build time; fallback to reading package.json in dev mode declare const __APP_VERSION__: string @@ -86,7 +86,6 @@ export async function bootstrap() { await mkdir(config.uploadDir, { recursive: true }) await mkdir(config.dataDir, { recursive: true }) - const authToken = await getToken() await initLoginLimiter() try { const skillInjector = new HermesSkillInjector() @@ -138,15 +137,10 @@ export async function bootstrap() { console.log('[bootstrap] cors + bodyParser registered') // Register all routes (handles auth internally) - const proxyMiddleware = registerRoutes(app, requireAuth(authToken)) + const proxyMiddleware = registerRoutes(app, [requireUserJwt, resolveUserProfile]) app.use(proxyMiddleware) console.log('[bootstrap] routes registered') - if (authToken) { - console.log(`Auth enabled — token: ${authToken}`) - logger.info('Auth enabled — token: %s', authToken) - } - // SPA fallback const distDir = resolve(__dirname, '..', 'client') app.use(serve(distDir)) diff --git a/packages/server/src/middleware/user-auth.ts b/packages/server/src/middleware/user-auth.ts new file mode 100644 index 0000000..f64b70b --- /dev/null +++ b/packages/server/src/middleware/user-auth.ts @@ -0,0 +1,215 @@ +import type { Context, Next } from 'koa' +import { createHmac, timingSafeEqual } from 'crypto' +import { getToken } from '../services/auth' +import { + findUserById, + touchUserLogin, + userCanAccessProfile, + type UserRecord, + type UserRole, +} from '../db/hermes/users-store' + +export interface AuthenticatedUser { + id: number + username: string + role: UserRole +} + +export interface RequestProfile { + name: string +} + +interface JwtPayload { + sub: string + username: string + role: UserRole + type: 'access' + aud: 'hermes-web-ui' + iat: number + exp: number +} + +declare module 'koa' { + interface DefaultState { + user?: AuthenticatedUser + profile?: RequestProfile + } +} + +const JWT_AUDIENCE = 'hermes-web-ui' +const DEFAULT_EXPIRES_SECONDS = 60 * 60 * 24 * 30 + +function base64UrlJson(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString('base64url') +} + +function sign(input: string, secret: string): string { + return createHmac('sha256', secret).update(input).digest('base64url') +} + +function safeEqual(a: string, b: string): boolean { + try { + const left = Buffer.from(a) + const right = Buffer.from(b) + return left.length === right.length && timingSafeEqual(left, right) + } catch { + return false + } +} + +async function getJwtSecret(): Promise { + return process.env.AUTH_JWT_SECRET || await getToken() +} + +function requestToken(ctx: Context): string { + const auth = ctx.headers.authorization || '' + if (typeof auth === 'string' && auth.startsWith('Bearer ')) return auth.slice(7).trim() + return typeof ctx.query.token === 'string' ? ctx.query.token.trim() : '' +} + +export function signUserJwt(user: Pick, secret: string, now = Date.now()): string { + const iat = Math.floor(now / 1000) + const payload: JwtPayload = { + sub: String(user.id), + username: user.username, + role: user.role, + type: 'access', + aud: JWT_AUDIENCE, + iat, + exp: iat + DEFAULT_EXPIRES_SECONDS, + } + const header = base64UrlJson({ alg: 'HS256', typ: 'JWT' }) + const body = base64UrlJson(payload) + const unsigned = `${header}.${body}` + return `${unsigned}.${sign(unsigned, secret)}` +} + +export function verifyUserJwt(token: string, secret: string, now = Date.now()): JwtPayload | null { + const parts = token.split('.') + if (parts.length !== 3) return null + + const [header, body, signature] = parts + const expected = sign(`${header}.${body}`, secret) + if (!safeEqual(signature, expected)) return null + + try { + const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf-8')) as Partial + if (payload.type !== 'access' || payload.aud !== JWT_AUDIENCE) return null + if (!payload.sub || !payload.username || !payload.role || !payload.exp) return null + if (Math.floor(now / 1000) >= payload.exp) return null + return payload as JwtPayload + } catch { + return null + } +} + +export async function issueUserJwt(user: Pick): Promise { + const secret = await getJwtSecret() + if (!secret) throw new Error('Auth is disabled on this server') + return signUserJwt(user, secret) +} + +export function toAuthenticatedUser(user: Pick): AuthenticatedUser { + return { + id: user.id, + username: user.username, + role: user.role, + } +} + +export async function authenticateUserToken(token: string): Promise { + const secret = await getJwtSecret() + if (!secret) return null + + const payload = token ? verifyUserJwt(token, secret) : null + if (!payload) return null + + const user = findUserById(payload.sub) + if (!user || user.status !== 'active') return null + return toAuthenticatedUser(user) +} + +export async function isAuthEnabled(): Promise { + return !!await getJwtSecret() +} + +export async function requireUserJwt(ctx: Context, next: Next): Promise { + const secret = await getJwtSecret() + if (!secret) { + await next() + return + } + + const token = requestToken(ctx) + const payload = token ? verifyUserJwt(token, secret) : null + if (!payload) { + ctx.status = 401 + ctx.body = { error: 'Unauthorized' } + return + } + + const user = findUserById(payload.sub) + if (!user || user.status !== 'active') { + ctx.status = 403 + ctx.body = { error: 'User is disabled or does not exist' } + return + } + + ctx.state.user = toAuthenticatedUser(user) + touchUserLogin(user.id) + await next() +} + +export async function requireSuperAdmin(ctx: Context, next: Next): Promise { + if (ctx.state.user?.role !== 'super_admin') { + ctx.status = 403 + ctx.body = { error: 'Super administrator privileges are required' } + return + } + await next() +} + +export function resolveRequestedProfile(ctx: Context): string { + if (ctx.path === '/api/hermes/available-models' && typeof ctx.query.profile !== 'string') { + return '' + } + const headerProfile = ctx.get('x-hermes-profile') + const queryProfile = typeof ctx.query.profile === 'string' ? ctx.query.profile : '' + const body = ctx.request.body as { profile?: unknown } | undefined + const bodyProfile = typeof body?.profile === 'string' ? body.profile : '' + return (headerProfile || queryProfile || bodyProfile || '').trim() +} + +export async function resolveUserProfile(ctx: Context, next: Next): Promise { + const user = ctx.state.user + if (!user) { + await next() + return + } + + const profileName = resolveRequestedProfile(ctx) + if (!profileName) { + await next() + return + } + + if (user.role !== 'super_admin' && !userCanAccessProfile(user.id, profileName)) { + ctx.status = 403 + ctx.body = { error: `Profile "${profileName}" is not available for this user` } + return + } + + ctx.state.profile = { name: profileName } + await next() +} + +export async function requireUserProfile(ctx: Context, next: Next): Promise { + if (!ctx.state.profile?.name) { + ctx.status = 400 + ctx.body = { error: 'Profile is required' } + return + } + await next() +} + +export const userAuthMiddleware = [requireUserJwt, resolveUserProfile] diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index 53b27fd..9144f11 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -1,5 +1,6 @@ import Router from '@koa/router' import * as ctrl from '../controllers/auth' +import { requireSuperAdmin } from '../middleware/user-auth' // Public routes (no auth required) export const authPublicRoutes = new Router() @@ -9,8 +10,13 @@ authPublicRoutes.post('/api/auth/login', ctrl.login) // Protected routes (auth required) export const authProtectedRoutes = new Router() authProtectedRoutes.post('/api/auth/setup', ctrl.setupPassword) +authProtectedRoutes.get('/api/auth/me', ctrl.currentUser) authProtectedRoutes.post('/api/auth/change-password', ctrl.changePassword) authProtectedRoutes.post('/api/auth/change-username', ctrl.changeUsername) authProtectedRoutes.delete('/api/auth/password', ctrl.removePassword) +authProtectedRoutes.get('/api/auth/users', requireSuperAdmin, ctrl.listManagedUsers) +authProtectedRoutes.post('/api/auth/users', requireSuperAdmin, ctrl.createManagedUser) +authProtectedRoutes.put('/api/auth/users/:id', requireSuperAdmin, ctrl.updateManagedUser) +authProtectedRoutes.delete('/api/auth/users/:id', requireSuperAdmin, ctrl.deleteManagedUser) authProtectedRoutes.get('/api/auth/locked-ips', ctrl.listLockedIps) authProtectedRoutes.delete('/api/auth/locked-ips', ctrl.unlockIpHandler) diff --git a/packages/server/src/routes/hermes/kanban-events.ts b/packages/server/src/routes/hermes/kanban-events.ts index 6b46762..3159bb6 100644 --- a/packages/server/src/routes/hermes/kanban-events.ts +++ b/packages/server/src/routes/hermes/kanban-events.ts @@ -1,12 +1,14 @@ import { WebSocketServer } from 'ws' import type { WebSocket } from 'ws' import type { Server as HttpServer, IncomingMessage } from 'http' -import { getToken } from '../../services/auth' +import { authenticateUserToken, isAuthEnabled } from '../../middleware/user-auth' +import { userCanAccessProfile } from '../../db/hermes/users-store' import { logger } from '../../services/logger' import * as kanbanCli from '../../services/hermes/hermes-kanban' interface KanbanEventsRequest extends IncomingMessage { kanbanBoard?: string + kanbanProfile?: string } function sendJson(ws: WebSocket, payload: Record) { @@ -35,14 +37,21 @@ export function setupKanbanEventsWebSocket(httpServers: HttpServer | HttpServer[ const url = new URL(req.url || '', `http://${req.headers.host}`) if (url.pathname !== '/api/hermes/kanban/events') return - const authToken = await getToken() - if (authToken) { + if (await isAuthEnabled()) { const token = url.searchParams.get('token') || '' - if (token !== authToken) { + const user = await authenticateUserToken(token) + if (!user) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') socket.destroy() return } + const profile = (url.searchParams.get('profile') || '').trim() + if (profile && user.role !== 'super_admin' && !userCanAccessProfile(user.id, profile)) { + socket.write('HTTP/1.1 403 Forbidden\r\n\r\n') + socket.destroy() + return + } + req.kanbanProfile = profile || undefined } try { @@ -74,7 +83,7 @@ export function setupKanbanEventsWebSocket(httpServers: HttpServer | HttpServer[ child.stdout?.on('data', streamLines((line) => { if (line.toLowerCase().startsWith('watching kanban events')) return - sendJson(ws, { type: 'event', board, line }) + sendJson(ws, { type: 'event', board }) })) child.stderr?.on('data', streamLines((line) => { diff --git a/packages/server/src/routes/hermes/performance-monitor.ts b/packages/server/src/routes/hermes/performance-monitor.ts index ca5c1a6..e60284c 100644 --- a/packages/server/src/routes/hermes/performance-monitor.ts +++ b/packages/server/src/routes/hermes/performance-monitor.ts @@ -1,6 +1,7 @@ import Router from '@koa/router' import * as ctrl from '../../controllers/hermes/performance-monitor' +import { requireSuperAdmin } from '../../middleware/user-auth' export const performanceMonitorRoutes = new Router() -performanceMonitorRoutes.get('/api/hermes/performance/runtime', ctrl.runtime) +performanceMonitorRoutes.get('/api/hermes/performance/runtime', requireSuperAdmin, ctrl.runtime) diff --git a/packages/server/src/routes/hermes/profiles.ts b/packages/server/src/routes/hermes/profiles.ts index 06d3d89..163bed7 100644 --- a/packages/server/src/routes/hermes/profiles.ts +++ b/packages/server/src/routes/hermes/profiles.ts @@ -1,5 +1,6 @@ import Router from '@koa/router' import * as ctrl from '../../controllers/hermes/profiles' +import { requireSuperAdmin } from '../../middleware/user-auth' export const profileRoutes = new Router() @@ -14,6 +15,6 @@ profileRoutes.delete('/api/hermes/profiles/:name/avatar', ctrl.deleteAvatar) profileRoutes.get('/api/hermes/profiles/:name', ctrl.get) profileRoutes.delete('/api/hermes/profiles/:name', ctrl.remove) profileRoutes.post('/api/hermes/profiles/:name/rename', ctrl.rename) -profileRoutes.put('/api/hermes/profiles/active', ctrl.switchProfile) +profileRoutes.put('/api/hermes/profiles/active', requireSuperAdmin, ctrl.switchProfile) profileRoutes.post('/api/hermes/profiles/:name/export', ctrl.exportProfile) profileRoutes.post('/api/hermes/profiles/import', ctrl.importProfile) diff --git a/packages/server/src/routes/hermes/terminal.ts b/packages/server/src/routes/hermes/terminal.ts index 038279f..04cdd6c 100644 --- a/packages/server/src/routes/hermes/terminal.ts +++ b/packages/server/src/routes/hermes/terminal.ts @@ -5,7 +5,7 @@ import { dirname, join, isAbsolute, resolve as resolvePath } from 'path' import { homedir } from 'os' import { getActiveProfileDir } from '../../services/hermes/hermes-profile' import { getTerminalConfig, type TerminalConfig } from '../../services/hermes/file-provider' -import { getToken } from '../../services/auth' +import { authenticateUserToken, isAuthEnabled } from '../../middleware/user-auth' import { logger } from '../../services/logger' let pty: any = null @@ -151,10 +151,9 @@ export function setupTerminalWebSocket(httpServers: HttpServer | HttpServer[]) { } // Auth check - const authToken = await getToken() - if (authToken) { + if (await isAuthEnabled()) { const token = url.searchParams.get('token') || '' - if (token !== authToken) { + if (!await authenticateUserToken(token)) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') socket.destroy() return diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index 35112fc..84cf174 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -38,7 +38,7 @@ import { performanceMonitorRoutes } from './hermes/performance-monitor' * Public routes are registered first, then auth middleware, * then all protected routes. Returns the proxy middleware (must be mounted last). */ -export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next) => Promise) { +export function registerRoutes(app: any, authMiddleware: Array<(ctx: Context, next: Next) => Promise>) { // --- Public routes (no auth required) --- app.use(healthRoutes.routes()) app.use(webhookRoutes.routes()) @@ -46,7 +46,7 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next) app.use(ttsRoutes.routes()) // TTS proxy/generation — must be before auth // --- Auth middleware: all routes below require authentication --- - app.use(requireAuth) + authMiddleware.forEach((middleware) => app.use(middleware)) // --- Protected routes (auth required) --- app.use(authProtectedRoutes.routes()) diff --git a/packages/server/src/services/hermes/conversations.ts b/packages/server/src/services/hermes/conversations.ts index 2d14a5a..9d6b474 100644 --- a/packages/server/src/services/hermes/conversations.ts +++ b/packages/server/src/services/hermes/conversations.ts @@ -34,6 +34,7 @@ const exportCache = new Map() export interface ConversationSummary { id: string + profile?: string | null source: string model: string title: string | null diff --git a/packages/server/src/services/hermes/group-chat/index.ts b/packages/server/src/services/hermes/group-chat/index.ts index 8265f69..e9dce81 100644 --- a/packages/server/src/services/hermes/group-chat/index.ts +++ b/packages/server/src/services/hermes/group-chat/index.ts @@ -1,6 +1,5 @@ import { Server, Socket, Namespace } from 'socket.io' import type { Server as HttpServer } from 'http' -import { getToken } from '../../../services/auth' import { logger } from '../../../services/logger' import { getDb } from '../../../db' import { normalizeMessageContentForStorage, normalizeMessageContentForStorageRole } from '../../../db/hermes/message-content' @@ -9,6 +8,7 @@ import { ContextEngine } from '../context-engine/compressor' import { SessionDeleter } from '../session-deleter' import { countTokens, SUMMARY_PREFIX } from '../../../lib/context-compressor' import { AgentBridgeClient } from '../agent-bridge' +import { authenticateUserToken, isAuthEnabled } from '../../../middleware/user-auth' // ─── Types ──────────────────────────────────────────────────── @@ -780,12 +780,16 @@ export class GroupChatServer { // ─── Auth ─────────────────────────────────────────────────── private async authMiddleware(socket: Socket, next: (err?: Error) => void): Promise { - const authToken = await getToken() - const token = socket.handshake.auth.token || socket.handshake.query.token || '' - if (authToken) { - if (token !== authToken) { - return next(new Error('Unauthorized')) - } + const auth = socket.handshake.auth as { source?: string; agentSocketSecret?: string; token?: string } + const isAgentSocket = auth.source === 'agent' && auth.agentSocketSecret === GROUP_CHAT_AGENT_SOCKET_SECRET + if (isAgentSocket) { + next() + return + } + + const token = auth.token || socket.handshake.query.token || '' + if (await isAuthEnabled() && !await authenticateUserToken(String(token))) { + return next(new Error('Unauthorized')) } next() } diff --git a/packages/server/src/services/hermes/run-chat/index.ts b/packages/server/src/services/hermes/run-chat/index.ts index 32a12ae..2956d77 100644 --- a/packages/server/src/services/hermes/run-chat/index.ts +++ b/packages/server/src/services/hermes/run-chat/index.ts @@ -20,6 +20,8 @@ import { handleAbort } from './abort' import { getOrCreateSession } from './compression' import { handleSessionCommand, isSessionCommand, parseSessionCommand } from './session-command' import type { ContentBlock, QueuedRun, SessionState } from './types' +import { authenticateUserToken, isAuthEnabled, type AuthenticatedUser } from '../../../middleware/user-auth' +import { userCanAccessProfile } from '../../../db/hermes/users-store' export type { ContentBlock } from './types' @@ -43,31 +45,55 @@ export class ChatRunSocket { private async authMiddleware(socket: Socket, next: (err?: Error) => void) { const token = socket.handshake.auth?.token as string | undefined - if (!process.env.AUTH_DISABLED && process.env.AUTH_DISABLED !== '1') { - const { getToken } = await import('../../auth') - const serverToken = await getToken() - if (serverToken && token !== serverToken) { - return next(new Error('Authentication failed')) - } + if (!await isAuthEnabled()) { + next() + return } + + const user = await authenticateUserToken(token || '') + if (!user) { + return next(new Error('Authentication failed')) + } + const socketProfile = String(socket.handshake.query?.profile || '').trim() + if (socketProfile && !this.canAccessProfile(user, socketProfile)) { + return next(new Error('Profile access denied')) + } + socket.data.user = user next() } // --- Connection handler --- private onConnection(socket: Socket) { + const socketUser = socket.data.user as AuthenticatedUser | undefined const socketProfile = (socket.handshake.query?.profile as string) || 'default' - const currentProfile = () => getActiveProfileName() || socketProfile || 'default' + const currentProfile = () => socketProfile || getActiveProfileName() || 'default' const profileExists = (profile: string) => { if (!profile || profile === 'default') return true return listProfileNamesFromDisk().includes(profile) } const resolveRunProfile = (sessionId?: string, requested?: string) => { const requestedProfile = typeof requested === 'string' ? requested.trim() : '' - if (requestedProfile && profileExists(requestedProfile)) return requestedProfile - if (!sessionId) return currentProfile() + if (requestedProfile) { + if (!profileExists(requestedProfile)) throw new Error(`Profile "${requestedProfile}" does not exist`) + if (socketUser && !this.canAccessProfile(socketUser, requestedProfile)) { + throw new Error(`Profile "${requestedProfile}" is not available for this user`) + } + return requestedProfile + } + if (!sessionId) { + const profile = currentProfile() + if (socketUser && !this.canAccessProfile(socketUser, profile)) { + throw new Error(`Profile "${profile}" is not available for this user`) + } + return profile + } const storedProfile = getSession(sessionId)?.profile || '' - return storedProfile && profileExists(storedProfile) ? storedProfile : currentProfile() + const profile = storedProfile && profileExists(storedProfile) ? storedProfile : currentProfile() + if (socketUser && !this.canAccessProfile(socketUser, profile)) { + throw new Error(`Profile "${profile}" is not available for this user`) + } + return profile } socket.on('run', async (data: { @@ -81,7 +107,17 @@ export class ChatRunSocket { source?: string profile?: string }) => { - const runProfile = resolveRunProfile(data.session_id, data.profile) + let runProfile: string + try { + runProfile = resolveRunProfile(data.session_id, data.profile) + } catch (err) { + socket.emit('run.failed', { + event: 'run.failed', + session_id: data.session_id, + error: err instanceof Error ? err.message : String(err), + }) + return + } if (data.session_id) { const state = getOrCreateSession(this.sessionMap, data.session_id) const source = resolveRunSource(data.source, data.session_id) @@ -313,6 +349,10 @@ export class ChatRunSocket { } } + private canAccessProfile(user: AuthenticatedUser, profile: string): boolean { + return user.role === 'super_admin' || userCanAccessProfile(user.id, profile) + } + /** Close all active upstream response streams */ close() { for (const [sessionId, state] of this.sessionMap.entries()) { diff --git a/tests/client/api.test.ts b/tests/client/api.test.ts index e9be2db..3f82a7b 100644 --- a/tests/client/api.test.ts +++ b/tests/client/api.test.ts @@ -12,9 +12,15 @@ vi.mock('@/router', () => ({ }, })) -import { getApiKey, setApiKey, clearApiKey, hasApiKey, request } from '../../packages/client/src/api/client' +import { getApiKey, setApiKey, clearApiKey, hasApiKey, getStoredUserRole, isStoredSuperAdmin, request } from '../../packages/client/src/api/client' import router from '@/router' +function fakeJwt(payload: Record) { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + const body = btoa(JSON.stringify(payload)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + return `${header}.${body}.signature` +} + describe('API Client', () => { beforeEach(() => { localStorage.clear() @@ -42,6 +48,17 @@ describe('API Client', () => { expect(hasApiKey()).toBe(false) expect(getApiKey()).toBe('') }) + + it('reads the role from the stored JWT payload', () => { + setApiKey(fakeJwt({ sub: '1', role: 'super_admin' })) + + expect(getStoredUserRole()).toBe('super_admin') + expect(isStoredSuperAdmin()).toBe(true) + + setApiKey(fakeJwt({ sub: '2', role: 'admin' })) + expect(getStoredUserRole()).toBe('admin') + expect(isStoredSuperAdmin()).toBe(false) + }) }) describe('request', () => { @@ -56,6 +73,16 @@ describe('API Client', () => { expect(options.headers.Authorization).toBe('Bearer secret-key') }) + it('adds the active profile header, including default', async () => { + localStorage.setItem('hermes_active_profile_name', 'default') + mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) }) + + await request('/api/hermes/sessions') + + const [, options] = mockFetch.mock.calls[0] + expect(options.headers['X-Hermes-Profile']).toBe('default') + }) + it('does not add Authorization header when no token', async () => { mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) }) @@ -74,6 +101,32 @@ describe('API Client', () => { expect(router.replace).toHaveBeenCalledWith({ name: 'login' }) }) + it('emits a global auth notice on local 403 responses', async () => { + const listener = vi.fn() + window.addEventListener('hermes-auth-notice', listener) + mockFetch.mockResolvedValue({ ok: false, status: 403, text: () => Promise.resolve('Forbidden') }) + + await expect(request('/api/hermes/profiles')).rejects.toThrow('API Error 403') + + expect(listener).toHaveBeenCalledOnce() + expect(listener.mock.calls[0][0].detail).toEqual({ kind: 'forbidden' }) + window.removeEventListener('hermes-auth-notice', listener) + }) + + it('clears token and redirects when the JWT user no longer exists', async () => { + setApiKey('stale-jwt') + mockFetch.mockResolvedValue({ + ok: false, + status: 403, + text: () => Promise.resolve('{"error":"User is disabled or does not exist"}'), + }) + + await expect(request('/api/hermes/profiles')).rejects.toThrow('API Error 403') + + expect(hasApiKey()).toBe(false) + expect(router.replace).toHaveBeenCalledWith({ name: 'login' }) + }) + it('does NOT clear token on 401 for proxied v1 endpoints', async () => { setApiKey('secret-key') mockFetch.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve('') }) @@ -82,22 +135,6 @@ describe('API Client', () => { expect(hasApiKey()).toBe(true) }) - it('does NOT clear token on 401 for proxied jobs endpoints', async () => { - setApiKey('secret-key') - mockFetch.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve('') }) - - await expect(request('/api/hermes/jobs')).rejects.toThrow('API Error 401') - expect(hasApiKey()).toBe(true) - }) - - it('does NOT clear token on 401 for proxied skills endpoints', async () => { - setApiKey('secret-key') - mockFetch.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve('') }) - - await expect(request('/api/hermes/skills')).rejects.toThrow('API Error 401') - expect(hasApiKey()).toBe(true) - }) - it('throws error on non-401 failure', async () => { mockFetch.mockResolvedValue({ ok: false, diff --git a/tests/client/kanban-api.test.ts b/tests/client/kanban-api.test.ts index 2c3ca31..9677654 100644 --- a/tests/client/kanban-api.test.ts +++ b/tests/client/kanban-api.test.ts @@ -49,9 +49,10 @@ describe('Kanban API', () => { it('builds board-scoped kanban event websocket URLs with auth token', () => { mockGetBaseUrlValue.mockReturnValue('https://wui.example.test') mockGetApiKey.mockReturnValue('token value') + localStorage.setItem('hermes_active_profile_name', 'research') - expect(buildKanbanEventsWebSocketUrl({ board: 'project-a' })).toBe('wss://wui.example.test/api/hermes/kanban/events?board=project-a&token=token+value') - expect(buildKanbanEventsWebSocketUrl()).toBe('wss://wui.example.test/api/hermes/kanban/events?board=default&token=token+value') + expect(buildKanbanEventsWebSocketUrl({ board: 'project-a' })).toBe('wss://wui.example.test/api/hermes/kanban/events?board=project-a&token=token+value&profile=research') + expect(buildKanbanEventsWebSocketUrl()).toBe('wss://wui.example.test/api/hermes/kanban/events?board=default&token=token+value&profile=research') }) it('serializes board, list filters, and archived inclusion into query params', async () => { diff --git a/tests/client/login-view.test.ts b/tests/client/login-view.test.ts index 20d40d3..c2534c5 100644 --- a/tests/client/login-view.test.ts +++ b/tests/client/login-view.test.ts @@ -32,43 +32,38 @@ vi.mock('@/api/auth', () => ({ import LoginView from '@/views/LoginView.vue' -const mockFetch = vi.fn() -vi.stubGlobal('fetch', mockFetch) - -describe('LoginView token login', () => { +describe('LoginView password login', () => { beforeEach(() => { delete (window as any).__LOGIN_TOKEN__ vi.clearAllMocks() mockHasApiKey.mockReturnValue(false) - mockFetchAuthStatus.mockResolvedValue({ hasPasswordLogin: false }) - mockFetch.mockResolvedValue({ ok: true, status: 200 }) + mockFetchAuthStatus.mockResolvedValue({ hasPasswordLogin: true, username: 'admin' }) }) - it('validates token login against the Hermes sessions endpoint', async () => { + it('logs in with username and password', async () => { + mockLoginWithPassword.mockResolvedValue('jwt-token') const wrapper = mount(LoginView) - await wrapper.find('input.login-input').setValue('secret-token') + const inputs = wrapper.findAll('input.login-input') + await inputs[0].setValue('admin') + await inputs[1].setValue('123456') await wrapper.find('form.login-form').trigger('submit') - expect(mockFetch).toHaveBeenCalledOnce() - expect(mockFetch).toHaveBeenCalledWith('/api/hermes/sessions', { - headers: { Authorization: 'Bearer secret-token' }, - }) - expect(mockSetApiKey).toHaveBeenCalledWith('secret-token') + expect(mockLoginWithPassword).toHaveBeenCalledWith('admin', '123456') + expect(mockSetApiKey).toHaveBeenCalledWith('jwt-token') expect(mockReplace).toHaveBeenCalledWith('/hermes/chat') }) - it('keeps the existing invalid-token behavior on 401', async () => { - mockFetch.mockResolvedValue({ ok: false, status: 401 }) + it('shows an error when password login fails', async () => { + mockLoginWithPassword.mockRejectedValue(new Error('Invalid username or password')) const wrapper = mount(LoginView) - await wrapper.find('input.login-input').setValue('bad-token') + const inputs = wrapper.findAll('input.login-input') + await inputs[0].setValue('admin') + await inputs[1].setValue('bad-password') await wrapper.find('form.login-form').trigger('submit') - expect(mockFetch).toHaveBeenCalledWith('/api/hermes/sessions', { - headers: { Authorization: 'Bearer bad-token' }, - }) - expect(wrapper.find('.login-error').text()).toBe('login.invalidToken') + expect(wrapper.find('.login-error').text()).toBe('Invalid username or password') expect(mockSetApiKey).not.toHaveBeenCalled() expect(mockReplace).not.toHaveBeenCalled() }) diff --git a/tests/client/profiles-store.test.ts b/tests/client/profiles-store.test.ts index f1f8341..962ce35 100644 --- a/tests/client/profiles-store.test.ts +++ b/tests/client/profiles-store.test.ts @@ -207,12 +207,11 @@ describe('Profiles Store', () => { expect(localStorage.getItem('hermes_active_profile_name')).toBe('dev') }) - it('switchProfile rolls back if backend reports different active profile', async () => { + it('switchProfile keeps the local selected profile independent of backend active flags', async () => { const initialName = 'default' localStorage.setItem('hermes_active_profile_name', initialName) mockProfilesApi.switchProfile.mockResolvedValue(true) - // Backend returns success, but active profile is still default (not the one we switched to) mockProfilesApi.fetchProfiles.mockResolvedValue([ { name: 'default', active: true, model: 'gpt-4', alias: '' }, { name: 'dev', active: false, model: 'gpt-4', alias: '' }, @@ -222,11 +221,8 @@ describe('Profiles Store', () => { store.activeProfileName = initialName const result = await store.switchProfile('dev') - // Should return false (backend verification failed) - expect(result).toBe(false) - // activeProfileName should be rolled back to default - expect(store.activeProfileName).toBe('default') - // localStorage should be rolled back - expect(localStorage.getItem('hermes_active_profile_name')).toBe('default') + expect(result).toBe(true) + expect(store.activeProfileName).toBe('dev') + expect(localStorage.getItem('hermes_active_profile_name')).toBe('dev') }) }) diff --git a/tests/server/hermes-schemas.test.ts b/tests/server/hermes-schemas.test.ts index 0ff3eb3..3179a2c 100644 --- a/tests/server/hermes-schemas.test.ts +++ b/tests/server/hermes-schemas.test.ts @@ -21,7 +21,7 @@ describe('Hermes schema initialization', () => { }) it('initializes all tables with correct schemas', async () => { - const { initAllHermesTables, USAGE_TABLE, SESSIONS_TABLE, MESSAGES_TABLE, GC_ROOMS_TABLE } = + const { initAllHermesTables, USAGE_TABLE, SESSIONS_TABLE, MESSAGES_TABLE, GC_ROOMS_TABLE, USERS_TABLE, USER_PROFILES_TABLE } = await import('../../packages/server/src/db/hermes/schemas') expect(() => initAllHermesTables()).not.toThrow() @@ -32,6 +32,8 @@ describe('Hermes schema initialization', () => { expect(tables.map(t => t.name)).toContain(SESSIONS_TABLE) expect(tables.map(t => t.name)).toContain(MESSAGES_TABLE) expect(tables.map(t => t.name)).toContain(GC_ROOMS_TABLE) + expect(tables.map(t => t.name)).toContain(USERS_TABLE) + expect(tables.map(t => t.name)).toContain(USER_PROFILES_TABLE) // Verify USAGE_TABLE structure const usageCols = db.prepare(`PRAGMA table_info("${USAGE_TABLE}")`).all() as Array<{ name: string }> @@ -39,6 +41,17 @@ describe('Hermes schema initialization', () => { expect(usageCols.some(c => c.name === 'session_id')).toBe(true) expect(usageCols.some(c => c.name === 'input_tokens')).toBe(true) expect(usageCols.some(c => c.name === 'output_tokens')).toBe(true) + + const userCols = db.prepare(`PRAGMA table_info("${USERS_TABLE}")`).all() as Array<{ name: string }> + expect(userCols.some(c => c.name === 'id')).toBe(true) + expect(userCols.some(c => c.name === 'username')).toBe(true) + expect(userCols.some(c => c.name === 'password_hash')).toBe(true) + expect(userCols.some(c => c.name === 'role')).toBe(true) + + const profileCols = db.prepare(`PRAGMA table_info("${USER_PROFILES_TABLE}")`).all() as Array<{ name: string }> + expect(profileCols.some(c => c.name === 'user_id')).toBe(true) + expect(profileCols.some(c => c.name === 'profile_name')).toBe(true) + expect(profileCols.some(c => c.name === 'is_default')).toBe(true) }) it('preserves existing data when adding safe schema columns', async () => { diff --git a/tests/server/kanban-controller.test.ts b/tests/server/kanban-controller.test.ts index 048d3fe..030820d 100644 --- a/tests/server/kanban-controller.test.ts +++ b/tests/server/kanban-controller.test.ts @@ -28,6 +28,7 @@ const mockSearchSessions = vi.hoisted(() => vi.fn()) const mockGetSessionDetail = vi.hoisted(() => vi.fn()) const mockGetExactSessionDetail = vi.hoisted(() => vi.fn()) const mockFindLatestExactSessionId = vi.hoisted(() => vi.fn()) +const mockListUserProfiles = vi.hoisted(() => vi.fn()) vi.mock('fs/promises', () => ({ readFile: mockReadFile, @@ -75,6 +76,10 @@ vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({ findLatestExactSessionIdWithProfile: mockFindLatestExactSessionId, })) +vi.mock('../../packages/server/src/db/hermes/users-store', () => ({ + listUserProfiles: mockListUserProfiles, +})) + import * as ctrl from '../../packages/server/src/controllers/hermes/kanban' function ctx(overrides: Record = {}) { @@ -91,6 +96,7 @@ function ctx(overrides: Record = {}) { describe('kanban controller', () => { beforeEach(() => { vi.clearAllMocks() + mockListUserProfiles.mockReturnValue([{ profile_name: 'research' }]) }) it('lists boards and tasks with explicit/default board context', async () => { @@ -129,6 +135,48 @@ describe('kanban controller', () => { expect(mockListTasks).toHaveBeenLastCalledWith({ board: 'default', status: 'ready', assignee: undefined, tenant: undefined, includeArchived: false }) }) + it('filters kanban tasks, stats, and assignees to the requested profile', async () => { + const tasks = [ + { id: 'task-1', assignee: 'research', status: 'todo' }, + { id: 'task-2', assignee: 'travel', status: 'done' }, + { id: 'task-3', assignee: null, status: 'blocked' }, + ] + mockListTasks.mockResolvedValue(tasks) + mockGetAssignees.mockResolvedValue([ + { name: 'research', on_disk: true, counts: { todo: 1 } }, + { name: 'travel', on_disk: true, counts: { done: 1 } }, + { name: 'default', on_disk: true, counts: { blocked: 1 } }, + ]) + + const state = { user: { id: 7, role: 'admin' }, profile: { name: 'research' } } + const listCtx = ctx({ state, query: { board: 'default', includeArchived: 'true' } }) + await ctrl.list(listCtx) + expect(listCtx.body).toEqual({ tasks: [tasks[0]] }) + + const statsCtx = ctx({ state, query: { board: 'default' } }) + await ctrl.stats(statsCtx) + expect(statsCtx.body).toEqual({ stats: { by_status: { todo: 1 }, by_assignee: { research: 1 }, total: 1 } }) + + const assigneesCtx = ctx({ state, query: { board: 'default' } }) + await ctrl.assignees(assigneesCtx) + expect(assigneesCtx.body).toEqual({ assignees: [{ name: 'research', on_disk: true, counts: { todo: 1 } }] }) + }) + + it('defaults created kanban tasks to the requested profile and rejects unauthorized assignees', async () => { + mockCreateTask.mockResolvedValue({ id: 'task-1', assignee: 'research' }) + const state = { user: { id: 7, role: 'admin' }, profile: { name: 'research' } } + + const createCtx = ctx({ state, query: { board: 'default' }, request: { body: { title: 'Ship it' } } }) + await ctrl.create(createCtx) + expect(mockCreateTask).toHaveBeenCalledWith('Ship it', { board: 'default', body: undefined, assignee: 'research', priority: undefined, tenant: undefined }) + expect(createCtx.body).toEqual({ task: { id: 'task-1', assignee: 'research' } }) + + const assignCtx = ctx({ state, query: { board: 'default' }, params: { id: 'task-1' }, request: { body: { profile: 'travel' } } }) + await ctrl.assign(assignCtx) + expect(assignCtx.status).toBe(403) + expect(mockAssignTask).not.toHaveBeenCalled() + }) + it('proxies comment/log/diagnostics with explicit board context', async () => { const taskLog = { task_id: 'task-1', path: null, exists: true, size_bytes: 10, content: 'worker log', truncated: false } mockAddComment.mockResolvedValue({ ok: true, output: 'commented' }) diff --git a/tests/server/model-visibility-controller.test.ts b/tests/server/model-visibility-controller.test.ts index c67f5b0..86b3200 100644 --- a/tests/server/model-visibility-controller.test.ts +++ b/tests/server/model-visibility-controller.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockReadFile, mockReadConfigYaml, mockReadConfigYamlForProfile, mockFetchProviderModels, mockBuildModelGroups, mockReadAppConfig, mockWriteAppConfig, mockExistsSync, mockReadFileSync } = vi.hoisted(() => ({ +const { mockReadFile, mockReadConfigYaml, mockReadConfigYamlForProfile, mockFetchProviderModels, mockBuildModelGroups, mockReadAppConfig, mockWriteAppConfig, mockExistsSync, mockReadFileSync, mockListProfileNamesFromDisk, mockListUserProfiles } = vi.hoisted(() => ({ mockReadFile: vi.fn(), mockReadConfigYaml: vi.fn(), mockReadConfigYamlForProfile: vi.fn(), @@ -10,6 +10,8 @@ const { mockReadFile, mockReadConfigYaml, mockReadConfigYamlForProfile, mockFetc mockWriteAppConfig: vi.fn(), mockExistsSync: vi.fn(() => false), mockReadFileSync: vi.fn(), + mockListProfileNamesFromDisk: vi.fn(() => ['default']), + mockListUserProfiles: vi.fn(() => []), })) vi.mock('fs/promises', () => ({ @@ -26,7 +28,11 @@ vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({ getActiveAuthPath: () => '/fake/home/.hermes/auth.json', getActiveProfileName: () => 'default', getProfileDir: () => '/fake/home/.hermes', - listProfileNamesFromDisk: () => ['default'], + listProfileNamesFromDisk: mockListProfileNamesFromDisk, +})) + +vi.mock('../../packages/server/src/db/hermes/users-store', () => ({ + listUserProfiles: mockListUserProfiles, })) vi.mock('../../packages/server/src/services/config-helpers', () => ({ @@ -104,6 +110,8 @@ beforeEach(() => { mockWriteAppConfig.mockImplementation(async patch => patch) mockExistsSync.mockReturnValue(false) mockReadFileSync.mockReturnValue('{}') + mockListProfileNamesFromDisk.mockReturnValue(['default']) + mockListUserProfiles.mockReturnValue([]) }) describe('models controller — model visibility', () => { @@ -151,6 +159,44 @@ describe('models controller — model visibility', () => { deepseek: ['gemma-4-26b-a4b-it', 'deepseek-chat'], }) }) + + it('limits the default available-models response to profiles bound to regular admins', async () => { + mockListProfileNamesFromDisk.mockReturnValue(['default', 'research', 'private']) + mockListUserProfiles.mockReturnValue([ + { user_id: 7, profile_name: 'research', is_default: 1, created_at: 1 }, + ]) + mockReadConfigYamlForProfile.mockImplementation(async (profile: string) => ({ + model: { + default: `${profile}-model`, + provider: 'deepseek', + }, + })) + + const ctx = makeCtx() + ctx.state = { user: { id: 7, username: 'ops', role: 'admin' } } + ctx.get = vi.fn((name: string) => name.toLowerCase() === 'x-hermes-profile' ? 'private' : '') + await ctrl.getAvailable(ctx) + + expect(mockReadConfigYamlForProfile).toHaveBeenCalledTimes(1) + expect(mockReadConfigYamlForProfile).toHaveBeenCalledWith('research') + expect(ctx.body.profiles.map((profile: any) => profile.profile)).toEqual(['research']) + expect(ctx.body.groups).toEqual(expect.arrayContaining([ + expect.objectContaining({ provider: 'deepseek' }), + ])) + }) + + it('uses explicit query profile for single-profile model fetches', async () => { + mockListProfileNamesFromDisk.mockReturnValue(['default', 'research']) + + const ctx = makeCtx() + ctx.query = { profile: 'research' } + ctx.state = { profile: { name: 'default' }, user: { id: 1, username: 'admin', role: 'super_admin' } } + await ctrl.getAvailable(ctx) + + expect(mockReadConfigYamlForProfile).toHaveBeenCalledTimes(1) + expect(mockReadConfigYamlForProfile).toHaveBeenCalledWith('research') + expect(ctx.body.profiles.map((profile: any) => profile.profile)).toEqual(['research']) + }) it('accepts OAuth providers stored in credential_pool entries', async () => { mockExistsSync.mockReturnValue(true) mockReadFileSync.mockReturnValue(JSON.stringify({ diff --git a/tests/server/performance-monitor-controller.test.ts b/tests/server/performance-monitor-controller.test.ts index b8e6e15..40278bc 100644 --- a/tests/server/performance-monitor-controller.test.ts +++ b/tests/server/performance-monitor-controller.test.ts @@ -37,4 +37,22 @@ describe('performance monitor controller', () => { expect(ctx.status).toBeUndefined() expect(ctx.body).toEqual({ timestamp: 0, error: 'boom' }) }) + + it('requires super admin on the runtime route', async () => { + const { performanceMonitorRoutes } = await import('../../packages/server/src/routes/hermes/performance-monitor') + const layer = performanceMonitorRoutes.stack.find((entry: any) => entry.path === '/api/hermes/performance/runtime') + expect(layer).toBeTruthy() + + const deniedCtx: any = { state: { user: { role: 'admin' } }, status: 200, body: null } + const deniedNext = vi.fn(async () => {}) + await layer.stack[0](deniedCtx, deniedNext) + + expect(deniedCtx.status).toBe(403) + expect(deniedNext).not.toHaveBeenCalled() + + const allowedCtx: any = { state: { user: { role: 'super_admin' } }, status: 200, body: null } + const allowedNext = vi.fn(async () => {}) + await layer.stack[0](allowedCtx, allowedNext) + expect(allowedNext).toHaveBeenCalledOnce() + }) }) diff --git a/tests/server/sessions-controller.test.ts b/tests/server/sessions-controller.test.ts index c0ba61a..7db9fb6 100644 --- a/tests/server/sessions-controller.test.ts +++ b/tests/server/sessions-controller.test.ts @@ -5,6 +5,7 @@ const getConversationDetailFromDbMock = vi.fn() const listConversationSummariesMock = vi.fn() const getConversationDetailMock = vi.fn() const getSessionDetailFromDbMock = vi.fn() +const getSessionDetailFromDbWithProfileMock = vi.fn() const getExactSessionDetailFromDbWithProfileMock = vi.fn() const getUsageStatsFromDbMock = vi.fn() const getSessionMock = vi.fn() @@ -51,6 +52,7 @@ vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({ listSessionSummaries: vi.fn(), searchSessionSummaries: vi.fn(), getSessionDetailFromDb: getSessionDetailFromDbMock, + getSessionDetailFromDbWithProfile: getSessionDetailFromDbWithProfileMock, getExactSessionDetailFromDbWithProfile: getExactSessionDetailFromDbWithProfileMock, getUsageStatsFromDb: getUsageStatsFromDbMock, })) @@ -109,6 +111,7 @@ describe('session conversations controller', () => { listConversationSummariesMock.mockReset() getConversationDetailMock.mockReset() getSessionDetailFromDbMock.mockReset() + getSessionDetailFromDbWithProfileMock.mockReset() getExactSessionDetailFromDbWithProfileMock.mockReset() getUsageStatsFromDbMock.mockReset() getSessionMock.mockReset() @@ -157,7 +160,7 @@ describe('session conversations controller', () => { const ctx: any = { query: { humanOnly: 'true', limit: '5' }, body: null } await mod.listConversations(ctx) - expect(localListSessionsMock).toHaveBeenCalledWith('default', undefined, 5) + expect(localListSessionsMock).toHaveBeenCalledWith(undefined, undefined, 5) expect(listConversationSummariesMock).not.toHaveBeenCalled() expect(ctx.body.sessions[0]).toMatchObject({ id: 'local-conversation', source: 'cli', title: 'Local' }) }) @@ -261,6 +264,33 @@ describe('session conversations controller', () => { }) }) + it('reads Hermes history detail from the requested profile database', async () => { + localGetSessionDetailMock.mockReturnValue(null) + getSessionDetailFromDbWithProfileMock.mockResolvedValue({ + id: 'travel-session', + source: 'cli', + title: 'Travel detail', + messages: [ + { id: 1, session_id: 'travel-session', role: 'user', content: 'from travel', timestamp: 1 }, + ], + }) + + const mod = await import('../../packages/server/src/controllers/hermes/sessions') + const ctx: any = { params: { id: 'travel-session' }, query: { profile: 'travel' }, body: null } + await mod.getHermesSession(ctx) + + expect(localGetSessionDetailMock).toHaveBeenCalledWith('travel-session') + expect(getSessionDetailFromDbWithProfileMock).toHaveBeenCalledWith('travel-session', 'travel') + expect(getSessionDetailFromDbMock).not.toHaveBeenCalled() + expect(getSessionMock).not.toHaveBeenCalled() + expect(ctx.body.session).toMatchObject({ + id: 'travel-session', + profile: 'travel', + title: 'Travel detail', + messages: [{ content: 'from travel' }], + }) + }) + it('does not return api_server sessions from the Hermes history detail endpoint', async () => { localGetSessionDetailMock.mockReturnValue({ id: 'api-1', diff --git a/tests/server/user-auth.test.ts b/tests/server/user-auth.test.ts new file mode 100644 index 0000000..ab644ea --- /dev/null +++ b/tests/server/user-auth.test.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('user auth tables and middleware', () => { + let db: any = null + + beforeEach(async () => { + vi.resetModules() + vi.stubEnv('AUTH_JWT_SECRET', 'test-secret') + const { DatabaseSync } = await import('node:sqlite') + db = new DatabaseSync(':memory:') + vi.doMock('../../packages/server/src/db/index', () => ({ + getDb: () => db, + getStoragePath: () => ':memory:', + })) + }) + + afterEach(() => { + db?.close() + db = null + vi.doUnmock('../../packages/server/src/db/index') + vi.doUnmock('../../packages/server/src/services/hermes/hermes-profile') + vi.unstubAllEnvs() + vi.resetModules() + }) + + async function initUsers() { + const schemas = await import('../../packages/server/src/db/hermes/schemas') + schemas.initAllHermesTables() + return { + schemas, + users: await import('../../packages/server/src/db/hermes/users-store'), + auth: await import('../../packages/server/src/middleware/user-auth'), + } + } + + function makeCtx(user: any, profile: string) { + return { + state: { user }, + query: { profile }, + request: { body: {} }, + get: vi.fn((name: string) => name.toLowerCase() === 'x-hermes-profile' ? '' : ''), + status: 200, + body: null, + } as any + } + + it('creates the default super admin without profile bindings', async () => { + const { schemas, users } = await initUsers() + + const created = users.bootstrapDefaultSuperAdmin('admin', '123456') + expect(created?.id).toBe(1) + + const row = db.prepare(`SELECT * FROM ${schemas.USERS_TABLE} WHERE id = ?`).get(1) as any + expect(row.username).toBe('admin') + expect(row.role).toBe('super_admin') + expect(row.status).toBe('active') + expect(row.password_hash).not.toBe('123456') + expect(users.verifyPassword('123456', row.password_hash)).toBe(true) + + const profileCount = db.prepare(`SELECT COUNT(*) as count FROM ${schemas.USER_PROFILES_TABLE} WHERE user_id = ?`).get(1) as any + expect(profileCount.count).toBe(0) + }) + + it('allows super admin to access profiles without explicit binding', async () => { + const { users, auth } = await initUsers() + const created = users.bootstrapDefaultSuperAdmin('admin', '123456') + expect(created?.role).toBe('super_admin') + + const ctx = makeCtx({ id: created?.id, username: 'admin', role: 'super_admin' }, 'research') + const next = vi.fn(async () => {}) + + await auth.resolveUserProfile(ctx, next) + + expect(ctx.state.profile).toEqual({ name: 'research' }) + expect(next).toHaveBeenCalledOnce() + }) + + it('requires regular admins to be associated with the requested profile', async () => { + const { schemas, users, auth } = await initUsers() + const now = Date.now() + db.prepare( + `INSERT INTO ${schemas.USERS_TABLE} (username, password_hash, role, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)` + ).run('ops', users.hashPassword('secret'), 'admin', 'active', now, now) + const admin = users.findUserByUsername('ops') + expect(admin?.id).toBe(1) + + const deniedCtx = makeCtx({ id: admin!.id, username: 'ops', role: 'admin' }, 'research') + await auth.resolveUserProfile(deniedCtx, vi.fn(async () => {})) + expect(deniedCtx.status).toBe(403) + + db.prepare( + `INSERT INTO ${schemas.USER_PROFILES_TABLE} (user_id, profile_name, is_default, created_at) + VALUES (?, ?, 1, ?)` + ).run(admin!.id, 'research', now) + + const allowedCtx = makeCtx({ id: admin!.id, username: 'ops', role: 'admin' }, 'research') + const next = vi.fn(async () => {}) + await auth.resolveUserProfile(allowedCtx, next) + + expect(allowedCtx.state.profile).toEqual({ name: 'research' }) + expect(next).toHaveBeenCalledOnce() + }) + + it('does not infer a profile when the frontend does not send one', async () => { + const { auth } = await initUsers() + const ctx = makeCtx({ id: 1, username: 'admin', role: 'super_admin' }, '') + const next = vi.fn(async () => {}) + + await auth.resolveUserProfile(ctx, next) + + expect(ctx.state.profile).toBeUndefined() + expect(next).toHaveBeenCalledOnce() + + await auth.requireUserProfile(ctx, vi.fn(async () => {})) + expect(ctx.status).toBe(400) + expect(ctx.body).toEqual({ error: 'Profile is required' }) + }) + + it('ignores stale profile headers for the aggregate available-models endpoint', async () => { + const { auth } = await initUsers() + const ctx = { + path: '/api/hermes/available-models', + state: { user: { id: 1, username: 'ops', role: 'admin' } }, + query: {}, + request: { body: {} }, + get: vi.fn((name: string) => name.toLowerCase() === 'x-hermes-profile' ? 'private' : ''), + status: 200, + body: null, + } as any + const next = vi.fn(async () => {}) + + await auth.resolveUserProfile(ctx, next) + + expect(ctx.state.profile).toBeUndefined() + expect(next).toHaveBeenCalledOnce() + }) + + it('does not create the default super admin until first valid bootstrap login', async () => { + const { schemas, users } = await initUsers() + + expect(users.countUsers()).toBe(0) + expect(users.bootstrapDefaultSuperAdmin('admin', 'bad-password')).toBeNull() + expect(users.countUsers()).toBe(0) + + const created = users.bootstrapDefaultSuperAdmin('admin', '123456') + expect(created?.role).toBe('super_admin') + expect(users.countUsers()).toBe(1) + + const userCount = db.prepare(`SELECT COUNT(*) as count FROM ${schemas.USERS_TABLE}`).get() as any + expect(userCount.count).toBe(1) + }) + + it('signs and verifies user JWTs', async () => { + const { auth } = await initUsers() + const token = auth.signUserJwt({ id: 1, username: 'admin', role: 'super_admin' }, 'secret', 1000) + + const payload = auth.verifyUserJwt(token, 'secret', 1000) + expect(payload?.sub).toBe('1') + expect(payload?.username).toBe('admin') + expect(payload?.role).toBe('super_admin') + + expect(auth.verifyUserJwt(token, 'wrong', 1000)).toBeNull() + }) + + it('authenticates JWTs passed as query tokens for download and websocket URLs', async () => { + const { users, auth } = await initUsers() + const user = users.bootstrapDefaultSuperAdmin('admin', '123456')! + const token = auth.signUserJwt(user, 'test-secret') + const ctx = { + headers: {}, + query: { token }, + state: {}, + request: { body: {} }, + status: 200, + body: null, + } as any + const next = vi.fn(async () => {}) + + await auth.requireUserJwt(ctx, next) + + expect(ctx.state.user).toEqual({ id: user.id, username: 'admin', role: 'super_admin' }) + expect(next).toHaveBeenCalledOnce() + }) + + it('bootstraps the default super admin through password login and returns a user JWT', async () => { + await initUsers() + const ctrl = await import('../../packages/server/src/controllers/auth') + const ctx = { + request: { body: { username: 'admin', password: '123456' } }, + headers: {}, + ip: '127.0.0.1', + status: 200, + body: null, + } as any + + await ctrl.login(ctx) + + expect(ctx.status).toBe(200) + expect(ctx.body.token).toMatch(/^[^.]+\.[^.]+\.[^.]+$/) + }) + + it('lets super admins create regular admins with profile bindings', async () => { + const { users } = await initUsers() + vi.doMock('../../packages/server/src/services/hermes/hermes-profile', () => ({ + listProfileNamesFromDisk: () => ['default', 'research'], + })) + const ctrl = await import('../../packages/server/src/controllers/auth') + const ctx = { + state: { user: { id: 1, username: 'admin', role: 'super_admin' } }, + request: { + body: { + username: 'ops', + password: 'secret1', + role: 'admin', + status: 'active', + profiles: ['research'], + }, + }, + status: 200, + body: null, + } as any + + await ctrl.createManagedUser(ctx) + + expect(ctx.status).toBe(201) + const created = users.findUserByUsername('ops') + expect(created?.role).toBe('admin') + expect(users.listUserProfiles(created!.id).map(profile => profile.profile_name)).toEqual(['research']) + }) + + it('does not allow disabling the last active super admin', async () => { + const { users } = await initUsers() + const admin = users.bootstrapDefaultSuperAdmin('admin', '123456')! + vi.doMock('../../packages/server/src/services/hermes/hermes-profile', () => ({ + listProfileNamesFromDisk: () => ['default'], + })) + const ctrl = await import('../../packages/server/src/controllers/auth') + const ctx = { + state: { user: { id: admin.id, username: 'admin', role: 'super_admin' } }, + params: { id: String(admin.id) }, + request: { body: { status: 'disabled' } }, + status: 200, + body: null, + } as any + + await ctrl.updateManagedUser(ctx) + + expect(ctx.status).toBe(400) + expect(ctx.body).toEqual({ error: 'You cannot disable your own account' }) + }) + + it('requires super admin for super-admin-only middleware', async () => { + const { auth } = await initUsers() + const adminCtx = makeCtx({ id: 2, username: 'ops', role: 'admin' }, 'default') + await auth.requireSuperAdmin(adminCtx, vi.fn(async () => {})) + expect(adminCtx.status).toBe(403) + + const superCtx = makeCtx({ id: 1, username: 'admin', role: 'super_admin' }, 'default') + const next = vi.fn(async () => {}) + await auth.requireSuperAdmin(superCtx, next) + expect(next).toHaveBeenCalledOnce() + }) +})