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

29 KiB
Raw Blame History

ADR-015visionA → converter / FAA 採 pre-shared API key 認證(取代 OAuth client_credentials

狀態

Accepted — 2026-05-11

上位 / 同層 ADR

  • 部分 supersedesADR-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-006in-memory stateADR-010user login 的 OIDC BFF PatternADR-011(取代 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/readfiles: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 + converterjimchenFAA 由 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_KEYvisionA 端) / CONVERTER_API_KEYconverter 端) jimchen visionA → converter
VISIONA_FAA_API_KEYvisionA 端) / FAA_API_KEYFAA 端) 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.ServeMuxsubtle.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 既有架構選一即可。

寫法 AClassic 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();

寫法 BMinimal 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 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 嚴格分環境產 keyopenssl 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 設計中四個 scopeconverter:job.write/readfiles: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.goOIDCConfig.ServiceClientID / ServiceClientSecret 兩個欄位 欄位廢棄(保留欄位以保持 backward compat但 conversion 已不依賴env VISIONA_OIDC_SERVICE_CLIENT_ID / SECRET.env*.example 移除
internal/config/config.goConversionConfig.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 路徑的處理:

選項 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 §6「visionA 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_KEYvisionA 端) .env.stagejimchen 持有,不進 git AWS Secrets Manager / Vault
CONVERTER_API_KEYconverter 端) .envjimchen 持有,不進 git 同上
VISIONA_FAA_API_KEYvisionA 端) .env.stage 同上
FAA_API_KEYFAA 端) warrenchen 設置 同上

key 產生方式:openssl rand -hex 3264 字元 hex

考慮過的替代方案 (Alternatives Considered)

方案 A維持 OAuth client_credentialsADR-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 不好

方案 BmTLSmutual TLS

評估 內容
優點 不需傳遞 secret in plaintext憑證綁定、cert rotation 機制成熟
缺點 converter / FAA 都要支援 mTLS、需要 CA 管理、ingressnginx / Caddy / ALB也要配合 client cert termination、stage 環境部署成本高
排除原因 對 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 onboardingMC 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 log、log shipping、Slack 訊息等) (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_KEYconfig validate 啟動時檢查兩個都非空
開發環境 / stage / prod 用同一把 key 嚴格分環境產 keydev / 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 / §6OAuth 段落) / §7MC 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

相關文件

  • 部分 supersedesadr-014-conversion-integration.md§5 / §6 OAuth 段落 / §7 MC rows
  • 不影響:adr-013-public-client.mduser login 部分)
  • 詳細實作(本 ADR 同步更新):conversion.mdapi/api-conversion.mdoidc-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 時照抄