visionA/docs/autoflow/04-architecture/adr/adr-015-server-to-server-api-key.md
jim800121chen b9c228df4f docs(autoflow): Phase 0.8b ADR-015 + TDD 修訂 — server-to-server 改 API key
新增 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>
2026-05-15 06:39:45 +08:00

658 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ADR-015visionA → 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 CenterMC的 OAuth `client_credentials` grant
1. visionA backend 啟動時讀 `VISIONA_OIDC_SERVICE_CLIENT_ID` / `SECRET`
2. 第一次需要時打 MC `POST /oauth/token` 換 service tokenscope `converter:job.write/read``files:download.read/delegate`
3.`Authorization: Bearer <service-token>` 打 converter / FAA
4. converter / FAA 端 middleware 驗 JWKS 簽章 + 驗 scope + 驗 tenant
Phase 0.8 stage 部署實際跑到才發現**整條鏈路有 4 個串行 blocker**
| # | Blocker | 影響 |
|---|---------|------|
| 1 | MC stage 沒註冊 `converter:job.read/write` 兩個 scopeprogress.md 5/2 `assume 都有` 證實是錯的)| `POST /oauth/token` 直接 400 `invalid_scope` |
| 2 | stage 上 converter image 是 5 週前舊版,沒 OAuth middleware、沒 `/api/v1/jobs` endpoint | 即使 MC 補 scopeconverter 仍無法接受 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**jimchenFAA 由 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 compareC# `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 <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 端使用範例:
```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 既有架構選一即可。
**寫法 AClassic 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<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 使用範例:
```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();
```
**寫法 BMinimal 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<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 compareGo `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_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 路徑的處理:
**選項 APhase 0.8b 採用)— 短期:保持 server-side download proxy**
visionA backend 直接用 `Authorization: Bearer <FAA_API_KEY>` 拉 FAAstream 回 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 bottleneckPhase 1 量大時再評估
**選項 BPhase 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 middlewareAPI key (server-to-server) OR HMAC (browser direct) 二選一
```
此升級路徑與本 ADR 的決策無衝突記入 Phase 1 follow-up
### 8. user_id 注入 trust boundary 不變
ADR-014 §6visionA backend 是唯一灌 user_id 的點邏輯不變
- user_id 仍從 OIDC cookie session OIDC sub
- 仍透過 multipart streaming 注入 converter request `user_id` fieldconverter 端視 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_credentialsADR-014 原方案)
| 評估 | 內容 |
|------|------|
| 優點 | 標準化跨團隊可重用 TTL tokenscope 細粒度可控 |
| 缺點 | 需要 MC team 配合 onboard scopeconverter / FAA 都要重寫 middlewareJWKS 取得失敗時 graceful degrade 複雜stage e2e 鏈路 4 blocker 全要修齊 |
| 排除原因 | 1:1 internal trust 場景過度設計Phase 0.8 stage 部署實際遇到的 4 blocker 證明這條路 ROI 不好 |
### 方案 BmTLSmutual TLS
| 評估 | 內容 |
|------|------|
| 優點 | 不需傳遞 secret in plaintext憑證綁定)、cert rotation 機制成熟 |
| 缺點 | converter / FAA 都要支援 mTLS需要 CA 管理ingressnginx / Caddy / ALB也要配合 client cert terminationstage 環境部署成本高 |
| 排除原因 | 1:1 trust 過度設計公司 stage 環境 ingresshost nginx未對外開放 mTLS 配置維運成本不成比例 |
### 方案 CAPI key + IP allowlist 雙層防護
| 評估 | 內容 |
|------|------|
| 優點 | 即使 API key 洩漏攻擊者也需從特定 IP 才能用 |
| 缺點 | visionA AWS IP 不固定NAT gateway / ALB 對外多 IPconverter / 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 / tenantconverter 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 redeploysecret 管理責任更重
- **沒有 scope 細粒度**未來如果 visionA 內部要區分 init job 但不能 promote這種需求要回頭加 1:1 trust 場景幾乎不會有此需求
- **沒有 audit trail誰用 token 做什麼**OAuth + JWT sub claim 提供天然 auditAPI key 模式下 converter / FAA 只知道 visionA」,需要靠 visionA 內部 log + request_id 串接才能追到 user_id既有 request_id 機制可滿足
- **delegated download token 暫時不能用 302 redirect 模式**Phase 0.8b 退回 server-side download proxyPhase 1 量大時要另外設計 §7 選項 B
### 風險
| 風險 | 緩解 |
|------|------|
| API key 洩漏git loglog shippingSlack 訊息等| (1) `.gitignore` 嚴格 ignore `.env*`(2) log 不印 token(3) stage / prod 用不同 key(4) 一旦發現洩漏 env redeploy雙方協調 < 1 小時可完成|
| Rotate 流程缺失 | 配套必須產出 rotate runbookjimchen converterwarrenchen FAA |
| 兩個 key 管理混淆converter / FAA | env 命名嚴格區分`VISIONA_CONVERTER_API_KEY` / `VISIONA_FAA_API_KEY`config validate 啟動時檢查兩個都非空 |
| 開發環境 / stage / prod 用同一把 key | 嚴格分環境產 keydev / 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 / §6OAuth 段落 / §7MC 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 tokendelegated 機制(§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 時照抄 |