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:
uncle6me-web
2026-08-12 19:33:12 +08:00
parent a24f2912eb
commit 10d150ac2b
21 changed files with 1695 additions and 161 deletions
+28 -2
View File
@@ -1,9 +1,19 @@
import { Context, Next } from "hono";
import { Env } from "../types.js";
import { getAccessToken } from "../oauth/store.js";
import { getAccessToken, type PortalIdentity } from "../oauth/store.js";
import { constantTimeEqual } from "../oauth/crypto.js";
import { originOf, resourceUri, wwwAuthenticateHeader } from "../oauth/metadata.js";
/**
* 這條連線是**用誰的身分**進來的。決定知識面(kbdb_*)走哪條路:
* - "oauth":有人在同意頁輸入過 Portal 帳密 → 帶著他的 portal session 走 portal 資料面,
* 權限=他在 portal 網頁上看得到的那些(庫過濾照吃)。
* - "service"static token / partner key 這類**服務級**憑據(本身就是真祕密,
* 代表整個實例或整個租戶,不是某個人)→ 維持既有的 KBDB 直連行為,零回歸。
* 兩條路刻意分開命名,因為「這張 token 背後有沒有一個人」正是本次要能分辨的事。
*/
export type AuthPath = "oauth" | "service";
/**
* MCP / GUI 端點認證中介層。
*
@@ -19,7 +29,15 @@ import { originOf, resourceUri, wwwAuthenticateHeader } from "../oauth/metadata.
* 已從預設路徑移除;只在明確設 ALLOW_PLAINTEXT_NAMESPACE="true" 的遷移情境才恢復。
*/
export async function partnerAuthMiddleware(
c: Context<{ Bindings: Env; Variables: { org_namespace: string; partner_token: string } }>,
c: Context<{
Bindings: Env;
Variables: {
org_namespace: string;
partner_token: string;
portal?: PortalIdentity;
auth_path: AuthPath;
};
}>,
next: Next
) {
const origin = originOf(c.req.url);
@@ -50,6 +68,11 @@ export async function partnerAuthMiddleware(
}
c.set("org_namespace", at.namespace);
c.set("partner_token", at.namespace); // 下游 cypher 用 namespace 當 X-Arcrun-API-Key(與 CLI 同一份身份)
// 登入者的身分(2026-08-12):知識面工具(kbdb_*)帶著它打 cypher 的 portal 資料面,
// 權限與這個人在 portal 網頁上看到的完全一致。舊 token 沒有這欄 → undefined
// 知識面工具會要求重新連線(不偷偷退回服務金鑰那條老路)。
c.set("portal", at.portal);
c.set("auth_path", "oauth");
await next();
return;
}
@@ -60,6 +83,7 @@ export async function partnerAuthMiddleware(
const ns = c.env.MCP_OWNER_NAMESPACE || "leo";
c.set("org_namespace", ns);
c.set("partner_token", ns);
c.set("auth_path", "service");
await next();
return;
}
@@ -79,6 +103,7 @@ export async function partnerAuthMiddleware(
}
c.set("org_namespace", info.org_namespace);
c.set("partner_token", token);
c.set("auth_path", "service");
await next();
return;
}
@@ -89,6 +114,7 @@ export async function partnerAuthMiddleware(
if (c.env.ALLOW_PLAINTEXT_NAMESPACE === "true") {
c.set("org_namespace", token);
c.set("partner_token", token);
c.set("auth_path", "service");
await next();
return;
}