7ba78552a4
存取層:credentials.ts / auth-dispatcher.ts / portal.ts 全改走 kbdbBase()+fetch 到 /entries(照 execution-logger.ts 既有慣例),.prepare/.exec/.batch 命中 0。 資料層:0005 seed credential template;0006 把舊表資料搬進 entries 後拆表; 0002 標退役、deploy.ts 不再套用(加 kbdb-sql-ok 留痕,純歷史對照)。 總管親驗四項(不聽 agent 自評): 1 三個檔 .prepare/.exec/.batch 命中 0;六個檔全部通過 kbdb-api-wall-guard 2 0006 的 INSERT 欄位(id/entry_type/owner_id/page_name/metadata_json/ created_at/updated_at)與 0001_base 的 entries 表逐一對得上 3 不可逆風險查官方:D1 batch 是 transaction、任一句失敗整批 rollback; exec 出錯則「執行停止、後續不執行」=> 兩種語意下 INSERT 失敗都不會跑到 DROP TABLE,用戶 credential 目錄不會遺失 4 0006 搬在拆之前、冪等(NOT EXISTS 防重複)、豁免標記有留痕且理由正當 (拆表是牆內施工,API 不提供也不該提供拆表) 🔴 抓到一個假綠並修正:agent 中途被中斷,把 111 行的 credentials.test.ts 砍成一行「// placeholder — see edit below」,那個 edit 從來沒發生, 且 setup.ts 被刪。vitest 對這種檔案回報「Tests: no tests」, 很容易被讀成「沒失敗=通過」——正是 CP 記過的 「這條 route 曾整條消失過沒人發現」同型。 處置:還原 setup.ts/vitest.config.ts,credentials.test.ts 改成 **刻意會失敗的紅燈**並在檔頭列出要補的五項。空檔會被誤認為綠,紅燈不會。 未部署。本批要先上 stage 驗過才進 prod(leo 08-07 定)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
277 lines
12 KiB
TypeScript
277 lines
12 KiB
TypeScript
/**
|
||
* Auth Dispatcher
|
||
*
|
||
* 對需要認證的零件,在執行前 HTTP POST 到對應的 auth primitive Worker,
|
||
* 取回 auth_headers / auth_query / auth_body 合併進節點 context。
|
||
*
|
||
* 嚴格邊界(rule 02 §2.2):
|
||
* - 本檔**不做**任何 credential 解密 / template 展開 / JWT 簽章
|
||
* - 那些全部在 auth primitive WASM 零件內執行(透過 host function `secret_get` 等)
|
||
* - 本檔只做「查 recipe 決定走哪個 primitive Worker」+「HTTP fetch 取回注入結果」
|
||
*
|
||
* 目前階段接上 `auth_static_key` + `auth_service_account` + `auth_oauth2`,
|
||
* Phase 4 剩 `auth_mtls`(mTLS handshake 在 Worker runtime 層)。
|
||
*
|
||
* 執行時機:graph-executor 在節點 runner 執行前呼叫,取回的 ctx 會:
|
||
* 1. 本 dispatcher 命中 → return enriched ctx
|
||
* 2. 沒命中 → ctx 原樣往下(T10 起舊的 injectCredentials 雙讀 fallback 已移除)
|
||
*/
|
||
|
||
import type { Bindings } from '../types';
|
||
import { resolveAuthRecipe, resolveRecipe } from '../routes/recipes';
|
||
import { wasmWorkerUrl } from '../lib/component-loader';
|
||
import { createArcrunHostFunctions } from '../lib/wasi-shim';
|
||
import { getCredentialSecretRefs, touchLastUsed } from '../routes/credentials';
|
||
|
||
// ── credential-store 遷移 T6/T7(方案 A,D19)+ D38 圍牆修復(2026-08-07)───────────
|
||
//
|
||
// 密文值住 cypher-executor 自己的 per-script secrets(T5 寫入)。解密發生在獨立的
|
||
// auth_static_key / auth_service_account worker 上,它們讀不到 cypher 的 secrets。
|
||
// 故 cypher 這一層先取這個租戶的 credential 目錄(name → secret_ref)→ 用 secret_get(ref)
|
||
// (即 env[ref],T4)取明文 → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。
|
||
// WASM 收到優先用它,沒有才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。
|
||
//
|
||
// D38(leo 2026-06-14 立、2026-08-07 擴大):目錄不再直連 D1,改走 KBDB HTTP API
|
||
// (`credentials.ts` 的 `getCredentialSecretRefs`,內建 60 秒租戶級快取——這是熱路徑,
|
||
// 每次 workflow 執行都會呼叫,映射「幾乎不變」故快取後多數命中零網路呼叫,效能不因改走
|
||
// API 而變差,見 credentials.ts 檔頭「效能」段的實測數字)。
|
||
//
|
||
// 嚴格邊界(rule 02 §2.2):本檔只做「查目錄拿 ref → secret_get 取值 → 當字串塞 payload」。
|
||
// **不解密、不展開模板、不組 JWT**——secret_get 的實作(env[ref])在 wasi-shim host function
|
||
// 內,解密/注入邏輯仍全在 WASM 零件。
|
||
|
||
/**
|
||
* 對一組 credential name,從新家(cypher per-script secrets)取明文。
|
||
*
|
||
* 流程:查 KBDB credential 目錄(api_key + name,快取命中零網路呼叫)拿 `secret_ref`
|
||
* → 用 `secret_get(ref)`(host function,實作 = env[ref])取值。
|
||
*
|
||
* ⚠️ 只把「目錄有 ref 且 secret_get 真的取到值」的 name 放進回傳 map。查不到 ref、
|
||
* 或 secret_get 回 null(新家還沒這把值)→ **該 name 缺席**(不是放空字串!),
|
||
* 讓 WASM 對這把 key 走 fallback 舊 KV 路徑(T7 雙讀)。放空字串會讓 WASM 誤判命中用空值。
|
||
*
|
||
* 取到值的 name 順手更新 last_used_at(§2.5 治理面 last_used,見 touchLastUsed——
|
||
* fire-and-forget、非同步、不阻塞本函式回傳,失敗吞掉)。
|
||
*
|
||
* KBDB 不可達 / 這個租戶還沒有任何 credential → 回空 map(整組走 fallback),
|
||
* 不 throw——遷移過渡期(雙讀)本就允許「新家還沒資料」。
|
||
*/
|
||
/** credential name → 明文值對照(獨立型別別名,避免函式簽章直接內嵌逗號分隔泛型)。 */
|
||
type ResolvedSecretMap = Record<string, string>;
|
||
|
||
export async function resolveSecretsFromNewHome(
|
||
env: Bindings,
|
||
apiKey: string,
|
||
names: string[],
|
||
): Promise<ResolvedSecretMap> {
|
||
const resolved: ResolvedSecretMap = {};
|
||
if (names.length === 0) return resolved;
|
||
|
||
// 1. 拿這個租戶的 credential 目錄(name → secret_ref,快取層見 credentials.ts)
|
||
const refs = await getCredentialSecretRefs(env, apiKey);
|
||
if (Object.keys(refs).length === 0) return resolved; // 目錄空 / KBDB 不可達 → 整組走 fallback
|
||
|
||
// 2. 用 secret_ref 從新家取值(host function secret_get = env[ref])
|
||
const secretGet = createArcrunHostFunctions(env, apiKey).secret_get;
|
||
if (!secretGet) return resolved; // host function 未就緒 → 走 fallback
|
||
|
||
const resolvedNames: string[] = [];
|
||
for (const name of names) {
|
||
const ref = refs[name];
|
||
if (!ref) continue; // 目錄沒這個 name → 缺席,走 fallback
|
||
const value = await secretGet(ref);
|
||
// null(新家沒這把值 / 非 CRED_ 前綴被拒)→ 不放進 map,讓 WASM fallback 舊 KV
|
||
if (value === null) continue;
|
||
resolved[name] = value;
|
||
resolvedNames.push(name);
|
||
}
|
||
|
||
// 3. 順手更新 last_used_at(只更新真的從新家取到值的 name;fire-and-forget,非關鍵路徑)
|
||
if (resolvedNames.length > 0) touchLastUsed(env, apiKey, resolvedNames);
|
||
|
||
return resolved;
|
||
}
|
||
|
||
/** 對應 Phase 1-4 會部署的 auth primitive Worker */
|
||
const SUPPORTED_PRIMITIVES = new Set(['static_key', 'service_account', 'oauth2']);
|
||
|
||
/** auth primitive 本身的 componentId(避免自引用) */
|
||
const AUTH_PRIMITIVE_IDS = new Set([
|
||
'auth_static_key',
|
||
'auth_service_account',
|
||
'auth_oauth2',
|
||
'auth_mtls',
|
||
]);
|
||
|
||
/**
|
||
* 試著對零件做 auth 注入。
|
||
* - 命中(有對應 auth recipe 且 primitive 已支援)→ 回傳注入後的 ctx
|
||
* - 未命中 → 回傳 null(呼叫端繼續跑舊路徑)
|
||
*/
|
||
export async function tryAuthDispatch(
|
||
componentId: string,
|
||
input: Record<string, unknown>,
|
||
env: Bindings,
|
||
apiKey: string,
|
||
): Promise<Record<string, unknown> | null> {
|
||
if (AUTH_PRIMITIVE_IDS.has(componentId)) {
|
||
// auth primitive 本身不需要再做 auth
|
||
return null;
|
||
}
|
||
|
||
// 決定 auth service name:
|
||
// 1. 若 API recipe 宣告了 auth_service(例 recipe:kbdb_get → "kbdb")→ 用它,
|
||
// 讓多個 recipe 共用同一把 auth_recipe(不必每個 action 複製 auth recipe)。
|
||
// 2. 否則 fallback 到把 componentId 當 service name(向後相容舊行為)。
|
||
let service = componentId;
|
||
const apiRecipe = await resolveRecipe(componentId, env.RECIPES);
|
||
if (apiRecipe?.auth_service) {
|
||
service = apiRecipe.auth_service;
|
||
}
|
||
|
||
const recipe = await resolveAuthRecipe(service, env.RECIPES);
|
||
if (!recipe) return null;
|
||
if (!SUPPORTED_PRIMITIVES.has(recipe.primitive)) return null;
|
||
|
||
// T6:先從新家(cypher per-script secrets)取 recipe 需要的 credential 明文。
|
||
// 拿到的塞進 `resolved_secrets`,WASM 優先用;沒拿到的 name 缺席 → WASM fallback 舊 KV(T7)。
|
||
const secretNames = recipe.required_secrets
|
||
.filter(s => !s.optional)
|
||
.map(s => s.key);
|
||
const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, secretNames);
|
||
|
||
// 走新路徑:HTTP POST 到對應 auth primitive Worker
|
||
// 走 workers.dev 避開同 zone 死鎖(P0 #9)
|
||
const primitiveUrl = wasmWorkerUrl(`auth_${recipe.primitive}`, env.WORKER_SUBDOMAIN);
|
||
const res = await fetch(primitiveUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
action: 'authenticate',
|
||
api_key: apiKey,
|
||
service,
|
||
// 只在有取到值時帶上(空物件也無妨,WASM 對 nil/空 map 同樣 fallback)
|
||
resolved_secrets: resolvedSecrets,
|
||
}),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '');
|
||
throw new Error(
|
||
`auth primitive "${recipe.primitive}" 回傳 ${res.status}: ${text.slice(0, 200)}`,
|
||
);
|
||
}
|
||
|
||
const result = await res.json().catch(() => null) as {
|
||
success?: boolean;
|
||
error?: string;
|
||
auth_headers?: Record<string, string>;
|
||
auth_query?: Record<string, string>;
|
||
auth_body?: Record<string, string>;
|
||
auth_path?: Record<string, string>;
|
||
} | null;
|
||
|
||
if (!result || result.success === false) {
|
||
throw new Error(
|
||
`auth primitive 失敗: ${result?.error ?? '未知錯誤'}`,
|
||
);
|
||
}
|
||
|
||
return {
|
||
...input,
|
||
_auth_headers: result.auth_headers ?? {},
|
||
_auth_query: result.auth_query ?? {},
|
||
_auth_body: result.auth_body ?? {},
|
||
_auth_path: result.auth_path ?? {},
|
||
};
|
||
}
|
||
|
||
// ── 用戶面 {{credential.NAME}} 注入(design §8)────────────────────────────────
|
||
|
||
/** 匹配 {{credential.NAME}}(NAME 為 word 字元) */
|
||
const CREDENTIAL_REF = /\{\{credential\.(\w+)\}\}/g;
|
||
|
||
/** 遞迴收集任意值(string / 物件 / 陣列)裡所有 {{credential.NAME}} 的 NAME */
|
||
function collectCredentialNames(value: unknown, out: Set<string>): void {
|
||
if (typeof value === 'string') {
|
||
for (const m of value.matchAll(CREDENTIAL_REF)) out.add(m[1]);
|
||
} else if (Array.isArray(value)) {
|
||
for (const v of value) collectCredentialNames(v, out);
|
||
} else if (value && typeof value === 'object') {
|
||
for (const v of Object.values(value as Record<string, unknown>)) collectCredentialNames(v, out);
|
||
}
|
||
}
|
||
|
||
/** 遞迴把 {{credential.NAME}} 替換成 resolved[NAME](未知 name 原樣保留) */
|
||
function replaceCredentialRefs(value: unknown, resolved: Record<string, string>): unknown {
|
||
if (typeof value === 'string') {
|
||
return value.replace(CREDENTIAL_REF, (orig, name: string) =>
|
||
Object.prototype.hasOwnProperty.call(resolved, name) ? resolved[name] : orig,
|
||
);
|
||
}
|
||
if (Array.isArray(value)) return value.map((v) => replaceCredentialRefs(v, resolved));
|
||
if (value && typeof value === 'object') {
|
||
const out: Record<string, unknown> = {};
|
||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||
out[k] = replaceCredentialRefs(v, resolved);
|
||
}
|
||
return out;
|
||
}
|
||
return value;
|
||
}
|
||
|
||
/**
|
||
* 展開節點 data 裡用戶寫的 `{{credential.NAME}}`(design §8)。
|
||
*
|
||
* 嚴格邊界(rule 02 §2.2):本函式**不解密**。偵測到 {{credential.X}} 後,把 names 交給
|
||
* auth_static_key WASM 的 `resolve_credentials` action(WASM 內 kv_get + crypto_decrypt),
|
||
* 拿回明文後只做字串回填。本檔不解密、不持有任何金鑰。
|
||
*
|
||
* - 無 {{credential.}} → 原樣回傳(不打 WASM,零開銷)
|
||
* - 解密失敗 / 缺 credential → throw(誠實報錯,不假綠)
|
||
*/
|
||
export async function resolveCredentialRefs(
|
||
data: Record<string, unknown>,
|
||
env: Bindings,
|
||
apiKey: string,
|
||
): Promise<Record<string, unknown>> {
|
||
const names = new Set<string>();
|
||
collectCredentialNames(data, names);
|
||
if (names.size === 0) return data;
|
||
|
||
const nameList = [...names];
|
||
|
||
// T6:先從新家取這些 credential 的明文,塞 `resolved_secrets`;WASM 優先用它,
|
||
// 沒取到的 name 缺席 → WASM 的 resolve_credentials 分支 fallback 舊 KV(T7)。
|
||
const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, nameList);
|
||
|
||
const url = wasmWorkerUrl('auth_static_key', env.WORKER_SUBDOMAIN);
|
||
const res = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
action: 'resolve_credentials',
|
||
api_key: apiKey,
|
||
names: nameList,
|
||
resolved_secrets: resolvedSecrets,
|
||
}),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '');
|
||
throw new Error(`credential resolve 回傳 ${res.status}: ${text.slice(0, 200)}`);
|
||
}
|
||
|
||
const result = (await res.json().catch(() => null)) as {
|
||
success?: boolean;
|
||
error?: string;
|
||
credentials?: Record<string, string>;
|
||
} | null;
|
||
|
||
if (!result || result.success === false) {
|
||
throw new Error(`credential resolve 失敗: ${result?.error ?? '未知錯誤'}`);
|
||
}
|
||
|
||
return replaceCredentialRefs(data, result.credentials ?? {}) as Record<string, unknown>;
|
||
}
|