subaccountPermissions.test.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
  1. // @vitest-environment node
  2. import { randomUUID } from 'node:crypto'
  3. import { mkdtempSync, rmSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  7. import request from 'supertest'
  8. import { createApplication, type ApplicationRuntime } from '../../src/server/app'
  9. import { loadServerConfig } from '../../src/server/config'
  10. import { LocalAuthStore } from '../../src/server/localAuth'
  11. let runtime: ApplicationRuntime, home: string, admin: ReturnType<typeof request.agent>, child: ReturnType<typeof request.agent>
  12. let adminCSRF: string, childCSRF: string, childID: string, upstreamPaths: string[]
  13. const host = '127.0.0.1:15300'
  14. function call(client: ReturnType<typeof request.agent>, method: 'get' | 'post' | 'put' | 'patch' | 'delete', path: string, csrf = '') {
  15. return client[method](path).set('Host', host).set('Origin', `http://${host}`).set('X-CSRF-Token', csrf)
  16. }
  17. async function signInChild(password = 'child-password') {
  18. const boot = await call(child, 'get', '/api/app/bootstrap').expect(200)
  19. const login = await call(child, 'post', '/api/app/login', boot.body.csrfToken).send({ username: 'child', password }).expect(200)
  20. childCSRF = login.body.csrfToken
  21. }
  22. beforeEach(async () => {
  23. home = mkdtempSync(join(tmpdir(), 'yaoyao-subaccount-')); upstreamPaths = []
  24. const config = loadServerConfig({ HERMES_YAOYAO_HOME: home, HERMES_UPSTREAM_URL: 'http://127.0.0.1:1' })
  25. runtime = createApplication({ config, fetchImpl: (async input => {
  26. const path = new URL(String(input)).pathname; upstreamPaths.push(path)
  27. if (path === '/api/status') return Response.json({ auth_required: true })
  28. if (path === '/api/auth/me') return Response.json({ user_id: 'service' })
  29. if (path === '/api/profiles') return Response.json({ profiles: [{ name: 'allowed' }, { name: 'private' }] })
  30. return Response.json({ ok: true })
  31. }) as typeof fetch })
  32. vi.spyOn(runtime.workspaceRuntime, 'wake').mockImplementation(() => {})
  33. admin = request.agent(runtime.app.callback()); child = request.agent(runtime.app.callback())
  34. const boot = await call(admin, 'get', '/api/app/bootstrap').expect(200)
  35. const setup = await call(admin, 'post', '/api/app/setup', boot.body.csrfToken).send({ username: 'owner', password: 'owner-password' }).expect(200)
  36. adminCSRF = setup.body.csrfToken
  37. const created = await call(admin, 'post', '/api/app/admin/users', adminCSRF).send({ username: 'child', password: 'temporary-password', assignedProfiles: ['allowed'] }).expect(201)
  38. childID = created.body.id
  39. await signInChild('temporary-password')
  40. const changed = await call(child, 'put', '/api/app/account/credentials', childCSRF).send({ currentPassword: 'temporary-password', newPassword: 'child-password' }).expect(200)
  41. childCSRF = changed.body.csrfToken
  42. })
  43. afterEach(() => { runtime.close(); rmSync(home, { recursive: true, force: true }); vi.restoreAllMocks() })
  44. describe('subaccount Bot-only security boundary with real login sessions', () => {
  45. it('persists assignment, filters sources, hides native profiles and rejects unassigned/local-node spoofing', async () => {
  46. expect((await call(child, 'get', '/api/app/bootstrap')).body.profiles).toEqual([])
  47. expect((await call(child, 'get', '/api/profiles')).body.profiles).toEqual([])
  48. const sources = await call(child, 'get', '/api/app/agents/sources').expect(200)
  49. expect(sources.body.sources.map((s: any) => s.profile)).toEqual(['allowed'])
  50. expect(new LocalAuthStore(home).canUseSource(childID, 'local', 'allowed')).toBe(true)
  51. expect(new LocalAuthStore(home).canUseSource(childID, 'local', 'private')).toBe(false)
  52. const caps = await call(child, 'get', '/api/app/capabilities').expect(200)
  53. expect(caps.body.features).not.toContain('nodes')
  54. await call(child, 'post', '/api/app/agents', childCSRF).send({ name: 'mine', profile: 'allowed' }).expect(201)
  55. for (const input of [{ profile: 'private' }, { profile: 'allowed', nodeId: randomUUID() }, { profile: 'ALLOWED' }]) {
  56. await call(child, 'post', '/api/app/agents', childCSRF).send({ name: 'forbidden', ...input }).expect(403)
  57. }
  58. })
  59. it('denies native REST, realtime, relay, settings secrets and administration before any upstream request', async () => {
  60. upstreamPaths.length = 0
  61. for (const path of ['/Users/test/.hermes/cache/private.png', '/Users/test/.hermes/profiles/private/images/private.png', '/Users/test/Agents/private.txt', '/attachments/private.txt', '/api/sessions', '/api/profiles/sessions', '/api/profile-identities', '/api/files/download?path=/tmp/private', '/api/realtime/capabilities', '/api/app/hermes-bot/local/api/profiles', '/api/app/admin/users', '/api/app/system/push-config', '/api/app/voice/runtime', '/api/app/tts/settings', '/api/app/models', '/api/app/nodes', '/api/app/session-context/test']) {
  62. await call(child, 'get', path).expect(403)
  63. }
  64. for (const path of ['/api/realtime/channels', '/api/app/nodes', '/api/app/agents/remote', '/api/app/agents/remote/', '/api/app/pairings', '/api/app/admin/users']) {
  65. await call(child, 'post', path, childCSRF).send({ channel: 'chat' }).expect(403)
  66. }
  67. expect(upstreamPaths).toEqual([])
  68. })
  69. it('keeps agents, conversations, files, runs and interactions isolated by owner', async () => {
  70. const a = await call(admin, 'post', '/api/app/agents', adminCSRF).send({ name: 'admin bot', profile: 'private' }).expect(201)
  71. const c = (await call(admin, 'get', '/api/app/conversations')).body.conversations[0]
  72. expect((await call(child, 'get', '/api/app/agents')).body.agents).toEqual([])
  73. expect((await call(child, 'get', '/api/app/conversations')).body.conversations).toEqual([])
  74. for (const path of [`/api/app/conversations/${c.id}`, `/api/app/conversations/${c.id}/messages`, `/api/app/files/${randomUUID()}/download`]) await call(child, 'get', path).expect(404)
  75. await call(child, 'patch', `/api/app/agents/${a.body.agent.id}`, childCSRF).send({ name: 'stolen' }).expect(404)
  76. await call(child, 'post', `/api/app/conversations/${c.id}/messages`, childCSRF).send({ requestId: randomUUID(), content: 'stolen' }).expect(404)
  77. await call(child, 'post', `/api/app/runs/${randomUUID()}/reconcile`, childCSRF).send({}).expect(404)
  78. await call(child, 'post', `/api/app/interactions/${randomUUID()}/respond`, childCSRF).send({ answer: 'approve' }).expect(404)
  79. })
  80. it('invalidates sessions and stops existing work when assignment is revoked, including old agent references', async () => {
  81. const a = (await call(child, 'post', '/api/app/agents', childCSRF).send({ name: 'mine', profile: 'allowed' }).expect(201)).body.agent
  82. const c = (await call(child, 'get', '/api/app/conversations')).body.conversations[0]
  83. await call(child, 'post', `/api/app/conversations/${c.id}/messages`, childCSRF).send({ requestId: randomUUID(), content: 'allowed' }).expect(202)
  84. const stop = vi.spyOn(runtime.workspaceRuntime, 'stopConversation')
  85. await call(admin, 'patch', `/api/app/admin/users/${childID}`, adminCSRF).send({ assignedProfiles: [] }).expect(200)
  86. expect(stop).toHaveBeenCalledWith(childID, c.id)
  87. await call(child, 'get', '/api/app/agents').expect(401)
  88. await signInChild()
  89. expect((await call(child, 'get', '/api/app/agents/sources')).body.sources).toEqual([])
  90. await call(child, 'post', `/api/app/conversations/${c.id}/messages`, childCSRF).send({ requestId: randomUUID(), content: 'revoked' }).expect(403)
  91. await call(child, 'patch', `/api/app/agents/${a.id}`, childCSRF).send({ name: 'revoked' }).expect(403)
  92. await call(child, 'post', '/api/app/conversations', childCSRF).send({ name: 'bypass', memberIds: [a.id], administratorId: a.id }).expect(403)
  93. })
  94. })