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>
114 lines
4.8 KiB
TypeScript
114 lines
4.8 KiB
TypeScript
/**
|
||
* Portal 資料面 client — 「授權的 AI」用登入者的身分查東西的唯一管道。
|
||
*
|
||
* leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
|
||
* AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
|
||
* 「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ **下游不得再要求第二次認證**。
|
||
*
|
||
* 所以這裡帶的是 **portal session token**(同意頁輸入帳密時 cypher 發的那張,
|
||
* 與人類在 portal 網頁上拿到的完全同一種),不是任何服務內部金鑰。
|
||
* 端點是 cypher 的 `/portal/data/*`——庫過濾、租戶注入、停用即時生效全在那邊 server 側做完,
|
||
* 本檔不做任何判斷(薄殼鐵律 rule 07:能力長在 API,介面只轉換)。
|
||
*
|
||
* 走既有 CYPHER_EXECUTOR service binding,不新增 binding、不新增金鑰。
|
||
*/
|
||
|
||
import type { Env } from "../types.js";
|
||
import type { PortalIdentity } from "../oauth/store.js";
|
||
import { errorResponse } from "./cypher-client.js";
|
||
|
||
export interface PortalCallOpts {
|
||
method?: string;
|
||
body?: unknown;
|
||
query?: Record<string, string | number | undefined>;
|
||
}
|
||
|
||
/** 用登入者的 session 打 cypher 的 portal 資料面。 */
|
||
export async function portalFetch(
|
||
env: Env,
|
||
session: string,
|
||
path: string,
|
||
opts: PortalCallOpts = {},
|
||
): Promise<Response> {
|
||
if (!env.CYPHER_EXECUTOR) {
|
||
throw new Error("CYPHER_EXECUTOR service binding not configured");
|
||
}
|
||
const url = new URL(`https://cypher${path}`);
|
||
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
||
if (v !== undefined && v !== "") url.searchParams.set(k, String(v));
|
||
}
|
||
return env.CYPHER_EXECUTOR.fetch(url.toString(), {
|
||
method: opts.method ?? "GET",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${session}`,
|
||
},
|
||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 知識面工具的身分解析結果。
|
||
*
|
||
* 三態刻意分開,因為「查不到」和「沒有」不可以長得一樣(leo 的老原則):
|
||
* - portal :有登入者 → 走 portal 資料面(權限=這個人的權限)
|
||
* - service :服務級憑據(static token / partner key)→ 維持既有 KBDB 直連(零回歸)
|
||
* - stale :OAuth token 但沒帶身分(本次改版前簽發的舊 token)→ **誠實要求重新連線**,
|
||
* 不偷偷退回服務金鑰那條老路(那正是要修掉的「不管誰登入都看到同一格」)
|
||
*/
|
||
export type KnowledgeIdentity =
|
||
| { kind: "portal"; portal: PortalIdentity }
|
||
| { kind: "service" }
|
||
| { kind: "stale" };
|
||
|
||
export function resolveKnowledgeIdentity(
|
||
authPath: "oauth" | "service",
|
||
portal: PortalIdentity | undefined,
|
||
): KnowledgeIdentity {
|
||
if (authPath !== "oauth") return { kind: "service" };
|
||
return portal?.session ? { kind: "portal", portal } : { kind: "stale" };
|
||
}
|
||
|
||
/** 舊 token(沒帶身分)時的統一回覆:講清楚怎麼修,不假裝查不到資料。 */
|
||
export function staleIdentityError() {
|
||
return errorResponse(
|
||
"identity_missing",
|
||
"這條 MCP 連線是舊版簽發的 token,裡面沒有登入者身分,因此查不到任何知識內容。" +
|
||
"重新連線一次(在 claude.ai 的 connector 設定裡重新授權、輸入你的 Portal 帳密)即可——" +
|
||
"不需要另外找任何 credential 或金鑰。",
|
||
[
|
||
"到 claude.ai → Settings → Connectors,把這個 connector 重新連線一次(會跳出輸入 Portal 帳密的頁面)",
|
||
"重連後 kbdb_* 全部工具都會用你這個帳號的權限查詢",
|
||
],
|
||
);
|
||
}
|
||
|
||
/**
|
||
* portal 資料面的錯誤 → 給 AI 看的訊息。
|
||
* 401/403 特別處理:那代表**登入階段過期或帳號被停用**,不是「資料不存在」——
|
||
* 兩者混在一起會讓 AI 對使用者說「你的知識庫是空的」,那是畫面在說謊。
|
||
*/
|
||
export async function portalError(res: Response, what: string) {
|
||
const detail = await res.text().catch(() => "");
|
||
if (res.status === 401) {
|
||
return errorResponse(
|
||
"session_expired",
|
||
`${what}失敗:登入階段已過期(portal session 到期或已登出)。`,
|
||
[
|
||
"到 claude.ai → Settings → Connectors 重新連線這個 connector(重新輸入 Portal 帳密)",
|
||
"重連後權限與你在 portal 網頁上看到的一致",
|
||
],
|
||
detail,
|
||
);
|
||
}
|
||
if (res.status === 403) {
|
||
return errorResponse(
|
||
"forbidden",
|
||
`${what}失敗:這個帳號沒有這項權限(帳號可能已停用,或沒有被授權該知識庫)。`,
|
||
["請知識庫管理員在 portal 的帳號管理裡確認你的狀態與可用知識庫"],
|
||
detail,
|
||
);
|
||
}
|
||
return errorResponse(`portal_${res.status}`, `${what}失敗(HTTP ${res.status})`, ["稍後重試"], detail);
|
||
}
|