From b9c228df4f3088ff2b11ca8a7da6a8b8d53d9a22 Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Fri, 15 May 2026 06:39:45 +0800 Subject: [PATCH] =?UTF-8?q?docs(autoflow):=20Phase=200.8b=20ADR-015=20+=20?= =?UTF-8?q?TDD=20=E4=BF=AE=E8=A8=82=20=E2=80=94=20server-to-server=20?= =?UTF-8?q?=E6=94=B9=20API=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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) --- .../adr/adr-015-server-to-server-api-key.md | 657 ++++++++++++++++++ .../04-architecture/api/api-conversion.md | 64 +- docs/autoflow/04-architecture/conversion.md | 542 ++++++++------- docs/autoflow/04-architecture/oidc-tdd.md | 53 +- 4 files changed, 1052 insertions(+), 264 deletions(-) create mode 100644 docs/autoflow/04-architecture/adr/adr-015-server-to-server-api-key.md diff --git a/docs/autoflow/04-architecture/adr/adr-015-server-to-server-api-key.md b/docs/autoflow/04-architecture/adr/adr-015-server-to-server-api-key.md new file mode 100644 index 0000000..0d6ea70 --- /dev/null +++ b/docs/autoflow/04-architecture/adr/adr-015-server-to-server-api-key.md @@ -0,0 +1,657 @@ +# 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 ` 打 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 ` header** 取代 OAuth client_credentials 服務間認證。 + +### 1. visionA → converter + +``` +visionA backend 啟動時 + ↓ +讀 env VISIONA_CONVERTER_API_KEY + ↓ +[轉檔請求進來] + ↓ +打 converter: + Authorization: Bearer +``` + +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 +``` + +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 的 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 _logger; + private readonly byte[] _expectedKeyBytes; + + private const string BearerPrefix = "Bearer "; + + public ApiKeyAuthMiddleware( + RequestDelegate next, + ILogger 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(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() + .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: ` 這種格式(即使內容對也 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,stream 回 browser: + +``` +browser → visionA /download → visionA backend + ↓ + Bearer + ↓ + 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= + ↓ + 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 時照抄 | diff --git a/docs/autoflow/04-architecture/api/api-conversion.md b/docs/autoflow/04-architecture/api/api-conversion.md index a81f85a..f98959a 100644 --- a/docs/autoflow/04-architecture/api/api-conversion.md +++ b/docs/autoflow/04-architecture/api/api-conversion.md @@ -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` +> **Auth(user → 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_id,backend 會從 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` | visionA(Phase 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.8b:visionA-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 而言**呼叫方式完全一致**(`` / `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 + + ``` -browser 自動 follow Location,直連 FAA 下載 NEF。 +browser 收到 `Content-Disposition: attachment` 自動觸發下載對話框 / 直接存到 Downloads。 -### Frontend 使用方式 +### Frontend 使用方式(與 Phase 0.8 完全一致) ```html - + 下載 ``` @@ -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(包含 `` click 與 `window.location.href`)不適用 CORS;CORS 只管 JS 發起的 fetch / XHR。Server-side 302 redirect + 同源 endpoint 完全在 CORS 範圍外。 +**Phase 0.8b 不需要 FAA CORS**:visionA backend → FAA 是 server-side 同進程 outbound HTTP call,完全不適用 CORS(CORS 只管 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.`。 +> **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` pattern;token 不過 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 呼叫方式(`` / `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 不同步的運維事件 | diff --git a/docs/autoflow/04-architecture/conversion.md b/docs/autoflow/04-architecture/conversion.md index 68c4cad..37ddd3e 100644 --- a/docs/autoflow/04-architecture/conversion.md +++ b/docs/autoflow/04-architecture/conversion.md @@ -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-Goals(Phase 0.8 不做)](#8-non-goalsphase-08-不做) @@ -26,11 +26,12 @@ ## 1. 整體 flow(端對端) +> **Phase 0.8b 變更**:服務間認證從「打 MC 換 OAuth service token + JWKS 驗簽 + scope」改為「visionA 帶 `Authorization: Bearer ` 直接打 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
Authorization: Bearer
(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}
Authorization: Bearer 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
Authorization: Bearer + 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}
Authorization: Bearer + F->>F: middleware: ConstantTimeCompare(key, FAA_API_KEY) F-->>V: NEF stream V->>V: /api/models/init → /api/models/finalize
(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
(
或 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)
Authorization: Bearer C-->>V: {target_object_key} - V->>MC: POST /file-access/download-tokens
(scope=files:download.delegate) - MC-->>V: opaque token - V-->>B: HTTP 302 Found
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}
Authorization: Bearer + F-->>V: NEF stream + V-->>B: stream NEF(visionA 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-014(OAuth client_credentials) | Phase 0.8b(API key)| +|------|--------------------------------|------------------| +| visionA → converter 認證 | `Authorization: Bearer ` | `Authorization: Bearer ` | +| visionA → FAA 認證 | `Authorization: Bearer ` | `Authorization: Bearer ` | +| download 流程 | server-side 302 redirect → browser 直連 FAA(拿 MC delegated token) | server-side proxy(visionA backend 中轉 stream)| +| visionA → MC service token 路徑 | 啟動 lazy init MCTokenClient + cache | **完全移除** | +| converter / FAA middleware | 驗 JWKS 簽章 + 驗 scope + 驗 tenant | 比對 env 字串(constant-time)| + +> 為什麼 download 不繼續走 302 redirect:API key 模式下沒有 MC 簽 short-TTL delegated token;visionA 自己簽 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 client(pull 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 client(pull 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.8b:server-side proxy): + // 1. ownership 檢查 + // 2. promote (若需要) + // 3. 從 FAA pull NEF stream + // 4. handler 直接 io.Copy stream 給 client + // 不再產生 302 redirect URL(API 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.8b:DownloadGrant 移除(不再有 MC delegated token 換取流程)。 +// Download 走 server-side proxy;token 結構性不過 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.8b:pre-shared API key(VISIONA_CONVERTER_API_KEY) httpClient *http.Client - tokens *MCTokenClient } // CreateJobStream 把 io.Reader 當作 multipart body(content-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 ` + `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(§9);401/403 不 retry(API key 錯不會自己變對) ### 2.3 `faa_client.go` ```go type FAAClient struct { baseURL string + apiKey string // Phase 0.8b:pre-shared API key(VISIONA_FAA_API_KEY) httpClient *http.Client - tokens *MCTokenClient } -// Download server-to-server 拉檔(給「加到模型庫」流程用)。 -// 用 service token (scope=files:download.read)。 +// Download server-to-server 拉檔(給「加到模型庫」+「下載」兩個流程共用)。 +// 帶 Authorization: Bearer 。 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 token);download 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 token;cache 直到 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 // 預設 300(5 分鐘) -} -``` - -**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 token(client_credentials grant)+ 換 delegated download token。 +> +> Phase 0.8b 後: +> - 服務間認證直接帶 `Authorization: Bearer ` — 不需 cache、不需 refresh、不需 retry MC +> - download flow 退回 server-side proxy(visionA backend 中轉 stream),不再有 delegated token 概念 +> +> **Tenant 概念取消**:visionA → converter / FAA 不再帶 tenant_id;converter 端的 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.ConverterAPIKey(env VISIONA_CONVERTER_API_KEY) +讀 cfg.Conversion.FAAAPIKey(env 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= +VISIONA_FAA_API_KEY= + +# Phase 0.8b 移除: +# VISIONA_OIDC_SERVICE_CLIENT_ID +# VISIONA_OIDC_SERVICE_CLIENT_SECRET +# VISIONA_OIDC_TENANT_ID(conversion 不依賴;其他模組未發現使用) +``` + +### 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 字元 hex(256 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.2);Phase 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.2);Phase 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.8b:server-side stream proxy handler -仿 FAA TestSite `DownloadFileDirect`(`FileAccessAgent.TestSite/Controllers/HomeController.cs:255-282`): +> **變更**:Phase 0.8(ADR-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 給 client(io.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 dialog(filename 來自 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 用 `
` 觸發 — anchor tag 只能發 GET - GET semantically 對應「拿一個資源」,符合「下載這個 job 的結果」語意 - 既有 OIDC AuthMiddleware 會自然處理 GET 上的 cookie session,無 CSRF 風險(沒有狀態變更,promote 是冪等的) -**Frontend 使用範例**: +**Frontend 使用範例**(與 Phase 0.8 一致,無需改動): ```html - + 下載 ``` 或: ```ts -// 程式化觸發:等同 anchor tag +// 程式化觸發 window.location.href = `/api/conversion/${jobId}/download`; ``` -Frontend **永遠看不到** download token 與 raw object_key — token 只活在 visionA-backend → browser 的 302 Location header(browser memory,JS 看不到,除非開 devtools network 面板)→ 馬上被 browser 用來打 FAA 後消失。 +**安全性面比較(Phase 0.8 → Phase 0.8b)**: + +| 面向 | Phase 0.8(302 redirect + MC delegated token)| Phase 0.8b(server-side stream proxy)| +|------|----|----| +| Token 在 frontend JS / URL bar | △ 短暫(Location header 流經 browser,nav 完即消失) | ✓ 結構性不存在(無 token 概念)| +| 要 FAA CORS | ✓ 不需要(navigation 不適用 CORS)| ✓ 同 — visionA 為 same-origin,FAA 直連在 server-side | +| 跨 internet 流量(同 NEF 多次下載)| ✓ 直連 FAA、N× 流量算 FAA 出 | ✗ 每次都繞 visionA backend,N× 流量算 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 選項 B(visionA 自己簽 short-TTL HMAC token,FAA middleware 多支援「visionA HMAC」路徑)。 --- -## 4. Streaming proxy 設計(upload) +## 5. Streaming proxy 設計(upload) -### 4.1 為什麼要 streaming +### 5.1 為什麼要 streaming - 模型上限 500MB;ref_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 cancellation:handler 收到 client disconnect → ctx.Done() → goroutine 自動結束(pw.Close 觸發 reader EOF) 5. 不做 ContentLength forward(converter 自己 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.Cancel(client 斷線)→ 連帶 cancel converter request(如上 cleanup 鏈);converter 端 multer 收到 socket close 會自動 abort multipart parsing -### 4.4 Timeout +### 5.4 Timeout - handler 整體不設總 timeout(500MB 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= - client_secret= - 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 個 scope(MC 端發單一 token 含全部) -- `exp - 15s` 提前重取,避免下游使用時剛好過期 -- 併發保護:double-checked locking(5.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= -VISIONA_CONVERTER_BASE_URL=http://192.168.0.130:9501 -VISIONA_FAA_BASE_URL=http://192.168.0.130:5081 -VISIONA_OIDC_TENANT_ID= -``` - ---- - ## 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= | 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 401(API 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 401(API 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 回 4xx(scope 沒註冊 / 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.`,前端 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 proxy(Phase 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 用 `` 觸發時,若失敗 browser 會把錯誤頁顯示在頁面上。若希望 inline 處理錯誤(例如 toast 提示),改用 `fetch(..., {redirect: 'manual'})` + 檢查 status code(但這條路徑要小心 fetch 對 302 的處理)— Phase 0.8 不要求此 UX,先用 anchor tag 觸發即可。 +frontend 用 `` 觸發時,若失敗 browser 會把錯誤頁顯示在頁面上。Phase 0.8 不要求 inline 錯誤 UX。 --- @@ -833,28 +878,32 @@ frontend 用 `` 觸發時,若失敗 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`(內部 cleanup,Phase 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/403(API 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`(內部 cleanup,Phase 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 secret,rotate 同步是運維事件、不是瞬時抖動。401 通常意味「visionA env 與下游 env 不同步」,retry 100 次也不會自己變對,反而浪費 latency 並掩蓋運維事件。直接回 502 `*_auth_failed` 讓 SRE 看到。 + ### 9.2 graceful degradation | 場景 | 處理 | |------|------| | converter 完全不可達(持續 5xx) | `502 converter_unavailable`,UI 提示「轉檔服務暫時無法使用,請稍後再試」 | +| converter 回 401(API 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 / 502,UI 提示重試(Phase 0.8b 兩條 download path 都共用 visionA → FAA pull)| +| FAA 回 401(API key 不同步)| `502 faa_auth_failed`,UI 文字「檔案存取服務暫時無法使用」;SRE 從 log 排查 | +| visionA-backend 重啟 | in-memory ownership 與 promoted_key cache 全失,frontend 進 /conversion 時 `/active` lazy rebuild(§2.6.1);rebuild 不到的 job 由 converter 7 天 expire 自然兜底 | ### 9.3 同 user active job 衝突 @@ -878,32 +927,44 @@ frontend 用 `` 觸發時,若失敗 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 token;download 走 server-side stream proxy。原段落(5 分鐘 TTL、`VISIONA_FAA_DELEGATED_TTL_SECONDS` env)刪除。 +> +> Phase 1+ 若量大改 ADR-015 §7 選項 B(visionA 自簽 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-backend;in-memory cache 自然失效 +- log 永遠不印 key 全文;可印 `api_key_set=true` 或前 8 字元 prefix(debug 用) +- 若 key 洩漏:產新 key → 雙方同步 env → restart visionA / converter(或 FAA)→ 驗證 → 拔舊 key(runbook 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 自身的 navigation(devtools 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 不適用 CORS(CORS 只管 JS fetch / XHR);server-side 302 redirect 是 browser 原生 navigation 行為 +- Phase 0.8b:visionA-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,不適用 CORS(CORS 只管 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.8(302 + delegated token)| Phase 0.8b(server-side proxy)| +|------|----|----| +| Token 結構是否存在 | 是(MC issue,5 分鐘 TTL)| 否 | +| 攻擊者攔截 visionA → browser response 拿到 token | 短期可用 5 分鐘 | 結構性無 token | +| Frontend XSS 影響範圍 | 短 TTL token | 無 token 可竊 | +| Server compromise(visionA 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 成功;第二個重複呼叫 ensurePromoted(cache 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 用 `` 觸發時,若失敗 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 ` 直接 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 ` 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 用 `` 觸發時,若失敗 browser 會把錯誤頁顯示在 | 2026-04-30 | 0.1 | 初稿 — Phase 0.8 轉檔整合 TDD | | 2026-04-30 | 0.2 | Download flow 改為 server-side HTTP 302 redirect:endpoint 從 `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」(議題 #2,A4 方案);§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 proxy(Service 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 完全不動。 | diff --git a/docs/autoflow/04-architecture/oidc-tdd.md b/docs/autoflow/04-architecture/oidc-tdd.md index 9ed5127..9e721aa 100644 --- a/docs/autoflow/04-architecture/oidc-tdd.md +++ b/docs/autoflow/04-architecture/oidc-tdd.md @@ -2,13 +2,18 @@ ## Metadata - **作者**:Architect Agent -- **狀態**:Draft(Phase 0.6 增補;待使用者確認後進入 OB1 開發) -- **最後更新**:2026-04-26 +- **狀態**:Phase 0.8b 修訂(service client / server-to-server 改 API key;user 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 login(browser → visionA backend)**:完全不變。仍走 PKCE-only public client、ADR-013 描述的 redirect flow、cookie session、JWKS 驗 id_token,本文件 §1-§12、§14-§17 全部仍有效。 +> - **server-to-server(visionA 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 mode(ADR-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 ID;Phase 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 key(ADR-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 key(64 字元 hex),詳見 ADR-015 與 `conversion.md` §3 | +| `VISIONA_FAA_API_KEY` | — | **Phase 0.8b 新增** | visionA → FAA 服務間認證 pre-shared API key(64 字元 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 login(OIDC,未變)=== VISIONA_OIDC_ISSUER_URL=https://stage-9527.innovedus.com:7850/ VISIONA_OIDC_CLIENT_ID=b8093fea1a504a5d8f0e04bee9f78f2e # VISIONA_OIDC_CLIENT_SECRET 不設 ← 空值,走 public PKCE-only mode(ADR-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= VISIONA_SESSION_COOKIE_SECURE=true -# Service client 預留欄位但不啟用: -# VISIONA_OIDC_SERVICE_CLIENT_ID= -# VISIONA_OIDC_SERVICE_CLIENT_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= +VISIONA_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 `,只是 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:仍是 OIDC(PKCE / 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 部分全部不變。 |