feat(ai): encrypt MCP env and headers at rest and mask them in the admin API
env/headers are sealed into secretCipher with the BYOK AES-GCM/HKDF box. The admin GET returns masked values; a mask posted back for the same server and key keeps the stored value, any other mask is a 400. The chat and the test button use the stored values. upgradeAiData seals plaintext v1 rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a995900fba
commit
ddc66f5be5
8 files changed
+261
-12
No files matched your search
@@ -245,3 +245,41 @@ forEachDb("authoring uses core canView: the global author role reaches restricte
|
||||
await db.sql`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES ('secret', 'user', 'ann')`;
|
||||
expect((await call("POST", "/api/ai/studio/generate", "ann", body)).status).toBe(403);
|
||||
});
|
||||
|
||||
forEachDb("the admin API masks MCP secrets, and the test button and chat use the stored values", async (db) => {
|
||||
const seen: (string | null)[] = [];
|
||||
const mcpFetch = (async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
seen.push(new Headers(init?.headers).get("authorization"));
|
||||
const msg = JSON.parse(String(init?.body ?? "{}"));
|
||||
if (msg.id === undefined) return new Response(null, { status: 202 });
|
||||
const result = msg.method === "initialize" ? { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: { name: "f", version: "1" } }
|
||||
: msg.method === "tools/list" ? { tools: [{ name: "ping", description: "Ping", inputSchema: { type: "object" } }] }
|
||||
: {};
|
||||
return Response.json({ jsonrpc: "2.0", id: msg.id, result });
|
||||
}) as unknown as typeof fetch;
|
||||
const { call } = await setup(db, [() => ({ text: "ok" })], { fetch: mcpFetch });
|
||||
const web = { id: "web", name: "Web", transport: "http", url: "https://mcp.example.com/mcp", headers: { authorization: "Bearer hdr-SECRET-abcdef" } };
|
||||
expect((await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic", apiKey: "k" }, mcpServers: [web] })).status).toBe(200);
|
||||
const text = await (await call("GET", "/api/ai/admin/config", "owner")).text();
|
||||
expect(text).not.toContain("SECRET");
|
||||
const shown = JSON.parse(text).mcpServers[0];
|
||||
expect(shown.headers).toEqual({ authorization: "••••cdef" });
|
||||
|
||||
// Saving the page unchanged keeps the secret; a stale mask is a 400 and changes nothing.
|
||||
expect((await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic" }, mcpServers: [shown] })).status).toBe(200);
|
||||
const stale = await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic" }, mcpServers: [{ ...shown, id: "web2" }] });
|
||||
expect(stale.status).toBe(400);
|
||||
expect((await stale.json()).error).toContain("Re-enter");
|
||||
|
||||
// The test button posts the masked draft; the MCP server receives the stored header.
|
||||
const t = await (await call("POST", "/api/ai/admin/mcp/test", "owner", shown)).json();
|
||||
expect(t).toMatchObject({ ok: true, tools: [{ name: "ping" }] });
|
||||
expect(seen.filter(Boolean).every((h) => h === "Bearer hdr-SECRET-abcdef")).toBe(true);
|
||||
expect(seen.length).toBeGreaterThan(0);
|
||||
|
||||
// Chat connects with the stored header too.
|
||||
seen.length = 0;
|
||||
await (await call("POST", "/api/ai/courses/sample/chat", "ann", { message: "hello" })).text();
|
||||
expect(seen.length).toBeGreaterThan(0);
|
||||
expect(seen.filter(Boolean).every((h) => h === "Bearer hdr-SECRET-abcdef")).toBe(true);
|
||||
});
|
||||
Reference in new issue
Block a user