feat(credentials): T6 讀取/注入接新家 + T7 雙讀 fallback (credential-store-migration 方案 A)

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>
This commit is contained in:
uncle6me-web
2026-07-05 13:44:26 +08:00
parent 55a47d7c18
commit ed2e42e007
7 changed files with 314 additions and 26 deletions
+113 -1
View File
@@ -20,6 +20,102 @@
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 secretsT5 寫入)。解密發生在獨立的
// 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']);
@@ -62,6 +158,13 @@ export async function tryAuthDispatch(
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 舊 KVT7)。
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);
@@ -72,6 +175,8 @@ export async function tryAuthDispatch(
action: 'authenticate',
api_key: apiKey,
service,
// 只在有取到值時帶上(空物件也無妨,WASM 對 nil/空 map 同樣 fallback
resolved_secrets: resolvedSecrets,
}),
});
@@ -159,6 +264,12 @@ export async function resolveCredentialRefs(
collectCredentialNames(data, names);
if (names.size === 0) return data;
const nameList = [...names];
// T6:先從新家取這些 credential 的明文,塞 `resolved_secrets`WASM 優先用它,
// 沒取到的 name 缺席 → WASM 的 resolve_credentials 分支 fallback 舊 KVT7)。
const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, nameList);
const url = wasmWorkerUrl('auth_static_key', env.WORKER_SUBDOMAIN);
const res = await fetch(url, {
method: 'POST',
@@ -166,7 +277,8 @@ export async function resolveCredentialRefs(
body: JSON.stringify({
action: 'resolve_credentials',
api_key: apiKey,
names: [...names],
names: nameList,
resolved_secrets: resolvedSecrets,
}),
});
@@ -0,0 +1,105 @@
/**
* credential-store-migration T6/T7(方案 AD19)測試:auth-dispatcher 的
* `resolveSecretsFromNewHome`——「查 D1 拿 secret_ref → secret_get(env[ref]) 取值 → 組 map」。
*
* 覆蓋 T6 主路徑 + T7 雙讀分流的判準:
* - D1 有 ref 且 secret 存在(新家有值)→ name 進 mappayload 會含 resolved_secrets[name]
* - D1 無 ref(從未回填)→ name 缺席 map(讓 WASM fallback 舊 KVT7
* - D1 有 ref 但 secret 不存在(新家還沒寫值)→ secret_get 回 null → name 缺席(不放空字串!)
* - 取到值的 name 更新 last_used_at
*
* secret_get 的實作是 host function `env[ref]`wasi-shim),故測試把 CRED_* 值放進傳入的
* env 物件模擬「per-script secret 已注入」,用真實 D1 binding 存目錄(同 credentials.test.ts)。
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { env } from 'cloudflare:test';
import type { Bindings } from '../src/types';
import { resolveSecretsFromNewHome } from '../src/actions/auth-dispatcher';
const API_KEY = 'test-tenant-t67';
async function insertRefRow(name: string, secretRef: string): Promise<void> {
await env.CREDENTIALS_DB
.prepare(
`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, NULL)`,
)
.bind(API_KEY, name, null, 'standard', secretRef, Math.floor(Date.now() / 1000))
.run();
}
async function clearRows(): Promise<void> {
await env.CREDENTIALS_DB.prepare(`DELETE FROM credentials WHERE api_key = ?`).bind(API_KEY).run();
}
/** 把 CRED_* secret 值疊到真實 env 上(模擬 per-script secret 已注入 runtime)。 */
function envWithSecrets(secrets: Record<string, string>): Bindings {
return { ...(env as unknown as Record<string, unknown>), ...secrets } as unknown as Bindings;
}
describe('resolveSecretsFromNewHome (T6/T7 方案 A)', () => {
beforeEach(clearRows);
it('D1 有 ref 且新家有值 → name 進 mapT6 命中新家)', async () => {
await insertRefRow('openai_key', 'CRED_OPENAI_KEY_ABCDEF01');
const testEnv = envWithSecrets({ CRED_OPENAI_KEY_ABCDEF01: 'sk-live-123' });
const resolved = await resolveSecretsFromNewHome(testEnv, API_KEY, ['openai_key']);
expect(resolved).toEqual({ openai_key: 'sk-live-123' });
});
it('D1 無 ref(從未回填)→ name 缺席 map(走 WASM fallback 舊 KVT7', async () => {
// D1 沒有這個 name 的 row
const testEnv = envWithSecrets({ CRED_ANYTHING: 'unused' });
const resolved = await resolveSecretsFromNewHome(testEnv, API_KEY, ['never_migrated']);
expect(resolved).not.toHaveProperty('never_migrated');
expect(resolved).toEqual({});
});
it('D1 有 ref 但新家沒值 → secret_get 回 null → name 缺席(不放空字串)', async () => {
await insertRefRow('notion_token', 'CRED_NOTION_TOKEN_ABCDEF01');
// env 裡沒有 CRED_NOTION_TOKEN_ABCDEF01 → env[ref] 為 undefined → secret_get 回 null
const testEnv = envWithSecrets({});
const resolved = await resolveSecretsFromNewHome(testEnv, API_KEY, ['notion_token']);
// 關鍵:缺席,不是 { notion_token: '' }(空字串會讓 WASM 誤判命中用空值)
expect(resolved).not.toHaveProperty('notion_token');
expect(resolved).toEqual({});
});
it('混合:一把有值一把沒 ref → 只有有值的進 map', async () => {
await insertRefRow('has_value', 'CRED_HAS_VALUE_ABCDEF01');
const testEnv = envWithSecrets({ CRED_HAS_VALUE_ABCDEF01: 'the-value' });
const resolved = await resolveSecretsFromNewHome(testEnv, API_KEY, ['has_value', 'no_ref']);
expect(resolved).toEqual({ has_value: 'the-value' });
});
it('空 names → 空 map(零開銷)', async () => {
const resolved = await resolveSecretsFromNewHome(envWithSecrets({}), API_KEY, []);
expect(resolved).toEqual({});
});
it('取到值後更新 last_used_at(治理面)', async () => {
await insertRefRow('used_key', 'CRED_USED_KEY_ABCDEF01');
const before = await env.CREDENTIALS_DB
.prepare(`SELECT last_used_at FROM credentials WHERE api_key = ? AND name = ?`)
.bind(API_KEY, 'used_key')
.first<{ last_used_at: number | null }>();
expect(before?.last_used_at).toBeNull();
const testEnv = envWithSecrets({ CRED_USED_KEY_ABCDEF01: 'v' });
await resolveSecretsFromNewHome(testEnv, API_KEY, ['used_key']);
const after = await env.CREDENTIALS_DB
.prepare(`SELECT last_used_at FROM credentials WHERE api_key = ? AND name = ?`)
.bind(API_KEY, 'used_key')
.first<{ last_used_at: number | null }>();
expect(typeof after?.last_used_at).toBe('number');
});
});