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:
parent
700b7b08ba
commit
b9c228df4f
@ -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 <service-token>` 打 converter / FAA
|
||||
4. converter / FAA 端 middleware 驗 JWKS 簽章 + 驗 scope + 驗 tenant
|
||||
|
||||
Phase 0.8 stage 部署實際跑到才發現**整條鏈路有 4 個串行 blocker**:
|
||||
|
||||
| # | Blocker | 影響 |
|
||||
|---|---------|------|
|
||||
| 1 | MC stage 沒註冊 `converter:job.read/write` 兩個 scope(progress.md 5/2 `assume 都有` 證實是錯的)| `POST /oauth/token` 直接 400 `invalid_scope` |
|
||||
| 2 | stage 上 converter image 是 5 週前舊版,沒 OAuth middleware、沒 `/api/v1/jobs` endpoint | 即使 MC 補 scope,converter 仍無法接受 service token |
|
||||
| 3 | converter 缺 `MEMBER_CENTER_*` env 設定 | converter 無法 init OIDC middleware |
|
||||
| 4 | FAA stage 也可能要 OAuth 整合(warrenchen 維護)| 不確定狀態,需跨人協調 |
|
||||
|
||||
要修齊這 4 個 blocker 必須跨 3 個 repo 改 + 同步 MC team + redeploy converter + warrenchen 配合 FAA — 對「2 條 1:1 trust 關係的 server-to-server 整合」明顯過度設計。
|
||||
|
||||
### 過度設計的訊號
|
||||
|
||||
OAuth `client_credentials` + JWKS + scope 機制適合的場景:
|
||||
|
||||
- **多個 client 對同一個 resource server**(如 SaaS 平台對外開放 API、第三方 dev 串接),需要區分不同 client 權限、需要短 TTL token 限制 blast radius、需要 scope 細粒度
|
||||
- **不同團隊 / 不同信任邊界**,client 端的 secret 不能由 server 端管理
|
||||
|
||||
visionA → converter / FAA 的場景**完全不符合上面任一個**:
|
||||
|
||||
- **1:1 trust 關係**(visionA 是 converter / FAA 唯一的 server-to-server caller,沒有第三方)
|
||||
- **使用者同時維護 visionA + converter**(jimchen),FAA 由 warrenchen 維護(同公司、可協調)
|
||||
- **全部 internal trust**(不是給外部 dev 用,沒有 untrusted client)
|
||||
- **無需 scope 細分**(converter 只關心「是否為 visionA」、FAA 只關心「是否為 visionA」,單一布林)
|
||||
|
||||
而成本:
|
||||
|
||||
- **複雜度**:MC 端要 onboard scope、issuer JWKS 要可達、token cache 要寫對、TTL 邊界要處理、4 個 5xx / 4xx 失敗模式要 retry & graceful degrade
|
||||
- **鏈路長度**:visionA → MC → cache → converter / FAA,任一節點掛掉都不能轉檔
|
||||
- **可觀測性負擔**:要追的 metrics 包含 token cache hit rate、MC 失敗率、scope 對齊狀態
|
||||
|
||||
### 已洩漏的 stage service client secret(觀察事實)
|
||||
|
||||
`RciRUyiCkbd60ikkZGkfQ2xV4r02VW3/j0ASKV/DD/E=` 已在對話中外洩(progress.md 2026-05-11 紀錄)。改用 API key 後此 secret 直接作廢、不需 rotate,這是 Phase 0.8b 的順帶收益。
|
||||
|
||||
## 決策 (Decision)
|
||||
|
||||
採 **pre-shared API key + `Authorization: Bearer <api-key>` header** 取代 OAuth client_credentials 服務間認證。
|
||||
|
||||
### 1. visionA → converter
|
||||
|
||||
```
|
||||
visionA backend 啟動時
|
||||
↓
|
||||
讀 env VISIONA_CONVERTER_API_KEY
|
||||
↓
|
||||
[轉檔請求進來]
|
||||
↓
|
||||
打 converter:
|
||||
Authorization: Bearer <VISIONA_CONVERTER_API_KEY>
|
||||
```
|
||||
|
||||
converter 端 middleware:
|
||||
|
||||
```
|
||||
讀 env CONVERTER_API_KEY
|
||||
↓
|
||||
[收到請求]
|
||||
↓
|
||||
parse Authorization header → 取 token
|
||||
↓
|
||||
subtle.ConstantTimeCompare(token, CONVERTER_API_KEY)
|
||||
↓
|
||||
match → 放行;mismatch → 401
|
||||
```
|
||||
|
||||
### 2. visionA → FAA
|
||||
|
||||
對稱設計:
|
||||
|
||||
```
|
||||
visionA backend 啟動時
|
||||
↓
|
||||
讀 env VISIONA_FAA_API_KEY
|
||||
↓
|
||||
[加到模型庫流程要 pull NEF]
|
||||
↓
|
||||
打 FAA:
|
||||
Authorization: Bearer <VISIONA_FAA_API_KEY>
|
||||
```
|
||||
|
||||
FAA 端 middleware:
|
||||
|
||||
```
|
||||
讀 env FAA_API_KEY
|
||||
↓
|
||||
[收到請求]
|
||||
↓
|
||||
parse Authorization header → 取 token
|
||||
↓
|
||||
constant-time compare(C# `CryptographicOperations.FixedTimeEquals` 或等效)
|
||||
↓
|
||||
match → 放行;mismatch → 401
|
||||
```
|
||||
|
||||
### 3. 每個下游各自獨立的 key
|
||||
|
||||
**不共用一把**:
|
||||
|
||||
| Key | 持有者 | 用途 |
|
||||
|-----|--------|------|
|
||||
| `VISIONA_CONVERTER_API_KEY`(visionA 端) / `CONVERTER_API_KEY`(converter 端) | jimchen | visionA → converter |
|
||||
| `VISIONA_FAA_API_KEY`(visionA 端) / `FAA_API_KEY`(FAA 端) | jimchen / warrenchen | visionA → FAA |
|
||||
|
||||
理由:每條 trust boundary 各自獨立,一條 rotate 不影響另一條;converter / FAA 不應該因為對方洩漏被連坐。
|
||||
|
||||
### 3.5 Reference Middleware Implementation
|
||||
|
||||
本節提供兩端 middleware 的可直接照抄 reference snippet。converter 端 jimchen 跨 repo 用 Go 實作;FAA 端 warrenchen 跨 repo 用 C# 實作。兩端的設計原則一致:constant-time compare、統一 401、不洩漏 token、不洩漏「key 對 / 不對」的差異。
|
||||
|
||||
#### 3.5.1 converter 端(Go — net/http 標準 middleware pattern)
|
||||
|
||||
採 `net/http` 標準 middleware pattern,可直接套用 `chi` / `gorilla/mux` / 原生 `http.ServeMux`。`subtle.ConstantTimeCompare` 是 Go 標準庫提供的 constant-time 比較函式(避免 timing attack)。
|
||||
|
||||
```go
|
||||
// internal/middleware/apikey.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrAPIKeyNotConfigured 啟動時 server 端 API key 未設定 — 應在 main() init 時 fail-fast、
|
||||
// 不要等到第一個 request 才發現
|
||||
var ErrAPIKeyNotConfigured = errors.New("CONVERTER_API_KEY env not set")
|
||||
|
||||
// NewAPIKeyAuth 回傳一個驗證 Authorization: Bearer <api-key> 的 middleware。
|
||||
// 若 expectedKey 為空字串,會直接 panic(啟動 fail-fast)— 避免「未設定 = 全部放行」的災難。
|
||||
func NewAPIKeyAuth(expectedKey string, logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
if expectedKey == "" {
|
||||
// 啟動時偵測 — 不允許 server 在沒有 key 的狀態下啟動
|
||||
panic(ErrAPIKeyNotConfigured)
|
||||
}
|
||||
expectedBytes := []byte(expectedKey)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
|
||||
// missing Authorization header
|
||||
if authHeader == "" {
|
||||
logger.Warn("api key auth failed",
|
||||
"reason", "missing_authorization_header",
|
||||
"path", r.URL.Path,
|
||||
"remote", r.RemoteAddr)
|
||||
writeUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
// 不是 Bearer prefix(必須有 "Bearer " 前綴 + 空格)
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(authHeader, prefix) {
|
||||
logger.Warn("api key auth failed",
|
||||
"reason", "missing_bearer_prefix",
|
||||
"path", r.URL.Path,
|
||||
"remote", r.RemoteAddr)
|
||||
writeUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(authHeader[len(prefix):])
|
||||
|
||||
// token 為空("Bearer " 後面什麼都沒有)
|
||||
if token == "" {
|
||||
logger.Warn("api key auth failed",
|
||||
"reason", "empty_token",
|
||||
"path", r.URL.Path,
|
||||
"remote", r.RemoteAddr)
|
||||
writeUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
// constant-time compare — 即使長度不同 ConstantTimeCompare 也會回 0,但為了
|
||||
// 提早 short-circuit、先檢查長度可避免 hash 不必要的工作(長度本身不是 secret)
|
||||
if subtle.ConstantTimeCompare([]byte(token), expectedBytes) != 1 {
|
||||
logger.Warn("api key auth failed",
|
||||
"reason", "token_mismatch",
|
||||
"path", r.URL.Path,
|
||||
"remote", r.RemoteAddr)
|
||||
// 注意:log 絕對不印 token 本身
|
||||
writeUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
// 通過 — 放行到 next handler
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// writeUnauthorized 統一回 401 — 不洩漏「key 對 / 不對」/ 「missing / mismatch」的差異
|
||||
func writeUnauthorized(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
}
|
||||
```
|
||||
|
||||
main 端使用範例:
|
||||
|
||||
```go
|
||||
// cmd/converter/main.go(節錄)
|
||||
expectedKey := os.Getenv("CONVERTER_API_KEY")
|
||||
authMiddleware := middleware.NewAPIKeyAuth(expectedKey, logger)
|
||||
// 套用到所有 /api/v1/* routes
|
||||
mux.Handle("/api/v1/", authMiddleware(apiHandler))
|
||||
```
|
||||
|
||||
#### 3.5.2 FAA 端(C# ASP.NET Core middleware)
|
||||
|
||||
`CryptographicOperations.FixedTimeEquals` 是 .NET 6+ 提供的 constant-time byte 比較函式。下方提供 classic Middleware class 寫法(推薦給既有 ASP.NET Core MVC / Web API 專案)+ minimal API 的 inline middleware 寫法(推薦給 .NET 8 minimal API 新 project)。warrenchen 視 FAA 既有架構選一即可。
|
||||
|
||||
**寫法 A:Classic Middleware Class(推薦既有 ASP.NET Core 專案)**
|
||||
|
||||
```csharp
|
||||
// Middleware/ApiKeyAuthMiddleware.cs
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FAA.Middleware;
|
||||
|
||||
public class ApiKeyAuthMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<ApiKeyAuthMiddleware> _logger;
|
||||
private readonly byte[] _expectedKeyBytes;
|
||||
|
||||
private const string BearerPrefix = "Bearer ";
|
||||
|
||||
public ApiKeyAuthMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<ApiKeyAuthMiddleware> logger,
|
||||
string expectedKey)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
|
||||
// 啟動時 fail-fast — 不允許 server 在沒有 key 的狀態下啟動
|
||||
if (string.IsNullOrEmpty(expectedKey))
|
||||
{
|
||||
throw new InvalidOperationException("FAA_API_KEY env not set");
|
||||
}
|
||||
_expectedKeyBytes = Encoding.UTF8.GetBytes(expectedKey);
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var authHeader = context.Request.Headers.Authorization.ToString();
|
||||
|
||||
// missing Authorization header
|
||||
if (string.IsNullOrEmpty(authHeader))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"api key auth failed: reason=missing_authorization_header path={Path} remote={Remote}",
|
||||
context.Request.Path, context.Connection.RemoteIpAddress);
|
||||
await WriteUnauthorizedAsync(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// 不是 Bearer prefix
|
||||
if (!authHeader.StartsWith(BearerPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"api key auth failed: reason=missing_bearer_prefix path={Path} remote={Remote}",
|
||||
context.Request.Path, context.Connection.RemoteIpAddress);
|
||||
await WriteUnauthorizedAsync(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var token = authHeader[BearerPrefix.Length..].Trim();
|
||||
|
||||
// token 為空
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"api key auth failed: reason=empty_token path={Path} remote={Remote}",
|
||||
context.Request.Path, context.Connection.RemoteIpAddress);
|
||||
await WriteUnauthorizedAsync(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// constant-time compare — FixedTimeEquals 要求兩邊長度相同,所以先比長度
|
||||
// (長度本身不是 secret,預先 reject 不會洩漏資訊)
|
||||
var tokenBytes = Encoding.UTF8.GetBytes(token);
|
||||
if (tokenBytes.Length != _expectedKeyBytes.Length ||
|
||||
!CryptographicOperations.FixedTimeEquals(tokenBytes, _expectedKeyBytes))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"api key auth failed: reason=token_mismatch path={Path} remote={Remote}",
|
||||
context.Request.Path, context.Connection.RemoteIpAddress);
|
||||
// 注意:log 絕對不印 token 本身
|
||||
await WriteUnauthorizedAsync(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// 通過 — 放行到 next middleware
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private static async Task WriteUnauthorizedAsync(HttpContext context)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsync("{\"error\":\"unauthorized\"}");
|
||||
}
|
||||
}
|
||||
|
||||
// IApplicationBuilder extension — 讓 Program.cs 可以 app.UseApiKeyAuth(...)
|
||||
public static class ApiKeyAuthMiddlewareExtensions
|
||||
{
|
||||
public static IApplicationBuilder UseApiKeyAuth(
|
||||
this IApplicationBuilder builder, string expectedKey)
|
||||
{
|
||||
return builder.UseMiddleware<ApiKeyAuthMiddleware>(expectedKey);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Program.cs 使用範例:
|
||||
|
||||
```csharp
|
||||
// Program.cs(節錄)
|
||||
var expectedKey = builder.Configuration["FAA_API_KEY"]
|
||||
?? Environment.GetEnvironmentVariable("FAA_API_KEY")
|
||||
?? throw new InvalidOperationException("FAA_API_KEY env not set");
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseApiKeyAuth(expectedKey);
|
||||
// 之後才掛 controllers / endpoints
|
||||
app.MapControllers();
|
||||
app.Run();
|
||||
```
|
||||
|
||||
**寫法 B:Minimal API Inline Middleware(.NET 8 新 project)**
|
||||
|
||||
```csharp
|
||||
// Program.cs(節錄)
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var expectedKey = builder.Configuration["FAA_API_KEY"]
|
||||
?? Environment.GetEnvironmentVariable("FAA_API_KEY")
|
||||
?? throw new InvalidOperationException("FAA_API_KEY env not set");
|
||||
|
||||
var expectedKeyBytes = Encoding.UTF8.GetBytes(expectedKey);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("ApiKeyAuth");
|
||||
var authHeader = context.Request.Headers.Authorization.ToString();
|
||||
const string prefix = "Bearer ";
|
||||
|
||||
string? failReason = authHeader switch
|
||||
{
|
||||
"" => "missing_authorization_header",
|
||||
var s when !s.StartsWith(prefix, StringComparison.Ordinal) => "missing_bearer_prefix",
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (failReason is null)
|
||||
{
|
||||
var token = authHeader[prefix.Length..].Trim();
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
failReason = "empty_token";
|
||||
}
|
||||
else
|
||||
{
|
||||
var tokenBytes = Encoding.UTF8.GetBytes(token);
|
||||
if (tokenBytes.Length != expectedKeyBytes.Length ||
|
||||
!CryptographicOperations.FixedTimeEquals(tokenBytes, expectedKeyBytes))
|
||||
{
|
||||
failReason = "token_mismatch";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failReason is not null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"api key auth failed: reason={Reason} path={Path} remote={Remote}",
|
||||
failReason, context.Request.Path, context.Connection.RemoteIpAddress);
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsync("{\"error\":\"unauthorized\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
app.MapGet("/api/v1/files/{key}", (string key) => Results.Ok(/* ... */));
|
||||
app.Run();
|
||||
```
|
||||
|
||||
#### 3.5.3 兩端共通的部署檢查清單
|
||||
|
||||
不分 Go / C#,部署前必須逐項確認:
|
||||
|
||||
| # | 檢查項 | 為什麼 |
|
||||
|---|--------|--------|
|
||||
| 1 | env 已設定且非空(啟動 fail-fast)| 避免「未設定 = 全部放行」災難;server 應在啟動時 panic / throw、不要等到第一個 request 才發現 |
|
||||
| 2 | constant-time compare(Go `subtle.ConstantTimeCompare` / C# `CryptographicOperations.FixedTimeEquals`)| 避免 timing attack 反推 key |
|
||||
| 3 | 401 response body 統一(不洩漏「key 對 / 不對」/ 「missing / mismatch」差異)| 對外只回 `{"error":"unauthorized"}`,差異只記在 server 端 log |
|
||||
| 4 | log 絕對不印 token 本身 | 即使是失敗的 token 也不印(攻擊者可能用半正確的 token 試探);只印 reason / path / remote |
|
||||
| 5 | Bearer prefix 嚴格驗證(缺 prefix 也 401)| 不允許 `Authorization: <token>` 這種格式(即使內容對也 reject、強制 client 用標準 Bearer scheme) |
|
||||
| 6 | 每環境(dev / stage / prod)獨立 key | 嚴格分環境產 key(`openssl rand -hex 32` 各 64 字元 hex),不重用 |
|
||||
| 7 | key 不進 git | `.gitignore` 嚴格 ignore `.env*`;CI / CD secret 從 Secrets Manager / Vault 注入 |
|
||||
| 8 | 健康檢查 endpoint 是否要 bypass auth | 通常 `/healthz` / `/readyz` 應 bypass(讓 LB / k8s 可探測),但業務 endpoint 全部要 auth |
|
||||
|
||||
### 4. 不再有 scope 概念
|
||||
|
||||
OAuth `client_credentials` 設計中四個 scope(`converter:job.write/read`、`files:download.read/delegate`)取消:
|
||||
|
||||
- 單一 API key 就是「visionA 有權打 converter」/ 「visionA 有權打 FAA」的完整證明
|
||||
- 不再有「同一個 client 但拿不同 scope」的細粒度區分(在 1:1 trust 中本來就沒意義)
|
||||
- converter / FAA 端 middleware 也不需要驗 scope
|
||||
|
||||
### 5. 不再有 tenant 概念
|
||||
|
||||
visionA → converter / FAA 不再帶 tenant_id:
|
||||
|
||||
- converter 端的 user_id 從 multipart body `user_id` field 拿(仍由 visionA 從 OIDC sub 灌入,這條 trust boundary 不變)
|
||||
- FAA 端也不需要 tenant 概念(pull NEF 用 object_key 定位)
|
||||
- `VISIONA_OIDC_TENANT_ID` env 在 conversion 場景**廢棄**(如果其他場景還有用就保留,目前未發現其他依賴)
|
||||
|
||||
### 6. visionA backend 移除的程式碼
|
||||
|
||||
| 項目 | 處理 |
|
||||
|------|------|
|
||||
| `internal/conversion/mc_token_client.go`(整個 package) | **整個檔案刪除**(~440 行 — token cache + delegated download token + double-checked locking)|
|
||||
| `internal/conversion/converter_client.go` 內呼叫 `MCTokenClient.ServiceToken()` 的地方 | 改成讀 `cfg.Conversion.ConverterAPIKey` 直接 set header |
|
||||
| `internal/conversion/faa_client.go` 內呼叫 `MCTokenClient.ServiceToken()` 的地方 | 改成讀 `cfg.Conversion.FAAAPIKey` 直接 set header |
|
||||
| `internal/conversion/flow.go` 內呼叫 `mc.IssueDelegatedDownload()` 的地方 | **delegated download token 路徑取消**(見下方 §7) |
|
||||
| `internal/config/config.go` 內 `OIDCConfig.ServiceClientID` / `ServiceClientSecret` 兩個欄位 | **欄位廢棄**(保留欄位以保持 backward compat,但 conversion 已不依賴);env `VISIONA_OIDC_SERVICE_CLIENT_ID` / `SECRET` 從 `.env*.example` 移除 |
|
||||
| `internal/config/config.go` 內 `ConversionConfig.TenantID` 欄位 + env `VISIONA_OIDC_TENANT_ID` | conversion 模組不再依賴;如其他模組未使用即可移除 |
|
||||
|
||||
新增的 config 欄位:
|
||||
|
||||
```go
|
||||
// internal/config/config.go
|
||||
type ConversionConfig struct {
|
||||
ConverterBaseURL string // 既有
|
||||
FAABaseURL string // 既有
|
||||
ConverterAPIKey string // 新增 — env VISIONA_CONVERTER_API_KEY
|
||||
FAAAPIKey string // 新增 — env VISIONA_FAA_API_KEY
|
||||
// TenantID 廢棄
|
||||
}
|
||||
|
||||
// Enabled 改判定:
|
||||
func (c ConversionConfig) Enabled() bool {
|
||||
return c.ConverterBaseURL != "" &&
|
||||
c.FAABaseURL != "" &&
|
||||
c.ConverterAPIKey != "" &&
|
||||
c.FAAAPIKey != ""
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Delegated download token 路徑的處理(重要 — Phase 0.8b 範圍說明)
|
||||
|
||||
ADR-014 §2 設計 download 流程是:
|
||||
|
||||
```
|
||||
browser → visionA /download → MC issue delegated token → 302 → browser → FAA?access_token=...
|
||||
```
|
||||
|
||||
API key 模式下「MC issue delegated token」這條鏈不存在了。Phase 0.8b 對 download 路徑的處理:
|
||||
|
||||
**選項 A(Phase 0.8b 採用)— 短期:保持 server-side download proxy**
|
||||
|
||||
visionA backend 直接用 `Authorization: Bearer <FAA_API_KEY>` 拉 FAA,stream 回 browser:
|
||||
|
||||
```
|
||||
browser → visionA /download → visionA backend
|
||||
↓
|
||||
Bearer <FAA_API_KEY>
|
||||
↓
|
||||
FAA
|
||||
↓
|
||||
stream NEF 回 browser
|
||||
```
|
||||
|
||||
- 跨 internet 流量 N 倍是接受的取捨(Phase 0.8 MVP user 量小、單檔 NEF 通常 < 50MB)
|
||||
- token 結構性不過 frontend JS(仍維持 ADR-014 §2 表格的「server-side 比 frontend 拿 token 更安全」的所有優點)
|
||||
- visionA backend 變成 streaming bottleneck,Phase 1 量大時再評估
|
||||
|
||||
**選項 B(Phase 1+ 升級路徑)— FAA 上 delegated token 機制改用「visionA 自己簽 short-TTL HMAC token」**
|
||||
|
||||
如果 Phase 1 流量壓力大要回 302 redirect 模式,visionA 可以自己簽 short-TTL HMAC token(不需要 MC 介入),FAA 端 middleware 多加一條「驗 visionA HMAC token」的路徑:
|
||||
|
||||
```
|
||||
browser → visionA /download → visionA 用 HMAC_KEY 簽 short-TTL token
|
||||
↓
|
||||
302 → browser
|
||||
↓
|
||||
browser → FAA?access_token=<visionA-signed-hmac>
|
||||
↓
|
||||
FAA middleware:API key (server-to-server) OR HMAC (browser direct) 二選一
|
||||
```
|
||||
|
||||
此升級路徑與本 ADR 的決策無衝突,記入 Phase 1 follow-up。
|
||||
|
||||
### 8. user_id 注入 trust boundary 不變
|
||||
|
||||
ADR-014 §6「visionA backend 是唯一灌 user_id 的點」邏輯不變:
|
||||
|
||||
- user_id 仍從 OIDC cookie session 拿(OIDC sub)
|
||||
- 仍透過 multipart streaming 注入 converter request 的 `user_id` field(converter 端視 visionA 為 trusted caller)
|
||||
- API key 證明的是「caller 是 visionA」,user_id 的真實性由 visionA 內部的 OIDC 機制保證 — 兩條獨立鏈
|
||||
|
||||
### 9. 部署層的 env 注入
|
||||
|
||||
Phase 0.8b 採用:
|
||||
|
||||
| Env | Stage | Production |
|
||||
|-----|-------|-----------|
|
||||
| `VISIONA_CONVERTER_API_KEY`(visionA 端) | `.env.stage`(jimchen 持有,不進 git) | AWS Secrets Manager / Vault |
|
||||
| `CONVERTER_API_KEY`(converter 端) | `.env`(jimchen 持有,不進 git) | 同上 |
|
||||
| `VISIONA_FAA_API_KEY`(visionA 端) | `.env.stage` | 同上 |
|
||||
| `FAA_API_KEY`(FAA 端) | warrenchen 設置 | 同上 |
|
||||
|
||||
key 產生方式:`openssl rand -hex 32`(64 字元 hex)
|
||||
|
||||
## 考慮過的替代方案 (Alternatives Considered)
|
||||
|
||||
### 方案 A:維持 OAuth client_credentials(ADR-014 原方案)
|
||||
|
||||
| 評估 | 內容 |
|
||||
|------|------|
|
||||
| 優點 | 標準化、跨團隊可重用、短 TTL token、scope 細粒度可控 |
|
||||
| 缺點 | 需要 MC team 配合 onboard scope、converter / FAA 都要重寫 middleware、JWKS 取得失敗時 graceful degrade 複雜、stage e2e 鏈路 4 個 blocker 全要修齊 |
|
||||
| 排除原因 | 對 1:1 internal trust 場景過度設計;Phase 0.8 stage 部署實際遇到的 4 個 blocker 證明這條路 ROI 不好 |
|
||||
|
||||
### 方案 B:mTLS(mutual TLS)
|
||||
|
||||
| 評估 | 內容 |
|
||||
|------|------|
|
||||
| 優點 | 不需傳遞 secret in plaintext(憑證綁定)、cert rotation 機制成熟 |
|
||||
| 缺點 | converter / FAA 都要支援 mTLS、需要 CA 管理、ingress(nginx / Caddy / ALB)也要配合 client cert termination、stage 環境部署成本高 |
|
||||
| 排除原因 | 對 1:1 trust 過度設計;公司 stage 環境 ingress(host nginx)未對外開放 mTLS 配置;維運成本不成比例 |
|
||||
|
||||
### 方案 C:API key + IP allowlist 雙層防護
|
||||
|
||||
| 評估 | 內容 |
|
||||
|------|------|
|
||||
| 優點 | 即使 API key 洩漏,攻擊者也需從特定 IP 才能用 |
|
||||
| 缺點 | visionA 上 AWS 後 IP 不固定(NAT gateway / ALB 對外多 IP);converter / FAA 在公司內網 IP allowlist 維護成本高;對「不是內網」的 prod 場景幾乎沒用 |
|
||||
| 排除原因 | Phase 0.8 stage 仍在公司內網(visionA stage 在 192.168.0.x),加 IP allowlist 在 stage 可行但對 prod 沒有延展性;不採用 |
|
||||
|
||||
### 方案 D:共用一把 API key(不分 converter / FAA)
|
||||
|
||||
| 評估 | 內容 |
|
||||
|------|------|
|
||||
| 優點 | env 少一個、部署設定簡單 |
|
||||
| 缺點 | 一處洩漏兩處連坐;converter rotate 必須同步 FAA;違反「每條 trust boundary 各自獨立」原則 |
|
||||
| 排除原因 | 每個下游各自獨立的 key 是低成本(只多一個 env),但隔離效益高,採方案決策的反方案 |
|
||||
|
||||
## 後果 (Consequences)
|
||||
|
||||
### 正面影響
|
||||
|
||||
- **實作大幅簡化**:visionA backend 砍 `internal/conversion/mc_token_client.go`(~440 行 — 含 token cache + delegated download token + double-checked locking + retry policy)
|
||||
- **不依賴 MC scope onboarding**:MC team 完全不需介入,stage e2e blocker 從 4 個降到 0
|
||||
- **converter / FAA middleware 極簡**:兩端各自只需「比對單一字串 + constant-time compare」,無需驗 JWKS / scope / tenant;converter Phase 1 之前舊 image 可直接補 middleware 上線(不需 redeploy 大改)
|
||||
- **失敗模式收斂**:原本「MC 5xx / MC 4xx / token cache miss / scope mismatch」四個失敗類型,收斂為「API key 對 / 不對」單一布林
|
||||
- **可觀測性減負**:不需追 token cache hit rate、不需追 MC 失敗率
|
||||
- **已洩漏的 stage service client secret 直接作廢**:不需協調 MC team rotate
|
||||
|
||||
### 負面影響(接受的取捨)
|
||||
|
||||
- **API key 是 long-lived secret**:不像 OAuth token 有 TTL(通常 1 小時);rotate 需要 visionA + 下游同步換 env 並 redeploy;secret 管理責任更重
|
||||
- **沒有 scope 細粒度**:未來如果 visionA 內部要區分「能 init job 但不能 promote」這種需求,要回頭加(但 1:1 trust 場景幾乎不會有此需求)
|
||||
- **沒有 audit trail(誰用 token 做什麼)**:OAuth + JWT 的 sub claim 提供天然 audit;API key 模式下 converter / FAA 只知道「是 visionA」,需要靠 visionA 內部 log + request_id 串接才能追到 user_id(既有 request_id 機制可滿足)
|
||||
- **delegated download token 暫時不能用 302 redirect 模式**:Phase 0.8b 退回 server-side download proxy;Phase 1 量大時要另外設計(見 §7 選項 B)
|
||||
|
||||
### 風險
|
||||
|
||||
| 風險 | 緩解 |
|
||||
|------|------|
|
||||
| API key 洩漏(git log、log shipping、Slack 訊息等)| (1) `.gitignore` 嚴格 ignore `.env*`;(2) log 不印 token;(3) stage / prod 用不同 key;(4) 一旦發現洩漏 → 換 env → redeploy(雙方協調 < 1 小時可完成)|
|
||||
| Rotate 流程缺失 | 配套必須產出 rotate runbook(jimchen 對 converter,warrenchen 對 FAA) |
|
||||
| 兩個 key 管理混淆(converter / FAA) | env 命名嚴格區分(`VISIONA_CONVERTER_API_KEY` / `VISIONA_FAA_API_KEY`);config validate 啟動時檢查兩個都非空 |
|
||||
| 開發環境 / stage / prod 用同一把 key | 嚴格分環境產 key(dev / stage / prod 各自 `openssl rand -hex 32`),不重用 |
|
||||
|
||||
## 合規性
|
||||
|
||||
- [x] 與使用者確認:採 API key + Authorization Bearer + 每個下游獨立 key
|
||||
- [x] 與 jimchen 確認(同時為 visionA + converter 維護者):converter middleware 改寫由 jimchen 處理
|
||||
- [ ] 與 warrenchen 確認:FAA 端 middleware 改寫由 warrenchen 處理(**待 Phase 0.8b 步驟 5**)
|
||||
- [x] 與 ADR-014 對齊:本 ADR 部分 supersede ADR-014 §5 / §6(OAuth 段落) / §7(MC token retry rows),不影響 ADR-014 其他段落
|
||||
- [x] 與 ADR-013 對齊:本 ADR 不影響 user login 的 public PKCE client
|
||||
- [ ] DevOps rotate runbook 待產出(Phase 0.9 follow-up)
|
||||
- [ ] 已洩漏的 stage service client secret `RciRUyi...` 自動作廢(不需 MC rotate)
|
||||
|
||||
## 配套產出(給後續 Phase)
|
||||
|
||||
### Phase 0.8b 範圍內
|
||||
|
||||
- visionA backend 程式碼改造(backend agent 任務)
|
||||
- converter middleware 改造(jimchen 跨 repo)
|
||||
- FAA middleware 改造(warrenchen 跨 repo)
|
||||
- `.env.stage.example` 更新(移除 service client env、新增 API key env)
|
||||
- 設計文件更新(conversion.md / api-conversion.md / oidc-tdd.md — 本 ADR 同步產出)
|
||||
|
||||
### Phase 0.9 / Phase 1 follow-up
|
||||
|
||||
- [ ] API key rotate runbook(每個下游一份,含「產新 key → 雙方同步 env → restart → 驗證 → 拔舊 key」步驟)
|
||||
- [ ] 是否設 API key 有效期(例如 1 年到期自動提醒)— 由 SRE 流程決定
|
||||
- [ ] FAA 上「visionA 自己簽 HMAC token」delegated 機制(§7 選項 B),用於 download 路徑回 302 redirect
|
||||
- [ ] 觀察 server-side download proxy 在 Phase 1 量大時的效能 / 頻寬 cost
|
||||
|
||||
## 相關文件
|
||||
|
||||
- 部分 supersedes:[`adr-014-conversion-integration.md`](./adr-014-conversion-integration.md)(§5 / §6 OAuth 段落 / §7 MC rows)
|
||||
- 不影響:[`adr-013-public-client.md`](./adr-013-public-client.md)(user login 部分)
|
||||
- 詳細實作(本 ADR 同步更新):`conversion.md`、`api/api-conversion.md`、`oidc-tdd.md`
|
||||
- 觸發背景:`progress.md` 「Phase 0.8b 啟動原因(2026-05-11)」段落
|
||||
|
||||
## 版本記錄
|
||||
|
||||
| 日期 | 版本 | 變更 |
|
||||
|------|------|------|
|
||||
| 2026-05-11 | 1.0 | 初版 — Phase 0.8b 將 server-to-server 認證從 OAuth client_credentials 改 pre-shared API key |
|
||||
| 2026-05-15 | 1.1 | 補 §3.5 Reference Middleware Implementation — Go (converter) + C# (FAA) snippet + 部署檢查清單,給跨 repo 改 middleware 時照抄 |
|
||||
@ -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 而言**呼叫方式完全一致**(`<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 tag,browser 原生處理 -->
|
||||
<!-- 推薦:anchor tag,browser 自動處理 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`)不適用 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.<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` 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 呼叫方式(`<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 不同步的運維事件 |
|
||||
|
||||
@ -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 <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 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 <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 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 <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(§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 <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 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 <pre-shared API key>` — 不需 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=<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_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 用 `<a href="..." download>` 觸發 — anchor tag 只能發 GET
|
||||
- GET semantically 對應「拿一個資源」,符合「下載這個 job 的結果」語意
|
||||
- 既有 OIDC AuthMiddleware 會自然處理 GET 上的 cookie session,無 CSRF 風險(沒有狀態變更,promote 是冪等的)
|
||||
|
||||
**Frontend 使用範例**:
|
||||
**Frontend 使用範例**(與 Phase 0.8 一致,無需改動):
|
||||
|
||||
```html
|
||||
<!-- 推薦:anchor tag,browser 自動處理 navigation + 302 follow -->
|
||||
<!-- 推薦:anchor tag,browser 自動處理 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 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=<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 個 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=<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 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.<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 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 用 `<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`(內部 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 用 `<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 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 用 `<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 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 完全不動。 |
|
||||
|
||||
@ -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=<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:仍是 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 部分全部不變。 |
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user