新增 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>
658 lines
29 KiB
Markdown
658 lines
29 KiB
Markdown
# 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 <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` 兩個 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)。
|
||
|
||
```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 既有架構選一即可。
|
||
|
||
**寫法 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<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();
|
||
```
|
||
|
||
**寫法 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<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_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_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_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 時照抄 |
|