admin-agents.ts181 lines · main
| 1 | import { Hono, type Context } from 'hono'; |
| 2 | import { z } from 'zod'; |
| 3 | |
| 4 | import { requireAdmin } from '../middleware/admin.js'; |
| 5 | import { requireAuth } from '../middleware/session.js'; |
| 6 | import { requireRecentMfa } from '../middleware/step-up.js'; |
| 7 | import type { AppEnv } from '../types/app-env.js'; |
| 8 | import { platformAgentScope } from '../db/schema.js'; |
| 9 | import { audit, hashIp } from '../services/audit.js'; |
| 10 | import { |
| 11 | createPlatformAgent, |
| 12 | deletePlatformAgent, |
| 13 | listPlatformAgents, |
| 14 | testPlatformAgent, |
| 15 | updatePlatformAgent, |
| 16 | } from '../services/platform-agents.js'; |
| 17 | |
| 18 | /** |
| 19 | * Admin AI-agent manager — /v1/admin/agents. The write surface behind the |
| 20 | * cockpit's "AI Agents" page: register named agents (provider + endpoint + |
| 21 | * model + scope), rotate their encrypted provider keys, flip them on/off, |
| 22 | * and ping their endpoints. Split out of routes/admin.ts the same way |
| 23 | * admin-manifest.ts is, to keep that file from growing without bound. |
| 24 | * |
| 25 | * Guard chain mirrors admin.ts EXACTLY: session → admin bit → step-up |
| 26 | * freshness on every mutation (reads pass so the page renders without |
| 27 | * re-prompting). The api key is accepted as input, encrypted at rest via |
| 28 | * services/platform-agents.ts, and NEVER returned or logged — audit rows |
| 29 | * record only that a key was set/rotated, never its value. |
| 30 | */ |
| 31 | |
| 32 | function ipHash(c: Context<AppEnv>): string | null { |
| 33 | const fwd = c.req.raw.headers.get('x-forwarded-for'); |
| 34 | const ip = fwd ? fwd.split(',')[0]!.trim() : null; |
| 35 | return hashIp(ip); |
| 36 | } |
| 37 | |
| 38 | export const adminAgentsRouter = new Hono<AppEnv>(); |
| 39 | |
| 40 | for (const path of ['/v1/admin/agents', '/v1/admin/agents/*'] as const) { |
| 41 | adminAgentsRouter.use(path, requireAuth()); |
| 42 | adminAgentsRouter.use(path, requireAdmin()); |
| 43 | } |
| 44 | // Every agent MUTATION requires fresh step-up auth per CLAUDE.md §5.4 — |
| 45 | // same method-gated pattern as routes/admin.ts. |
| 46 | const mfa = requireRecentMfa(10); |
| 47 | for (const path of ['/v1/admin/agents', '/v1/admin/agents/*'] as const) { |
| 48 | adminAgentsRouter.use(path, async (c, next) => { |
| 49 | const method = c.req.method; |
| 50 | if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') { |
| 51 | return next(); |
| 52 | } |
| 53 | return mfa(c, next); |
| 54 | }); |
| 55 | } |
| 56 | |
| 57 | /* ─── validation ─────────────────────────────────────────────────────── */ |
| 58 | |
| 59 | const scopeSchema = z.enum(platformAgentScope); |
| 60 | // Min 12 keeps the masked prefix(4)+suffix(4) hint from ever revealing a |
| 61 | // whole key; max 4096 bounds the ciphertext column. |
| 62 | const apiKeySchema = z.string().min(12).max(4096); |
| 63 | const endpointSchema = z.string().url().max(2000); |
| 64 | |
| 65 | const createAgentSchema = z.object({ |
| 66 | name: z.string().min(1).max(120), |
| 67 | provider: z.string().min(1).max(40), |
| 68 | endpoint: endpointSchema.optional(), |
| 69 | apiKey: apiKeySchema.optional(), |
| 70 | model: z.string().min(1).max(120), |
| 71 | scope: scopeSchema, |
| 72 | enabled: z.boolean().optional(), |
| 73 | }); |
| 74 | |
| 75 | const updateAgentSchema = z |
| 76 | .object({ |
| 77 | name: z.string().min(1).max(120).optional(), |
| 78 | provider: z.string().min(1).max(40).optional(), |
| 79 | // Nullable so an admin can clear a stale endpoint back to the provider |
| 80 | // default without deleting the agent. |
| 81 | endpoint: endpointSchema.nullable().optional(), |
| 82 | apiKey: apiKeySchema.optional(), |
| 83 | model: z.string().min(1).max(120).optional(), |
| 84 | scope: scopeSchema.optional(), |
| 85 | enabled: z.boolean().optional(), |
| 86 | }) |
| 87 | .refine((v) => Object.keys(v).length > 0, { message: 'at least one field is required' }); |
| 88 | |
| 89 | /* ─── routes ─────────────────────────────────────────────────────────── */ |
| 90 | |
| 91 | adminAgentsRouter.get('/v1/admin/agents', async (c) => { |
| 92 | const agents = await listPlatformAgents(); |
| 93 | return c.json({ agents }); |
| 94 | }); |
| 95 | |
| 96 | adminAgentsRouter.post('/v1/admin/agents', async (c) => { |
| 97 | const actor = c.get('user')!; |
| 98 | const body = await c.req.json().catch(() => null); |
| 99 | const parsed = createAgentSchema.safeParse(body); |
| 100 | if (!parsed.success) { |
| 101 | return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400); |
| 102 | } |
| 103 | const agent = await createPlatformAgent(parsed.data, actor.id); |
| 104 | await audit({ |
| 105 | actorId: actor.id, |
| 106 | projectId: null, |
| 107 | action: 'admin.agent.create', |
| 108 | ipHash: ipHash(c), |
| 109 | userAgent: c.req.header('user-agent') ?? null, |
| 110 | // Never the key — only whether one was supplied. |
| 111 | metadata: { |
| 112 | agentId: agent.id, |
| 113 | name: agent.name, |
| 114 | provider: agent.provider, |
| 115 | scope: agent.scope, |
| 116 | keySet: agent.hasKey, |
| 117 | }, |
| 118 | }); |
| 119 | return c.json({ agent }, 201); |
| 120 | }); |
| 121 | |
| 122 | adminAgentsRouter.patch('/v1/admin/agents/:id', async (c) => { |
| 123 | const actor = c.get('user')!; |
| 124 | const agentId = c.req.param('id'); |
| 125 | const body = await c.req.json().catch(() => null); |
| 126 | const parsed = updateAgentSchema.safeParse(body); |
| 127 | if (!parsed.success) { |
| 128 | return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400); |
| 129 | } |
| 130 | const { apiKey, ...rest } = parsed.data; |
| 131 | const agent = await updatePlatformAgent(agentId, parsed.data); |
| 132 | await audit({ |
| 133 | actorId: actor.id, |
| 134 | projectId: null, |
| 135 | action: 'admin.agent.update', |
| 136 | ipHash: ipHash(c), |
| 137 | userAgent: c.req.header('user-agent') ?? null, |
| 138 | metadata: { |
| 139 | agentId, |
| 140 | fields: Object.keys(rest), |
| 141 | keyRotated: apiKey !== undefined, |
| 142 | }, |
| 143 | }); |
| 144 | return c.json({ agent }); |
| 145 | }); |
| 146 | |
| 147 | /** |
| 148 | * Server-side connectivity ping — decrypts the key in memory, GETs the |
| 149 | * agent's endpoint (or the provider's well-known models url), and reports |
| 150 | * ok / rejected / unreachable. POST (not GET) because it spends real |
| 151 | * network I/O against a third party, so it belongs behind the step-up gate. |
| 152 | */ |
| 153 | adminAgentsRouter.post('/v1/admin/agents/:id/test', async (c) => { |
| 154 | const actor = c.get('user')!; |
| 155 | const agentId = c.req.param('id'); |
| 156 | const result = await testPlatformAgent(agentId); |
| 157 | await audit({ |
| 158 | actorId: actor.id, |
| 159 | projectId: null, |
| 160 | action: 'admin.agent.test', |
| 161 | ipHash: ipHash(c), |
| 162 | userAgent: c.req.header('user-agent') ?? null, |
| 163 | metadata: { agentId, ok: result.ok, status: result.status }, |
| 164 | }); |
| 165 | return c.json({ agentId, ...result }); |
| 166 | }); |
| 167 | |
| 168 | adminAgentsRouter.delete('/v1/admin/agents/:id', async (c) => { |
| 169 | const actor = c.get('user')!; |
| 170 | const agentId = c.req.param('id'); |
| 171 | await deletePlatformAgent(agentId); |
| 172 | await audit({ |
| 173 | actorId: actor.id, |
| 174 | projectId: null, |
| 175 | action: 'admin.agent.delete', |
| 176 | ipHash: ipHash(c), |
| 177 | userAgent: c.req.header('user-agent') ?? null, |
| 178 | metadata: { agentId }, |
| 179 | }); |
| 180 | return c.json({ ok: true }); |
| 181 | }); |