對齊 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>
143 lines
5.4 KiB
Go
143 lines
5.4 KiB
Go
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 C1:VISIONA_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", ""),
|
||
// A1:client_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
|
||
}
|