Add user-scoped Hermes profile access

This commit is contained in:
ekko
2026-05-23 18:44:53 +08:00
committed by ekko
parent 56e7716302
commit 3f6a25d8f1
54 changed files with 2656 additions and 592 deletions
+14 -1
View File
@@ -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 () => {
+48
View File
@@ -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<string, any> = {}) {
@@ -91,6 +96,7 @@ function ctx(overrides: Record<string, any> = {}) {
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' })
@@ -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({
@@ -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()
})
})
+31 -1
View File
@@ -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',
+264
View File
@@ -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()
})
})