/** * kbdb_* 資料層工具:**用登入進來的那個人的身分查詢**(2026-08-12)。 * * leo:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;AI 透過輸入帳密的 * MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」 * 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ 下游不得再要求第二次認證。 * * 本檔守三件事: * ① 以帳密連線時,查詢**帶登入者的 portal session** 打 cypher `/portal/data/*` * ——不再拿 KBDB 的服務內部金鑰直打 KBDB(那條路繞過所有庫過濾)。 * ② 呼叫端自帶的 owner_id **一律不生效**(範圍由帳號權限決定,不由呼叫端指定)。 * ③ 舊 token(沒有身分)**fail-closed**:誠實要求重新連線,不偷偷退回服務金鑰那條老路。 * ④ 服務級憑據(static token / partner key)維持既有 KBDB 直連(零回歸)。 */ import { describe, it, expect } from "vitest"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { Env } from "../../../src/types.js"; import { registerAllKbdbDataTools } from "../../../src/tools/kbdb_data.js"; import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js"; type ToolHandler = (args: Record) => Promise<{ content: { type: string; text: string }[]; isError?: boolean; }>; function makeServer() { const tools = new Map(); const server = { tool(name: string, description: string, _schema: unknown, handler: ToolHandler) { tools.set(name, { description, handler }); }, }; return { server: server as unknown as McpServer, tools }; } /** 兩個 binding 都掛上,才驗得出「該走哪一條」——走錯的那條會被記錄下來。 */ function makeEnv(respond: (which: "cypher" | "kbdb", url: URL, init?: RequestInit) => Response) { const cypherCalls: { url: URL; init?: RequestInit }[] = []; const kbdbCalls: { url: URL; init?: RequestInit }[] = []; const env = { CYPHER_EXECUTOR: { fetch: async (input: string, init?: RequestInit) => { const url = new URL(input); cypherCalls.push({ url, init }); return respond("cypher", url, init); }, }, KBDB: { fetch: async (input: string, init?: RequestInit) => { const url = new URL(input); kbdbCalls.push({ url, init }); return respond("kbdb", url, init); }, }, KBDB_INTERNAL_TOKEN: "service-key-should-not-be-used-on-portal-path", } as unknown as Env; return { env, cypherCalls, kbdbCalls }; } function parseResult(r: { content: { text: string }[] }) { return JSON.parse(r.content[0].text) as Record; } const PORTAL: KnowledgeIdentity = { kind: "portal", portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] }, }; const SERVICE: KnowledgeIdentity = { kind: "service" }; const STALE: KnowledgeIdentity = { kind: "stale" }; function tools(identity: KnowledgeIdentity, respond: Parameters[0]) { const { server, tools } = makeServer(); const e = makeEnv(respond); registerAllKbdbDataTools(server, e.env, identity); return { tools, ...e }; } const OK = () => new Response(JSON.stringify({ success: true, entries: [], records: [], count: 0 })); describe("kbdb_* 以登入者身分查詢(portal 資料面)", () => { const cases: Array<{ tool: string; args: Record; path: string; method?: string }> = [ { tool: "kbdb_search", args: { q: "火星座標" }, path: "/portal/data/search" }, { tool: "kbdb_query", args: { template: "triplet" }, path: "/portal/data/records/by-template/triplet" }, { tool: "kbdb_get_record", args: { record_id: "rec_1" }, path: "/portal/data/records/rec_1" }, { tool: "kbdb_list_templates", args: {}, path: "/portal/data/templates" }, { tool: "kbdb_create_template", args: { name: "contact", slots: ["name"] }, path: "/portal/data/templates", method: "POST" }, { tool: "kbdb_create_record", args: { template: "contact", values: { name: "Leo" } }, path: "/portal/data/records", method: "POST" }, ]; for (const c of cases) { it(`${c.tool} → 打 ${c.path},帶登入者 session,完全不碰 KBDB 服務金鑰`, async () => { const { tools: t, cypherCalls, kbdbCalls } = tools(PORTAL, OK); const res = await t.get(c.tool)!.handler(c.args); expect(res.isError).toBeUndefined(); // 走的是 cypher 的 portal 資料面,不是 KBDB 直連 expect(kbdbCalls, `${c.tool} 不該直打 KBDB`).toHaveLength(0); expect(cypherCalls).toHaveLength(1); expect(cypherCalls[0].url.pathname).toBe(c.path); expect(cypherCalls[0].init?.method ?? "GET").toBe(c.method ?? "GET"); // 帶的是「那個人的 session」,不是任何服務金鑰 const auth = new Headers(cypherCalls[0].init!.headers as HeadersInit).get("Authorization"); expect(auth).toBe("Bearer sess-abc"); expect(auth).not.toContain("service-key"); }); } it("呼叫端自帶 owner_id 一律不生效(不讓呼叫端自己挑租戶/歸屬)", async () => { const { tools: t, cypherCalls } = tools(PORTAL, OK); await t.get("kbdb_search")!.handler({ q: "x", owner_id: "someone-else" }); await t.get("kbdb_query")!.handler({ template: "triplet", owner_id: "someone-else" }); for (const call of cypherCalls) { expect(call.url.searchParams.get("owner_id")).toBeNull(); } }); it("寫入時 owner_id 不從呼叫端 body 走(server 定死成登入者的歸屬)", async () => { const { tools: t, cypherCalls } = tools(PORTAL, OK); await t.get("kbdb_create_record")!.handler({ template: "contact", values: { name: "Leo" }, owner_id: "someone-else", }); const body = JSON.parse(String(cypherCalls[0].init!.body)) as Record; expect(body).not.toHaveProperty("owner_id"); }); it("越庫寫入被擋(403)→ 誠實講是權限問題", async () => { const { tools: t } = tools(PORTAL, () => new Response(JSON.stringify({ error: '無「secret」庫的權限,不能寫入該庫' }), { status: 403 }), ); const res = await t.get("kbdb_create_record")!.handler({ template: "note", values: { library: "secret", body: "x" }, }); expect(res.isError).toBe(true); expect(parseResult(res).error_code).toBe("forbidden"); }); it("查不是自己的 record(404)→ 與「不存在」同一句話(不洩存在性)", async () => { const { tools: t } = tools(PORTAL, () => new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 }), ); const res = await t.get("kbdb_get_record")!.handler({ record_id: "rec_someone_else" }); expect(res.isError).toBe(true); expect(parseResult(res).error_code).toBe("not_found"); expect(String(parseResult(res).human_message)).toContain("不在你的權限範圍內"); }); it("session 過期(401)→ session_expired,不謊稱資料是空的", async () => { const { tools: t } = tools(PORTAL, () => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }), ); const res = await t.get("kbdb_search")!.handler({ q: "x" }); expect(res.isError).toBe(true); expect(parseResult(res).error_code).toBe("session_expired"); }); }); describe("fail-closed:舊 token 沒有身分就查不到東西(不退回服務金鑰)", () => { for (const name of [ "kbdb_search", "kbdb_query", "kbdb_get_record", "kbdb_list_templates", "kbdb_create_template", "kbdb_create_record", ]) { it(`${name} → identity_missing,且一個查詢都不發`, async () => { const { tools: t, cypherCalls, kbdbCalls } = tools(STALE, OK); const res = await t.get(name)!.handler({ q: "x", template: "t", record_id: "r", name: "n", slots: ["a"], values: { a: "b" }, }); expect(res.isError).toBe(true); expect(parseResult(res).error_code).toBe("identity_missing"); expect(cypherCalls).toHaveLength(0); expect(kbdbCalls).toHaveLength(0); }); } }); describe("回歸:服務級憑據維持既有 KBDB 直連", () => { it("kbdb_search 仍直打 KBDB /entries/search,且照舊吃 owner_id", async () => { const { tools: t, cypherCalls, kbdbCalls } = tools(SERVICE, OK); const res = await t.get("kbdb_search")!.handler({ q: "x", owner_id: "leo" }); expect(res.isError).toBeUndefined(); expect(cypherCalls).toHaveLength(0); expect(kbdbCalls).toHaveLength(1); expect(kbdbCalls[0].url.pathname).toBe("/entries/search"); expect(kbdbCalls[0].url.searchParams.get("owner_id")).toBe("leo"); }); it("kbdb_query / kbdb_get_record 路徑不變", async () => { const { tools: t, kbdbCalls } = tools(SERVICE, OK); await t.get("kbdb_query")!.handler({ template: "triplet" }); await t.get("kbdb_get_record")!.handler({ record_id: "rec_1" }); expect(kbdbCalls.map((c) => c.url.pathname)).toEqual([ "/records/by-template/triplet", "/records/rec_1", ]); }); });