# ADR-015:visionA → converter / FAA 採 pre-shared API key 認證(取代 OAuth client_credentials) ## 狀態 Accepted — 2026-05-11 ## 上位 / 同層 ADR - **部分 supersedes**:[ADR-014](./adr-014-conversion-integration.md) §5「Service token cache — 仿 converter scheduler 模式」、§6「user_id 注入 + trust boundary」(OAuth service token 段落)、§7「失敗模式 retry 矩陣」(MC token / MC delegated token 兩個 row)。ADR-014 的其他段落(upload streaming proxy、download 走 302 redirect、半自動 promote、不擴 model schema、模組劃分)仍有效。 - **不影響**:[ADR-013](./adr-013-public-client.md) — user login 的 OIDC public PKCE client 仍照舊。本 ADR 只動「server-to-server」這條線;「user login」這條線完全不變。 - 沿用:[ADR-006](./adr-006-no-redis-in-prototype.md)(in-memory state)、[ADR-010](./adr-010-oidc-bff.md)(user login 的 OIDC BFF Pattern)、[ADR-011](./adr-011-supersede-adr-005.md)(取代 StaticAuth) ## 背景 (Context) ### Phase 0.8 OAuth client_credentials 鏈路失敗事件(2026-05-09 ~ 11) ADR-014 原本設計 visionA backend → converter / FAA 的 server-to-server 認證走 Member Center(MC)的 OAuth `client_credentials` grant: 1. visionA backend 啟動時讀 `VISIONA_OIDC_SERVICE_CLIENT_ID` / `SECRET` 2. 第一次需要時打 MC `POST /oauth/token` 換 service token(scope `converter:job.write/read`、`files:download.read/delegate`) 3. 帶 `Authorization: Bearer ` 打 converter / FAA 4. converter / FAA 端 middleware 驗 JWKS 簽章 + 驗 scope + 驗 tenant Phase 0.8 stage 部署實際跑到才發現**整條鏈路有 4 個串行 blocker**: | # | Blocker | 影響 | |---|---------|------| | 1 | MC stage 沒註冊 `converter:job.read/write` 兩個 scope(progress.md 5/2 `assume 都有` 證實是錯的)| `POST /oauth/token` 直接 400 `invalid_scope` | | 2 | stage 上 converter image 是 5 週前舊版,沒 OAuth middleware、沒 `/api/v1/jobs` endpoint | 即使 MC 補 scope,converter 仍無法接受 service token | | 3 | converter 缺 `MEMBER_CENTER_*` env 設定 | converter 無法 init OIDC middleware | | 4 | FAA stage 也可能要 OAuth 整合(warrenchen 維護)| 不確定狀態,需跨人協調 | 要修齊這 4 個 blocker 必須跨 3 個 repo 改 + 同步 MC team + redeploy converter + warrenchen 配合 FAA — 對「2 條 1:1 trust 關係的 server-to-server 整合」明顯過度設計。 ### 過度設計的訊號 OAuth `client_credentials` + JWKS + scope 機制適合的場景: - **多個 client 對同一個 resource server**(如 SaaS 平台對外開放 API、第三方 dev 串接),需要區分不同 client 權限、需要短 TTL token 限制 blast radius、需要 scope 細粒度 - **不同團隊 / 不同信任邊界**,client 端的 secret 不能由 server 端管理 visionA → converter / FAA 的場景**完全不符合上面任一個**: - **1:1 trust 關係**(visionA 是 converter / FAA 唯一的 server-to-server caller,沒有第三方) - **使用者同時維護 visionA + converter**(jimchen),FAA 由 warrenchen 維護(同公司、可協調) - **全部 internal trust**(不是給外部 dev 用,沒有 untrusted client) - **無需 scope 細分**(converter 只關心「是否為 visionA」、FAA 只關心「是否為 visionA」,單一布林) 而成本: - **複雜度**:MC 端要 onboard scope、issuer JWKS 要可達、token cache 要寫對、TTL 邊界要處理、4 個 5xx / 4xx 失敗模式要 retry & graceful degrade - **鏈路長度**:visionA → MC → cache → converter / FAA,任一節點掛掉都不能轉檔 - **可觀測性負擔**:要追的 metrics 包含 token cache hit rate、MC 失敗率、scope 對齊狀態 ### 已洩漏的 stage service client secret(觀察事實) `RciRUyiCkbd60ikkZGkfQ2xV4r02VW3/j0ASKV/DD/E=` 已在對話中外洩(progress.md 2026-05-11 紀錄)。改用 API key 後此 secret 直接作廢、不需 rotate,這是 Phase 0.8b 的順帶收益。 ## 決策 (Decision) 採 **pre-shared API key + `Authorization: Bearer ` header** 取代 OAuth client_credentials 服務間認證。 ### 1. visionA → converter ``` visionA backend 啟動時 ↓ 讀 env VISIONA_CONVERTER_API_KEY ↓ [轉檔請求進來] ↓ 打 converter: Authorization: Bearer ``` converter 端 middleware: ``` 讀 env CONVERTER_API_KEY ↓ [收到請求] ↓ parse Authorization header → 取 token ↓ subtle.ConstantTimeCompare(token, CONVERTER_API_KEY) ↓ match → 放行;mismatch → 401 ``` ### 2. visionA → FAA 對稱設計: ``` visionA backend 啟動時 ↓ 讀 env VISIONA_FAA_API_KEY ↓ [加到模型庫流程要 pull NEF] ↓ 打 FAA: Authorization: Bearer ``` FAA 端 middleware: ``` 讀 env FAA_API_KEY ↓ [收到請求] ↓ parse Authorization header → 取 token ↓ constant-time compare(C# `CryptographicOperations.FixedTimeEquals` 或等效) ↓ match → 放行;mismatch → 401 ``` ### 3. 每個下游各自獨立的 key **不共用一把**: | Key | 持有者 | 用途 | |-----|--------|------| | `VISIONA_CONVERTER_API_KEY`(visionA 端) / `CONVERTER_API_KEY`(converter 端) | jimchen | visionA → converter | | `VISIONA_FAA_API_KEY`(visionA 端) / `FAA_API_KEY`(FAA 端) | jimchen / warrenchen | visionA → FAA | 理由:每條 trust boundary 各自獨立,一條 rotate 不影響另一條;converter / FAA 不應該因為對方洩漏被連坐。 ### 3.5 Reference Middleware Implementation 本節提供兩端 middleware 的可直接照抄 reference snippet。converter 端 jimchen 跨 repo 用 Go 實作;FAA 端 warrenchen 跨 repo 用 C# 實作。兩端的設計原則一致:constant-time compare、統一 401、不洩漏 token、不洩漏「key 對 / 不對」的差異。 #### 3.5.1 converter 端(Go — net/http 標準 middleware pattern) 採 `net/http` 標準 middleware pattern,可直接套用 `chi` / `gorilla/mux` / 原生 `http.ServeMux`。`subtle.ConstantTimeCompare` 是 Go 標準庫提供的 constant-time 比較函式(避免 timing attack)。 ```go // internal/middleware/apikey.go package middleware import ( "crypto/subtle" "errors" "log/slog" "net/http" "strings" ) // ErrAPIKeyNotConfigured 啟動時 server 端 API key 未設定 — 應在 main() init 時 fail-fast、 // 不要等到第一個 request 才發現 var ErrAPIKeyNotConfigured = errors.New("CONVERTER_API_KEY env not set") // NewAPIKeyAuth 回傳一個驗證 Authorization: Bearer 的 middleware。 // 若 expectedKey 為空字串,會直接 panic(啟動 fail-fast)— 避免「未設定 = 全部放行」的災難。 func NewAPIKeyAuth(expectedKey string, logger *slog.Logger) func(http.Handler) http.Handler { if expectedKey == "" { // 啟動時偵測 — 不允許 server 在沒有 key 的狀態下啟動 panic(ErrAPIKeyNotConfigured) } expectedBytes := []byte(expectedKey) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") // missing Authorization header if authHeader == "" { logger.Warn("api key auth failed", "reason", "missing_authorization_header", "path", r.URL.Path, "remote", r.RemoteAddr) writeUnauthorized(w) return } // 不是 Bearer prefix(必須有 "Bearer " 前綴 + 空格) const prefix = "Bearer " if !strings.HasPrefix(authHeader, prefix) { logger.Warn("api key auth failed", "reason", "missing_bearer_prefix", "path", r.URL.Path, "remote", r.RemoteAddr) writeUnauthorized(w) return } token := strings.TrimSpace(authHeader[len(prefix):]) // token 為空("Bearer " 後面什麼都沒有) if token == "" { logger.Warn("api key auth failed", "reason", "empty_token", "path", r.URL.Path, "remote", r.RemoteAddr) writeUnauthorized(w) return } // constant-time compare — 即使長度不同 ConstantTimeCompare 也會回 0,但為了 // 提早 short-circuit、先檢查長度可避免 hash 不必要的工作(長度本身不是 secret) if subtle.ConstantTimeCompare([]byte(token), expectedBytes) != 1 { logger.Warn("api key auth failed", "reason", "token_mismatch", "path", r.URL.Path, "remote", r.RemoteAddr) // 注意:log 絕對不印 token 本身 writeUnauthorized(w) return } // 通過 — 放行到 next handler next.ServeHTTP(w, r) }) } } // writeUnauthorized 統一回 401 — 不洩漏「key 對 / 不對」/ 「missing / mismatch」的差異 func writeUnauthorized(w http.ResponseWriter) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) } ``` main 端使用範例: ```go // cmd/converter/main.go(節錄) expectedKey := os.Getenv("CONVERTER_API_KEY") authMiddleware := middleware.NewAPIKeyAuth(expectedKey, logger) // 套用到所有 /api/v1/* routes mux.Handle("/api/v1/", authMiddleware(apiHandler)) ``` #### 3.5.2 FAA 端(C# ASP.NET Core middleware) `CryptographicOperations.FixedTimeEquals` 是 .NET 6+ 提供的 constant-time byte 比較函式。下方提供 classic Middleware class 寫法(推薦給既有 ASP.NET Core MVC / Web API 專案)+ minimal API 的 inline middleware 寫法(推薦給 .NET 8 minimal API 新 project)。warrenchen 視 FAA 既有架構選一即可。 **寫法 A:Classic Middleware Class(推薦既有 ASP.NET Core 專案)** ```csharp // Middleware/ApiKeyAuthMiddleware.cs using System.Security.Cryptography; using System.Text; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; namespace FAA.Middleware; public class ApiKeyAuthMiddleware { private readonly RequestDelegate _next; private readonly ILogger _logger; private readonly byte[] _expectedKeyBytes; private const string BearerPrefix = "Bearer "; public ApiKeyAuthMiddleware( RequestDelegate next, ILogger logger, string expectedKey) { _next = next; _logger = logger; // 啟動時 fail-fast — 不允許 server 在沒有 key 的狀態下啟動 if (string.IsNullOrEmpty(expectedKey)) { throw new InvalidOperationException("FAA_API_KEY env not set"); } _expectedKeyBytes = Encoding.UTF8.GetBytes(expectedKey); } public async Task InvokeAsync(HttpContext context) { var authHeader = context.Request.Headers.Authorization.ToString(); // missing Authorization header if (string.IsNullOrEmpty(authHeader)) { _logger.LogWarning( "api key auth failed: reason=missing_authorization_header path={Path} remote={Remote}", context.Request.Path, context.Connection.RemoteIpAddress); await WriteUnauthorizedAsync(context); return; } // 不是 Bearer prefix if (!authHeader.StartsWith(BearerPrefix, StringComparison.Ordinal)) { _logger.LogWarning( "api key auth failed: reason=missing_bearer_prefix path={Path} remote={Remote}", context.Request.Path, context.Connection.RemoteIpAddress); await WriteUnauthorizedAsync(context); return; } var token = authHeader[BearerPrefix.Length..].Trim(); // token 為空 if (string.IsNullOrEmpty(token)) { _logger.LogWarning( "api key auth failed: reason=empty_token path={Path} remote={Remote}", context.Request.Path, context.Connection.RemoteIpAddress); await WriteUnauthorizedAsync(context); return; } // constant-time compare — FixedTimeEquals 要求兩邊長度相同,所以先比長度 // (長度本身不是 secret,預先 reject 不會洩漏資訊) var tokenBytes = Encoding.UTF8.GetBytes(token); if (tokenBytes.Length != _expectedKeyBytes.Length || !CryptographicOperations.FixedTimeEquals(tokenBytes, _expectedKeyBytes)) { _logger.LogWarning( "api key auth failed: reason=token_mismatch path={Path} remote={Remote}", context.Request.Path, context.Connection.RemoteIpAddress); // 注意:log 絕對不印 token 本身 await WriteUnauthorizedAsync(context); return; } // 通過 — 放行到 next middleware await _next(context); } private static async Task WriteUnauthorizedAsync(HttpContext context) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.ContentType = "application/json"; await context.Response.WriteAsync("{\"error\":\"unauthorized\"}"); } } // IApplicationBuilder extension — 讓 Program.cs 可以 app.UseApiKeyAuth(...) public static class ApiKeyAuthMiddlewareExtensions { public static IApplicationBuilder UseApiKeyAuth( this IApplicationBuilder builder, string expectedKey) { return builder.UseMiddleware(expectedKey); } } ``` Program.cs 使用範例: ```csharp // Program.cs(節錄) var expectedKey = builder.Configuration["FAA_API_KEY"] ?? Environment.GetEnvironmentVariable("FAA_API_KEY") ?? throw new InvalidOperationException("FAA_API_KEY env not set"); var app = builder.Build(); app.UseApiKeyAuth(expectedKey); // 之後才掛 controllers / endpoints app.MapControllers(); app.Run(); ``` **寫法 B:Minimal API Inline Middleware(.NET 8 新 project)** ```csharp // Program.cs(節錄) using System.Security.Cryptography; using System.Text; var builder = WebApplication.CreateBuilder(args); var expectedKey = builder.Configuration["FAA_API_KEY"] ?? Environment.GetEnvironmentVariable("FAA_API_KEY") ?? throw new InvalidOperationException("FAA_API_KEY env not set"); var expectedKeyBytes = Encoding.UTF8.GetBytes(expectedKey); var app = builder.Build(); app.Use(async (context, next) => { var logger = context.RequestServices.GetRequiredService() .CreateLogger("ApiKeyAuth"); var authHeader = context.Request.Headers.Authorization.ToString(); const string prefix = "Bearer "; string? failReason = authHeader switch { "" => "missing_authorization_header", var s when !s.StartsWith(prefix, StringComparison.Ordinal) => "missing_bearer_prefix", _ => null }; if (failReason is null) { var token = authHeader[prefix.Length..].Trim(); if (string.IsNullOrEmpty(token)) { failReason = "empty_token"; } else { var tokenBytes = Encoding.UTF8.GetBytes(token); if (tokenBytes.Length != expectedKeyBytes.Length || !CryptographicOperations.FixedTimeEquals(tokenBytes, expectedKeyBytes)) { failReason = "token_mismatch"; } } } if (failReason is not null) { logger.LogWarning( "api key auth failed: reason={Reason} path={Path} remote={Remote}", failReason, context.Request.Path, context.Connection.RemoteIpAddress); context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.ContentType = "application/json"; await context.Response.WriteAsync("{\"error\":\"unauthorized\"}"); return; } await next(); }); app.MapGet("/api/v1/files/{key}", (string key) => Results.Ok(/* ... */)); app.Run(); ``` #### 3.5.3 兩端共通的部署檢查清單 不分 Go / C#,部署前必須逐項確認: | # | 檢查項 | 為什麼 | |---|--------|--------| | 1 | env 已設定且非空(啟動 fail-fast)| 避免「未設定 = 全部放行」災難;server 應在啟動時 panic / throw、不要等到第一個 request 才發現 | | 2 | constant-time compare(Go `subtle.ConstantTimeCompare` / C# `CryptographicOperations.FixedTimeEquals`)| 避免 timing attack 反推 key | | 3 | 401 response body 統一(不洩漏「key 對 / 不對」/ 「missing / mismatch」差異)| 對外只回 `{"error":"unauthorized"}`,差異只記在 server 端 log | | 4 | log 絕對不印 token 本身 | 即使是失敗的 token 也不印(攻擊者可能用半正確的 token 試探);只印 reason / path / remote | | 5 | Bearer prefix 嚴格驗證(缺 prefix 也 401)| 不允許 `Authorization: ` 這種格式(即使內容對也 reject、強制 client 用標準 Bearer scheme) | | 6 | 每環境(dev / stage / prod)獨立 key | 嚴格分環境產 key(`openssl rand -hex 32` 各 64 字元 hex),不重用 | | 7 | key 不進 git | `.gitignore` 嚴格 ignore `.env*`;CI / CD secret 從 Secrets Manager / Vault 注入 | | 8 | 健康檢查 endpoint 是否要 bypass auth | 通常 `/healthz` / `/readyz` 應 bypass(讓 LB / k8s 可探測),但業務 endpoint 全部要 auth | ### 4. 不再有 scope 概念 OAuth `client_credentials` 設計中四個 scope(`converter:job.write/read`、`files:download.read/delegate`)取消: - 單一 API key 就是「visionA 有權打 converter」/ 「visionA 有權打 FAA」的完整證明 - 不再有「同一個 client 但拿不同 scope」的細粒度區分(在 1:1 trust 中本來就沒意義) - converter / FAA 端 middleware 也不需要驗 scope ### 5. 不再有 tenant 概念 visionA → converter / FAA 不再帶 tenant_id: - converter 端的 user_id 從 multipart body `user_id` field 拿(仍由 visionA 從 OIDC sub 灌入,這條 trust boundary 不變) - FAA 端也不需要 tenant 概念(pull NEF 用 object_key 定位) - `VISIONA_OIDC_TENANT_ID` env 在 conversion 場景**廢棄**(如果其他場景還有用就保留,目前未發現其他依賴) ### 6. visionA backend 移除的程式碼 | 項目 | 處理 | |------|------| | `internal/conversion/mc_token_client.go`(整個 package) | **整個檔案刪除**(~440 行 — token cache + delegated download token + double-checked locking)| | `internal/conversion/converter_client.go` 內呼叫 `MCTokenClient.ServiceToken()` 的地方 | 改成讀 `cfg.Conversion.ConverterAPIKey` 直接 set header | | `internal/conversion/faa_client.go` 內呼叫 `MCTokenClient.ServiceToken()` 的地方 | 改成讀 `cfg.Conversion.FAAAPIKey` 直接 set header | | `internal/conversion/flow.go` 內呼叫 `mc.IssueDelegatedDownload()` 的地方 | **delegated download token 路徑取消**(見下方 §7) | | `internal/config/config.go` 內 `OIDCConfig.ServiceClientID` / `ServiceClientSecret` 兩個欄位 | **欄位廢棄**(保留欄位以保持 backward compat,但 conversion 已不依賴);env `VISIONA_OIDC_SERVICE_CLIENT_ID` / `SECRET` 從 `.env*.example` 移除 | | `internal/config/config.go` 內 `ConversionConfig.TenantID` 欄位 + env `VISIONA_OIDC_TENANT_ID` | conversion 模組不再依賴;如其他模組未使用即可移除 | 新增的 config 欄位: ```go // internal/config/config.go type ConversionConfig struct { ConverterBaseURL string // 既有 FAABaseURL string // 既有 ConverterAPIKey string // 新增 — env VISIONA_CONVERTER_API_KEY FAAAPIKey string // 新增 — env VISIONA_FAA_API_KEY // TenantID 廢棄 } // Enabled 改判定: func (c ConversionConfig) Enabled() bool { return c.ConverterBaseURL != "" && c.FAABaseURL != "" && c.ConverterAPIKey != "" && c.FAAAPIKey != "" } ``` ### 7. Delegated download token 路徑的處理(重要 — Phase 0.8b 範圍說明) ADR-014 §2 設計 download 流程是: ``` browser → visionA /download → MC issue delegated token → 302 → browser → FAA?access_token=... ``` API key 模式下「MC issue delegated token」這條鏈不存在了。Phase 0.8b 對 download 路徑的處理: **選項 A(Phase 0.8b 採用)— 短期:保持 server-side download proxy** visionA backend 直接用 `Authorization: Bearer ` 拉 FAA,stream 回 browser: ``` browser → visionA /download → visionA backend ↓ Bearer ↓ FAA ↓ stream NEF 回 browser ``` - 跨 internet 流量 N 倍是接受的取捨(Phase 0.8 MVP user 量小、單檔 NEF 通常 < 50MB) - token 結構性不過 frontend JS(仍維持 ADR-014 §2 表格的「server-side 比 frontend 拿 token 更安全」的所有優點) - visionA backend 變成 streaming bottleneck,Phase 1 量大時再評估 **選項 B(Phase 1+ 升級路徑)— FAA 上 delegated token 機制改用「visionA 自己簽 short-TTL HMAC token」** 如果 Phase 1 流量壓力大要回 302 redirect 模式,visionA 可以自己簽 short-TTL HMAC token(不需要 MC 介入),FAA 端 middleware 多加一條「驗 visionA HMAC token」的路徑: ``` browser → visionA /download → visionA 用 HMAC_KEY 簽 short-TTL token ↓ 302 → browser ↓ browser → FAA?access_token= ↓ FAA middleware:API key (server-to-server) OR HMAC (browser direct) 二選一 ``` 此升級路徑與本 ADR 的決策無衝突,記入 Phase 1 follow-up。 ### 8. user_id 注入 trust boundary 不變 ADR-014 §6「visionA backend 是唯一灌 user_id 的點」邏輯不變: - user_id 仍從 OIDC cookie session 拿(OIDC sub) - 仍透過 multipart streaming 注入 converter request 的 `user_id` field(converter 端視 visionA 為 trusted caller) - API key 證明的是「caller 是 visionA」,user_id 的真實性由 visionA 內部的 OIDC 機制保證 — 兩條獨立鏈 ### 9. 部署層的 env 注入 Phase 0.8b 採用: | Env | Stage | Production | |-----|-------|-----------| | `VISIONA_CONVERTER_API_KEY`(visionA 端) | `.env.stage`(jimchen 持有,不進 git) | AWS Secrets Manager / Vault | | `CONVERTER_API_KEY`(converter 端) | `.env`(jimchen 持有,不進 git) | 同上 | | `VISIONA_FAA_API_KEY`(visionA 端) | `.env.stage` | 同上 | | `FAA_API_KEY`(FAA 端) | warrenchen 設置 | 同上 | key 產生方式:`openssl rand -hex 32`(64 字元 hex) ## 考慮過的替代方案 (Alternatives Considered) ### 方案 A:維持 OAuth client_credentials(ADR-014 原方案) | 評估 | 內容 | |------|------| | 優點 | 標準化、跨團隊可重用、短 TTL token、scope 細粒度可控 | | 缺點 | 需要 MC team 配合 onboard scope、converter / FAA 都要重寫 middleware、JWKS 取得失敗時 graceful degrade 複雜、stage e2e 鏈路 4 個 blocker 全要修齊 | | 排除原因 | 對 1:1 internal trust 場景過度設計;Phase 0.8 stage 部署實際遇到的 4 個 blocker 證明這條路 ROI 不好 | ### 方案 B:mTLS(mutual TLS) | 評估 | 內容 | |------|------| | 優點 | 不需傳遞 secret in plaintext(憑證綁定)、cert rotation 機制成熟 | | 缺點 | converter / FAA 都要支援 mTLS、需要 CA 管理、ingress(nginx / Caddy / ALB)也要配合 client cert termination、stage 環境部署成本高 | | 排除原因 | 對 1:1 trust 過度設計;公司 stage 環境 ingress(host nginx)未對外開放 mTLS 配置;維運成本不成比例 | ### 方案 C:API key + IP allowlist 雙層防護 | 評估 | 內容 | |------|------| | 優點 | 即使 API key 洩漏,攻擊者也需從特定 IP 才能用 | | 缺點 | visionA 上 AWS 後 IP 不固定(NAT gateway / ALB 對外多 IP);converter / FAA 在公司內網 IP allowlist 維護成本高;對「不是內網」的 prod 場景幾乎沒用 | | 排除原因 | Phase 0.8 stage 仍在公司內網(visionA stage 在 192.168.0.x),加 IP allowlist 在 stage 可行但對 prod 沒有延展性;不採用 | ### 方案 D:共用一把 API key(不分 converter / FAA) | 評估 | 內容 | |------|------| | 優點 | env 少一個、部署設定簡單 | | 缺點 | 一處洩漏兩處連坐;converter rotate 必須同步 FAA;違反「每條 trust boundary 各自獨立」原則 | | 排除原因 | 每個下游各自獨立的 key 是低成本(只多一個 env),但隔離效益高,採方案決策的反方案 | ## 後果 (Consequences) ### 正面影響 - **實作大幅簡化**:visionA backend 砍 `internal/conversion/mc_token_client.go`(~440 行 — 含 token cache + delegated download token + double-checked locking + retry policy) - **不依賴 MC scope onboarding**:MC team 完全不需介入,stage e2e blocker 從 4 個降到 0 - **converter / FAA middleware 極簡**:兩端各自只需「比對單一字串 + constant-time compare」,無需驗 JWKS / scope / tenant;converter Phase 1 之前舊 image 可直接補 middleware 上線(不需 redeploy 大改) - **失敗模式收斂**:原本「MC 5xx / MC 4xx / token cache miss / scope mismatch」四個失敗類型,收斂為「API key 對 / 不對」單一布林 - **可觀測性減負**:不需追 token cache hit rate、不需追 MC 失敗率 - **已洩漏的 stage service client secret 直接作廢**:不需協調 MC team rotate ### 負面影響(接受的取捨) - **API key 是 long-lived secret**:不像 OAuth token 有 TTL(通常 1 小時);rotate 需要 visionA + 下游同步換 env 並 redeploy;secret 管理責任更重 - **沒有 scope 細粒度**:未來如果 visionA 內部要區分「能 init job 但不能 promote」這種需求,要回頭加(但 1:1 trust 場景幾乎不會有此需求) - **沒有 audit trail(誰用 token 做什麼)**:OAuth + JWT 的 sub claim 提供天然 audit;API key 模式下 converter / FAA 只知道「是 visionA」,需要靠 visionA 內部 log + request_id 串接才能追到 user_id(既有 request_id 機制可滿足) - **delegated download token 暫時不能用 302 redirect 模式**:Phase 0.8b 退回 server-side download proxy;Phase 1 量大時要另外設計(見 §7 選項 B) ### 風險 | 風險 | 緩解 | |------|------| | API key 洩漏(git log、log shipping、Slack 訊息等)| (1) `.gitignore` 嚴格 ignore `.env*`;(2) log 不印 token;(3) stage / prod 用不同 key;(4) 一旦發現洩漏 → 換 env → redeploy(雙方協調 < 1 小時可完成)| | Rotate 流程缺失 | 配套必須產出 rotate runbook(jimchen 對 converter,warrenchen 對 FAA) | | 兩個 key 管理混淆(converter / FAA) | env 命名嚴格區分(`VISIONA_CONVERTER_API_KEY` / `VISIONA_FAA_API_KEY`);config validate 啟動時檢查兩個都非空 | | 開發環境 / stage / prod 用同一把 key | 嚴格分環境產 key(dev / stage / prod 各自 `openssl rand -hex 32`),不重用 | ## 合規性 - [x] 與使用者確認:採 API key + Authorization Bearer + 每個下游獨立 key - [x] 與 jimchen 確認(同時為 visionA + converter 維護者):converter middleware 改寫由 jimchen 處理 - [ ] 與 warrenchen 確認:FAA 端 middleware 改寫由 warrenchen 處理(**待 Phase 0.8b 步驟 5**) - [x] 與 ADR-014 對齊:本 ADR 部分 supersede ADR-014 §5 / §6(OAuth 段落) / §7(MC token retry rows),不影響 ADR-014 其他段落 - [x] 與 ADR-013 對齊:本 ADR 不影響 user login 的 public PKCE client - [ ] DevOps rotate runbook 待產出(Phase 0.9 follow-up) - [ ] 已洩漏的 stage service client secret `RciRUyi...` 自動作廢(不需 MC rotate) ## 配套產出(給後續 Phase) ### Phase 0.8b 範圍內 - visionA backend 程式碼改造(backend agent 任務) - converter middleware 改造(jimchen 跨 repo) - FAA middleware 改造(warrenchen 跨 repo) - `.env.stage.example` 更新(移除 service client env、新增 API key env) - 設計文件更新(conversion.md / api-conversion.md / oidc-tdd.md — 本 ADR 同步產出) ### Phase 0.9 / Phase 1 follow-up - [ ] API key rotate runbook(每個下游一份,含「產新 key → 雙方同步 env → restart → 驗證 → 拔舊 key」步驟) - [ ] 是否設 API key 有效期(例如 1 年到期自動提醒)— 由 SRE 流程決定 - [ ] FAA 上「visionA 自己簽 HMAC token」delegated 機制(§7 選項 B),用於 download 路徑回 302 redirect - [ ] 觀察 server-side download proxy 在 Phase 1 量大時的效能 / 頻寬 cost ## 相關文件 - 部分 supersedes:[`adr-014-conversion-integration.md`](./adr-014-conversion-integration.md)(§5 / §6 OAuth 段落 / §7 MC rows) - 不影響:[`adr-013-public-client.md`](./adr-013-public-client.md)(user login 部分) - 詳細實作(本 ADR 同步更新):`conversion.md`、`api/api-conversion.md`、`oidc-tdd.md` - 觸發背景:`progress.md` 「Phase 0.8b 啟動原因(2026-05-11)」段落 ## 版本記錄 | 日期 | 版本 | 變更 | |------|------|------| | 2026-05-11 | 1.0 | 初版 — Phase 0.8b 將 server-to-server 認證從 OAuth client_credentials 改 pre-shared API key | | 2026-05-15 | 1.1 | 補 §3.5 Reference Middleware Implementation — Go (converter) + C# (FAA) snippet + 部署檢查清單,給跨 repo 改 middleware 時照抄 |