新增 ADR-015:visionA → converter / FAA 從 OAuth client_credentials 改 pre-shared API key - §1-§9 決策、4 個替代方案、後果分析、合規性 - §3.5 reference middleware snippet(Go converter + C# FAA 兩種寫法)+ 部署檢查清單 - 部分 supersede ADR-014 §5/§6/§7(service token / scope / MC retry rows) - 觸發背景:Phase 0.8 stage e2e 撞 4 個 blocker,1:1 internal trust 用 OAuth client_credentials 過度設計 3 份 TDD 配合修訂: - conversion.md:重寫 §3 服務間認證、§4.1 download 退回 server-side stream proxy、刪 §2.4 mc_token_client、§5.3 補 cancel 鏈、§10.3 改 pre-shared key 保護 - api-conversion.md:error code idp_unavailable → converter_auth_failed/faa_auth_failed;download response 從 302 redirect 改 200 + Content-Disposition: attachment + NEF stream - oidc-tdd.md:標廢棄 service client env 兩 row、新增 API key env 兩 row、§13.1.3 user login 與 server-to-server 脫鉤說明、v0.2 changelog 未動:source code(步驟 2 由 backend agent 處理;範圍含 mc_token_client 刪除、TenantID 移除、API key 改造,含 test files) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
29 KiB
ADR-015:visionA → converter / FAA 採 pre-shared API key 認證(取代 OAuth client_credentials)
狀態
Accepted — 2026-05-11
上位 / 同層 ADR
- 部分 supersedes:ADR-014 §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 — user login 的 OIDC public PKCE client 仍照舊。本 ADR 只動「server-to-server」這條線;「user login」這條線完全不變。
- 沿用:ADR-006(in-memory state)、ADR-010(user login 的 OIDC BFF Pattern)、ADR-011(取代 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:
- visionA backend 啟動時讀
VISIONA_OIDC_SERVICE_CLIENT_ID/SECRET - 第一次需要時打 MC
POST /oauth/token換 service token(scopeconverter:job.write/read、files:download.read/delegate) - 帶
Authorization: Bearer <service-token>打 converter / FAA - 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 <api-key> header 取代 OAuth client_credentials 服務間認證。
1. visionA → converter
visionA backend 啟動時
↓
讀 env VISIONA_CONVERTER_API_KEY
↓
[轉檔請求進來]
↓
打 converter:
Authorization: Bearer <VISIONA_CONVERTER_API_KEY>
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 <VISIONA_FAA_API_KEY>
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)。
// 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 <api-key> 的 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 端使用範例:
// 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 專案)
// 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<ApiKeyAuthMiddleware> _logger;
private readonly byte[] _expectedKeyBytes;
private const string BearerPrefix = "Bearer ";
public ApiKeyAuthMiddleware(
RequestDelegate next,
ILogger<ApiKeyAuthMiddleware> 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<ApiKeyAuthMiddleware>(expectedKey);
}
}
Program.cs 使用範例:
// 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)
// 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<ILoggerFactory>()
.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: <token> 這種格式(即使內容對也 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_idfield 拿(仍由 visionA 從 OIDC sub 灌入,這條 trust boundary 不變) - FAA 端也不需要 tenant 概念(pull NEF 用 object_key 定位)
VISIONA_OIDC_TENANT_IDenv 在 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 欄位:
// 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_API_KEY> 拉 FAA,stream 回 browser:
browser → visionA /download → visionA backend
↓
Bearer <FAA_API_KEY>
↓
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=<visionA-signed-hmac>
↓
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_idfield(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),不重用 |
合規性
- 與使用者確認:採 API key + Authorization Bearer + 每個下游獨立 key
- 與 jimchen 確認(同時為 visionA + converter 維護者):converter middleware 改寫由 jimchen 處理
- 與 warrenchen 確認:FAA 端 middleware 改寫由 warrenchen 處理(待 Phase 0.8b 步驟 5)
- 與 ADR-014 對齊:本 ADR 部分 supersede ADR-014 §5 / §6(OAuth 段落) / §7(MC token retry rows),不影響 ADR-014 其他段落
- 與 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(§5 / §6 OAuth 段落 / §7 MC rows) - 不影響:
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 時照抄 |