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>
This commit is contained in:
jim800121chen 2026-05-15 06:39:45 +08:00
parent 700b7b08ba
commit b9c228df4f
4 changed files with 1052 additions and 264 deletions

View File

@ -0,0 +1,657 @@
# 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 §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_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 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 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 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_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 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 時照抄 |

View File

@ -1,8 +1,9 @@
# API — Conversion轉檔功能Phase 0.8
# API — Conversion轉檔功能Phase 0.8 / Phase 0.8b
> **base URL**`https://stage-9527.innovedus.com:9527/`stage / `http://localhost:3721`dev
> **Auth**OIDC cookie session`visiona_session`),參見 `oidc-tdd.md`
> **同層**`api/api-spec.md`(總覽)、`conversion.md`(內部設計)、`adr/adr-014-conversion-integration.md`
> **Authuser → visionA**OIDC cookie session`visiona_session`),參見 `oidc-tdd.md` — 與 Phase 0.8 完全一致,未變
> **服務間認證visionA → converter / FAA****Phase 0.8b 已改為 pre-shared API key**(取代 OAuth client_credentials— 對 frontend 透明,不影響本 API 契約;詳見 `conversion.md` §3、[`adr/adr-015-server-to-server-api-key.md`](../adr/adr-015-server-to-server-api-key.md)
> **同層**`api/api-spec.md`(總覽)、`conversion.md`(內部設計)、`adr/adr-014-conversion-integration.md`(仍有效部分)、`adr/adr-015-server-to-server-api-key.md`Phase 0.8b 認證機制)
> **角色**:給 visionA-frontend 實作時的 API 契約
---
@ -72,7 +73,8 @@ multipart fields**注意:不要帶 user_idbackend 會從 cookie 灌**
| 409 | `active_job_exists` | visionA pre-check / converter | 顯示「你已有進行中任務」+ details.job |
| 413 | `payload_too_large` | converter | 提示檔案大小限制 |
| 502 | `converter_unavailable` | visionA | 提示「轉檔服務暫時無法使用」+ 重試按鈕 |
| 503 | `idp_unavailable` / `service_busy` | visionA / converter | 提示稍後重試 |
| 502 | `converter_auth_failed` | visionAPhase 0.8b 新增)| 同上文字 — frontend 看不出差別SRE 從 log 排查 API key 同步 |
| 503 | `service_busy` | converter | 提示稍後重試 |
---
@ -183,7 +185,9 @@ Content-Type: application/json
| 404 | `not_found` | job_id 不存在 |
| 409 | `job_not_completed` | job 還沒 completed不能 promote |
| 502 | `converter_unavailable` | promote 失敗,可重試 |
| 502 | `converter_auth_failed` | converter API key 不同步(運維事件)|
| 502 | `faa_unavailable` | FAA pull 失敗,可重試 |
| 502 | `faa_auth_failed` | FAA API key 不同步(運維事件)|
**冪等性**:對同一 `job_id` 重複呼叫;若已建過 model record回 200 + 既有 model 詳情(不重新建)。
@ -191,9 +195,9 @@ Content-Type: application/json
## 4. `GET /api/conversion/{job_id}/download`
「下載」 — visionA-backend server-side HTTP 302 redirect 到 FAA delegated URL。**Token 永遠不過 frontend JS**
「下載」 — **Phase 0.8bvisionA-backend server-side stream proxy**(從 FAA pull NEF stream 後中轉回 browser。Phase 0.8 原本的「302 redirect to FAA delegated URL」設計因服務間認證改 API key 而退回 proxy 模式,詳見 [ADR-015](../adr/adr-015-server-to-server-api-key.md) §7、`conversion.md` §4.1
仿 FAA TestSite `DownloadFileDirect``FileAccessAgent.TestSite/Controllers/HomeController.cs:255-282`pattern
對 frontend 而言**呼叫方式完全一致**`<a href>` / `window.location.href`),但 response 從「302 Location」變成「200 + NEF binary stream + Content-Disposition: attachment」
### Request
@ -204,21 +208,24 @@ Cookie: visiona_session=...
無 query string、無 body。
### Response 302成功
### Response 200成功 — Phase 0.8b 變更
```
HTTP/1.1 302 Found
Location: http://192.168.0.130:5081/files/jobs/550e8400-.../result.nef?access_token=opaque-token-xxx
HTTP/1.1 200 OK
Content-Type: application/octet-stream
Content-Length: 12345678
Content-Disposition: attachment; filename="yolov5s_kl720.nef"
Cache-Control: no-store, no-cache, must-revalidate, max-age=0
Pragma: no-cache
<NEF binary stream...>
```
browser 自動 follow Location直連 FAA 下載 NEF
browser 收到 `Content-Disposition: attachment` 自動觸發下載對話框 / 直接存到 Downloads
### Frontend 使用方式
### Frontend 使用方式(與 Phase 0.8 完全一致)
```html
<!-- 推薦anchor tagbrowser 原生處理 -->
<!-- 推薦anchor tagbrowser 自動處理 attachment download -->
<a href={`/api/conversion/${jobId}/download`} download>下載</a>
```
@ -229,11 +236,11 @@ browser 自動 follow Location直連 FAA 下載 NEF。
window.location.href = `/api/conversion/${jobId}/download`;
```
Frontend **不需要也看不到** download URL / token / object_key — 全在 server-side + browser navigation 中流轉
Frontend **不需處理 token、不需處理 redirect**`Content-Disposition: attachment` 觸發 browser 原生 download 行為
**為什麼不需要 FAA CORS**browser navigation request包含 `<a href>` click 與 `window.location.href`)不適用 CORSCORS 只管 JS 發起的 fetch / XHR。Server-side 302 redirect + 同源 endpoint 完全在 CORS 範圍外
**Phase 0.8b 不需要 FAA CORS**visionA backend → FAA 是 server-side 同進程 outbound HTTP call完全不適用 CORSCORS 只管 browser JS fetch / XHR。同源 endpoint + server-side stream + attachment header = 無 CORS 議題
### 錯誤(不 redirect依 Accept header 回 JSON 或 HTML 錯誤頁)
### 錯誤(依 Accept header 回 JSON 或 HTML 錯誤頁)
| HTTP | code | 處理 |
|------|------|-----|
@ -242,7 +249,9 @@ Frontend **不需要也看不到** download URL / token / object_key — 全在
| 404 | `not_found` | job_id 不存在 / 已過期 |
| 409 | `job_not_completed` | job 還沒 completed不能下載 |
| 502 | `converter_unavailable` | promote 失敗(首次下載且尚未 promote 過時可能發生)|
| 502 | `mc_token_unavailable` / `download_token_failed` | MC 換 delegated token 失敗,提示重試 |
| 502 | `converter_auth_failed` | converter API key 不同步運維事件frontend 不需區分)|
| 502 | `faa_unavailable` | FAA pull 失敗 |
| 502 | `faa_auth_failed` | FAA API key 不同步運維事件frontend 不需區分)|
**錯誤回應格式**:依 `Accept` header
- `Accept: application/json``{success:false, error:{code, message}}`
@ -250,7 +259,7 @@ Frontend **不需要也看不到** download URL / token / object_key — 全在
**注意**
- 每次「下載」按鈕都直接打 `/download` endpoint不要前端 cache 任何中間狀態
- Token TTL 短5 分鐘預設),不過反正 frontend 也碰不到 token
- Phase 0.8b 退回 server-side proxy 後visionA backend 變 streaming bottleneck — Phase 1 量大評估升級ADR-015 §7 選項 B
- 不會與 `promote-to-models` 衝突;兩者內部都會 ensurePromoted冪等兩條路徑都拿同一個 target_object_key
---
@ -309,6 +318,8 @@ Frontend 在「轉檔」入口的 `/conversion` 頁載入時打這個 endpoint
對齊 `conversion.md` §6。前端 i18n key 統一 `conversion.error.<short-name>`
> **Phase 0.8b 變更**:移除 4 個 MC 相關錯誤碼(`download_token_failed` / `mc_token_unavailable` / `idp_unavailable` / `idp_misconfigured`)— 服務間認證取消 MC 依賴。新增 2 個 `*_auth_failed` 錯誤碼對應 API key 不同步的運維事件frontend 不需區分UX 文字仍是「服務暫時無法使用」)。
| code | HTTP | i18n key | 預設訊息zh-TW |
|------|------|----------|------------------|
| `validation_failed` | 400 | `conversion.error.validation` | 上傳的內容不符合要求 |
@ -319,12 +330,22 @@ Frontend 在「轉檔」入口的 `/conversion` 頁載入時打這個 endpoint
| `job_not_completed` | 409 | `conversion.error.not_completed` | 任務尚未完成(`promote-to-models``download` 共用) |
| `payload_too_large` | 413 | `conversion.error.too_large` | 檔案超過大小限制 |
| `converter_unavailable` | 502 | `conversion.error.converter_down` | 轉檔服務暫時無法使用 |
| `converter_auth_failed` | 502 | `conversion.error.converter_down` | 轉檔服務暫時無法使用Phase 0.8b 新增 — frontend 文字同 converter_unavailable|
| `faa_unavailable` | 502 | `conversion.error.faa_down` | 檔案存取服務暫時無法使用 |
| `download_token_failed` | 502 | `conversion.error.token_failed` | 無法取得下載授權MC 4xx|
| `mc_token_unavailable` | 502 | `conversion.error.token_failed` | 無法取得下載授權MC 5xx / 持續失敗) |
| `idp_unavailable` | 503 | `conversion.error.idp_down` | 認證服務暫時無法使用 |
| `faa_auth_failed` | 502 | `conversion.error.faa_down` | 檔案存取服務暫時無法使用Phase 0.8b 新增)|
| `service_busy` | 503 | `conversion.error.busy` | 系統繁忙,請稍後再試 |
**Phase 0.8b 移除(不會再出現的舊 code**
| 已移除 | 原 HTTP | 原語意 |
|------|---------|------|
| `idp_misconfigured` | 500 | MC token endpoint 4xx |
| `idp_unavailable` | 503 | MC token endpoint 5xx |
| `download_token_failed` | 502 | MC delegated token 4xx |
| `mc_token_unavailable` | 502 | MC 持續失敗 |
frontend i18n 字典可保留 `conversion.error.idp_down` / `conversion.error.token_failed` 兩個 key 暫不刪除(防舊版 client 拿到舊 error code 時還能翻譯),但新版 backend 已不再回這 4 個 code。
---
## 版本記錄
@ -334,3 +355,4 @@ Frontend 在「轉檔」入口的 `/conversion` 頁載入時打這個 endpoint
| 2026-04-30 | 0.1 | 初稿Phase 0.8 MVP 範圍) |
| 2026-04-30 | 0.2 | §4 download endpoint 從 `POST /{job}/download-token`(回 JSON `{download_url, expires_at}`)改為 `GET /{job}/download`HTTP 302 redirect仿 FAA TestSite `DownloadFileDirect` patterntoken 不過 frontend JS、不需 FAA CORS`job_not_completed` HTTP code 從 400 改為 409 + 補 `mc_token_unavailable` |
| 2026-04-30 | 0.3 | Phase 0.8 三方交叉審閱回饋整合Job response shape 補 `expires_at` / `source_filename` / `target_chip`(議題 #7`/api/conversion/active` 行為文件化 lazy rebuild 機制(議題 #2 重啟恢復);`promote-to-models` request body 對齊 Design 單欄位(議題 #4`description` 留 Phase 1 |
| 2026-05-11 | 0.4 | **Phase 0.8b** 對應 [ADR-015](../adr/adr-015-server-to-server-api-key.md)(1) Header / 文件 metadata 標示服務間認證改 pre-shared API key對 frontend 透明);(2) §4 download endpoint response 從「302 Location」改為「200 + NEF binary + `Content-Disposition: attachment`」— frontend 呼叫方式(`<a href>` / `window.location.href`)完全一致;(3) 錯誤碼總覽:移除 4 個 MC 相關 code`idp_misconfigured` / `idp_unavailable` / `download_token_failed` / `mc_token_unavailable`),新增 2 個 `*_auth_failed``converter_auth_failed` / `faa_auth_failed`)對應 API key 不同步的運維事件 |

View File

@ -1,11 +1,11 @@
# Conversion — 轉檔功能整合Phase 0.8
# Conversion — 轉檔功能整合Phase 0.8 / Phase 0.8b
> **角色**visionA-backend 端的「轉檔」實作 spec內部模組設計 + API + flow
> **上位文件**`adr/adr-014-conversion-integration.md``TDD.md``security.md`
> **上位文件**[`adr/adr-015-server-to-server-api-key.md`](./adr/adr-015-server-to-server-api-key.md)Phase 0.8b 認證機制 — 部分 supersede ADR-014、[`adr/adr-014-conversion-integration.md`](./adr/adr-014-conversion-integration.md)仍有效upload streaming、download 302 設計、模組劃分等)`TDD.md``security.md`
> **同層文件**`api/api-conversion.md`(對 frontend 的 API 規格細節)
> **作者**Architect Agent
> **狀態**Draft待 PM / Backend / Frontend / DevOps 交叉審閱)
> **最後更新**2026-04-30
> **狀態**Phase 0.8b 修訂v0.4)— OAuth client_credentials 改 pre-shared API key
> **最後更新**2026-05-11
---
@ -13,9 +13,9 @@
1. [整體 flow端對端](#1-整體-flow端對端)
2. [模組設計 — `internal/conversion/`](#2-模組設計--internalconversion)
3. [新增 visionA-backend API](#3-新增-visiona-backend-api)
4. [Streaming proxy 設計upload](#4-streaming-proxy-設計upload)
5. [Service-to-service token 機制](#5-service-to-service-token-機制)
3. [服務間認證API key— 取代 OAuth client_credentials](#3-服務間認證api-key--取代-oauth-client_credentials)
4. [新增 visionA-backend API](#4-新增-visiona-backend-api)
5. [Streaming proxy 設計upload](#5-streaming-proxy-設計upload)
6. [錯誤碼 mapping + i18n key](#6-錯誤碼-mapping--i18n-key)
7. [user_id 注入與 trust boundary](#7-user_id-注入與-trust-boundary)
8. [Non-GoalsPhase 0.8 不做)](#8-non-goalsphase-08-不做)
@ -26,11 +26,12 @@
## 1. 整體 flow端對端
> **Phase 0.8b 變更**:服務間認證從「打 MC 換 OAuth service token + JWKS 驗簽 + scope」改為「visionA 帶 `Authorization: Bearer <pre-shared-api-key>` 直接打 converter / FAA」。詳見 §3 與 ADR-015。
```mermaid
sequenceDiagram
participant B as Browser
participant V as visionA-backend
participant MC as Member Center
participant C as Converter
participant F as FAA
@ -38,9 +39,8 @@ sequenceDiagram
B->>V: POST /api/conversion/init (multipart)
V->>V: AuthMiddleware → user_id (OIDC sub)
V->>V: 檢查同 user active job
V->>MC: POST /oauth/token (cache miss only)
MC-->>V: service token (4 scopes)
V->>C: POST /api/v1/jobs (streamed multipart, user_id 注入)
V->>C: POST /api/v1/jobs<br/>Authorization: Bearer <VISIONA_CONVERTER_API_KEY><br/>(streamed multipart, user_id 注入)
C->>C: middleware: ConstantTimeCompare(key, CONVERTER_API_KEY)
C-->>V: 201 {job_id, status:created, stage:onnx}
V->>V: 記錄 job_id ↔ user_id mapping
V-->>B: 200 {job_id, status:running, stage:onnx}
@ -49,35 +49,30 @@ sequenceDiagram
loop 直到 completed / failed
B->>V: GET /api/conversion/{job_id}
V->>V: ownership 檢查
V->>C: GET /api/v1/jobs/{id} (cache 1-2s)
V->>C: GET /api/v1/jobs/{id}<br/>Authorization: Bearer <VISIONA_CONVERTER_API_KEY>
C-->>V: {status, stage, progress, ...}
V-->>B: 整形後 status
end
Note over B,F: Stage 3a — User 選「加到模型庫」
B->>V: POST /api/conversion/{job_id}/promote-to-models
V->>C: POST /api/v1/jobs/{id}/promote
C->>F: PUT /files/{key} (NEF)
V->>C: POST /api/v1/jobs/{id}/promote<br/>Authorization: Bearer <VISIONA_CONVERTER_API_KEY>
C->>F: PUT /files/{key} (NEF — converter 內部認證,與 visionA 無關)
C-->>V: {target_object_key}
V->>F: GET /files/{key} (Bearer service token, scope=files:download.read)
V->>F: GET /files/{key}<br/>Authorization: Bearer <VISIONA_FAA_API_KEY>
F->>F: middleware: ConstantTimeCompare(key, FAA_API_KEY)
F-->>V: NEF stream
V->>V: /api/models/init → /api/models/finalize<br/>(Source=converted, SourceJobID=job_id)
V-->>B: 201 {model_id}
Note over B,F: Stage 3b — User 選「下載」server-side 302 redirect
B->>V: GET /api/conversion/{job_id}/download<br/>(<a href> 或 window.location.href)
Note over B,F: Stage 3b — User 選「下載」(Phase 0.8b: server-side proxy 302 redirect
B->>V: GET /api/conversion/{job_id}/download
V->>V: AuthMiddleware → user_id + ownership 檢查
V->>C: POST /api/v1/jobs/{id}/promote (若還沒 promote)
C->>F: PUT /files/{key}
V->>C: POST /api/v1/jobs/{id}/promote (若還沒 promote)<br/>Authorization: Bearer <VISIONA_CONVERTER_API_KEY>
C-->>V: {target_object_key}
V->>MC: POST /file-access/download-tokens<br/>(scope=files:download.delegate)
MC-->>V: opaque token
V-->>B: HTTP 302 Found<br/>Location: https://faa/files/{key}?access_token=...
Note over B: browser 自動 follow 302
B->>F: GET /files/{key}?access_token=... (browser direct)
F->>MC: validate token
MC-->>F: ok
F-->>B: NEF stream
V->>F: GET /files/{key}<br/>Authorization: Bearer <VISIONA_FAA_API_KEY>
F-->>V: NEF stream
V-->>B: stream NEFvisionA backend 中轉token 結構性不過 browser
```
**critical path 說明**
@ -85,22 +80,40 @@ sequenceDiagram
- visionA-backend 在 upload / download / promote 任一階段都先做 OIDC AuthMiddleware既有+ ownership 檢查
- promote 動作是冪等的converter 端對同一 job 重複 promote 接受visionA-backend 內部以 `job_id ↔ promoted_object_key` 記錄避免重複呼叫
- 加到模型庫流程promote → FAA pull → `/api/models/init` 取得 model_id + presigned PUT URL → visionA-backend 自己 PUT 到 storage → `/api/models/finalize`(這條路徑要重用既有 handler 邏輯,不繞過)
- **Phase 0.8b 認證鏈簡化**:不再有 visionA ↔ MC 鏈路不再有「token cache miss / scope mismatch / JWKS 不可達」失敗模式。converter / FAA 端 middleware 各自只比對單一字串。
**Phase 0.8b 與 ADR-014 的差異說明**
| 面向 | ADR-014OAuth client_credentials | Phase 0.8bAPI key|
|------|--------------------------------|------------------|
| visionA → converter 認證 | `Authorization: Bearer <MC-issued service token>` | `Authorization: Bearer <VISIONA_CONVERTER_API_KEY>` |
| visionA → FAA 認證 | `Authorization: Bearer <MC-issued service token>` | `Authorization: Bearer <VISIONA_FAA_API_KEY>` |
| download 流程 | server-side 302 redirect → browser 直連 FAA拿 MC delegated token | server-side proxyvisionA backend 中轉 stream|
| visionA → MC service token 路徑 | 啟動 lazy init MCTokenClient + cache | **完全移除** |
| converter / FAA middleware | 驗 JWKS 簽章 + 驗 scope + 驗 tenant | 比對 env 字串constant-time|
> 為什麼 download 不繼續走 302 redirectAPI key 模式下沒有 MC 簽 short-TTL delegated tokenvisionA 自己簽 HMAC token 給 browser 的方案留給 Phase 1+(見 ADR-015 §7 選項 B
---
## 2. 模組設計 — `internal/conversion/`
> **Phase 0.8b 變更**:移除 `mc_token_client.go` 整個檔案。converter / FAA client 直接從 config 讀預設 API key。
```
internal/conversion/
├── conversion.go # Service interface + 對外暴露的 type
├── converter_client.go # converter scheduler API client
├── faa_client.go # FAA API clientpull NEF
├── mc_token_client.go # MC token endpoint (client_credentials) + cache
├── converter_client.go # converter scheduler API client直接帶 VISIONA_CONVERTER_API_KEY
├── faa_client.go # FAA API clientpull NEF直接帶 VISIONA_FAA_API_KEY
├── flow.go # 整體 flow 協調
├── types.go # request / response struct
└── errors.go # error code 定義
```
**Phase 0.8b 移除**
- ❌ `mc_token_client.go`~440 行 — 整檔砍)— 不再需要與 MC 換 service token / delegated download token
- ❌ `MCTokenClient` 在 flow / converter_client / faa_client 上的所有 reference
### 2.1 `conversion.go` — 對外 interface
```go
@ -127,10 +140,14 @@ type Service interface {
// 回傳新建的 model_id。
PromoteToModels(ctx context.Context, userID, jobID string) (modelID string, err error)
// DownloadRedirectURL — 「下載」流程promote (若需要) → MC delegated token → 組好的 FAA download URL。
// handler 拿到後直接 c.Redirect(http.StatusFound, url)token 不出現在任何 JSON response。
// 仿 FAA TestSite `DownloadFileDirect` pattern。
DownloadRedirectURL(ctx context.Context, userID, jobID string) (downloadURL string, err error)
// DownloadStream — 「下載」流程Phase 0.8bserver-side proxy
// 1. ownership 檢查
// 2. promote (若需要)
// 3. 從 FAA pull NEF stream
// 4. handler 直接 io.Copy stream 給 client
// 不再產生 302 redirect URLAPI key 模式下無 MC delegated token
// 詳見 ADR-015 §7 — Phase 1+ 量大時再評估「visionA 自簽 HMAC token + 302」升級路徑。
DownloadStream(ctx context.Context, userID, jobID string) (stream io.ReadCloser, meta *DownloadMetadata, err error)
// ActiveJob 查 user 當前是否有 active job給 frontend pre-check 用)。
ActiveJob(ctx context.Context, userID string) (*Job, error)
@ -160,14 +177,11 @@ type Job struct {
ErrorMessage string `json:"error_message,omitempty"`
}
// DownloadGrant 是 mc_token_client 內部用的中間 struct從 MC 換 token 時的回傳)。
// **不對 frontend JSON 序列化** — Phase 0.8 起 download flow 走 server-side 302 redirect
// token 與 URL 永遠不出現在任何 visionA-backend → frontend 的 JSON response。
// 留 json tag 純粹給 mc_token_client 內部 unmarshal MC response 用。
type DownloadGrant struct {
DownloadURL string `json:"download_url"`
ExpiresAt time.Time `json:"expires_at"`
}
// Phase 0.8bDownloadGrant 移除(不再有 MC delegated token 換取流程)。
// Download 走 server-side proxytoken 結構性不過 frontend。
// DownloadMetadata — DownloadStream 回傳的中介資料(沿用 §2.3 既定的型別)。
// (定義在 faa_client.go避免重複
```
### 2.2 `converter_client.go`
@ -175,8 +189,8 @@ type DownloadGrant struct {
```go
type ConverterClient struct {
baseURL string
apiKey string // Phase 0.8bpre-shared API keyVISIONA_CONVERTER_API_KEY
httpClient *http.Client
tokens *MCTokenClient
}
// CreateJobStream 把 io.Reader 當作 multipart bodycontent-type 含 boundary透傳給 converter。
@ -188,25 +202,28 @@ func (c *ConverterClient) CreateJobStream(ctx context.Context, contentType strin
func (c *ConverterClient) GetJob(ctx context.Context, jobID string) (*Job, error)
func (c *ConverterClient) PromoteJob(ctx context.Context, jobID string) (targetObjectKey string, err error)
// ListActiveJobsByUser — lazy rebuild ownership 用§2.6.1
func (c *ConverterClient) ListActiveJobsByUser(ctx context.Context, userID string) (*Job, error)
```
每個方法內部:
每個方法內部Phase 0.8b 簡化)
1. `c.tokens.Get(ctx)` 取 service token自動 cache
2. 帶 `Authorization: Bearer <service-token>` + `X-Request-Id` (從 ctx 取,沿用 visionA-backend 既有 request_id 中介層)
3. response 5xx / network error 走 retry§9
1. `req.Header.Set("Authorization", "Bearer "+c.apiKey)` — 直接帶 pre-shared API key**不查 cache、不打 MC、不重簽**
2. 帶 `X-Request-Id`(從 ctx 取,沿用 visionA-backend 既有 request_id 中介層)
3. response 5xx / network error 走 retry§9401/403 不 retryAPI key 錯不會自己變對)
### 2.3 `faa_client.go`
```go
type FAAClient struct {
baseURL string
apiKey string // Phase 0.8bpre-shared API keyVISIONA_FAA_API_KEY
httpClient *http.Client
tokens *MCTokenClient
}
// Download server-to-server 拉檔(給「加到模型庫」流程用)。
// 用 service token (scope=files:download.read)
// Download server-to-server 拉檔(給「加到模型庫」+「下載」兩個流程用)。
// 帶 Authorization: Bearer <VISIONA_FAA_API_KEY>
func (c *FAAClient) Download(ctx context.Context, objectKey string) (io.ReadCloser, *DownloadMetadata, error)
type DownloadMetadata struct {
@ -216,55 +233,19 @@ type DownloadMetadata struct {
}
```
`DownloadGrant` 不在這裡產(在 `mc_token_client.go`,因為 token 是 MC 簽的不是 FAA 簽的)
**Phase 0.8b**:不再需要 `DownloadGrant`(無 MC delegated tokendownload flow 用同一個 `FAAClient.Download()` 拉 stream 後由 handler 中轉給 client
### 2.4 `mc_token_client.go`
### 2.4 ~~`mc_token_client.go`~~Phase 0.8b 移除)
```go
type MCTokenClient struct {
issuerURL string
clientID string
clientSecret string
httpClient *http.Client
mu sync.RWMutex
cachedToken string
cachedExp time.Time
}
// Get 取 service tokencache 直到 exp - 15s 內仍可用。
func (c *MCTokenClient) Get(ctx context.Context) (string, error) {
c.mu.RLock()
if c.cachedToken != "" && time.Until(c.cachedExp) > 15*time.Second {
defer c.mu.RUnlock()
return c.cachedToken, nil
}
c.mu.RUnlock()
c.mu.Lock()
defer c.mu.Unlock()
// double-check 避免併發重複取
if c.cachedToken != "" && time.Until(c.cachedExp) > 15*time.Second {
return c.cachedToken, nil
}
// POST {issuer}/oauth/token grant_type=client_credentials
// 失敗依 §9 retry
// ...
}
// IssueDelegatedDownload 跟 MC 換 browser 直連 FAA 用的 opaque token。
func (c *MCTokenClient) IssueDelegatedDownload(ctx context.Context, in DelegatedDownloadInput) (*DownloadGrant, error)
type DelegatedDownloadInput struct {
TenantID string
UserID string
ObjectKey string
Method string // "GET"
ExpiresInSeconds int // 預設 3005 分鐘)
}
```
**Tenant 處理**visionA 是 MC 的單一 tenant`tenant_id` 從 config 取(`VISIONA_OIDC_TENANT_ID` 或從 issuer / client metadata 推得)— 由 `mcConfig.TenantID` 注入。
> **整個檔案在 Phase 0.8b 移除**。詳見 [ADR-015](./adr/adr-015-server-to-server-api-key.md)。
>
> 原本職責:跟 MC 換 service tokenclient_credentials grant+ 換 delegated download token。
>
> Phase 0.8b 後:
> - 服務間認證直接帶 `Authorization: Bearer <pre-shared API key>` — 不需 cache、不需 refresh、不需 retry MC
> - download flow 退回 server-side proxyvisionA backend 中轉 stream不再有 delegated token 概念
>
> **Tenant 概念取消**visionA → converter / FAA 不再帶 tenant_idconverter 端的 user_id 仍由 visionA 灌入§7 trust boundary 不變)。
### 2.5 `flow.go` — 流程協調
@ -272,7 +253,7 @@ type DelegatedDownloadInput struct {
type Flow struct {
converter *ConverterClient
faa *FAAClient
tokens *MCTokenClient
// Phase 0.8b:不再需要 tokens *MCTokenClient
models model.Repository // 沿用既有 model store
storage storage.Store // 沿用既有 LocalFS / S3
ownership ownershipStore // job_id → user_id mapping (in-memory map)
@ -369,7 +350,108 @@ func (f *Flow) ActiveJob(ctx context.Context, userID string) (*conversion.Job, e
---
## 3. 新增 visionA-backend API
## 3. 服務間認證API key— 取代 OAuth client_credentials
> **Phase 0.8b 變更**:本節為新增;對應 [ADR-015](./adr/adr-015-server-to-server-api-key.md)。
>
> **歷史**ADR-014 §5 原本設計為「visionA → MC `POST /oauth/token` 換 service token + cache + 帶 JWT 給 converter / FAA」。Phase 0.8 stage e2e 卡關MC scope 沒註冊、converter image 舊、跨 repo 配合複雜)後,使用者決策改用 pre-shared API key。
### 3.1 取得流程
```
visionA-backend 啟動
讀 cfg.Conversion.ConverterAPIKeyenv VISIONA_CONVERTER_API_KEY
讀 cfg.Conversion.FAAAPIKeyenv VISIONA_FAA_API_KEY
[轉檔請求進來]
converter_client / faa_client 發 request 時:
req.Header.Set("Authorization", "Bearer "+apiKey)
converter / FAA middleware
- parse Authorization header → 取 token
- subtle.ConstantTimeCompare(token, envKey)
- match → 放行mismatch → 401 + log不附原因
```
**沒有 token cache、沒有 refresh、沒有 retry MC、沒有 scope 驗證**。整條鏈路是「visionA → 下游」一步。
### 3.2 Config 對齊
`visionA-backend/internal/config/config.go` 變更:
```go
type ConversionConfig struct {
ConverterBaseURL string `env:"VISIONA_CONVERTER_BASE_URL"`
FAABaseURL string `env:"VISIONA_FAA_BASE_URL"`
ConverterAPIKey string `env:"VISIONA_CONVERTER_API_KEY"` // Phase 0.8b 新增
FAAAPIKey string `env:"VISIONA_FAA_API_KEY"` // Phase 0.8b 新增
// Phase 0.8b 廢棄欄位(為 backward compat 保留 struct field 但 conversion 不再使用):
// TenantID string `env:"VISIONA_OIDC_TENANT_ID"`
}
func (c ConversionConfig) Enabled() bool {
return c.ConverterBaseURL != "" &&
c.FAABaseURL != "" &&
c.ConverterAPIKey != "" &&
c.FAAAPIKey != ""
}
```
`OIDCConfig.ServiceClientID` / `ServiceClientSecret` 兩個欄位 Phase 0.8b **conversion 不再依賴**;如其他模組未使用即可從 struct 移除。env 端從 `.env*.example` 移除以免誤導。
新增的 stage env
```bash
# .env.stage
VISIONA_CONVERTER_BASE_URL=http://192.168.0.130:9501
VISIONA_FAA_BASE_URL=http://192.168.0.130:5081
VISIONA_CONVERTER_API_KEY=<openssl rand -hex 32 產的值 converter CONVERTER_API_KEY 對齊>
VISIONA_FAA_API_KEY=<openssl rand -hex 32 產的值 FAA FAA_API_KEY 對齊>
# Phase 0.8b 移除:
# VISIONA_OIDC_SERVICE_CLIENT_ID
# VISIONA_OIDC_SERVICE_CLIENT_SECRET
# VISIONA_OIDC_TENANT_IDconversion 不依賴;其他模組未發現使用)
```
### 3.3 啟動時驗證
api-server 啟動時 log 一行(**不可 log key 本身**
```
[INFO] conversion config: converter=http://192.168.0.130:9501 (api_key_set=true) faa=http://192.168.0.130:5081 (api_key_set=true)
```
`Enabled() == false` → conversion 模組整個 disabled與 Phase 0.8 「partial deploy」相容sidebar tab 仍會顯示但會回 503 `service_busy`)。
### 3.4 Key 產生 / 部署 / Rotate
| 項目 | 規格 |
|------|------|
| 長度 | 64 字元 hex256 bit 熵) — `openssl rand -hex 32` |
| 環境隔離 | dev / stage / prod 各自獨立的 key**不重用** |
| 兩個下游 | converter / FAA 各自一把,**不共用** |
| 儲存dev| `.env.dev`gitignore |
| 儲存stage| stage host `.env.stage`(不進 git |
| 儲存prod| AWS Secrets Manager / Vault |
| Rotate | runbook Phase 0.9 補;流程:產新 key → 雙方同步 env → restart → 驗證 → 拔舊 key |
| Log policy | 永遠不印 key 全文;可印 `api_key_set=true/false` 或前 8 字元 prefix |
### 3.5 Trust boundary 對齊
API key 證明的是「caller 是 visionA」machine 身份user_id 的真實性由 visionA 內部的 OIDC cookie session 保證user 身份)。兩條獨立鏈:
- **machine auth**visionA → converter / FAA 用 API key
- **user auth**browser → visionA 用 OIDC cookie session既有未變
- visionA 是橋樑:從 OIDC sub 解出 user_id → 透過 multipart body / API path 灌進對下游的請求
詳見 §7。
---
## 4. 新增 visionA-backend API
詳細請求 / 回應 schema 見 `api/api-conversion.md`;這裡列總覽。
@ -383,7 +465,7 @@ func (f *Flow) ActiveJob(ctx context.Context, userID string) (*conversion.Job, e
> **不對外暴露但內部使用的 converter endpoint**`GET /api/v1/jobs?user_id=&status=in_progress`§2.6.1 lazy rebuild。Phase 0.8 frontend 看不到「歷史列表」UI但後端會用此內部 endpoint 做韌性處理。
>
> **`POST /api/v1/jobs/{id}/cancel`**converter Phase 1 **尚未實作**(已驗證 routes/v1/jobs.js 與 openapi.yaml。Phase 0.8 失敗 cleanup 採「socket close 自然 abort」§4.3.2Phase 1 待 converter 補上 endpoint 後再升級為 best-effort 主動 cancel。
> **`POST /api/v1/jobs/{id}/cancel`**converter Phase 1 **尚未實作**(已驗證 routes/v1/jobs.js 與 openapi.yaml。Phase 0.8 失敗 cleanup 採「socket close 自然 abort」§5.3.2Phase 1 待 converter 補上 endpoint 後再升級為 best-effort 主動 cancel。
所有 endpoint 通用:
@ -392,9 +474,9 @@ func (f *Flow) ActiveJob(ctx context.Context, userID string) (*conversion.Job, e
- response 用既有 `WriteSuccess` / `WriteError` helper
- request_id 透傳給 converter`X-Request-Id` header
### 3.1 `GET /api/conversion/{job_id}/download` — server-side 302 redirect handler
### 4.1 `GET /api/conversion/{job_id}/download` — Phase 0.8bserver-side stream proxy handler
仿 FAA TestSite `DownloadFileDirect``FileAccessAgent.TestSite/Controllers/HomeController.cs:255-282`
> **變更**Phase 0.8ADR-014 v1.1)原本是 `c.Redirect(302, FAA_URL_with_delegated_token)`Phase 0.8b API key 模式下無 MC delegated token改為 visionA backend 中轉 stream。
```go
// GET /api/conversion/{job_id}/download
@ -403,57 +485,71 @@ func conversionDownloadHandler(deps Deps) gin.HandlerFunc {
uc, _ := UserContextFrom(c) // AuthMiddleware 已驗
jobID := c.Param("job_id")
// service 內部完成ownership 檢查 → ensurePromoted → MC 換 delegated token → 組 URL
downloadURL, err := deps.Conversion.DownloadRedirectURL(c.Request.Context(), uc.UserID, jobID)
// service 內部完成ownership 檢查 → ensurePromoted → 從 FAA pull stream
stream, meta, err := deps.Conversion.DownloadStream(c.Request.Context(), uc.UserID, jobID)
if err != nil {
// 錯誤情況不 redirect直接走既有 WriteError依 Accept header 回 JSON 或 HTML 錯誤頁)
// mapping 見 §6 + §12
writeConversionError(c, err)
writeConversionError(c, err) // §6 錯誤碼分類
return
}
defer stream.Close()
// server-side HTTP 302 — token 在 Location header不過 frontend JS、不需 CORS
// 防快取:避免 browser 把 302 + Location 存進 history / disk cache
// streaming proxy 給 clientio.Copy不暫存 disk / RAM 全 buffer
c.Header("Content-Type", meta.ContentType)
if meta.SizeBytes > 0 {
c.Header("Content-Length", strconv.FormatInt(meta.SizeBytes, 10))
}
// 鼓勵 browser 觸發 save dialogfilename 來自 promote 結果)
c.Header("Content-Disposition", `attachment; filename="`+sanitizeFilename(meta.Filename)+`"`)
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
c.Header("Pragma", "no-cache")
c.Redirect(http.StatusFound, downloadURL)
c.Status(http.StatusOK)
io.Copy(c.Writer, stream)
}
}
```
**為什麼用 GET 不用 POST**
**為什麼用 GET**
- frontend 用 `<a href="..." download>` 觸發 — anchor tag 只能發 GET
- GET semantically 對應「拿一個資源」,符合「下載這個 job 的結果」語意
- 既有 OIDC AuthMiddleware 會自然處理 GET 上的 cookie session無 CSRF 風險沒有狀態變更promote 是冪等的)
**Frontend 使用範例**
**Frontend 使用範例**(與 Phase 0.8 一致,無需改動)
```html
<!-- 推薦anchor tagbrowser 自動處理 navigation + 302 follow -->
<!-- 推薦anchor tagbrowser 自動處理 navigation + 收 attachment -->
<a href={`/api/conversion/${jobId}/download`} download>下載</a>
```
或:
```ts
// 程式化觸發:等同 anchor tag
// 程式化觸發
window.location.href = `/api/conversion/${jobId}/download`;
```
Frontend **永遠看不到** download token 與 raw object_key — token 只活在 visionA-backend → browser 的 302 Location headerbrowser memoryJS 看不到,除非開 devtools network 面板)→ 馬上被 browser 用來打 FAA 後消失。
**安全性面比較Phase 0.8 → Phase 0.8b**
| 面向 | Phase 0.8302 redirect + MC delegated token| Phase 0.8bserver-side stream proxy|
|------|----|----|
| Token 在 frontend JS / URL bar | △ 短暫Location header 流經 browsernav 完即消失) | ✓ 結構性不存在(無 token 概念)|
| 要 FAA CORS | ✓ 不需要navigation 不適用 CORS| ✓ 同 — visionA 為 same-originFAA 直連在 server-side |
| 跨 internet 流量(同 NEF 多次下載)| ✓ 直連 FAA、N× 流量算 FAA 出 | ✗ 每次都繞 visionA backendN× 流量算 visionA 出 |
| visionA backend 是否變 streaming bottleneck | ✓ 不是 | ✗ 是 — Phase 0.8 MVP user 量小可接受Phase 1 量大需改 ADR-015 §7 選項 B |
| 認證鏈簡化 | ✗ 需要 MC scope `files:download.delegate` | ✓ 一把 API key 解決 |
**Phase 1 升級路徑**:如量大需回 302 redirect 模式,採 ADR-015 §7 選項 BvisionA 自己簽 short-TTL HMAC tokenFAA middleware 多支援「visionA HMAC」路徑
---
## 4. Streaming proxy 設計upload
## 5. Streaming proxy 設計upload
### 4.1 為什麼要 streaming
### 5.1 為什麼要 streaming
- 模型上限 500MBref_images 100×10MB = 1GB 上限
- 全 buffer 在 RAM → 同時 N 個 user upload 直接 OOM
- 暫存 disk → 增加 IO 與磁碟空間需求;壞掉的 cleanup 麻煩
### 4.2 實作 pattern
### 5.2 實作 pattern
```go
// handler 收到 request
@ -555,9 +651,9 @@ func (f *Flow) InitJob(ctx context.Context, in conversion.InitJobInput) (*conver
4. context cancellationhandler 收到 client disconnect → ctx.Done() → goroutine 自動結束pw.Close 觸發 reader EOF
5. 不做 ContentLength forwardconverter 自己 multer 算)
### 4.3 進度 / 取消
### 5.3 進度 / 取消
#### 4.3.1 進度語意(重要:給 Frontend / Design 對齊)
#### 5.3.1 進度語意(重要:給 Frontend / Design 對齊)
XHR `upload.onprogress` 計算的是 **browser → visionA-backend** 的進度,**不是** browser → backend → converter 的端到端進度。在 streaming proxy 模式下,這兩者有時間差:
@ -580,11 +676,11 @@ T3: backend 200 回 frontend
| backend 實作複雜度 | 低(直接同步等)| 高(需要 background goroutine + ownership 標 `upload_in_progress` + 額外狀態管理) |
| 失敗處理複雜度 | 低(同步錯誤直接回 frontend| 高(背景 forward 失敗時 frontend 已切 processing要額外 push 錯誤通知)|
**選項 A 的 UX 補償**:當 XHR `loaded === total` 但 backend 還沒回 200 時frontend 顯示 `即將完成…` / `伺服器處理中…`(對齊 flow-conversion.md §5.3。這明確告訴使用者「browser 端已送完,正在等 server 收尾」,不是欺騙性的進度條。
**選項 A 的 UX 補償**:當 XHR `loaded === total` 但 backend 還沒回 200 時frontend 顯示 `即將完成…` / `伺服器處理中…`(對齊 flow-conversion.md §5.3,本文件 §5.3.1。這明確告訴使用者「browser 端已送完,正在等 server 收尾」,不是欺騙性的進度條。
> **Phase 1 升級路徑**:若 Phase 1 量大需要更精準的端到端進度,可改成 SSE 推送 `upload_progress` 事件backend 主動報「已 forward N bytes 給 converter」但 Phase 0.8 MVP 不做。
#### 4.3.2 Cancel 與 Cleanup 鏈(重要)
#### 5.3.2 Cancel 與 Cleanup 鏈(重要)
**情境分類**
@ -670,87 +766,23 @@ converter multer 偵測 incomplete multipart → 拒絕收 job不會建 job_i
**C1 特別處理(使用者按「取消上傳」)**frontend 在 `xhr.abort()` 之前**不應**先打 cancel API多此一舉TCP RST 即已觸發 cleanup後端會自動處理。
#### 4.3.3 既有 4.3 內容(保留)
#### 5.3.3 既有 4.3 內容(保留)
- **進度**visionA-backend 不做進度回報frontend 用 XHR `upload.onprogress` 自己顯示(既有前端模式)
- **取消**context.Cancelclient 斷線)→ 連帶 cancel converter request如上 cleanup 鏈converter 端 multer 收到 socket close 會自動 abort multipart parsing
### 4.4 Timeout
### 5.4 Timeout
- handler 整體不設總 timeout500MB upload 可能 5-10 分鐘)
- 但每個 io.Copy 之間用 `http.Server.WriteTimeout`/`IdleTimeout` 控制 keep-alive具體值由 DevOps 在 Nginx / ingress 設定(建議 600s
---
## 5. Service-to-service token 機制
### 5.1 取得流程
```
visionA-backend 啟動
讀 cfg.OIDC.ServiceClientID/Secret (config 既有預埋)
lazy init MCTokenClient不主動取
[第一個轉檔請求進來]
flow → converter_client → tokens.Get(ctx)
cache miss → POST {issuer}/oauth/token
grant_type=client_credentials
client_id=<ServiceClientID>
client_secret=<ServiceClientSecret>
scope=converter:job.write converter:job.read files:download.read files:download.delegate
MC 回 {access_token, expires_in, scope}
cache (exp = now + expires_in - 15s)
return token
```
### 5.2 Cache 策略
- 單一 token cache 涵蓋 4 個 scopeMC 端發單一 token 含全部)
- `exp - 15s` 提前重取,避免下游使用時剛好過期
- 併發保護double-checked locking5.4 §2.4 範例)
- 重啟即清空in-memory無持久化
### 5.3 Config 對齊
`visionA-backend/internal/config/config.go` 已預埋 `OIDCConfig.ServiceClientID/Secret`A1 階段不啟用。Phase 0.8 啟用:
```diff
// OIDCConfig.Validate
func (c *Config) Validate() error {
...
+ // Phase 0.8 起 Service Client 啟用(轉檔功能依賴)
+ if c.OIDC.ServiceClientID == "" {
+ missing = append(missing, "VISIONA_OIDC_SERVICE_CLIENT_ID")
+ }
+ if c.OIDC.ServiceClientSecret == "" {
+ missing = append(missing, "VISIONA_OIDC_SERVICE_CLIENT_SECRET")
+ }
...
}
```
新增 env
```
VISIONA_OIDC_SERVICE_CLIENT_ID=23605e14a2c64660abd97e29963d8d58
VISIONA_OIDC_SERVICE_CLIENT_SECRET=<from MC, never commit>
VISIONA_CONVERTER_BASE_URL=http://192.168.0.130:9501
VISIONA_FAA_BASE_URL=http://192.168.0.130:5081
VISIONA_OIDC_TENANT_ID=<visionA tenant id at MC>
```
---
## 6. 錯誤碼 mapping + i18n key
| Converter / FAA / MC error | visionA error code | HTTP | i18n key | user-friendly 訊息zh-TW |
> **Phase 0.8b 變更**移除所有「MC token」相關錯誤碼`idp_misconfigured` / `idp_unavailable` / `download_token_failed` / `mc_token_unavailable`)— 服務間認證已不再經 MC。新增「API key 驗證失敗」錯誤碼visionA 端不直接面對,但下游若回 401 要處理)。
| Converter / FAA error | visionA error code | HTTP | i18n key | user-friendly 訊息zh-TW |
|----------|--------------------|------|----------|--------------------------|
| converter `validation_error` | `validation_failed` | 400 | `conversion.error.validation` | 上傳的檔案不符合要求(請查看詳細欄位錯誤) |
| converter `invalid_multipart` | `validation_failed` | 400 | `conversion.error.invalid_multipart` | 上傳格式錯誤,請重新嘗試 |
@ -759,26 +791,39 @@ VISIONA_OIDC_TENANT_ID=<visionA tenant id at MC>
| converter `service_busy` | `service_busy` | 503 | `conversion.error.busy` | 系統繁忙,請稍後再試 |
| converter `storage_unavailable` | `converter_unavailable` | 502 | `conversion.error.converter_down` | 轉檔服務暫時無法使用 |
| converter 5xx / network | `converter_unavailable` | 502 | `conversion.error.converter_down` | 同上 |
| **converter 401API key 不對 / 過期 / rotate 未同步)** | `converter_auth_failed` | 502 | `conversion.error.converter_down` | 轉檔服務暫時無法使用(內部 log 區分 auth_failed vs 5xx|
| FAA 5xx / network | `faa_unavailable` | 502 | `conversion.error.faa_down` | 檔案存取服務暫時無法使用 |
| MC token 4xx | `idp_misconfigured` | 500 | `conversion.error.idp_misconfig` | 系統設定錯誤,請聯絡支援 |
| MC token 5xx | `idp_unavailable` | 503 | `conversion.error.idp_down` | 認證服務暫時無法使用 |
| MC delegated 4xx | `download_token_failed` | 502 | `conversion.error.token_failed` | 無法取得下載授權,請重試 |
| MC delegated 5xx / network 持續失敗 | `mc_token_unavailable` | 502 | `conversion.error.token_failed` | 無法取得下載授權,請重試 |
| **FAA 401API key 不對 / 過期 / rotate 未同步)** | `faa_auth_failed` | 502 | `conversion.error.faa_down` | 檔案存取服務暫時無法使用(內部 log 區分 auth_failed vs 5xx|
| job 不屬於當前 user | `forbidden` | 403 | `conversion.error.forbidden` | 你無權存取此轉檔任務 |
| job_id 不存在 | `not_found` | 404 | `conversion.error.not_found` | 轉檔任務不存在 |
| job 還沒 completed | `job_not_completed` | 409 | `conversion.error.not_completed` | 任務尚未完成,請等轉檔完成再下載 |
**Phase 0.8b 移除的錯誤碼**(與 MC token 相關,認證路徑取消後不會發生):
| 已移除 | 原來語意 | Phase 0.8b 替代 |
|------|----------|-----|
| `idp_misconfigured`500 | MC token endpoint 回 4xxscope 沒註冊 / client 設錯)| — |
| `idp_unavailable`503 | MC token endpoint 5xx | — |
| `download_token_failed`502 | MC delegated token 4xx | — |
| `mc_token_unavailable`502 | MC 持續失敗 | — |
下游 401 對待原則:
- 對 visionA 端而言,下游 401 是「**部署設定錯誤**」API key 不對齊)— 跟「使用者沒登入」visionA → frontend 401完全不同層次
- visionA 從 `converter_client` / `faa_client` 收到 401 → log error含 request_id方便 SRE 排查)→ 回 frontend `502 converter_auth_failed` / `faa_auth_failed`,不要對 frontend 暴露「API key 不對」這個內部細節
- 401 不 retry同 §9— rotate 流程不同步是運維事件,需人工介入
i18n key 命名:`conversion.error.<short-name>`,前端 i18n 字典在 `visionA-frontend/messages/{zh-TW,en}.json` 補。
**`/download` endpoint 錯誤回應策略**GET + 302 redirect 場景):
**`/download` endpoint 錯誤回應策略**GET + server-side stream proxy 場景):
由於 `GET /api/conversion/{job_id}/download` 採 server-side 302錯誤情況**不 redirect**,改用既有 `WriteError` helper 依 `Accept` header 回應:
由於 `GET /api/conversion/{job_id}/download` 採 server-side stream proxyPhase 0.8b 變更),錯誤情況直接走既有 `WriteError` helper 依 `Accept` header 回應:
- `Accept: application/json` → 回標準 visionA error JSON `{success:false, error:{code, message}}`(給 fetch / 程式化 retry 用)
- `Accept: application/json` → 回標準 visionA error JSON `{success:false, error:{code, message}}`
- `Accept: text/html`(一般 anchor tag / window.location.href 觸發)→ 回 HTML 錯誤頁browser 直接顯示
- 其他 → 預設 JSON
frontend 用 `<a href>` 觸發時,若失敗 browser 會把錯誤頁顯示在頁面上。若希望 inline 處理錯誤(例如 toast 提示),改用 `fetch(..., {redirect: 'manual'})` + 檢查 status code但這條路徑要小心 fetch 對 302 的處理)— Phase 0.8 不要求此 UX先用 anchor tag 觸發即可。
frontend 用 `<a href>` 觸發時,若失敗 browser 會把錯誤頁顯示在頁面上。Phase 0.8 不要求 inline 錯誤 UX
---
@ -833,28 +878,32 @@ frontend 用 `<a href>` 觸發時,若失敗 browser 會把錯誤頁顯示在
### 9.1 retry 規則
| 操作 | 4xx | 5xx | network / timeout | max retry | 退避 |
|------|-----|-----|------------------|-----------|------|
| Converter `POST /jobs` | 透傳 | retry | retry | 2 | 1s, 2s |
| Converter `GET /jobs/{id}` | 透傳 | retry | retry | 3 | 0.5s, 1s, 2s |
| Converter `POST /jobs/{id}/cancel`(內部 cleanupPhase 1+| 不重試 | 不重試 | 不重試 | 0 | best-effort失敗只 log不影響主流程**Phase 0.8 converter 未實作此 endpoint靠 socket close 兜底**§4.3.2|
| Converter `GET /jobs?user_id=&status=in_progress`lazy rebuild| 透傳 | retry | retry | 1 | 0.5s |
| Converter `POST /promote` | 透傳 | retry | retry | 2 | 1s, 2s |
| FAA `GET /files/{key}`s2s | 透傳 | retry | retry | 2 | 1s, 2s |
| MC `POST /oauth/token` | 4xx → fatal | retry | retry | 2 | 1s, 2s |
| MC `POST /file-access/download-tokens` | 透傳 | retry | retry | 2 | 1s, 2s |
> **Phase 0.8b 變更**:移除 MC 兩 row下游 401/403API key 不對)一律不 retry。
| 操作 | 401 / 403 | 4xx 其他 | 5xx | network / timeout | max retry | 退避 |
|------|-----------|---------|-----|------------------|-----------|------|
| Converter `POST /jobs` | **不重試**auth_failed| 透傳 | retry | retry | 2 | 1s, 2s |
| Converter `GET /jobs/{id}` | **不重試** | 透傳 | retry | retry | 3 | 0.5s, 1s, 2s |
| Converter `POST /jobs/{id}/cancel`(內部 cleanupPhase 1+| 不重試 | 不重試 | 不重試 | 不重試 | 0 | best-effort失敗只 log**Phase 0.8 converter 未實作此 endpoint靠 socket close 兜底**§5.3.2|
| Converter `GET /jobs?user_id=&status=in_progress`lazy rebuild| **不重試** | 透傳 | retry | retry | 1 | 0.5s |
| Converter `POST /promote` | **不重試** | 透傳 | retry | retry | 2 | 1s, 2s |
| FAA `GET /files/{key}`s2s download / s2s pull | **不重試** | 透傳 | retry | retry | 2 | 1s, 2s |
每次 retry 之間檢查 `ctx.Done()`ctx cancel → 立即 return ctx.Err()。
**401 不重試的理由**API key 是 long-lived secretrotate 同步是運維事件、不是瞬時抖動。401 通常意味「visionA env 與下游 env 不同步」retry 100 次也不會自己變對,反而浪費 latency 並掩蓋運維事件。直接回 502 `*_auth_failed` 讓 SRE 看到。
### 9.2 graceful degradation
| 場景 | 處理 |
|------|------|
| converter 完全不可達(持續 5xx | `502 converter_unavailable`UI 提示「轉檔服務暫時無法使用,請稍後再試」 |
| converter 回 401API key 不同步)| `502 converter_auth_failed`UI 同上文字SRE 從 log 看到 auth_failed 計數異常 → 檢查 env |
| 完成後 promote 失敗converter 5xx | job 留在 completed 狀態FAA 上沒檔但 visionA 知道UI 給 user 「重試 promote」按鈕重打 promote-to-models / download |
| FAA pull 失敗(加到模型庫流程) | model record 不寫入UI 提示重試;不影響「下載」路徑(後者直接 browser ↔ FAA |
| MC delegated token 失敗 | UI 給 user 「改用『加到模型庫』流程」備援選項 |
| visionA-backend 重啟 | in-memory ownership 與 promoted_key cache 全失user 已建立的 job 在 frontend 看不到,但 converter 端仍存在 — 需 user 知道下次只能等 converter 自然 expire接受的取捨MVP 階段內部使用者) |
| FAA pull 失敗 — 加到模型庫流程5xx| model record 不寫入UI 提示重試 |
| FAA pull 失敗 — 下載流程5xx| visionA backend 中轉時 503 / 502UI 提示重試Phase 0.8b 兩條 download path 都共用 visionA → FAA pull|
| FAA 回 401API key 不同步)| `502 faa_auth_failed`UI 文字「檔案存取服務暫時無法使用」SRE 從 log 排查 |
| visionA-backend 重啟 | in-memory ownership 與 promoted_key cache 全失frontend 進 /conversion 時 `/active` lazy rebuild§2.6.1rebuild 不到的 job 由 converter 7 天 expire 自然兜底 |
### 9.3 同 user active job 衝突
@ -878,32 +927,44 @@ frontend 用 `<a href>` 觸發時,若失敗 browser 會把錯誤頁顯示在
詳見 §7。任何繞過此原則的設計都必須先過 ADR review。
### 10.2 Delegated download token TTL
### 10.2 ~~Delegated download token TTL~~Phase 0.8b 移除)
- 預設 5 分鐘300 秒),可由 `VISIONA_FAA_DELEGATED_TTL_SECONDS` env 調整(範圍 60-900
- TTL 越短越安全但 user UX 越差MVP 取 5 分鐘平衡
- visionA-frontend **不應快取** download_url每次「下載」都重新打 backend 換新 token
> Phase 0.8b 不再有 delegated download tokendownload 走 server-side stream proxy。原段落5 分鐘 TTL、`VISIONA_FAA_DELEGATED_TTL_SECONDS` env刪除。
>
> Phase 1+ 若量大改 ADR-015 §7 選項 BvisionA 自簽 HMAC token那時再回設 TTL 規格。
### 10.3 Service token 保護
### 10.3 Pre-shared API key 保護(取代 Service token 保護
- `VISIONA_OIDC_SERVICE_CLIENT_SECRET` 不可進 git既有 `.gitignore``.env`
- `VISIONA_CONVERTER_API_KEY` / `VISIONA_FAA_API_KEY` 不可進 git既有 `.gitignore``.env*`,配合 `!.env*.example`
- 部署用 AWS Secrets Manager / k8s Secret 注入
- log 永遠不印 secret 與 access token只印 token 前 8 字元前綴 `Bearer ey1234...`
- 若 secret 洩漏MC 端 rotate → 重新部署 visionA-backendin-memory cache 自然失效
- log 永遠不印 key 全文;可印 `api_key_set=true` 或前 8 字元 prefixdebug 用)
- 若 key 洩漏:產新 key → 雙方同步 env → restart visionA / converter或 FAA→ 驗證 → 拔舊 keyrunbook Phase 0.9 補)
- 已洩漏的 stage service client secret `RciRUyi...` 改 API key 後直接作廢,無 rotate 動作
### 10.4 Object key 與 download token 不暴露給 frontend JS
### 10.4 Object key 不暴露給 frontend JS
- visionA-backend 透過 HTTP 302 redirect 把含 token 的 download URL 放在 `Location` header**不回 JSON body、不放 URL bar 永久 history**
- Token 與 raw `object_key` **永遠不出現**在任何 visionA-backend → frontend 的 JSON response — frontend JS 對它們完全沒有 reference
- 唯一觀察點是 browser 自身的 navigationdevtools network 面板能看到 302 Location但這是 browser 本機的事,跟 server 把 token 寫進 JSON 給 JS 處理是不同的攻擊面)
- 防快取handler 設 `Cache-Control: no-store` + `Pragma: no-cache`,避免 browser 把 302 Location 寫入 disk cache
- 即使有人 capture URL例如從 devtools 複製貼出去),也只能在 5 分鐘 TTL 內用,且 method=GET 被綁死
- **不需 FAA CORS**browser navigation request 不適用 CORSCORS 只管 JS fetch / XHRserver-side 302 redirect 是 browser 原生 navigation 行為
- Phase 0.8bvisionA-backend 透過 server-side stream proxy 把 NEF stream 中轉回 browser**FAA URL / object_key 都不出現在任何 frontend response**
- frontend JS 對 object_key / 內部 FAA 路徑完全沒有 reference
- 防快取handler 設 `Cache-Control: no-store, no-cache, must-revalidate`,避免 browser cache NEF stream
- **不需 FAA CORS**visionA → FAA 是 server-side 同進程內 outbound HTTP call不適用 CORSCORS 只管 browser JS fetch / XHR
- visionA backend 是 attack surface任何能拿到 visionA cookie session 的 attacker 都能下載自己 user_id 的 NEF — 但這本來就是 user 自己的檔,無 escalation
### 10.4b Phase 0.8 → Phase 0.8b 安全面遷移摘要
| 面向 | Phase 0.8302 + delegated token| Phase 0.8bserver-side proxy|
|------|----|----|
| Token 結構是否存在 | 是MC issue5 分鐘 TTL| 否 |
| 攻擊者攔截 visionA → browser response 拿到 token | 短期可用 5 分鐘 | 結構性無 token |
| Frontend XSS 影響範圍 | 短 TTL token | 無 token 可竊 |
| Server compromisevisionA backend 被攻破)| 攻擊者可簽任意 MC delegated token | 攻擊者拿到 API key 後可任意打 converter / FAA |
| Defense in depth | Token TTL + scope 限制 | API key + visionA OIDC 上游 user auth |
| 結論 | 兩者都安全可接受Phase 0.8b 取捨「實作簡化 + bottleneck」換「無 token in browser 的更乾淨模型」|
### 10.5 Race condition
- 同 user 同時兩 tab init → 第一個成功寫 ownership / converter 接受;第二個 pre-check 通過但 converter 409
- 兩 tab 同時 promote-to-models → 第一個寫 model record 成功;第二個重複呼叫 ensurePromotedcache hit→ FAA pull 兩次接受的取捨FAA 端冪等)→ models repo 寫入時可能撞 model_id 衝突 — 改用 model_id 在 finalize 前 SELECT 檢查
- 兩 tab 同時 download → visionA backend 各自獨立 FAA pull無 cache兩條 stream 同時跑、兩條都成功FAA 端冪等讀)— Phase 0.8b 可接受,量大時再加 server-side stream cache
### 10.6 DoS 防護最小集Phase 1 強化)
@ -917,14 +978,26 @@ frontend 用 `<a href>` 觸發時,若失敗 browser 會把錯誤頁顯示在
實作此 spec 會動到的檔案(給 Backend Agent 參考Backend 自己拆任務):
- 新增:`visionA-backend/internal/conversion/*.go`(含 `_test.go`
- 新增:`visionA-backend/internal/api/conversion.go`handler
- 新增:`visionA-backend/internal/api/conversion_test.go`
- 修改:`visionA-backend/internal/config/config.go`(啟用 ServiceClientID/Secret + 新增 ConverterBaseURL / FAABaseURL / TenantID
- 修改:`visionA-backend/internal/api/api.go`Deps 加 `Conversion conversion.Service`、router 註冊 `/api/conversion/*`
- 修改:`visionA-backend/cmd/api-server/main.go`wire conversion.Flow
**Phase 0.8b 變更(在 Phase 0.8 已上的 code 上面動)**
- 砍:`visionA-backend/internal/conversion/mc_token_client.go`~440 行整檔刪除)
- 砍:`visionA-backend/internal/conversion/mc_token_client_test.go`(對應 test
- 改:`visionA-backend/internal/conversion/converter_client.go` — 移除 `tokens *MCTokenClient` 欄位,改 `apiKey string`;每個 method 內 `Authorization: Bearer <apiKey>` 直接 set
- 改:`visionA-backend/internal/conversion/faa_client.go` — 同上模式
- 改:`visionA-backend/internal/conversion/flow.go` — 移除 `tokens` 欄位download path 從 `DownloadRedirectURL` 改為 `DownloadStream`(從 FAA pull stream 回給 caller
- 改:`visionA-backend/internal/conversion/conversion.go``Service` interface `DownloadRedirectURL` 改為 `DownloadStream(...) (io.ReadCloser, *DownloadMetadata, error)`
- 改:`visionA-backend/internal/api/conversion.go``conversionDownloadHandler``c.Redirect(302, ...)` 改為 `io.Copy(c.Writer, stream)` + 設好 Content-Type / Content-Disposition / Cache-Control
- 改:`visionA-backend/internal/config/config.go`
- `ConversionConfig`:新增 `ConverterAPIKey` / `FAAAPIKey` 兩欄位
- `ConversionConfig.Enabled()` 加入兩個 API key 非空檢查
- `OIDCConfig.ServiceClientID` / `ServiceClientSecret`conversion 不再依賴;如其他模組未使用即從 struct 移除(檢查 grep
- `ConversionConfig.TenantID`conversion 不再依賴;如其他模組未使用即移除
- 改:`visionA-backend/cmd/api-server/main.go` — wire conversion.Flow 時不再傳 MCTokenClient改傳兩個 API key
- 改:`.env.stage.example` / `.env.dev.example` — 移除 `VISIONA_OIDC_SERVICE_CLIENT_ID` / `_SECRET` / `VISIONA_OIDC_TENANT_ID`;新增 `VISIONA_CONVERTER_API_KEY` / `VISIONA_FAA_API_KEY`
- 改:對應的 unit test / integration test — 移除 MC mock改用 fake converter / FAA server`Authorization: Bearer <apiKey>` header 正確帶上
- 不動:`internal/model/*`schema 不變)
- 不動:`internal/api/models.go`(既有 init/finalize 不動flow.PromoteToModels 內部呼叫 helper
- 不動OIDC user login 相關全部(`internal/oidc/``internal/usersession/``/api/auth/*` handlers
---
@ -935,3 +1008,4 @@ frontend 用 `<a href>` 觸發時,若失敗 browser 會把錯誤頁顯示在
| 2026-04-30 | 0.1 | 初稿 — Phase 0.8 轉檔整合 TDD |
| 2026-04-30 | 0.2 | Download flow 改為 server-side HTTP 302 redirectendpoint 從 `POST /{job}/download-token` 改為 `GET /{job}/download`、Service interface `DownloadToken``DownloadRedirectURL``DownloadGrant` 改為 mc_token_client 內部 struct不對外 JSON、補 §3.1 handler 範例、補 §10.4 token 不過 frontend JS 的安全分析、§6 補 `/download` 錯誤回應策略 |
| 2026-04-30 | 0.3 | Phase 0.8 三方交叉審閱回饋整合§2.6.1 補「visionA-backend 重啟後 lazy rebuild ownership」議題 #2A4 方案§2.6.2 補 expires_at 來源(議題 #7§4.3.1 streaming proxy 進度語意明確化(議題 #6,採選項 A等 converter 201 才回 200§4.3.2 補 cancel cleanup 鏈與 best-effort cancel converter議題 #5 |
| 2026-05-11 | 0.4 | **Phase 0.8b**:服務間認證從 OAuth `client_credentials` 改為 pre-shared API key對應 [ADR-015](./adr/adr-015-server-to-server-api-key.md))。主要變更:(1) §1 端對端 sequence 拿掉 MC node(2) §2 砍 `mc_token_client.go` 整個檔;(3) §3 新增「服務間認證API key」章節原 §5 OAuth 章節整段刪除,章節編號 4→5(4) §4.1 `/download` handler 從 `c.Redirect(302)` 改 server-side stream proxyService interface `DownloadRedirectURL``DownloadStream`(5) §6 錯誤碼 mapping 移除 MC 4 個 code、新增 `converter_auth_failed` / `faa_auth_failed`(6) §9.1 retry 矩陣移除 MC 2 row、所有下游 401/403 不重試;(7) §10.2 刪除 delegated token TTL、§10.3 改為 pre-shared API key 保護、§10.4 改為 server-side stream proxy 安全模型;(8) 變更影響清單列出 backend agent 後續實作要動的 .go 檔。OIDC user login 完全不動。 |

View File

@ -2,13 +2,18 @@
## Metadata
- **作者**Architect Agent
- **狀態**DraftPhase 0.6 增補;待使用者確認後進入 OB1 開發
- **最後更新**2026-04-26
- **狀態**Phase 0.8b 修訂service client / server-to-server 改 API keyuser login 部分不變
- **最後更新**2026-05-11
- **文件角色**Phase 0.6 把 visionA-backend 的 `StaticAuthProvider` 替換為 OIDC 接 Innovedus Member Center
- **上位文件**`TDD.md``security.md``adr/adr-005-no-db-auth-in-prototype.md``adr/adr-010-oidc-bff.md`
- **上位文件**`TDD.md``security.md``adr/adr-005-no-db-auth-in-prototype.md``adr/adr-010-oidc-bff.md``adr/adr-013-public-client.md`、[`adr/adr-015-server-to-server-api-key.md`](./adr/adr-015-server-to-server-api-key.md)Phase 0.8b 服務間認證部分 supersede ADR-014 §5/§6 service token 段落user login 部分不受影響)
- **下位文件**`adr/adr-010-oidc-bff.md`(本文件 §16
- **讀者**Backend / Frontend / DevOps / Testing Agents
> **Phase 0.8b 範圍說明(重要)**
>
> - **user loginbrowser → visionA backend**:完全不變。仍走 PKCE-only public client、ADR-013 描述的 redirect flow、cookie session、JWKS 驗 id_token本文件 §1-§12、§14-§17 全部仍有效。
> - **server-to-servervisionA backend → converter / FAA**Phase 0.8b 改用 pre-shared API key 取代原本的 OAuth `client_credentials` grant。詳見 ADR-015本文件 §13.1 「Service Client 預留欄位」段落隨之更新(**改為標示廢棄、不再啟用**)。
---
## 索引
@ -1450,8 +1455,10 @@ VISIONA_PAIRING_TOKEN=vAc_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
| `VISIONA_OIDC_CLIENT_SECRET` | — | **選填**A1 改造後)| 為空時走 **public PKCE-only mode**;非空時走 confidential modeADR-013|
| `VISIONA_OIDC_REDIRECT_URL` | — | ✅ | dev: `http://localhost:3721/api/auth/callback`stage: `https://stage-9527.innovedus.com:9527/api/auth/callback`prod: `https://api.visiona.cloud/api/auth/callback` |
| `VISIONA_OIDC_SCOPES` | `openid email profile` | — | 空格分隔 |
| `VISIONA_OIDC_SERVICE_CLIENT_ID` | — | **選填(預留)** | client_credentials grant 用的 confidential client IDPhase 0.7 不啟用Phase 1 接 MC API 時用ADR-013|
| `VISIONA_OIDC_SERVICE_CLIENT_SECRET` | — | **選填(預留)** | 同上對應的 secret**禁止 commit** |
| ~~`VISIONA_OIDC_SERVICE_CLIENT_ID`~~ | — | **Phase 0.8b 廢棄** | ~~client_credentials grant 用的 confidential client ID~~。Phase 0.8 短暫啟用後Phase 0.8b 改用 API keyADR-015取代env 從 `.env.stage.example` 移除 |
| ~~`VISIONA_OIDC_SERVICE_CLIENT_SECRET`~~ | — | **Phase 0.8b 廢棄** | 同上廢棄stage 上已洩漏的 secret 直接作廢、不 rotate |
| `VISIONA_CONVERTER_API_KEY` | — | **Phase 0.8b 新增** | visionA → converter 服務間認證 pre-shared API key64 字元 hex詳見 ADR-015 與 `conversion.md` §3 |
| `VISIONA_FAA_API_KEY` | — | **Phase 0.8b 新增** | visionA → FAA 服務間認證 pre-shared API key64 字元 hex詳見 ADR-015 與 `conversion.md` §3 |
| `VISIONA_FRONTEND_URL` | — | ✅ | dev: `http://localhost:3000`stage: `https://stage-9527.innovedus.com:9527`prod: `https://app.visiona.cloud` |
| `VISIONA_SESSION_SECRET` | — | ✅ | 至少 32 byte 隨機字串HMAC cookie 簽章。產法:`openssl rand -hex 32` |
| `VISIONA_SESSION_COOKIE_NAME` | `visiona_session` | — | — |
@ -1477,10 +1484,11 @@ VISIONA_SESSION_COOKIE_SECURE=false
# Service client 不設Phase 0.7 不接 MC API
```
**stage 環境public PKCE-only client—— Innovedus stage MC 配給的真實 client**
**stage 環境public PKCE-only client + Phase 0.8b API key 服務間認證)—— Innovedus stage MC 配給的真實 client**
```bash
# .env.stage
# === user loginOIDC未變===
VISIONA_OIDC_ISSUER_URL=https://stage-9527.innovedus.com:7850/
VISIONA_OIDC_CLIENT_ID=b8093fea1a504a5d8f0e04bee9f78f2e
# VISIONA_OIDC_CLIENT_SECRET 不設 ← 空值,走 public PKCE-only modeADR-013
@ -1488,9 +1496,17 @@ VISIONA_OIDC_REDIRECT_URL=https://stage-9527.innovedus.com:9527/api/auth/callbac
VISIONA_FRONTEND_URL=https://stage-9527.innovedus.com:9527
VISIONA_SESSION_SECRET=<openssl rand -hex 32 產的值stage host 持有不進 git>
VISIONA_SESSION_COOKIE_SECURE=true
# Service client 預留欄位但不啟用:
# VISIONA_OIDC_SERVICE_CLIENT_ID=<see stage .env.stage>
# VISIONA_OIDC_SERVICE_CLIENT_SECRET=<stage MC service secret>
# === Phase 0.8b 服務間認證API key取代 OAuth service token===
VISIONA_CONVERTER_BASE_URL=http://192.168.0.130:9501
VISIONA_FAA_BASE_URL=http://192.168.0.130:5081
VISIONA_CONVERTER_API_KEY=<openssl rand -hex 32 產的值 converter CONVERTER_API_KEY 對齊>
VISIONA_FAA_API_KEY=<openssl rand -hex 32 產的值 FAA FAA_API_KEY 對齊>
# === Phase 0.8b 移除(不再使用)===
# VISIONA_OIDC_SERVICE_CLIENT_ID=...已廢棄ADR-015
# VISIONA_OIDC_SERVICE_CLIENT_SECRET=...已廢棄stage 上已洩漏的值直接作廢、不 rotate
# VISIONA_OIDC_TENANT_ID=...conversion 不再依賴;其他模組未發現使用)
```
**prod 環境(依 IT 配置):**
@ -1510,6 +1526,24 @@ api-server 啟動時應 log 一行(**不可 log secret 本身**
判斷依據:`OIDCConfig.ClientSecret == ""`
這條 log 是排查「為什麼 token exchange 401」的第一步 — IdP 註冊的 client 類型必須與 visionA-backend 啟動時的 mode 對齊(兩端錯配會 401 unauthorized client
#### 13.1.3 Phase 0.8b — Server-to-server 認證從 MC OIDC 解耦
> Phase 0.6-0.8 階段保留的「Service Client」概念在 Phase 0.8b 全面廢棄。詳見 [ADR-015](./adr/adr-015-server-to-server-api-key.md) 與 `conversion.md` §3。
>
> 摘要:
>
> - **不再透過 MC**visionA → converter / FAA 不再走 `POST /oauth/token` 換 service token + JWKS 驗 + scope 驗 的鏈路
> - **改用 pre-shared API key**:每個下游各自獨立的 64-hex API key`VISIONA_CONVERTER_API_KEY` / `VISIONA_FAA_API_KEY`
> - **header 格式不變**:仍是 `Authorization: Bearer <key>`,只是 token 來源從「MC 簽的 JWT」變成「visionA env 內的 pre-shared secret」
> - **converter / FAA 端 middleware 同步改寫**constant-time compare env 字串,不再驗 JWKS / scope / tenant
>
> **為什麼把這個段落放在 OIDC TDD**:原本 ADR-014 §5 把「service client / client_credentials grant」與「user login OIDC」放同一條 OIDC 整合線Phase 0.8b 後這兩條線完全脫鉤:
>
> - user login仍是 OIDCPKCE / cookie session / JWKS — 本文件 §1-§12 全部適用
> - server-to-server不再是 OIDC、不再屬於本文件範圍 — 看 `conversion.md` §3 與 ADR-015
>
> 此小節保留作為「OIDC 路徑 → API key 路徑」的指引,避免讀者讀到本文件 §13.1 看到舊 service client env 還以為要啟用。
### 13.2 visionA-frontend 新增
| 變數 | 預設 | 說明 |
@ -1810,3 +1844,4 @@ OF1 (與 OF2 平行)
| 日期 | 版本 | 變更 |
|------|------|------|
| 2026-04-26 | 0.1 | Architect Agent 初稿Phase 0.6 OIDC 接入 TDD 增補) |
| 2026-05-11 | 0.2 | **Phase 0.8b** 對應 [ADR-015](./adr/adr-015-server-to-server-api-key.md)(1) Metadata 區補 Phase 0.8b 範圍說明user login 不變、server-to-server 改 API key(2) §13.1 環境變數表把 `VISIONA_OIDC_SERVICE_CLIENT_ID` / `_SECRET` 兩 row 標廢棄、新增 `VISIONA_CONVERTER_API_KEY` / `VISIONA_FAA_API_KEY` 兩 row(3) §13.1.1 stage env 範例移除 service client 區、新增 API key 區;(4) 新增 §13.1.3 說明 server-to-server 與 user login OIDC 已脫鉤,引導讀者去 `conversion.md` §3 與 ADR-015。本文件其他章節§1-§12、§14-§17關於 user login 部分全部不變。 |