10d150ac2b
leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ 下游不得再要求第二次認證。
病根(不是金鑰沒同步,是身分沒接住):
oauth/routes.ts 驗完 Portal 帳密只留下 `loginOk = res.ok` 一個布林值,身分當場丟棄,
namespace 改從 `MCP_OWNER_NAMESPACE || "leo"` 拿。於是查詢時手上沒有身分可帶,
只好用 KBDB_INTERNAL_TOKEN 直打 KBDB——那條路繞過 portal 所有庫過濾,
而且不管誰登入都看到同一格、看到全部。CLI 也從不注入 MCP_OWNER_NAMESPACE,
所以那個 "leo" 預設值是每台實例的實際行為,不是理論上的邊角。
修法(走既有那條路,不發明新的):
1. 接住身分:/authorize 解析 /portal/login 回應,把 portal session token +
display_name/role/libraries 存進 authorization code → access token。
/portal/login 補回 session_expires_in,access_token TTL 夾成
min(自己的 TTL, portal session TTL)——不讓「MCP 還連著、底下 session 早死」。
cypher 回 200 但沒給 session_token(舊版)→ 不發碼,不簽一張沒有身分的 token。
2. 攜帶身分:kbdb_* 全部改走 cypher `/portal/data/*`,Authorization 帶登入者的
session。庫過濾/租戶注入/停用即時生效全在 server 側,與人類走 portal 網頁同一道閘。
kbdb_graph_neighbors 因此不再需要 kbdb_base(server 自己知道查哪個庫)。
藏書地圖(含連線時注入 instructions 的那份)同樣只回有權限的庫,快取改 per-session
分格——地圖本身就是情報,不能讓先連上的人把視野留給下一個。
3. fail-closed:舊 token 沒有身分 → 誠實要求重新連線,不偷偷退回服務金鑰那條老路。
服務級憑據(static token / partner key)維持既有 KBDB 直連,arcrun_* 零回歸。
新增 cypher portal 資料面端點(能力長在 API,MCP 只暴露;rule 07):
GET /portal/data/map、/portal/data/map/:library
GET /portal/data/templates、POST /portal/data/templates
GET /portal/data/records/by-template/:t、GET /portal/data/records/:id
POST /portal/data/records
全部:呼叫端自帶 owner_id 一律不生效;越權與不存在同回 404;寫入 owner_id 由 server 定死。
KBDB base:`GET /records/:id` 與 by-template 補回 owner_id 欄位——原本不回,
呼叫端無從判斷「這筆是不是我的」,按 id 直讀等於沒有租戶邊界。
沒動:KBDB fail-closed 閘、任何金鑰、租戶字串仍不下發給呼叫端。
驗證:
mcp tsc 綠;vitest 113/113 綠(改前 48 綠 29 紅)
cypher vitest 400 綠 / 14 紅,14 紅與 base commit a24f291 逐條相同(既有)
kbdb vitest 208 綠 / 5 紅,5 紅同為既有(migrations/*.sql 被 gitignore)
端到端 ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
209 lines
9.1 KiB
TypeScript
209 lines
9.1 KiB
TypeScript
/**
|
|
* 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<string, unknown>) => Promise<{
|
|
content: { type: string; text: string }[];
|
|
isError?: boolean;
|
|
}>;
|
|
|
|
function makeServer() {
|
|
const tools = new Map<string, { description: string; handler: ToolHandler }>();
|
|
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<string, unknown>;
|
|
}
|
|
|
|
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<typeof makeEnv>[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<string, unknown>; 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<string, unknown>;
|
|
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",
|
|
]);
|
|
});
|
|
});
|