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

322 lines
14 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 定義 visionA-backend 的組態結構,對齊 TDD §2.10。
//
// 雛形遵循 12-Factor App所有可變設定皆透過環境變數注入不寫死在程式碼裡。
// `api-server` 與 `remote-proxy` 共享同一份 Config各自只消費自己需要的欄位。
package config
import "time"
// Config 是整個 visionA-backend 的環境設定。
//
// 所有欄位皆由 Load() 從環境變數讀取並套用預設值。
// 欄位命名對齊 TDD §2.10;新增欄位時請同步更新 `.env.example`(待 B6
type Config struct {
Server ServerConfig
Session SessionConfig
Auth AuthConfig
OIDC OIDCConfig
UserSession UserSessionConfig
Storage StorageConfig
Model ModelConfig
Tunnel TunnelConfig
Logger LoggerConfig
CORS CORSConfig
// Conversion 控制 Phase 0.8 轉檔功能整合converter / FAA / MC service token
// 對齊 .autoflow/04-architecture/conversion.md §5.3。
Conversion ConversionConfig
}
// ServerConfig 控制 HTTP listener 的位址與埠號。
//
// api-server 端使用 Port 提供 REST / WebSocket
// remote-proxy 端使用 TunnelPort面向 local agent與 InternalPort面向 api-server
//
// Port 預設為 3721 — 對齊 local-tool 的 base URL這樣 local-tool 前端切到雲端版時
// base URL 可以維持一致,降低前端的 dev 流程切換成本B4 決定)。
type ServerConfig struct {
Host string // VISIONA_HOST預設 "0.0.0.0"
Port int // VISIONA_API_PORT預設 3721對齊 local-tool
TunnelPort int // VISIONA_TUNNEL_PORT預設 3800
InternalPort int // VISIONA_PROXY_INTERNAL_PORT預設 3801
// RelayPublicURL 是 agent 連 tunnel 用的對外可達 URL通常是 wss://.../tunnel/connect
// 的 origin 部分wss://relay.visionA.cloud
// AB11 新增:/api/pairing/exchange 會把這個值回傳給 agent。
// 雛形預設為空 — handler 會 fallback 到 placeholder `wss://relay.visionA.cloud`。
RelayPublicURL string
// SeedDemoData 控制 api-server 啟動時是否塞入示範用 device + model + pairing token。
// 預設 false本機開發或 demo 時可設 VISIONA_SEED_DEMO_DATA=true 開啟,
// 方便前端不必跑完整 pairing 流程就能看到資料。
SeedDemoData bool
}
// SessionConfig 控制 SessionStore 的實作選擇與連線資訊。
//
// Backend:
// - "inmemory" — remote-proxy 端持有 yamux session 的唯一來源
// - "proxy-client" — api-server 端透過 internal HTTP 查詢 remote-proxy
type SessionConfig struct {
Backend string // VISIONA_SESSION_BACKEND預設 "inmemory"
ProxyInternalURL string // VISIONA_PROXY_INTERNAL_URL預設 "http://localhost:3801"
}
// AuthConfig 控制雛形專用的 user fallback 與 pairing token。
//
// OB52026-04-26起認證走 OIDCOIDCConfig
// Phase 0.72026-05-01security audit 移除了 api.Deps.StaticUserID handler fallback
// (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md C1
// 此處的 StaticUserID 欄位**僅供 dev seedVISIONA_SEED_DEMO_DATA=true與 unit test
// fixture 讀取使用**,不再注入 api.Deps、不影響 stage/prod 認證行為。
type AuthConfig struct {
// StaticUserID — Deprecated for routing/auth use. 僅供 dev seed / unit test。
// 見 internal/api/api.go 的 Deps 註解stage/prod 留空無影響。
StaticUserID string // VISIONA_STATIC_USER_ID預設 "demo-user"dev seed only
PairingToken string // VISIONA_PAIRING_TOKEN格式必須為 vAc_ + 32 hex
SigningSecret string // VISIONA_STORAGE_SIGNING_SECRETpresigned URL HMAC secret
}
// OIDCConfig 控制 OpenID Connect 登入流程BFF 模式)。
//
// 對齊 oidc-tdd.md §13.1 + ADR-010 + ADR-011 + ADR-013。
// OB5 起 OIDC 是唯一認證路徑A1 起支援 public PKCE-only client。
type OIDCConfig struct {
// IssuerURL 是 OIDC IdP 的 issuer不帶結尾斜線例如
// dev: http://localhost:5050
// prod: https://members.innovedus.com
// 對齊 VISIONA_OIDC_ISSUER_URL。
IssuerURL string
// ClientID 是 visionA 在 IdP 註冊的 OAuth client idconfidential 或 public 皆可)。
// 對齊 VISIONA_OIDC_CLIENT_ID。
ClientID string
// ClientSecret 為**選填**A1, 2026-05-01
// - 有值 → confidential client modeclient_secret + PKCE 雙保險)
// - 留空 → PKCE-only public client mode純依靠 PKCE 防 code interception
// 兩種 mode 由 IdP 決定visionA-backend 都支援(見 ADR-013
// **禁止 commit 進 repo**;對齊 VISIONA_OIDC_CLIENT_SECRET。
ClientSecret string
// RedirectURL 是 visionA-backend 的 callback URL必須與 IdP 註冊值完全一致。
// dev: http://localhost:3721/api/auth/callback
// prod: https://api.visiona.cloud/api/auth/callback
// 對齊 VISIONA_OIDC_REDIRECT_URL。
RedirectURL string
// PostLoginURL 是 callback 完成後 302 回 frontend 的 base URL。
// dev: http://localhost:3000
// prod: https://app.visiona.cloud
// 對齊 VISIONA_FRONTEND_URL沿用 oidc-tdd.md §13.1 命名)。
PostLoginURL string
// ServiceClientID 是「visionA-backend 以服務身份呼叫 MC API」用的 client id
// 預留給未來 client_credentials grant flow例如查詢使用者組織、推送通知等
//
// **A1 階段不啟用**Validate() 不檢查、main.go 不 wire只先把 config 鉤子留好,
// 之後接時不必再改 OIDCConfig schema。對齊 VISIONA_OIDC_SERVICE_CLIENT_ID。
ServiceClientID string
// ServiceClientSecret 是 service clientclient_credentials grant的 secret。
// 與 ServiceClientID 配對使用;同樣 A1 階段不啟用、Validate() 不檢查。
// **禁止 commit 進 repo**;對齊 VISIONA_OIDC_SERVICE_CLIENT_SECRET。
ServiceClientSecret string
}
// UserSessionConfig 控制 OIDC 登入後在 browser 端建立的 cookie session。
//
// 注意:與既有 SessionConfigtunnel session 用)刻意分開,避免命名混淆。
// 對齊 oidc-tdd.md §5、§13.1。
type UserSessionConfig struct {
// Secret 是 cookie HMAC-SHA256 簽章金鑰;應為至少 32 byte 隨機字串。
// 對齊 VISIONA_SESSION_SECRET。
Secret string
// CookieName 預設 "visiona_session"。
// 對齊 VISIONA_SESSION_COOKIE_NAME。
CookieName string
// CookieDomaindev 留空host-only cookieprod 設 ".visiona.cloud"。
// 對齊 VISIONA_SESSION_COOKIE_DOMAIN。
CookieDomain string
// CookieSecure 控制 Secure flag。dev=falsehttpprod=truehttps
// 對齊 VISIONA_SESSION_COOKIE_SECURE。
CookieSecure bool
// AbsoluteTTL 是 session 的最長存活時間(從 Create 起算)。預設 168h7 天)。
// 對齊 VISIONA_SESSION_ABSOLUTE_TTL。
AbsoluteTTL time.Duration
// IdleTTL 是 session 的閒置存活時間(從 LastSeenAt 起算)。預設 24h。
// 對齊 VISIONA_SESSION_IDLE_TTL。
IdleTTL time.Duration
}
// StorageConfig 控制儲存層實作LocalFS / S3與路徑。
type StorageConfig struct {
Backend string // VISIONA_STORAGE_BACKEND預設 "localfs"
RootDir string // VISIONA_STORAGE_LOCALFS_ROOT預設 "./data/storage"
BaseURL string // VISIONA_STORAGE_LOCALFS_BASE_URL預設 "http://localhost:3721/storage"(對齊 api-server port
}
// ModelConfig 針對模型資源的驗證限制(大小等)。
type ModelConfig struct {
// MaxSizeMB 是允許上傳的單一模型檔案大小上限MB
// PRD §8.4 規範 Phase 0 為 100 MB可由 VISIONA_MODEL_MAX_SIZE_MB 覆寫。
MaxSizeMB int
}
// TunnelConfig 控制 tunnel 心跳與掉線判定閾值,對齊 tunnel.md §4.2。
type TunnelConfig struct {
// HeartbeatInterval 為 yamux KeepAliveInterval 值。預設 10s。
HeartbeatInterval time.Duration
// IdleTimeout 為判定對端失聯的時間。預設 30s= 3 次心跳未回)。
IdleTimeout time.Duration
}
// LoggerConfig 控制結構化 logger 的輸出等級。
type LoggerConfig struct {
Level string // VISIONA_LOG_LEVELdebug / info / warn / error預設 "info"
}
// ConversionConfig 控制 Phase 0.8 / 0.8b 轉檔功能整合。
//
// 對齊 .autoflow/04-architecture/conversion.md §3、`adr/adr-015-server-to-server-api-key.md`。
//
// 啟用判定(由 Enabled() 給 main.go 用Phase 0.8b 起4 個欄位ConverterBaseURL /
// FAABaseURL / ConverterAPIKey / FAAAPIKey**全部非空**才視為啟用;任一缺即視為未啟用,
// 5 個 /api/conversion/* endpoint 不會 wiremain.go 在 wire 階段跳過、log warn
//
// **Phase 0.8b 變更**:服務間認證從 OAuth client_credentials 改為 pre-shared API keyADR-015
// `Enabled()` 加入兩個 API key 非空檢查。
//
// **Phase 0.8b T5 完成**(見 conversion.md §3.2 / ADR-015 §5 §7原暫留欄位
// TenantID / DelegatedTTLSeconds 已移除 — MC 認證鏈與 delegated download token 機制
// 都不存在了,兩個欄位連同對應 envVISIONA_OIDC_TENANT_ID /
// VISIONA_FAA_DELEGATED_TTL_SECONDS一併清除。
type ConversionConfig struct {
// ConverterBaseURL 是 kneron_model_converter task-scheduler 服務的 base URL。
// 例http://192.168.0.130:9501dev / stage / https://converter.visiona.cloudprod
// 對齊 VISIONA_CONVERTER_BASE_URL留空 = 不啟用 Phase 0.8 轉檔功能。
ConverterBaseURL string
// FAABaseURL 是 File Access Agent 的 base URL。
// 例http://192.168.0.130:5081dev / stage / https://faa.innovedus.comprod
// 對齊 VISIONA_FAA_BASE_URL留空 = 不啟用 Phase 0.8 轉檔功能。
FAABaseURL string
// ConverterAPIKey 是 visionA → converter 服務間認證的 pre-shared API keyPhase 0.8b 新增)。
// 對齊 VISIONA_CONVERTER_API_KEY以 `Authorization: Bearer <key>` 形式帶上。
// 雙方獨立產生(`openssl rand -hex 32`visionA 端的值必須與 converter 端的
// `CONVERTER_API_KEY` env 對齊;不對齊 → 下游 401visionA 端不重試,回 502 converter_auth_failed
// 對應 ADR-015 §3。
//
// 安全log 永遠不印此值全文(可印 `api_key_set=true/false` 或前 8 字元 prefix
// 部署用 AWS Secrets Manager / Vault嚴格分環境dev / stage / prod 各自獨立 key
ConverterAPIKey string
// FAAAPIKey 是 visionA → FAA 服務間認證的 pre-shared API keyPhase 0.8b 新增)。
// 對齊 VISIONA_FAA_API_KEY以 `Authorization: Bearer <key>` 形式帶上。
// 與 ConverterAPIKey **不共用**(每條 trust boundary 各自獨立,避免一處洩漏連坐 — ADR-015 §3
// 對應 FAA 端的 `FAA_API_KEY` env由 warrenchen 配置(跨 repo 同步)。
FAAAPIKey string
// MaxModelSizeMB 是 visionA-backend 端對上傳模型檔的大小上限MB
// 與 converter 端 limit 對齊converter 預設 500 MB
// 對齊 VISIONA_CONVERTER_MAX_MODEL_SIZE_MB預設 500。
MaxModelSizeMB int
}
// Enabled 回傳 Phase 0.8 / 0.8b conversion 是否啟用。
//
// **Phase 0.8b 變更**ADR-015 §6除既有的 ConverterBaseURL / FAABaseURL 外,
// 加入 ConverterAPIKey / FAAAPIKey 非空檢查4 個欄位皆非空才算啟用。
// 任一缺 → 視為未啟用main.go 不會 wire conversion.Service5 個 endpoint 回 501 / 不註冊)。
func (c ConversionConfig) Enabled() bool {
return c.ConverterBaseURL != "" &&
c.FAABaseURL != "" &&
c.ConverterAPIKey != "" &&
c.FAAAPIKey != ""
}
// CORSConfig 控制 api-server 對瀏覽器的 CORS 白名單。
//
// AllowedOrigins 為逗號分隔字串解析後的 slice
// 空時 api.Deps.validate() 會 fallback 到 http://localhost:3000前端 dev server
type CORSConfig struct {
AllowedOrigins []string // VISIONA_CORS_ALLOWED_ORIGINS逗號分隔
}
// Validate 在 Load() 之後檢查交叉依賴與必填欄位。
//
// OB5 起 OIDC 是唯一認證路徑,所有 OIDC 必填欄位永遠都要非空:
// - IssuerURL / ClientID / RedirectURL / PostLoginURL
// - UserSession.Secretcookie HMAC 簽章)
//
// ClientSecret 為**選填**A1, 2026-05-01
// - 有值 → confidential client mode標準 OAuth + PKCE 雙保險)
// - 留空 → PKCE-only public client mode依靠 PKCE 防 code interception
//
// 兩種 mode 由 IdP 決定visionA 都支援(見 ADR-013、oidc-tdd.md §13.1)。
//
// ServiceClientID / ServiceClientSecret 為 client_credentials grant 預留欄位,
// A1 階段不啟用、不檢查;之後若接服務間 API 呼叫再補 Validate。
//
// 缺任何**必填**項 → 回 *MissingEnvErrormain.go 啟動時 fatal log 退出。
// 維持單一 error 而非列表 — caller 只是 fail-fast 紀錄,不需要結構化處理。
func (c *Config) Validate() error {
missing := make([]string, 0, 5)
if c.OIDC.IssuerURL == "" {
missing = append(missing, "VISIONA_OIDC_ISSUER_URL")
}
if c.OIDC.ClientID == "" {
missing = append(missing, "VISIONA_OIDC_CLIENT_ID")
}
// ClientSecret 為選填public PKCE-only client 留空)— 不檢查。
if c.OIDC.RedirectURL == "" {
missing = append(missing, "VISIONA_OIDC_REDIRECT_URL")
}
if c.OIDC.PostLoginURL == "" {
missing = append(missing, "VISIONA_FRONTEND_URL")
}
if c.UserSession.Secret == "" {
missing = append(missing, "VISIONA_SESSION_SECRET")
}
if len(missing) > 0 {
return &MissingEnvError{Vars: missing}
}
return nil
}
// MissingEnvError 表示 OIDC 必填環境變數缺少OB5 起永遠檢查)。
type MissingEnvError struct {
Vars []string
}
func (e *MissingEnvError) Error() string {
return "config: OIDC enabled but required env vars are missing: " + joinStrings(e.Vars, ", ")
}
// joinStrings 是 strings.Join 的本地版本,避免單純為了 join 引入 strings package。
func joinStrings(parts []string, sep string) string {
switch len(parts) {
case 0:
return ""
case 1:
return parts[0]
}
n := len(sep) * (len(parts) - 1)
for _, p := range parts {
n += len(p)
}
out := make([]byte, 0, n)
out = append(out, parts[0]...)
for _, p := range parts[1:] {
out = append(out, sep...)
out = append(out, p...)
}
return string(out)
}