jim800121chen 86b7175649 feat(visionA-backend): Phase 0.8b 步驟 2 — visionA → converter / FAA 改 API key 認證
對齊 ADR-015:visionA backend 從 OAuth client_credentials 改 pre-shared API key 服務間認證。Phase 0.8 stage e2e 撞 4 個 blocker(MC scope 沒註冊 / converter image 舊版 / converter 缺 env / FAA 不確定)後,使用者拍板 1:1 internal trust 用 OAuth 過度設計,改 API key。

實作(5 個增量 task,T1-T5 全綠 + Reviewer 5 輪 + final cross-task review):

T1 config + env:
- ConversionConfig 新增 ConverterAPIKey / FAAAPIKey 欄位
- Enabled() 改判定 4 欄位齊全(含兩個 API key)
- .env*.example 移除 OIDC service client / OIDC tenant / FAA delegated TTL env、新增 API key env
- TenantID / DelegatedTTLSeconds T1 暫留、T5 整批清

T2 client 改造:
- converter_client / faa_client 移除 MCTokenClient 依賴
- 直接讀 cfg.Conversion.{Converter,FAA}APIKey、set Authorization: Bearer <key>
- NewConverterClient / NewFAAClient APIKey 為空時 panic(fail-fast,對齊 ADR-015 §3.5.3 #1)
- 新增 ErrConverterAuthFailed / ErrFAAAuthFailed sentinel
- 對外 mask 成 converter_unavailable / faa_unavailable(不洩漏 401 細節,對齊 ADR-015 §3.5.3 #3)

T3 砍 mc_token_client:
- mc_token_client.go (624 行) + mc_token_client_test.go (864 行) 整檔砍
- 砍 5 個僅 mc_token_client 用的 sentinel(ErrServiceClientUnauthorized / ErrMCTokenUnavailable / ErrIDPMisconfigured / ErrIDPUnavailable / ErrDownloadTokenFailed)
- helper(truncate / silentLogger)搬到 util.go / testing_helpers_test.go

T4 flow + handler stream proxy:
- Service interface DownloadRedirectURL → DownloadStream(ctx) (io.ReadCloser, *DownloadMetadata, error)
- flow.DownloadStream 用 faa.GetFile 直接 stream NEF binary(取代 MC delegated token + 302)
- handler conversionDownloadHandler 改 io.Copy + Content-Type/Disposition/Cache-Control header
- 新增 sanitizeDownloadFilename helper 防 HTTP header injection
- 跨 package handler test (conversion_test.go) 改測 ErrFAAUnavailable + 補 *_AuthFailed 對稱 test

T5 wire 切換 + cleanup:
- main.go 砍 mcTokenClient wire、改 APIKey 注入、startup log 用 *_api_key_set boolean(不印 key)
- ConverterClient/FAAClient struct Tokens 欄位移除
- mc_token_stub.go (T4 過渡期) 整檔砍
- ConversionConfig TenantID / DelegatedTTLSeconds 欄位移除
- e2e_test.go TestConversionE2E_Download302Redirect 改寫為 TestConversionE2E_DownloadStream
- 補 MaxDownloadStreamBytes = 1 GiB size cap(io.CopyN,T4 reviewer Minor M-1)
- escapeObjectKeyPath Phase 1+ 預留 godoc + //nolint:unused(T4 reviewer Minor M-2)
- conversion.md §4.1 line 502 filename 來源描述歧義修訂(T4 reviewer Minor M-3,由 architect 處理)
- conversion_e2e_test.go 檔頭 docstring 更新(final reviewer Minor #1)

驗證:
- go build ./... exit 0
- go test -race -count=3 ./... 17 packages 全綠
- ADR-015 §6 砍除清單 100% cover(reviewer 獨立 grep 確認 MC chain / TenantID / DelegatedTTLSeconds 全清)
- ADR-015 §3.5.3 部署檢查清單 visionA 範圍 4/4 達成(fail-fast / mask / 不印 token / placeholder env)

不動:
- OIDCConfig.ServiceClientID/Secret 欄位保留(使用者拍板 backward compat)
- user login OIDC 完全不動

下一步:
- 步驟 4 — converter scheduler middleware 改 API key(jimchen 跨 repo,ADR-015 §3.5.1 Go snippet)
- 步驟 5 — FAA middleware 改 API key(warrenchen 跨 repo,ADR-015 §3.5.2 C# snippet)
- 步驟 6 — stage redeploy + e2e 完整測試

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:45:45 +08:00

143 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package config
import (
"os"
"strconv"
"strings"
"time"
)
// Load 從環境變數讀取並組出一個 Config。
//
// 所有欄位皆有預設值(雛形便利),因此 Load 不會回傳 error
// 未來加入必填欄位時(例如 Phase 1 的 DB URL應改為回傳 error。
func Load() *Config {
return &Config{
Server: ServerConfig{
Host: getEnvString("VISIONA_HOST", "0.0.0.0"),
Port: getEnvInt("VISIONA_API_PORT", 3721),
TunnelPort: getEnvInt("VISIONA_TUNNEL_PORT", 3800),
InternalPort: getEnvInt("VISIONA_PROXY_INTERNAL_PORT", 3801),
RelayPublicURL: getEnvString("VISIONA_RELAY_PUBLIC_URL", ""),
SeedDemoData: getEnvBool("VISIONA_SEED_DEMO_DATA", false),
},
Session: SessionConfig{
Backend: getEnvString("VISIONA_SESSION_BACKEND", "inmemory"),
ProxyInternalURL: getEnvString("VISIONA_PROXY_INTERNAL_URL", "http://localhost:3801"),
},
Auth: AuthConfig{
// Phase 0.7 security fix C1VISIONA_STATIC_USER_ID 僅供 dev seed / unit test 用,
// stage/prod 留空無影響;不再注入 api.Deps見 internal/api/api.go Deps 註解)。
StaticUserID: getEnvString("VISIONA_STATIC_USER_ID", "demo-user"),
PairingToken: getEnvString("VISIONA_PAIRING_TOKEN", ""),
SigningSecret: getEnvString("VISIONA_STORAGE_SIGNING_SECRET", "dev-signing-secret-do-not-use-in-prod"),
},
OIDC: OIDCConfig{
IssuerURL: getEnvString("VISIONA_OIDC_ISSUER_URL", ""),
ClientID: getEnvString("VISIONA_OIDC_CLIENT_ID", ""),
ClientSecret: getEnvString("VISIONA_OIDC_CLIENT_SECRET", ""),
RedirectURL: getEnvString("VISIONA_OIDC_REDIRECT_URL", ""),
PostLoginURL: getEnvString("VISIONA_FRONTEND_URL", ""),
// A1client_credentials grant 預留欄位,留空表「不啟用 service client」。
ServiceClientID: getEnvString("VISIONA_OIDC_SERVICE_CLIENT_ID", ""),
ServiceClientSecret: getEnvString("VISIONA_OIDC_SERVICE_CLIENT_SECRET", ""),
},
UserSession: UserSessionConfig{
Secret: getEnvString("VISIONA_SESSION_SECRET", ""),
CookieName: getEnvString("VISIONA_SESSION_COOKIE_NAME", "visiona_session"),
CookieDomain: getEnvString("VISIONA_SESSION_COOKIE_DOMAIN", ""),
CookieSecure: getEnvBool("VISIONA_SESSION_COOKIE_SECURE", false),
AbsoluteTTL: getEnvDuration("VISIONA_SESSION_ABSOLUTE_TTL", 168*time.Hour),
IdleTTL: getEnvDuration("VISIONA_SESSION_IDLE_TTL", 24*time.Hour),
},
Storage: StorageConfig{
Backend: getEnvString("VISIONA_STORAGE_BACKEND", "localfs"),
RootDir: getEnvString("VISIONA_STORAGE_LOCALFS_ROOT", "./data/storage"),
BaseURL: getEnvString("VISIONA_STORAGE_LOCALFS_BASE_URL", "http://localhost:3721/storage"),
},
Model: ModelConfig{
MaxSizeMB: getEnvInt("VISIONA_MODEL_MAX_SIZE_MB", 100),
},
Tunnel: TunnelConfig{
HeartbeatInterval: getEnvDuration("VISIONA_TUNNEL_HEARTBEAT_INTERVAL", 10*time.Second),
IdleTimeout: getEnvDuration("VISIONA_TUNNEL_IDLE_TIMEOUT", 30*time.Second),
},
Logger: LoggerConfig{
Level: getEnvString("VISIONA_LOG_LEVEL", "info"),
},
CORS: CORSConfig{
AllowedOrigins: getEnvStringSlice("VISIONA_CORS_ALLOWED_ORIGINS", nil),
},
// Phase 0.8 / 0.8b conversion (見 .autoflow/04-architecture/conversion.md §3、ADR-015)
// Phase 0.8b T5原暫留欄位 TenantID / DelegatedTTLSeconds 與對應 env
// VISIONA_OIDC_TENANT_ID / VISIONA_FAA_DELEGATED_TTL_SECONDS已移除 —
// MC 認證鏈與 delegated download token 機制不存在了。
Conversion: ConversionConfig{
ConverterBaseURL: getEnvString("VISIONA_CONVERTER_BASE_URL", ""),
FAABaseURL: getEnvString("VISIONA_FAA_BASE_URL", ""),
ConverterAPIKey: getEnvString("VISIONA_CONVERTER_API_KEY", ""),
FAAAPIKey: getEnvString("VISIONA_FAA_API_KEY", ""),
MaxModelSizeMB: getEnvInt("VISIONA_CONVERTER_MAX_MODEL_SIZE_MB", 500),
},
}
}
// getEnvStringSlice 從環境變數取逗號分隔字串,拆成 slice。
// 每段都會 TrimSpace空段會被過濾。若環境變數未設定或為空回傳 fallback。
func getEnvStringSlice(key string, fallback []string) []string {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return fallback
}
parts := strings.Split(v, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
if trimmed := strings.TrimSpace(p); trimmed != "" {
result = append(result, trimmed)
}
}
if len(result) == 0 {
return fallback
}
return result
}
// getEnvString 從環境變數取字串,不存在或為空則回傳預設值。
func getEnvString(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return fallback
}
// getEnvInt 從環境變數取整數,若無法解析則回傳預設值。
func getEnvInt(key string, fallback int) int {
if v, ok := os.LookupEnv(key); ok && v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
// getEnvDuration 從環境變數取 time.Duration支援 "10s"、"1m" 等格式)。
func getEnvDuration(key string, fallback time.Duration) time.Duration {
if v, ok := os.LookupEnv(key); ok && v != "" {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return fallback
}
// getEnvBool 從環境變數取布林值(接受 "true"/"false"/"1"/"0",大小寫不敏感)。
// 解析失敗或未設定回傳 fallback。
func getEnvBool(key string, fallback bool) bool {
if v, ok := os.LookupEnv(key); ok && v != "" {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return fallback
}