fix(mcp): MCP 用登入者的身分查詢,不再去找一把服務內部金鑰
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>
This commit is contained in:
@@ -7,6 +7,32 @@ import {
|
||||
renderLibraryMapLines,
|
||||
__resetLibraryMapInstructionsCacheForTests,
|
||||
} from "../../../src/lib/library-map.js";
|
||||
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
|
||||
|
||||
/** 服務級憑據(static token / partner key)——既有 KBDB 直連路徑,行為零變更。 */
|
||||
const SERVICE: KnowledgeIdentity = { kind: "service" };
|
||||
/** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面(只看得到自己有權限的庫)。 */
|
||||
const PORTAL: KnowledgeIdentity = {
|
||||
kind: "portal",
|
||||
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] },
|
||||
};
|
||||
/** 本次改版前簽發的舊 token(沒有身分)。 */
|
||||
const STALE: KnowledgeIdentity = { kind: "stale" };
|
||||
|
||||
/** 假 CYPHER_EXECUTOR binding(portal 資料面用)。 */
|
||||
function makePortalEnv(respond: (url: URL, init?: RequestInit) => Response) {
|
||||
const calls: { url: URL; init?: RequestInit }[] = [];
|
||||
const env = {
|
||||
CYPHER_EXECUTOR: {
|
||||
fetch: async (input: string, init?: RequestInit) => {
|
||||
const url = new URL(input);
|
||||
calls.push({ url, init });
|
||||
return respond(url, init);
|
||||
},
|
||||
},
|
||||
} as unknown as Env;
|
||||
return { env, calls };
|
||||
}
|
||||
|
||||
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫(比照 kbdb-graph.test.ts)──────
|
||||
type ToolHandler = (args: Record<string, unknown>) => Promise<{
|
||||
@@ -56,7 +82,7 @@ describe("kbdb_get_map: registration", () => {
|
||||
it("registers under kbdb_* prefix (D17) with the 'call this first' hint in description", () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env } = makeEnv(() => new Response("{}"));
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
expect(tools.has("kbdb_get_map")).toBe(true);
|
||||
// 任務規格:description 必含「不確定該查什麼時,先呼叫此工具」
|
||||
expect(tools.get("kbdb_get_map")!.description).toContain("不確定該查什麼時,先呼叫此工具");
|
||||
@@ -69,7 +95,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
||||
const { env, calls } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||
);
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
@@ -90,7 +116,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
||||
const { env, calls } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||||
);
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
await tools.get("kbdb_get_map")!.handler({ owner_id: "leo" });
|
||||
expect(calls[0].url.searchParams.get("owner_id")).toBe("leo");
|
||||
});
|
||||
@@ -105,7 +131,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [row], count: 1 })),
|
||||
);
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
const data = parseResult(res).data as {
|
||||
libraries: { top_entities: string[]; triplet_count: number }[];
|
||||
@@ -119,7 +145,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||||
);
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
const body = parseResult(res);
|
||||
expect(body.ok).toBe(true);
|
||||
@@ -135,7 +161,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||||
);
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
const body = parseResult(res);
|
||||
const hintsText = JSON.stringify(body.hints);
|
||||
@@ -149,7 +175,7 @@ describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
||||
it("HTTP error → map_fetch_failed with recompute hint, not a crash", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
expect(res.isError).toBe(true);
|
||||
const body = parseResult(res);
|
||||
@@ -178,7 +204,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
|
||||
const { env, calls } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, map: DETAIL })),
|
||||
);
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
|
||||
expect(calls[0].url.pathname).toBe("/map/kb");
|
||||
const map = (parseResult(res).data as { map: typeof DETAIL }).map;
|
||||
@@ -197,7 +223,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
|
||||
triplet_count: "111",
|
||||
};
|
||||
const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: raw })));
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
|
||||
expect(res.isError).toBeUndefined();
|
||||
const map = (parseResult(res).data as { map: Record<string, unknown> }).map;
|
||||
@@ -212,7 +238,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: false, error: "not found" }), { status: 404 }),
|
||||
);
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({ library: "ghost" });
|
||||
expect(res.isError).toBe(true);
|
||||
const body = parseResult(res);
|
||||
@@ -233,7 +259,7 @@ describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
|
||||
},
|
||||
},
|
||||
} as unknown as Env;
|
||||
registerGetMap(server, env);
|
||||
registerGetMap(server, env, SERVICE);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("internal_error");
|
||||
@@ -258,7 +284,7 @@ describe("buildLibraryMapInstructions", () => {
|
||||
}),
|
||||
),
|
||||
);
|
||||
const text = await buildLibraryMapInstructions(env);
|
||||
const text = await buildLibraryMapInstructions(env, SERVICE);
|
||||
expect(text).not.toBeNull();
|
||||
// design §4 格式:{library}:{narrative}|核心:{top3}|{triplet_count} triplets
|
||||
expect(text!).toContain("kb:leo 的知識庫主庫|核心:00-INDEX、kb/00-INDEX、Gitea|111 triplets");
|
||||
@@ -269,7 +295,7 @@ describe("buildLibraryMapInstructions", () => {
|
||||
|
||||
it("HTTP error → null(靜默略過,不 throw 不擋連線)", async () => {
|
||||
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
|
||||
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
|
||||
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("binding throws → null(靜默略過)", async () => {
|
||||
@@ -280,22 +306,22 @@ describe("buildLibraryMapInstructions", () => {
|
||||
},
|
||||
},
|
||||
} as unknown as Env;
|
||||
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
|
||||
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("empty libraries → null(沒地圖就不注入,不塞空段落)", async () => {
|
||||
const { env } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||||
);
|
||||
await expect(buildLibraryMapInstructions(env)).resolves.toBeNull();
|
||||
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("caches within TTL:same isolate 第二次不再打 /map", async () => {
|
||||
const { env, calls } = makeEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||
);
|
||||
const first = await buildLibraryMapInstructions(env);
|
||||
const second = await buildLibraryMapInstructions(env);
|
||||
const first = await buildLibraryMapInstructions(env, SERVICE);
|
||||
const second = await buildLibraryMapInstructions(env, SERVICE);
|
||||
expect(second).toBe(first);
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
@@ -318,3 +344,95 @@ describe("renderLibraryMapLines", () => {
|
||||
expect(renderLibraryMapLines([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2026-08-12:地圖也要跟著登入者的權限走 ────────────────────────────────────
|
||||
// 地圖本身就是情報(有哪些庫、各有多少關聯、核心 entity 是誰)——不能整館推給
|
||||
// 一個只有部分權限的帳號。
|
||||
describe("藏書地圖:登入身分(portal 資料面)", () => {
|
||||
beforeEach(() => __resetLibraryMapInstructionsCacheForTests());
|
||||
|
||||
it("kbdb_get_map 打 /portal/data/map,帶登入者 session,不碰 KBDB 服務金鑰", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env, calls } = makePortalEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||
);
|
||||
registerGetMap(server, env, PORTAL);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
expect(res.isError).toBeUndefined();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].url.pathname).toBe("/portal/data/map");
|
||||
expect(new Headers(calls[0].init!.headers as HeadersInit).get("Authorization")).toBe("Bearer sess-abc");
|
||||
});
|
||||
|
||||
it("呼叫端硬塞 owner_id 也不生效(查詢範圍由帳號權限決定,不由呼叫端指定)", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env, calls } = makePortalEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||
);
|
||||
registerGetMap(server, env, PORTAL);
|
||||
await tools.get("kbdb_get_map")!.handler({ owner_id: "someone-else" });
|
||||
expect(calls[0].url.searchParams.get("owner_id")).toBeNull();
|
||||
});
|
||||
|
||||
it("查沒權限的庫 → 與「不存在」同一句話(不洩存在性)", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env } = makePortalEnv(() => new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 }));
|
||||
registerGetMap(server, env, PORTAL);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({ library: "secret-lib" });
|
||||
expect(res.isError).toBe(true);
|
||||
const body = parseResult(res);
|
||||
expect(body.error_code).toBe("map_not_found");
|
||||
expect(String(body.human_message)).toContain("不在你被授權");
|
||||
});
|
||||
|
||||
it("session 過期(401)→ session_expired,不說「地圖是空的」", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env } = makePortalEnv(
|
||||
() => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
|
||||
);
|
||||
registerGetMap(server, env, PORTAL);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("session_expired");
|
||||
});
|
||||
|
||||
it("舊 token(沒身分)→ identity_missing,且一個查詢都不發(fail-closed)", async () => {
|
||||
const { server, tools } = makeServer();
|
||||
const { env, calls } = makePortalEnv(() => new Response("{}"));
|
||||
registerGetMap(server, env, STALE);
|
||||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||||
expect(res.isError).toBe(true);
|
||||
expect(parseResult(res).error_code).toBe("identity_missing");
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("instructions 的地圖也走 portal 資料面(連線開場推的庫名不得超出權限)", async () => {
|
||||
const { env, calls } = makePortalEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||
);
|
||||
const text = await buildLibraryMapInstructions(env, PORTAL);
|
||||
expect(text).toContain("kb");
|
||||
expect(calls[0].url.pathname).toBe("/portal/data/map");
|
||||
});
|
||||
|
||||
it("**快取不跨身分共用**:不同 session 各自打一次,不會拿到別人的視野", async () => {
|
||||
const { env, calls } = makePortalEnv(
|
||||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||||
);
|
||||
const other: KnowledgeIdentity = {
|
||||
kind: "portal",
|
||||
portal: { session: "sess-other", display_name: "小明", role: "user", libraries: ["notes"] },
|
||||
};
|
||||
await buildLibraryMapInstructions(env, PORTAL);
|
||||
await buildLibraryMapInstructions(env, other);
|
||||
expect(calls).toHaveLength(2); // 兩次真的各打一次
|
||||
await buildLibraryMapInstructions(env, PORTAL);
|
||||
expect(calls).toHaveLength(2); // 同一 session 第二次才吃快取
|
||||
});
|
||||
|
||||
it("舊 token → 不給地圖(instructions 不外洩任何庫名)", async () => {
|
||||
const { env, calls } = makePortalEnv(() => new Response("{}"));
|
||||
expect(await buildLibraryMapInstructions(env, STALE)).toBeNull();
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user