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:
@@ -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 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']);
|
||||
@@ -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 舊 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);
|
||||
@@ -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 舊 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',
|
||||
@@ -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(方案 A,D19)測試:auth-dispatcher 的
|
||||
* `resolveSecretsFromNewHome`——「查 D1 拿 secret_ref → secret_get(env[ref]) 取值 → 組 map」。
|
||||
*
|
||||
* 覆蓋 T6 主路徑 + T7 雙讀分流的判準:
|
||||
* - D1 有 ref 且 secret 存在(新家有值)→ name 進 map(payload 會含 resolved_secrets[name])
|
||||
* - D1 無 ref(從未回填)→ name 缺席 map(讓 WASM fallback 舊 KV,T7)
|
||||
* - 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 進 map(T6 命中新家)', 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 舊 KV,T7)', 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user