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
@@ -62,6 +62,11 @@ type Input struct {
APIKey string `json:"api_key"`
Service string `json:"service"`
Request json.RawMessage `json:"request,omitempty"`
// ResolvedSecretscredential-store 遷移 T6/T7 方案 AD19)——cypher-executor 從自己的
// per-script secrets(新家)用 secret_get(D1 ref) 取到的明文,key = credential name
// (對 SA 而言就是 required_secrets[0].key)。有值優先用,沒有才 fallback 舊 KV+crypto_decrypt。
// default(此欄空/nil)行為完全等於遷移前的舊碼。
ResolvedSecrets map[string]string `json:"resolved_secrets,omitempty"`
}
type SecretRequirement struct {
@@ -176,28 +181,38 @@ func main() {
return
}
// 2. 解密 service account JSON (慣例:required_secrets[0] 是 SA JSON)
// 2. 取得 service account JSON 明文 (慣例:required_secrets[0] 是 SA JSON)
saReq := recipe.RequiredSecrets[0]
kvKey := input.APIKey + ":cred:" + saReq.Key
encJSON, s := kvGet(kvKey)
if s == 2 {
writeError("缺少 credential: " + saReq.Key + " (" + saReq.Label + ")。修復: 編輯 credentials.yaml 後執行 acr creds push")
return
}
if s != 0 {
writeError("kv_get 失敗(credential " + saReq.Key + ")")
return
}
var rec EncryptedRecord
if err := json.Unmarshal([]byte(encJSON), &rec); err != nil {
writeError("credential " + saReq.Key + " 格式錯誤: " + err.Error())
return
}
saJSONStr, ok := cryptoDecrypt(rec.Encrypted, rec.IV)
if !ok {
writeError("credential " + saReq.Key + " 解密失敗")
return
var saJSONStr string
// T6 方案 Acypher 已從新家(per-script secrets)取到明文就直接用,不走 KV。
// input.ResolvedSecrets 為 nil 時此 map 讀取回 ("", false),行為與遷移前一致。
if v, ok := input.ResolvedSecrets[saReq.Key]; ok {
saJSONStr = v
} else {
// T7 雙讀 fallback:新家沒給 → 舊路徑 kv_get + crypto_decrypt。
kvKey := input.APIKey + ":cred:" + saReq.Key
encJSON, s := kvGet(kvKey)
if s == 2 {
writeError("缺少 credential: " + saReq.Key + " (" + saReq.Label + ")。修復: 編輯 credentials.yaml 後執行 acr creds push")
return
}
if s != 0 {
writeError("kv_get 失敗(credential " + saReq.Key + ")")
return
}
var rec EncryptedRecord
if err := json.Unmarshal([]byte(encJSON), &rec); err != nil {
writeError("credential " + saReq.Key + " 格式錯誤: " + err.Error())
return
}
decrypted, ok := cryptoDecrypt(rec.Encrypted, rec.IV)
if !ok {
writeError("credential " + saReq.Key + " 解密失敗")
return
}
saJSONStr = decrypted
}
// 3. 解析 service account JSON
@@ -50,6 +50,11 @@ type Input struct {
// Namesresolve_credentials action 用——要解密的 credential 名稱清單
// (用戶在 workflow node.data 寫 {{credential.NAME}} 時,graph-executor 收集後傳入)。
Names []string `json:"names,omitempty"`
// ResolvedSecretscredential-store 遷移 T6/T7 方案 AD19)——cypher-executor 已從
// 自己的 per-script secrets(新家)用 secret_get(D1 ref) 取到的明文,key = credential name。
// 有值就優先用,沒有才 fallback 舊路徑(kv_get {api_key}:cred:{name} + crypto_decrypt),
// 這個 fallback 就是雙讀(T7)。default(此欄空/nil)行為完全等於遷移前的舊碼。
ResolvedSecrets map[string]string `json:"resolved_secrets,omitempty"`
}
type SecretRequirement struct {
@@ -143,6 +148,15 @@ func main() {
if req.Optional {
continue
}
// T6 方案 Acypher 已從新家(per-script secrets)取到明文就直接用,不走 KV。
// input.ResolvedSecrets 為 nil 時此 map 讀取回 ("", false),行為與遷移前一致。
if v, ok := input.ResolvedSecrets[req.Key]; ok {
secrets[req.Key] = v
continue
}
// T7 雙讀 fallback:新家沒給 → 走舊路徑 kv_get + crypto_decrypt。
kvKey := input.APIKey + ":cred:" + req.Key
encJSON, s := kvGet(kvKey)
if s == 2 {
@@ -220,6 +234,14 @@ func handleResolveCredentials(input Input) {
if name == "" {
continue
}
// T6 方案 A:cypher 已從新家取到明文就直接用(同 authenticate 分支)。
if v, ok := input.ResolvedSecrets[name]; ok {
credentials[name] = v
continue
}
// T7 雙讀 fallback:新家沒給 → 舊路徑 kv_get + crypto_decrypt。
kvKey := input.APIKey + ":cred:" + name
encJSON, s := kvGet(kvKey)
if s == 2 {