ed2e42e007
D19「擁有目錄不擁有內容物」遷移的讀取斷點。密文住 cypher per-script secrets、 D1 只存 secret_ref、auth worker 讀不到 cypher 的 secrets → cypher 先取值塞 payload。 WASM 端(T7 fallback 骨架,零行為改變可獨立部署驗): - auth_static_key/main.go + auth_service_account/main.go:Input 加 resolved_secrets, 解密處改「有 resolved 就用、沒有才 fallback 舊 kv_get+crypto_decrypt」。default 等於舊碼。 - tinygo build 兩支通過,copy 到 .component-builds/*/component.wasm。 TS 端(T6 主路徑): - auth-dispatcher.ts 新增 resolveSecretsFromNewHome:查 D1 拿 secret_ref → secret_get(env[ref], T4) 取明文 → 組 map(取不到的 name 缺席,不放空字串) → 更新 last_used_at。tryAuthDispatch + resolveCredentialRefs 都塞 resolved_secrets。 - rule 02 §2.2 對齊:只查 ref/取值/塞字串,不解密不展開模板不組 JWT。 驗證:cypher-executor + cli tsc exit 0;vitest 41/42(新增 auth-dispatcher.test.ts 6 案例全過, 剩 1 pre-existing 無關失敗)。待 leo21c 部署驗 WASM 端到端(不由本任務部署,acr update 硬綁 GitHub codeload=mistakes #23)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
302 lines
12 KiB
TypeScript
302 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 `crypto_decrypt` 等)
|
||
* - 本檔只做「查 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. 沒命中 fallback 到 `injectCredentials`(Phase 1.9 才刪除)
|
||
*/
|
||
|
||
import type { Bindings } from '../types';
|
||
import { resolveAuthRecipe, resolveRecipe } from '../routes/recipes';
|
||
import { wasmWorkerUrl } from '../lib/component-loader';
|
||
import { createArcrunHostFunctions } from '../lib/wasi-shim';
|
||
|
||
// ── credential-store 遷移 T6/T7(方案 A,D19)────────────────────────────────
|
||
//
|
||
// 密文值住 cypher-executor 自己的 per-script secrets(T5 寫入)。解密發生在獨立的
|
||
// auth_static_key / auth_service_account worker 上,它們讀不到 cypher 的 secrets。
|
||
// 故 cypher 這一層先查 D1 拿 secret_ref → 用 secret_get(ref)(即 env[ref],T4)取明文
|
||
// → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。WASM 收到優先用它,沒有
|
||
// 才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。
|
||
//
|
||
// 嚴格邊界(rule 02 §2.2):本檔只做「查 D1 ref → secret_get 取值 → 當字串塞 payload」。
|
||
// **不解密、不展開模板、不組 JWT**——secret_get 的實作(env[ref])在 wasi-shim host function
|
||
// 內,解密/注入邏輯仍全在 WASM 零件。
|
||
|
||
/** D1 credentials 目錄一列(只取本檔需要的欄位)。 */
|
||
interface CredentialRefRow {
|
||
name: string;
|
||
secret_ref: string;
|
||
}
|
||
|
||
/**
|
||
* 對一組 credential name,從新家(cypher per-script secrets)取明文。
|
||
*
|
||
* 流程:查 D1 `credentials`(api_key + name)拿 `secret_ref` → 用 `secret_get(ref)`
|
||
* (host function,實作 = env[ref])取值。
|
||
*
|
||
* ⚠️ 只把「D1 有 ref 且 secret_get 真的取到值」的 name 放進回傳 map。查不到 ref、
|
||
* 或 secret_get 回 null(新家還沒這把值)→ **該 name 缺席**(不是放空字串!),
|
||
* 讓 WASM 對這把 key 走 fallback 舊 KV 路徑(T7 雙讀)。放空字串會讓 WASM 誤判命中用空值。
|
||
*
|
||
* 取到值的 name 順手更新 D1 `last_used_at`(§2.5 治理面 last_used)。
|
||
*
|
||
* D1 未建表 / migration 未跑 / CREDENTIALS_DB 未綁 → 回空 map(整組走 fallback),
|
||
* 不 throw——遷移過渡期(雙讀)本就允許「新家還沒資料」。
|
||
*/
|
||
export async function resolveSecretsFromNewHome(
|
||
env: Bindings,
|
||
apiKey: string,
|
||
names: string[],
|
||
): Promise<Record<string, string>> {
|
||
const resolved: Record<string, string> = {};
|
||
if (names.length === 0) return resolved;
|
||
|
||
const db = env.CREDENTIALS_DB;
|
||
if (!db) return resolved; // 未綁 D1 → 整組走 fallback
|
||
|
||
// 1. 查 D1 拿每個 name 的 secret_ref
|
||
let rows: CredentialRefRow[];
|
||
try {
|
||
const placeholders = names.map(() => '?').join(', ');
|
||
const result = await db
|
||
.prepare(
|
||
`SELECT name, secret_ref FROM credentials
|
||
WHERE api_key = ? AND name IN (${placeholders})`,
|
||
)
|
||
.bind(apiKey, ...names)
|
||
.all<CredentialRefRow>();
|
||
rows = result.results ?? [];
|
||
} catch {
|
||
// D1 未建表 / query 失敗 → 過渡期整組走 fallback(雙讀),不假綠
|
||
return resolved;
|
||
}
|
||
if (rows.length === 0) return resolved;
|
||
|
||
// 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 row of rows) {
|
||
const value = await secretGet(row.secret_ref);
|
||
// null(新家沒這把值 / 非 CRED_ 前綴被拒)→ 不放進 map,讓 WASM fallback 舊 KV
|
||
if (value === null) continue;
|
||
resolved[row.name] = value;
|
||
resolvedNames.push(row.name);
|
||
}
|
||
|
||
// 3. 順手更新 last_used_at(只更新真的從新家取到值的 name)
|
||
if (resolvedNames.length > 0) {
|
||
try {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const placeholders = resolvedNames.map(() => '?').join(', ');
|
||
await db
|
||
.prepare(
|
||
`UPDATE credentials SET last_used_at = ?
|
||
WHERE api_key = ? AND name IN (${placeholders})`,
|
||
)
|
||
.bind(now, apiKey, ...resolvedNames)
|
||
.run();
|
||
} catch {
|
||
// last_used 更新失敗不影響注入主流程(治理面欄位,非關鍵路徑)
|
||
}
|
||
}
|
||
|
||
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),
|
||
* 拿回明文後只做字串回填。ENCRYPTION_KEY 永不經此處。
|
||
*
|
||
* - 無 {{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>;
|
||
}
|