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>
254 lines
10 KiB
TypeScript
254 lines
10 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||
import type { Env } from "../../../src/types.js";
|
||
import {
|
||
registerGraphNeighbors,
|
||
GRAPH_NEIGHBORS_WORKFLOW,
|
||
} from "../../../src/tools/kbdb_graph.js";
|
||
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
|
||
|
||
/** 服務級憑據(static token / partner key)——既有路徑,行為零變更。 */
|
||
const SERVICE: KnowledgeIdentity = { kind: "service" };
|
||
/** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面。 */
|
||
const PORTAL: KnowledgeIdentity = {
|
||
kind: "portal",
|
||
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] },
|
||
};
|
||
/** 本次改版前簽發的舊 token(沒有身分)。 */
|
||
const STALE: KnowledgeIdentity = { kind: "stale" };
|
||
|
||
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫 ─────────────────────────
|
||
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 };
|
||
}
|
||
|
||
// ── 假 CYPHER_EXECUTOR binding:記錄請求、回預設 response ───────────────────────
|
||
function makeEnv(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 };
|
||
}
|
||
|
||
function parseResult(r: { content: { text: string }[] }) {
|
||
return JSON.parse(r.content[0].text) as Record<string, unknown>;
|
||
}
|
||
|
||
describe("kbdb_graph_neighbors: registration", () => {
|
||
it("registers under kbdb_* prefix (D17 KBDB MCP boundary)", () => {
|
||
const { server, tools } = makeServer();
|
||
const { env } = makeEnv(() => new Response("{}"));
|
||
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||
expect(tools.has("kbdb_graph_neighbors")).toBe(true);
|
||
expect(tools.get("kbdb_graph_neighbors")!.description).toContain("graph");
|
||
});
|
||
});
|
||
|
||
describe("kbdb_graph_neighbors: request shape", () => {
|
||
it("hits GET /q/:ns/graph_neighbors with workflow input shape", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env, calls } = makeEnv(
|
||
() =>
|
||
new Response(
|
||
JSON.stringify({ success: true, start: "Arcrun", depth: 2, neighbors: [], count: 0 }),
|
||
{ status: 200 },
|
||
),
|
||
);
|
||
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
||
subject: "Arcrun",
|
||
depth: 2,
|
||
kbdb_base: "https://kbdb.example.com",
|
||
});
|
||
|
||
expect(calls).toHaveLength(1);
|
||
const url = calls[0].url;
|
||
expect(url.pathname).toBe(`/q/leo/${GRAPH_NEIGHBORS_WORKFLOW}`);
|
||
// workflow input 形狀(registry/examples/graph-neighbors/workflow.yaml)
|
||
expect(url.searchParams.get("node")).toBe("Arcrun");
|
||
expect(url.searchParams.get("depth")).toBe("2");
|
||
expect(url.searchParams.get("template")).toBe("graph_triplet"); // 預設
|
||
expect(url.searchParams.get("namespace")).toBe("leo"); // orgNamespace 注入
|
||
expect(url.searchParams.get("kbdb_base")).toBe("https://kbdb.example.com");
|
||
expect(url.searchParams.get("directed")).toBeNull(); // 預設無向 → 不帶
|
||
|
||
const body = parseResult(res);
|
||
expect(body.ok).toBe(true);
|
||
});
|
||
|
||
it("depth defaults to 1, directed=true is forwarded, template overridable", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env, calls } = makeEnv(
|
||
() => new Response(JSON.stringify({ success: true, neighbors: [], count: 0 })),
|
||
);
|
||
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||
await tools.get("kbdb_graph_neighbors")!.handler({
|
||
subject: "A",
|
||
kbdb_base: "https://kbdb.example.com",
|
||
template: "my_triplet",
|
||
directed: true,
|
||
});
|
||
const url = calls[0].url;
|
||
expect(url.searchParams.get("depth")).toBe("1");
|
||
expect(url.searchParams.get("directed")).toBe("true");
|
||
expect(url.searchParams.get("template")).toBe("my_triplet");
|
||
});
|
||
});
|
||
|
||
describe("kbdb_graph_neighbors: honest errors (鐵律:workflow 沒裝不 crash)", () => {
|
||
it("404 → workflow_not_installed with install hints, not a crash", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env } = makeEnv(
|
||
() => new Response(JSON.stringify({ error: '找不到 workflow "graph_neighbors"' }), { status: 404 }),
|
||
);
|
||
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
||
subject: "A",
|
||
kbdb_base: "https://kbdb.example.com",
|
||
});
|
||
expect(res.isError).toBe(true);
|
||
const body = parseResult(res);
|
||
expect(body.error_code).toBe("workflow_not_installed");
|
||
expect(JSON.stringify(body.next_actions)).toContain("graph-neighbors/workflow.yaml");
|
||
});
|
||
|
||
it("500 execution failure → graph_query_failed, error passed through", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env } = makeEnv(
|
||
() => new Response(JSON.stringify({ success: false, error: "boom", trace: [] }), { status: 500 }),
|
||
);
|
||
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
||
subject: "A",
|
||
kbdb_base: "https://kbdb.example.com",
|
||
});
|
||
expect(res.isError).toBe(true);
|
||
const body = parseResult(res);
|
||
expect(body.error_code).toBe("graph_query_failed");
|
||
expect(String(body.human_message)).toContain("boom");
|
||
});
|
||
|
||
it("HTTP 200 but workflow-level success:false → not faked as success", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env } = makeEnv(
|
||
() =>
|
||
new Response(JSON.stringify({ success: false, error: "graph_neighbors 缺 start(node)參數" }), {
|
||
status: 200,
|
||
}),
|
||
);
|
||
registerGraphNeighbors(server, env, "leo", SERVICE);
|
||
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
||
subject: "A",
|
||
kbdb_base: "https://kbdb.example.com",
|
||
});
|
||
expect(res.isError).toBe(true);
|
||
const body = parseResult(res);
|
||
expect(body.error_code).toBe("graph_query_failed");
|
||
});
|
||
|
||
it("empty orgNamespace → no_namespace error, no fetch made", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env, calls } = makeEnv(() => new Response("{}"));
|
||
registerGraphNeighbors(server, env, "", SERVICE);
|
||
const res = await tools.get("kbdb_graph_neighbors")!.handler({
|
||
subject: "A",
|
||
kbdb_base: "https://kbdb.example.com",
|
||
});
|
||
expect(res.isError).toBe(true);
|
||
expect(parseResult(res).error_code).toBe("no_namespace");
|
||
expect(calls).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
// ── 2026-08-12:以帳密連線時走登入者的身分(leo:主人查得到的,授權的 AI 就查得到)──
|
||
describe("kbdb_graph_neighbors: 登入身分(portal 資料面)", () => {
|
||
it("打 cypher 的 /portal/data/graph/neighbors,且帶的是登入者的 session(不是服務金鑰)", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env, calls } = makeEnv(
|
||
() =>
|
||
new Response(
|
||
JSON.stringify({
|
||
neighbors: [{ node: "B", predicate: "uses", from: "A", depth: 1 }],
|
||
edges: [],
|
||
count: 1,
|
||
}),
|
||
{ status: 200 },
|
||
),
|
||
);
|
||
registerGraphNeighbors(server, env, "leo", PORTAL);
|
||
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A", depth: 2 });
|
||
|
||
expect(calls).toHaveLength(1);
|
||
expect(calls[0].url.pathname).toBe("/portal/data/graph/neighbors/A");
|
||
expect(calls[0].url.searchParams.get("depth")).toBe("2");
|
||
const headers = new Headers(calls[0].init!.headers as HeadersInit);
|
||
expect(headers.get("Authorization")).toBe("Bearer sess-abc");
|
||
|
||
expect(res.isError).toBeUndefined();
|
||
expect((parseResult(res).data as { count: number }).count).toBe(1);
|
||
});
|
||
|
||
it("**不需要 kbdb_base**:已經登入過了,不再要第二次「證明你是誰/你的庫在哪」", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env, calls } = makeEnv(
|
||
() => new Response(JSON.stringify({ neighbors: [], edges: [], count: 0 })),
|
||
);
|
||
registerGraphNeighbors(server, env, "leo", PORTAL);
|
||
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
|
||
expect(res.isError).toBeUndefined();
|
||
expect(calls).toHaveLength(1);
|
||
// 呼叫端就算硬塞 kbdb_base 也不會被拿去用(server 自己知道要查哪個庫)
|
||
expect(calls[0].url.searchParams.get("kbdb_base")).toBeNull();
|
||
});
|
||
|
||
it("session 過期(401)→ 誠實說是登入過期,不說「查不到資料」", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env } = makeEnv(
|
||
() => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
|
||
);
|
||
registerGraphNeighbors(server, env, "leo", PORTAL);
|
||
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
|
||
expect(res.isError).toBe(true);
|
||
expect(parseResult(res).error_code).toBe("session_expired");
|
||
});
|
||
|
||
it("無 graph 權限(403)→ 誠實回沒權限,不假裝「沒有關聯」", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env } = makeEnv(
|
||
() => new Response(JSON.stringify({ error: "無知識圖譜檢視權限" }), { status: 403 }),
|
||
);
|
||
registerGraphNeighbors(server, env, "leo", PORTAL);
|
||
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
|
||
expect(res.isError).toBe(true);
|
||
expect(parseResult(res).error_code).toBe("forbidden");
|
||
});
|
||
|
||
it("舊 token(沒有身分)→ 不偷偷退回服務金鑰那條老路,要求重新連線", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env, calls } = makeEnv(() => new Response("{}"));
|
||
registerGraphNeighbors(server, env, "leo", STALE);
|
||
const res = await tools.get("kbdb_graph_neighbors")!.handler({ subject: "A" });
|
||
expect(res.isError).toBe(true);
|
||
expect(parseResult(res).error_code).toBe("identity_missing");
|
||
expect(calls).toHaveLength(0); // 一個查詢都沒發出去(fail-closed)
|
||
});
|
||
});
|