From c63886a194d9b55abbaf002ff619e4de22111f7b Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Sun, 7 Jun 2026 04:06:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(visionA-backend):=20Phase=200.9=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=BA=AB=E5=AD=98=E5=8F=96=20=E2=80=94=20FAA?= =?UTF-8?q?=20delegated=20download=20token=EF=BC=88B1+B2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 對齊 ADR-017 v1.2:模型庫下載走 visionA 簽 MC delegated token → Client 直連 FAA。 B2 — MC download token client(internal/fileaccess): - DownloadTokenIssuer: GetServiceToken(打 MC /oauth/token,client_credentials + scope files:download.delegate,含 token cache)+ IssueDownloadToken(打 MC Issue 簽 fdt_) - secret / service token / fdt token 三層全程用 hashShort 遮罩不 log - FileAccessConfig + VISIONA_FILE_ACCESS_* env + main.go wire(Enabled() 才接) B1 — object_key 斷層: - model.Model 加 FAAObjectKey(json:"-" 不揭露前端) - PromoteToModels 寫入(用 promote response TargetObjectKey = models/{userID}/{jobID}.nef) - 三方對映天然一致(visionA Issue / FAA path / MC validate) - 第一階段框死只 Source=converted 類 model,上傳類 download 回 501 download endpoint: - GET /api/models/:id/download(owner-only)→ {download_url, token, expires_at} - 前端帶 Authorization: Bearer 直連 FAA(不經 visionA、不經 AWS) - 401/403/404/501/502 分明,502 對外 mask 不洩漏 MC 內部狀態 測試: 13 + 8 unit test(mock MC + fake issuer,httptest 驗真 HTTP);go build/vet/test 全綠。 Reviewer: 0 Critical / 0 Major / 3 Minor / 4 Suggestion,通過。 技術債(正式上線前): 第一階段 PoC 共用 FAA service client,MC 規範禁止 client 混用 usage、secret 不共用,須 MC 配發 visionA 專屬 usage=file_api client。 Co-Authored-By: Claude Opus 4.8 (1M context) --- visionA-backend/.env.example | 43 ++ visionA-backend/.gitignore | 2 + .../cmd/api-server/conversion_adapters.go | 2 + visionA-backend/cmd/api-server/main.go | 38 ++ visionA-backend/internal/api/api.go | 12 + visionA-backend/internal/api/models.go | 129 ++++++ .../internal/api/models_download_test.go | 223 +++++++++ visionA-backend/internal/config/config.go | 65 +++ visionA-backend/internal/config/load.go | 11 + visionA-backend/internal/conversion/flow.go | 15 + visionA-backend/internal/fileaccess/client.go | 427 ++++++++++++++++++ .../internal/fileaccess/client_test.go | 360 +++++++++++++++ visionA-backend/internal/model/model.go | 12 + 13 files changed, 1339 insertions(+) create mode 100644 visionA-backend/internal/api/models_download_test.go create mode 100644 visionA-backend/internal/fileaccess/client.go create mode 100644 visionA-backend/internal/fileaccess/client_test.go diff --git a/visionA-backend/.env.example b/visionA-backend/.env.example index b13cc63..ca9574c 100644 --- a/visionA-backend/.env.example +++ b/visionA-backend/.env.example @@ -189,3 +189,46 @@ VISIONA_CONVERTER_API_KEY= # 上傳模型檔大小上限(MB)— 與 converter 端 limit 對齊 VISIONA_CONVERTER_MAX_MODEL_SIZE_MB=500 + +# ============================================================ +# Phase 0.9 — 模型庫 model 直連 FAA 下載(ADR-017 (a)) +# ============================================================ +# 對齊 docs/autoflow/04-architecture/adr/adr-017-model-library-access.md §10(stage e2e 實測藍本)。 +# +# 下載鏈:visionA 用 service client 打 MC /oauth/token(scope files:download.delegate) +# → 打 MC POST /file-access/download-tokens(Issue)簽 opaque fdt_ token +# → 回給 Client「FAA 下載 URL + fdt token」,Client 帶 Authorization: Bearer fdt_... +# 直接 GET {FAA}/files/{object_key}(不經 visionA、不經 AWS)。 +# +# 啟用判定:MC_BASE_URL / SERVICE_CLIENT_ID / SERVICE_CLIENT_SECRET / TENANT_ID / FAA_BASE_URL +# 全部非空才啟用;任一缺 → GET /api/models/:id/download 回 501。 +# +# ⚠️⚠️ 技術債(ADR-017 §7 R1 / Q10):第一階段 PoC 短期**共用 FAA 的 service client** +# (stage 用 4242ba63...,實測可拿 files:download.delegate token)。MC 規範明訂 +# 「OAuth client 禁止混用 usage、secret 不共用」——這份 secret 同時被 FAA 與 visionA 持有, +# 任一邊洩漏會波及兩個服務。**正式上線前須請 MC 配發 visionA 專屬 usage=file_api client** +# 換掉此共用 client,把 secret 邊界收回 visionA。 + +# Member Center API base URL(不帶結尾斜線) +# stage:https://stage-9527.innovedus.com:7850 +VISIONA_FILE_ACCESS_MC_BASE_URL= + +# Service client(client_credentials grant,打 MC /oauth/token + Issue download token) +# ⚠️ 技術債:第一階段 PoC 共用 FAA service client(stage:4242ba63099d4f318dd3f143d27ef4c5) +VISIONA_FILE_ACCESS_SERVICE_CLIENT_ID= + +# Service client secret +# ⚠️ 不可 commit;prod 用 Secrets Manager / Vault;log 永遠不印此值全文 +# ⚠️ 技術債:第一階段 PoC 共用 FAA service client secret(正式上線前換 visionA 專屬 client) +VISIONA_FILE_ACCESS_SERVICE_CLIENT_SECRET= + +# 簽 download token 時帶給 MC 的 tenant_id(須與 FAA validate 的 tenant 一致) +# stage:732270c0-449c-489c-bfad-321e9bf89b3d +VISIONA_FILE_ACCESS_TENANT_ID= + +# File Access Agent 對外 base URL(不帶結尾斜線)— 組回給 Client 的 download_url 用 +# stage:https://stage-9527.innovedus.com:5081 +VISIONA_FILE_ACCESS_FAA_BASE_URL= + +# download token 有效期(秒)— ADR-017 Q2 區間 60–300s,預設 120 +VISIONA_FILE_ACCESS_DOWNLOAD_TOKEN_TTL_SECONDS=120 diff --git a/visionA-backend/.gitignore b/visionA-backend/.gitignore index 2d29e1e..59da902 100644 --- a/visionA-backend/.gitignore +++ b/visionA-backend/.gitignore @@ -20,6 +20,8 @@ go.work.sum bin/ dist/ build/ +# go build 產生的 api-server 二進位(根目錄錨定,避免誤排除其他同名路徑) +/api-server # ---- 環境變數 / 密鑰 ----------------------------------------------------- .env diff --git a/visionA-backend/cmd/api-server/conversion_adapters.go b/visionA-backend/cmd/api-server/conversion_adapters.go index 04a245c..4b7af9b 100644 --- a/visionA-backend/cmd/api-server/conversion_adapters.go +++ b/visionA-backend/cmd/api-server/conversion_adapters.go @@ -67,6 +67,7 @@ func (a *conversionModelStoreAdapter) Save(ctx context.Context, rec *conversion. TargetChip: rec.TargetChip, Source: rec.Source, // 應為 "converted" SourceJobID: rec.SourceJobID, + FAAObjectKey: rec.FAAObjectKey, // ADR-017 (a) B1:promote 寫入的 FAA object key CreatedAt: rec.CreatedAt, UpdatedAt: rec.UpdatedAt, UploadedAt: &uploadedAt, // promote 完即 ready(對齊 toModelResponse) @@ -120,6 +121,7 @@ func modelToRecord(m *model.Model) *conversion.ModelRecord { TargetChip: m.TargetChip, Source: m.Source, SourceJobID: m.SourceJobID, + FAAObjectKey: m.FAAObjectKey, // ADR-017 (a) B1 CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, } diff --git a/visionA-backend/cmd/api-server/main.go b/visionA-backend/cmd/api-server/main.go index ff37178..6f5380c 100644 --- a/visionA-backend/cmd/api-server/main.go +++ b/visionA-backend/cmd/api-server/main.go @@ -30,6 +30,7 @@ import ( "visiona-backend/internal/conversion" "visiona-backend/internal/converter" "visiona-backend/internal/device" + "visiona-backend/internal/fileaccess" "visiona-backend/internal/logger" "visiona-backend/internal/model" "visiona-backend/internal/oidc" @@ -188,6 +189,41 @@ func main() { log.Info("conversion service disabled (set VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY to enable)") } + // ===== Phase 0.9 模型庫 model 直連 FAA 下載(ADR-017 (a)) ===== + // 對齊 docs/autoflow/04-architecture/adr/adr-017-model-library-access.md §10。 + // + // 啟用條件:cfg.FileAccess.Enabled() — 由 MC base / service client id+secret / tenant / + // FAA base 五欄位全非空決定。不啟用時 fileAccessIssuer 為 nil, + // GET /api/models/:id/download 自動回 501(modelsDownloadHandler 處理)。 + // + // ⚠️ 技術債(ADR-017 §7 R1 / Q10):第一階段 PoC 共用 FAA 的 service client; + // 正式上線前須換 visionA 專屬 usage=file_api client(見 internal/fileaccess 套件註解 + .env.example)。 + var fileAccessIssuer fileaccess.DownloadTokenIssuer + if cfg.FileAccess.Enabled() { + issuer, faErr := fileaccess.NewClient(fileaccess.Opts{ + MCBaseURL: cfg.FileAccess.MCBaseURL, + ServiceClientID: cfg.FileAccess.ServiceClientID, + ServiceClientSecret: cfg.FileAccess.ServiceClientSecret, + TenantID: cfg.FileAccess.TenantID, + DownloadTokenTTLSeconds: cfg.FileAccess.DownloadTokenTTLSeconds, + Logger: log, + }) + if faErr != nil { + log.Error("failed to init file access client", "error", faErr) + os.Exit(1) + } + fileAccessIssuer = issuer + log.Info("file access (FAA download) initialized", + "mc_base_url", cfg.FileAccess.MCBaseURL, + "faa_base_url", cfg.FileAccess.FAABaseURL, + "tenant_id", cfg.FileAccess.TenantID, + // 安全:絕不印 client secret 全文 + "service_client_secret_set", cfg.FileAccess.ServiceClientSecret != "", + "download_token_ttl_sec", cfg.FileAccess.DownloadTokenTTLSeconds) + } else { + log.Info("file access (FAA download) disabled (set VISIONA_FILE_ACCESS_* to enable)") + } + // ===== Seed demo data(可選) ===== if cfg.Server.SeedDemoData { if err := seedDemoData(deviceRepo, modelRepo, pairingStore, cfg.Auth.StaticUserID, log); err != nil { @@ -210,6 +246,8 @@ func main() { Storage: storageStore, Converter: converterClient, Conversion: conversionService, // Phase 0.8(nil 時 /api/conversion/* 回 501) + FileAccessIssuer: fileAccessIssuer, // Phase 0.9(nil 時 /api/models/:id/download 回 501) + FAABaseURL: cfg.FileAccess.FAABaseURL, MaxUploadSizeMB: cfg.Model.MaxSizeMB, CORSAllowedOrigins: cfg.CORS.AllowedOrigins, RelayPublicURL: cfg.Server.RelayPublicURL, diff --git a/visionA-backend/internal/api/api.go b/visionA-backend/internal/api/api.go index 6370b00..635160f 100644 --- a/visionA-backend/internal/api/api.go +++ b/visionA-backend/internal/api/api.go @@ -26,6 +26,7 @@ import ( "visiona-backend/internal/conversion" "visiona-backend/internal/converter" "visiona-backend/internal/device" + "visiona-backend/internal/fileaccess" "visiona-backend/internal/model" "visiona-backend/internal/oidc" "visiona-backend/internal/session" @@ -77,6 +78,17 @@ type Deps struct { // 設計選擇:用 conversion.Service interface 而非 concrete type — 方便 unit test 注入 stub。 Conversion conversion.Service + // FileAccessIssuer 是 Phase 0.9「模型庫 model 直連 FAA 下載」的 download token 簽發者 + // (ADR-017 (a))。為 nil 時 GET /api/models/:id/download 回 501 NOT_IMPLEMENTED + // (main.go 在 cfg.FileAccess.Enabled() 為 false 時不 wire)。 + // 用 interface 方便 unit test 注入 fake。 + FileAccessIssuer fileaccess.DownloadTokenIssuer + + // FAABaseURL 是 File Access Agent 對外 base URL(不帶結尾斜線),用來組回給 Client + // 的 download_url(`{FAABaseURL}/files/{object_key}`)。 + // 由 cfg.FileAccess.FAABaseURL 注入;FileAccessIssuer 非 nil 時必非空(main.go 確保)。 + FAABaseURL string + // CORSAllowedOrigins 是允許的瀏覽器 Origin 白名單;空 slice 預設放行 // http://localhost:3000(前端 dev server)。 CORSAllowedOrigins []string diff --git a/visionA-backend/internal/api/models.go b/visionA-backend/internal/api/models.go index edef9fd..f7ea9f0 100644 --- a/visionA-backend/internal/api/models.go +++ b/visionA-backend/internal/api/models.go @@ -20,6 +20,7 @@ import ( "encoding/json" "errors" "net/http" + "net/url" "strings" "time" @@ -41,6 +42,9 @@ func registerModelRoutes(g *gin.RouterGroup, deps Deps) { g.POST("/models/:id/finalize", modelsFinalizeHandler(deps)) g.DELETE("/models/:id", modelsDeleteHandler(deps)) + // Phase 0.9 模型庫 model 直連 FAA 下載(ADR-017 (a))。 + g.GET("/models/:id/download", modelsDownloadHandler(deps)) + // load-to-device 雛形先 stub(完整實作需要 presigned GET + 透過 tunnel 送指令給 local agent) g.POST("/models/:id/load-to-device", func(c *gin.Context) { WriteNotImplemented(c, "models.load-to-device — pending Phase 1") @@ -431,3 +435,128 @@ func modelsDeleteHandler(deps Deps) gin.HandlerFunc { c.Status(http.StatusNoContent) } } + +// ModelDownloadResponse 是 GET /api/models/:id/download 的 response data(ADR-017 (a) 決策 2)。 +// +// Client(local-tool / browser)拿到後,帶 `Authorization: Bearer {Token}` 直接 +// GET DownloadURL(= {FAA}/files/{object_key})下載——不經 visionA、不經 AWS。 +type ModelDownloadResponse struct { + // DownloadURL 是 FAA 下載 URL(`{FAABaseURL}/files/{object_key}`)。 + DownloadURL string `json:"download_url"` + // Token 是 MC 簽的 opaque download token(fdt_);放 Authorization: Bearer。 + Token string `json:"token"` + // ExpiresAt 是 token 到期時間(RFC3339 / UTC);MC 沒回填時為零值(前端不應依賴)。 + ExpiresAt time.Time `json:"expires_at,omitempty"` +} + +// modelsDownloadHandler 實作 GET /api/models/:id/download(ADR-017 (a) 模型庫直連 FAA 下載)。 +// +// 流程(對齊 adr-017 §10.3 e2e 藍本 + 決策 2): +// 1. ownership 驗(第一階段 owner-only;B 分享是後續階段) +// 2. model 必須有 FAAObjectKey(= 轉檔→promote 類);上傳類(空 key)回 501(Q7 範圍框死) +// 3. FileAccessIssuer 簽 MC download token(fdt_) +// 4. 回 {download_url, token, expires_at} 給 Client 直連 FAA +// +// 錯誤: +// - issuer / FAABaseURL 未配置(deps.FileAccessIssuer == nil)→ 501 NOT_IMPLEMENTED +// - model 不存在 / 非 owner → 404 / 403 +// - 上傳類 model(無 FAAObjectKey)→ 501 NOT_IMPLEMENTED(明確訊息) +// - 簽 token 失敗(MC 不可用)→ 502 INTERNAL_ERROR(對外 mask,不洩漏 MC 內部狀態) +func modelsDownloadHandler(deps Deps) gin.HandlerFunc { + return func(c *gin.Context) { + if deps.ModelRepo == nil { + WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) + return + } + // FAA 直連下載未啟用(cfg.FileAccess.Enabled() == false → main.go 不 wire) + if deps.FileAccessIssuer == nil || deps.FAABaseURL == "" { + WriteNotImplemented(c, "model FAA download not configured (set VISIONA_FILE_ACCESS_* env)") + return + } + + id := c.Param("id") + if id == "" { + WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil) + return + } + // Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md) + uc, ok := UserContextFrom(c) + if !ok || uc.UserID == "" { + WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, + "missing user context (auth middleware misconfigured?)", nil) + return + } + userID := uc.UserID + + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + + m, err := deps.ModelRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) + return + } + WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, + "get model failed: "+err.Error(), nil) + return + } + // 第一階段 owner-only(B 分享後續階段);非 owner 回 403。 + if m.OwnerUserID != userID { + WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil) + return + } + + // 第一階段只支援「轉檔→promote 進 FAA」類 model(有 FAAObjectKey)。 + // 上傳類 model 只在 visionA 自己 storage、不在 FAA,無法直連(ADR-017 Q7 範圍框死)。 + if m.FAAObjectKey == "" { + WriteNotImplemented(c, + "uploaded models do not support FAA direct download in this phase (only converted models)") + return + } + + // 簽 MC download token(fdt_)。userID = OIDC sub(MC 真實 user); + // objectKey 必須與 FAA GET path key 一致(= 簽 token 時的 object_key)。 + issued, err := deps.FileAccessIssuer.IssueDownloadToken(ctx, userID, m.FAAObjectKey) + if err != nil { + // 對外 mask 成 502(不洩漏「MC 不可用 / token 簽發細節」這類內部運維狀態); + // SRE 從 server log 的 error 看 fileaccess sentinel 分類。 + logOrDefault(deps.Logger).Warn("models: download token issue failed", + "model_id", m.ID, + "user_id", userID, + "err", err.Error(), + "request_id", RequestIDFrom(c)) + WriteError(c, http.StatusBadGateway, ErrCodeInternalError, + "download token service unavailable", nil) + return + } + + // 組對外 download_url:{FAABaseURL}/files/{object_key}。 + // object_key 內含 '/'(models/{userID}/{jobID}.nef),需逐段 escape 但保留 '/'。 + downloadURL := strings.TrimRight(deps.FAABaseURL, "/") + "/files/" + escapeFAAObjectKey(m.FAAObjectKey) + + logOrDefault(deps.Logger).Info("models: download token issued", + "model_id", m.ID, + "user_id", userID, + "request_id", RequestIDFrom(c)) + + WriteSuccess(c, http.StatusOK, ModelDownloadResponse{ + DownloadURL: downloadURL, + Token: issued.Token, + ExpiresAt: issued.ExpiresAt, + }) + } +} + +// escapeFAAObjectKey 對 FAA object_key 逐段 path-escape,保留 '/' 為 path separator。 +// +// object_key 形如 models/{userID}/{jobID}.nef;userID(OIDC sub) / jobID(UUID) 都是 +// 安全字元,但仍逐段 url.PathEscape 防禦(避免任何特殊字元破壞 URL 結構)。 +// 不整體 PathEscape 是因為那會把 '/' 變 %2F,破壞 FAA `/files/{**objectKey}` 的 path 比對。 +func escapeFAAObjectKey(objectKey string) string { + segs := strings.Split(objectKey, "/") + for i, s := range segs { + segs[i] = url.PathEscape(s) + } + return strings.Join(segs, "/") +} diff --git a/visionA-backend/internal/api/models_download_test.go b/visionA-backend/internal/api/models_download_test.go new file mode 100644 index 0000000..5a8acec --- /dev/null +++ b/visionA-backend/internal/api/models_download_test.go @@ -0,0 +1,223 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/fileaccess" + "visiona-backend/internal/model" +) + +// ========================================================================== +// fake DownloadTokenIssuer +// ========================================================================== + +type fakeIssuer struct { + token string + expiresAt time.Time + err error + + // 記錄被呼叫的參數,驗 handler 傳對 userID / objectKey。 + gotUserID string + gotObjectKey string + calls int +} + +func (f *fakeIssuer) IssueDownloadToken(ctx context.Context, userID, objectKey string) (*fileaccess.IssuedDownloadToken, error) { + f.calls++ + f.gotUserID = userID + f.gotObjectKey = objectKey + if f.err != nil { + return nil, f.err + } + return &fileaccess.IssuedDownloadToken{ + Token: f.token, + TokenType: "file_download", + ExpiresAt: f.expiresAt, + Scope: "files:download.read", + }, nil +} + +// newDownloadFixture 建一個帶 fake issuer 的 models route fixture。 +// +// issuer 為 nil 時模擬「FAA download 未啟用」(main.go 不 wire)。 +// faaBaseURL 空時也視為未配置。 +func newDownloadFixture(t *testing.T, issuer fileaccess.DownloadTokenIssuer, faaBaseURL, userID string) (*gin.Engine, *model.InMemoryRepository) { + t.Helper() + repo := model.NewInMemoryRepository() + + r := gin.New() + r.Use(RequestIDMiddleware()) + r.Use(injectStaticUserContext(userID, "")) + g := r.Group("/api") + registerModelRoutes(g, Deps{ + ModelRepo: repo, + MaxUploadSizeMB: 10, + FileAccessIssuer: issuer, + FAABaseURL: faaBaseURL, + }) + return r, repo +} + +// seedConvertedModel 直接塞一個「轉檔→promote」類 model(有 FAAObjectKey)。 +func seedConvertedModel(t *testing.T, repo *model.InMemoryRepository, id, owner, faaKey string) { + t.Helper() + now := time.Now().UTC() + require.NoError(t, repo.Save(context.Background(), &model.Model{ + ID: id, + OwnerUserID: owner, + Name: "converted-model", + StorageKey: "models/" + owner + "/" + id + ".nef", + FAAObjectKey: faaKey, + FileSize: 1024, + Source: model.SourceConverted, + UploadedAt: &now, + CreatedAt: now, + UpdatedAt: now, + })) +} + +// ========================================================================== +// happy path +// ========================================================================== + +func TestModelsDownload_OK(t *testing.T) { + exp := time.Date(2026, 6, 7, 12, 2, 0, 0, time.UTC) + iss := &fakeIssuer{token: "fdt_abc123", expiresAt: exp} + r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user") + seedConvertedModel(t, repo, "m-conv-1", "demo-user", "models/demo-user/job-1.nef") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models/m-conv-1/download", nil) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + + var sb SuccessBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb)) + data := sb.Data.(map[string]any) + + assert.Equal(t, "https://faa.example.com:5081/files/models/demo-user/job-1.nef", data["download_url"]) + assert.Equal(t, "fdt_abc123", data["token"]) + assert.Contains(t, data["expires_at"], "2026-06-07") + + // issuer 必須拿到正確的 userID(OIDC sub)+ objectKey(FAAObjectKey,非 StorageKey) + assert.Equal(t, "demo-user", iss.gotUserID) + assert.Equal(t, "models/demo-user/job-1.nef", iss.gotObjectKey) + assert.Equal(t, 1, iss.calls) +} + +// ========================================================================== +// 501 — 未配置 / 上傳類 +// ========================================================================== + +func TestModelsDownload_NotConfiguredWhenIssuerNil(t *testing.T) { + r, repo := newDownloadFixture(t, nil, "", "demo-user") + seedConvertedModel(t, repo, "m1", "demo-user", "models/demo-user/job.nef") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models/m1/download", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotImplemented, w.Code) + assert.Contains(t, w.Body.String(), ErrCodeNotImplemented) +} + +func TestModelsDownload_NotConfiguredWhenFAABaseURLEmpty(t *testing.T) { + iss := &fakeIssuer{token: "fdt_x"} + // issuer 有,但 FAABaseURL 空 → 仍視為未配置 + r, repo := newDownloadFixture(t, iss, "", "demo-user") + seedConvertedModel(t, repo, "m1", "demo-user", "models/demo-user/job.nef") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models/m1/download", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotImplemented, w.Code) + assert.Equal(t, 0, iss.calls, "should not call issuer when not configured") +} + +func TestModelsDownload_UploadedModelReturns501(t *testing.T) { + iss := &fakeIssuer{token: "fdt_x"} + r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user") + + // 上傳類 model:無 FAAObjectKey + now := time.Now().UTC() + require.NoError(t, repo.Save(context.Background(), &model.Model{ + ID: "m-upload", + OwnerUserID: "demo-user", + Name: "uploaded", + StorageKey: "models/demo-user/m-upload.nef", + Source: model.SourceUploaded, + UploadedAt: &now, + CreatedAt: now, + UpdatedAt: now, + })) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models/m-upload/download", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotImplemented, w.Code) + assert.Contains(t, w.Body.String(), "uploaded models") + assert.Equal(t, 0, iss.calls, "should not issue token for uploaded model") +} + +// ========================================================================== +// 404 / 403 — ownership +// ========================================================================== + +func TestModelsDownload_NotFound(t *testing.T) { + iss := &fakeIssuer{token: "fdt_x"} + r, _ := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models/does-not-exist/download", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + assert.Contains(t, w.Body.String(), ErrCodeNotFound) +} + +func TestModelsDownload_ForbiddenWhenNotOwner(t *testing.T) { + iss := &fakeIssuer{token: "fdt_x"} + // 登入 user = demo-user,但 model owner = other-user + r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user") + seedConvertedModel(t, repo, "m-other", "other-user", "models/other-user/job.nef") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models/m-other/download", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), ErrCodeForbidden) + assert.Equal(t, 0, iss.calls, "should not issue token for non-owner") +} + +// ========================================================================== +// 502 — issuer 失敗 +// ========================================================================== + +func TestModelsDownload_IssuerFailureReturns502(t *testing.T) { + iss := &fakeIssuer{err: errors.New("mc unavailable")} + r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user") + seedConvertedModel(t, repo, "m1", "demo-user", "models/demo-user/job.nef") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models/m1/download", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadGateway, w.Code) + assert.Contains(t, w.Body.String(), ErrCodeInternalError) + // 對外 mask:不應洩漏 "mc unavailable" 內部細節 + assert.NotContains(t, w.Body.String(), "mc unavailable") +} diff --git a/visionA-backend/internal/config/config.go b/visionA-backend/internal/config/config.go index 95d386d..f324bbd 100644 --- a/visionA-backend/internal/config/config.go +++ b/visionA-backend/internal/config/config.go @@ -24,6 +24,8 @@ type Config struct { // Conversion 控制 Phase 0.8 轉檔功能整合(converter / FAA / MC service token)。 // 對齊 .autoflow/04-architecture/conversion.md §5.3。 Conversion ConversionConfig + // FileAccess 控制 Phase 0.9 模型庫 model 直連 FAA 下載鏈(ADR-017 (a))。 + FileAccess FileAccessConfig } // ServerConfig 控制 HTTP listener 的位址與埠號。 @@ -223,6 +225,69 @@ type ConversionConfig struct { MaxModelSizeMB int } +// FileAccessConfig 控制「模型庫 model 直連 FAA 下載」鏈路(ADR-017 (a))。 +// +// 對齊 adr/adr-017-model-library-access.md §10(stage e2e 實測藍本): +// +// visionA 用 service client 打 MC `/oauth/token`(scope files:download.delegate) +// → 打 MC `POST /file-access/download-tokens`(Issue)簽 opaque `fdt_` token +// → 回給 Client「FAA 下載 URL + fdt token」,Client 帶 `Authorization: Bearer fdt_...` +// 直接 GET `{FAA}/files/{object_key}`。 +// +// 啟用判定(由 Enabled() 給 main.go 用):4 個必要欄位 +// (MCBaseURL / ServiceClientID / ServiceClientSecret / TenantID)全部非空才視為啟用; +// 任一缺即不 wire download token issuer,model download endpoint 回 501。 +// FAABaseURL 是「組對外 download_url」用,留空時 endpoint 也回 501(無從組 URL)。 +// +// ⚠️ 技術債(ADR-017 §7 R1 / Q10):第一階段 PoC 短期**共用 FAA 的 service client** +// (`4242ba63...`,stage 實測可拿 files:download.delegate token)。MC 規範明訂「OAuth client +// 禁止混用 usage、secret 不共用」,故正式上線前須請 MC 配發 visionA 專屬 usage=file_api +// client 換掉此共用 client,把 secret 邊界收回 visionA。詳見 .env.example 對應註解。 +type FileAccessConfig struct { + // MCBaseURL 是 Member Center API base URL(不帶結尾斜線)。 + // stage:https://stage-9527.innovedus.com:7850 + // 對齊 VISIONA_FILE_ACCESS_MC_BASE_URL。 + MCBaseURL string + + // ServiceClientID 是打 MC `/oauth/token`(client_credentials)的 client id。 + // ⚠️ 技術債:第一階段 PoC 共用 FAA service client(`4242ba63...`)。 + // 對齊 VISIONA_FILE_ACCESS_SERVICE_CLIENT_ID。 + ServiceClientID string + + // ServiceClientSecret 是 service client 的 secret。 + // **禁止 commit 進 repo**;對齊 VISIONA_FILE_ACCESS_SERVICE_CLIENT_SECRET。 + // 安全:log 永遠不印此值全文。 + ServiceClientSecret string + + // TenantID 是簽 download token 時帶給 MC 的 tenant_id(須與 FAA validate 的 tenant 一致)。 + // stage:732270c0-449c-489c-bfad-321e9bf89b3d + // 對齊 VISIONA_FILE_ACCESS_TENANT_ID。 + TenantID string + + // FAABaseURL 是 File Access Agent 對外 base URL(不帶結尾斜線),用來組回給 Client 的 + // download_url(`{FAABaseURL}/files/{object_key}`)。 + // stage:https://stage-9527.innovedus.com:5081 + // 對齊 VISIONA_FILE_ACCESS_FAA_BASE_URL。 + FAABaseURL string + + // DownloadTokenTTLSeconds 是簽 download token 時帶給 MC 的 expires_in_seconds。 + // ADR-017 Q2 區間 60–300s,預設 120s。對齊 VISIONA_FILE_ACCESS_DOWNLOAD_TOKEN_TTL_SECONDS。 + DownloadTokenTTLSeconds int +} + +// Enabled 回傳「模型庫 FAA 直連下載」是否啟用。 +// +// 4 個必要欄位(MCBaseURL / ServiceClientID / ServiceClientSecret / TenantID)+ FAABaseURL +// 全部非空才視為啟用;任一缺 → main.go 不 wire download token issuer, +// GET /api/models/:id/download 回 501。 +func (c FileAccessConfig) Enabled() bool { + return c.MCBaseURL != "" && + c.ServiceClientID != "" && + c.ServiceClientSecret != "" && + c.TenantID != "" && + c.FAABaseURL != "" +} + // Enabled 回傳 Phase 0.8 / 0.8b conversion 是否啟用。 // // **Phase 0.8b v0.6 T4 簡化**(ADR-016 §2 / conversion.md v0.6.1 §3.1):visionA 端撤回 diff --git a/visionA-backend/internal/config/load.go b/visionA-backend/internal/config/load.go index 39badf9..fbcaaab 100644 --- a/visionA-backend/internal/config/load.go +++ b/visionA-backend/internal/config/load.go @@ -81,6 +81,17 @@ func Load() *Config { ConverterAPIKey: getEnvString("VISIONA_CONVERTER_API_KEY", ""), MaxModelSizeMB: getEnvInt("VISIONA_CONVERTER_MAX_MODEL_SIZE_MB", 500), }, + // Phase 0.9 模型庫 FAA 直連下載(ADR-017 (a),見 adr-017 §10 stage e2e 藍本)。 + // ⚠️ 技術債:ServiceClientID/Secret 第一階段 PoC 共用 FAA 的 service client; + // 正式上線前須換 visionA 專屬 usage=file_api client(ADR-017 §7 R1 / Q10)。 + FileAccess: FileAccessConfig{ + MCBaseURL: getEnvString("VISIONA_FILE_ACCESS_MC_BASE_URL", ""), + ServiceClientID: getEnvString("VISIONA_FILE_ACCESS_SERVICE_CLIENT_ID", ""), + ServiceClientSecret: getEnvString("VISIONA_FILE_ACCESS_SERVICE_CLIENT_SECRET", ""), + TenantID: getEnvString("VISIONA_FILE_ACCESS_TENANT_ID", ""), + FAABaseURL: getEnvString("VISIONA_FILE_ACCESS_FAA_BASE_URL", ""), + DownloadTokenTTLSeconds: getEnvInt("VISIONA_FILE_ACCESS_DOWNLOAD_TOKEN_TTL_SECONDS", 120), + }, } } diff --git a/visionA-backend/internal/conversion/flow.go b/visionA-backend/internal/conversion/flow.go index bb243da..c141592 100644 --- a/visionA-backend/internal/conversion/flow.go +++ b/visionA-backend/internal/conversion/flow.go @@ -84,6 +84,10 @@ type ModelRecord struct { TargetChip string Source string // 永遠 "converted" SourceJobID string + // FAAObjectKey 是該 model 在 FAA 上的 object key(ADR-017 (a) B1)。 + // = converter promote 的 target_object_key(buildTargetObjectKey:models/{userID}/{jobID}.nef)。 + // PromoteToModels 寫入;adapter 對映到 model.Model.FAAObjectKey。 + FAAObjectKey string CreatedAt time.Time UpdatedAt time.Time } @@ -650,6 +654,16 @@ func (f *flow) PromoteToModels(ctx context.Context, userID, jobID, name string) } // 7. modelStore.Save + // + // FAAObjectKey(ADR-017 (a) B1):promote 把 NEF 推進 FAA 用的 object_key。 + // 以 converter promote response 回傳的 TargetObjectKey 為權威值(converter 可能對 key + // 正規化);promote response 萬一沒回(理論上不會,parseConverterPromoteResult 已要求非空) + // 則 fallback 回 visionA 端組的 targetObjectKey。download endpoint 用此 key Issue MC token。 + faaObjectKey := promoteRes.TargetObjectKey + if faaObjectKey == "" { + faaObjectKey = targetObjectKey + } + now := f.now().UTC() rec := &ModelRecord{ ID: modelID, @@ -661,6 +675,7 @@ func (f *flow) PromoteToModels(ctx context.Context, userID, jobID, name string) TargetChip: normalizeTargetChip(cj.Platform), Source: "converted", SourceJobID: jobID, + FAAObjectKey: faaObjectKey, CreatedAt: now, UpdatedAt: now, } diff --git a/visionA-backend/internal/fileaccess/client.go b/visionA-backend/internal/fileaccess/client.go new file mode 100644 index 0000000..76c5120 --- /dev/null +++ b/visionA-backend/internal/fileaccess/client.go @@ -0,0 +1,427 @@ +// Package fileaccess 實作「模型庫 model 直連 FAA 下載」的 MC 認證鏈(ADR-017 (a))。 +// +// 對齊 docs/autoflow/04-architecture/adr/adr-017-model-library-access.md §10 +// (stage 真實環境 e2e 實測藍本)。本套件只負責「跟 MC 拿 service token + 簽 +// download token(fdt_)」,不負責真的打 FAA 下載——下載由 Client(local-tool / +// browser)帶 fdt token 直接 GET {FAA}/files/{object_key}。 +// +// 與 internal/conversion 的關係: +// - conversion 走「visionA → converter pre-shared API key」(ADR-015/016),是另一條路徑。 +// - fileaccess 走「visionA → MC OAuth client_credentials → 簽 fdt token」(ADR-017 (a)), +// 是模型庫持久資產的下載路徑。兩者並存、用途不同(ADR-017 決策 4.5)。 +// +// ⚠️ 技術債(ADR-017 §7 R1 / Q10):第一階段 PoC 短期共用 FAA 的 service client +// (stage `4242ba63...`)。MC 規範明訂「OAuth client 禁止混用 usage、secret 不共用」, +// 正式上線前須請 MC 配發 visionA 專屬 usage=file_api client 換掉,把 secret 邊界收回 visionA。 +// +// 安全: +// - 絕不把 client secret / access token / fdt token 全文寫進 log(連前綴也不印)。 +// - 只 log object_key hash / user hash / 結果狀態。 +package fileaccess + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// ========================================================================== +// Errors(sentinel) +// ========================================================================== + +var ( + // ErrServiceTokenFailed 表示打 MC /oauth/token 拿 service token 失敗 + // (網路 / 4xx / 5xx / 解析)。caller(handler)對外 mask 成 download_unavailable / 502。 + ErrServiceTokenFailed = errors.New("fileaccess: service token request failed") + + // ErrIssueTokenFailed 表示打 MC Issue(POST /file-access/download-tokens)失敗。 + // caller 對外 mask 成 download_unavailable / 502。 + ErrIssueTokenFailed = errors.New("fileaccess: issue download token failed") + + // ErrConfigIncomplete 表示 client 建構參數不完整(缺 MCBaseURL / client / tenant)。 + ErrConfigIncomplete = errors.New("fileaccess: required config is missing") +) + +// ========================================================================== +// 對外 type / interface +// ========================================================================== + +// IssuedDownloadToken 是 IssueDownloadToken 的結果。 +// +// Token 是 MC 簽的 opaque `fdt_`(不是 JWT),caller 直接回給 Client +// 當 `Authorization: Bearer {Token}` 打 FAA。ExpiresAt 由 MC 回填(UTC)。 +type IssuedDownloadToken struct { + Token string // opaque fdt_ token(敏感,勿 log 全文) + TokenType string // MC 回 "file_download" + ExpiresAt time.Time // MC 回填的到期時間(UTC) + Scope string // MC 回 "files:download.read" +} + +// DownloadTokenIssuer 是「簽 model download token」的最小對外介面。 +// +// handler 依賴此 interface(而非具體 client),方便 unit test 注入 fake、 +// 也方便未來換成 visionA 專屬 client 時不動 handler。 +type DownloadTokenIssuer interface { + // IssueDownloadToken 對指定 (userID, objectKey) 簽一個 MC download token。 + // + // - userID 必須是 MC 真實 user(OIDC sub);visionA 登入走 MC OIDC, + // UserContext.UserID 即 OIDC sub,直接傳入即可(ADR-017 §10 契約細節)。 + // - objectKey 必須與 FAA GET 的 path key 完全一致(FAA 從 URL path 取 objectKey + // 去 MC validate boundary,不一致 FAA 回 object_key_mismatch)。 + // + // 失敗回 ErrServiceTokenFailed / ErrIssueTokenFailed(已包進細節)。 + IssueDownloadToken(ctx context.Context, userID, objectKey string) (*IssuedDownloadToken, error) +} + +// Opts 是 NewClient 的依賴注入。 +// +// 必填:MCBaseURL / ServiceClientID / ServiceClientSecret / TenantID。 +// HTTPClient / Now / Logger 為 optional(nil 自動填預設)— 方便 unit test 注入 fake。 +type Opts struct { + // MCBaseURL 是 Member Center API base URL(不帶結尾斜線)。 + MCBaseURL string + + // ServiceClientID / ServiceClientSecret 打 MC /oauth/token 用(client_credentials)。 + // ⚠️ 技術債:第一階段 PoC 共用 FAA service client(見套件註解)。 + ServiceClientID string + ServiceClientSecret string + + // TenantID 簽 download token 時帶給 MC(須與 FAA validate 的 tenant 一致)。 + TenantID string + + // DownloadTokenTTLSeconds 是簽 token 時帶給 MC 的 expires_in_seconds。 + // 0 → 用 defaultDownloadTokenTTLSeconds(120,ADR-017 Q2)。 + DownloadTokenTTLSeconds int + + // HTTPClient 為 optional;nil 用預設(timeout 10s)。MC call 是輕量 JSON POST。 + HTTPClient *http.Client + + // Now 為 optional;nil 用 time.Now(service token cache 過期判定用)。 + Now func() time.Time + + // Logger 為 optional;nil 用 slog.Default()。 + Logger *slog.Logger +} + +// ========================================================================== +// 內部常數 +// ========================================================================== + +const ( + // downloadDelegateScope 是打 MC /oauth/token 時要的 scope(ADR-017 §10.2/§10.3)。 + downloadDelegateScope = "files:download.delegate" + + // oauthTokenPath / issuePath 是 MC 的 endpoint path(ADR-017 §10)。 + oauthTokenPath = "/oauth/token" + issuePath = "/file-access/download-tokens" + + // defaultDownloadTokenTTLSeconds 是 download token 預設有效期(秒)—— ADR-017 Q2(傾向 120s)。 + defaultDownloadTokenTTLSeconds = 120 + + // defaultHTTPTimeout 是 MC call 的整體 timeout(OAuth / Issue 都是輕量 JSON)。 + defaultHTTPTimeout = 10 * time.Second + + // serviceTokenRefreshSkew 是 service token cache 提前刷新的安全邊際—— + // token 還有少於這個秒數就視為過期、重拿,避免「拿了正好過期的 token」。 + serviceTokenRefreshSkew = 30 * time.Second + + // issueMethod 是簽 download token 時帶給 MC 的 method(FAA download 是 GET)。 + issueMethod = "GET" +) + +// ========================================================================== +// 構造 + 內部 struct +// ========================================================================== + +// client 是 DownloadTokenIssuer 的預設實作。 +type client struct { + mcBaseURL string + clientID string + clientSecret string + tenantID string + ttlSeconds int + + http *http.Client + now func() time.Time + logger *slog.Logger + + // service token cache(client_credentials token 可重用到過期前)。 + mu sync.Mutex + cachedToken string + cachedTokenExp time.Time // UTC +} + +// 編譯時檢查:確保 client 實作 DownloadTokenIssuer。 +var _ DownloadTokenIssuer = (*client)(nil) + +// NewClient 建立一個 fileaccess client。 +// +// 必填欄位任一為空 → 回 ErrConfigIncomplete(讓 main.go fail-fast,不在「設定不全」 +// 狀態下把 endpoint 接起來)。 +func NewClient(opts Opts) (DownloadTokenIssuer, error) { + if opts.MCBaseURL == "" || opts.ServiceClientID == "" || + opts.ServiceClientSecret == "" || opts.TenantID == "" { + return nil, ErrConfigIncomplete + } + ttl := opts.DownloadTokenTTLSeconds + if ttl <= 0 { + ttl = defaultDownloadTokenTTLSeconds + } + httpClient := opts.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: defaultHTTPTimeout} + } + now := opts.Now + if now == nil { + now = time.Now + } + logger := opts.Logger + if logger == nil { + logger = slog.Default() + } + return &client{ + mcBaseURL: strings.TrimRight(opts.MCBaseURL, "/"), + clientID: opts.ServiceClientID, + clientSecret: opts.ServiceClientSecret, + tenantID: opts.TenantID, + ttlSeconds: ttl, + http: httpClient, + now: now, + logger: logger, + }, nil +} + +// ========================================================================== +// GetServiceToken — 打 MC /oauth/token(client_credentials, scope files:download.delegate) +// ========================================================================== + +// oauthTokenResponse 是 MC /oauth/token 的 response shape(標準 OAuth2 token response)。 +type oauthTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` // 秒 + Scope string `json:"scope"` +} + +// GetServiceToken 取得(或從 cache 重用)MC service access token。 +// +// 帶簡單 cache:token 未過期(含 serviceTokenRefreshSkew 安全邊際)直接回 cache, +// 否則打 MC /oauth/token 重拿。goroutine-safe(mutex 保護 cache)。 +// +// 失敗回 ErrServiceTokenFailed(已包細節)。 +func (c *client) GetServiceToken(ctx context.Context) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + + // cache 命中(還有效)→ 直接回 + if c.cachedToken != "" && c.now().UTC().Add(serviceTokenRefreshSkew).Before(c.cachedTokenExp) { + return c.cachedToken, nil + } + + form := url.Values{} + form.Set("grant_type", "client_credentials") + form.Set("client_id", c.clientID) + form.Set("client_secret", c.clientSecret) + form.Set("scope", downloadDelegateScope) + + endpoint := c.mcBaseURL + oauthTokenPath + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, + strings.NewReader(form.Encode())) + if err != nil { + return "", fmt.Errorf("%w: build oauth request: %v", ErrServiceTokenFailed, err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + // 網路 / timeout / ctx cancel 都歸 service token 失敗 + return "", fmt.Errorf("%w: do oauth request: %v", ErrServiceTokenFailed, err) + } + defer resp.Body.Close() + + body, readErr := io.ReadAll(io.LimitReader(resp.Body, errorBodyReadCap)) + if resp.StatusCode != http.StatusOK { + // 安全:不 log secret;body 可能含 error_description(不含 secret),可帶上 status + return "", fmt.Errorf("%w: oauth status=%d body=%s", ErrServiceTokenFailed, + resp.StatusCode, truncate(string(body))) + } + if readErr != nil { + return "", fmt.Errorf("%w: read oauth body: %v", ErrServiceTokenFailed, readErr) + } + + var parsed oauthTokenResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return "", fmt.Errorf("%w: parse oauth body: %v", ErrServiceTokenFailed, err) + } + if parsed.AccessToken == "" { + return "", fmt.Errorf("%w: oauth response missing access_token", ErrServiceTokenFailed) + } + + // 更新 cache。expires_in 缺省(0)時保守用 serviceTokenRefreshSkew * 2 當下限, + // 避免把過期時間設成「現在」導致每次都 miss。 + expiresIn := time.Duration(parsed.ExpiresIn) * time.Second + if expiresIn <= serviceTokenRefreshSkew { + expiresIn = 2 * serviceTokenRefreshSkew + } + c.cachedToken = parsed.AccessToken + c.cachedTokenExp = c.now().UTC().Add(expiresIn) + + c.logger.DebugContext(ctx, "fileaccess.service_token.refreshed", + slog.String("scope", parsed.Scope), + slog.Int("expires_in_sec", parsed.ExpiresIn), + ) + return parsed.AccessToken, nil +} + +// ========================================================================== +// IssueDownloadToken — 打 MC POST /file-access/download-tokens(Issue)簽 fdt_ token +// ========================================================================== + +// issueRequest 是 MC Issue 的 request body(ADR-017 §10 實測契約)。 +type issueRequest struct { + TenantID string `json:"tenant_id"` + UserID string `json:"user_id"` + ObjectKey string `json:"object_key"` + Method string `json:"method"` + ExpiresInSeconds int `json:"expires_in_seconds"` +} + +// issueResponse 是 MC Issue 的 response shape(ADR-017 §10.3 實測)。 +type issueResponse struct { + Token string `json:"token"` // fdt_ + TokenType string `json:"token_type"` // "file_download" + ExpiresAt string `json:"expires_at"` // RFC3339 + Scope string `json:"scope"` // "files:download.read" +} + +// IssueDownloadToken 對指定 (userID, objectKey) 簽一個 MC download token(fdt_)。 +// +// 流程(ADR-017 §10.3 e2e 藍本): +// 1. GetServiceToken — 拿(或重用)MC service token +// 2. 帶該 token + tenant/user/object_key/method/expires_in 打 MC Issue +// 3. 回 IssuedDownloadToken(Token / ExpiresAt / Scope) +func (c *client) IssueDownloadToken(ctx context.Context, userID, objectKey string) (*IssuedDownloadToken, error) { + if userID == "" { + return nil, fmt.Errorf("%w: userID is required", ErrIssueTokenFailed) + } + if objectKey == "" { + return nil, fmt.Errorf("%w: objectKey is required", ErrIssueTokenFailed) + } + + serviceToken, err := c.GetServiceToken(ctx) + if err != nil { + return nil, err // 已是 ErrServiceTokenFailed + } + + bodyJSON, err := json.Marshal(issueRequest{ + TenantID: c.tenantID, + UserID: userID, + ObjectKey: objectKey, + Method: issueMethod, + ExpiresInSeconds: c.ttlSeconds, + }) + if err != nil { + return nil, fmt.Errorf("%w: marshal issue request: %v", ErrIssueTokenFailed, err) + } + + endpoint := c.mcBaseURL + issuePath + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(bodyJSON)) + if err != nil { + return nil, fmt.Errorf("%w: build issue request: %v", ErrIssueTokenFailed, err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+serviceToken) + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: do issue request: %v", ErrIssueTokenFailed, err) + } + defer resp.Body.Close() + + body, readErr := io.ReadAll(io.LimitReader(resp.Body, errorBodyReadCap)) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: issue status=%d body=%s", ErrIssueTokenFailed, + resp.StatusCode, truncate(string(body))) + } + if readErr != nil { + return nil, fmt.Errorf("%w: read issue body: %v", ErrIssueTokenFailed, readErr) + } + + var parsed issueResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, fmt.Errorf("%w: parse issue body: %v", ErrIssueTokenFailed, err) + } + if parsed.Token == "" { + return nil, fmt.Errorf("%w: issue response missing token", ErrIssueTokenFailed) + } + + // expires_at 解析失敗不 hard fail(token 本身可用,TTL 只是給 client 參考); + // 解析成功則回填 UTC。 + var expiresAt time.Time + if parsed.ExpiresAt != "" { + if t, perr := time.Parse(time.RFC3339, parsed.ExpiresAt); perr == nil { + expiresAt = t.UTC() + } else { + c.logger.WarnContext(ctx, "fileaccess.issue.expires_at_parse_failed", + slog.String("raw", parsed.ExpiresAt), + slog.String("err", perr.Error()), + ) + } + } + + c.logger.InfoContext(ctx, "fileaccess.issue.success", + slog.String("user_hash", hashShort(userID)), + slog.String("object_key_hash", hashShort(objectKey)), + slog.String("scope", parsed.Scope), + slog.Int("ttl_sec", c.ttlSeconds), + ) + + return &IssuedDownloadToken{ + Token: parsed.Token, + TokenType: parsed.TokenType, + ExpiresAt: expiresAt, + Scope: parsed.Scope, + }, nil +} + +// ========================================================================== +// helpers +// ========================================================================== + +// errorBodyReadCap 是失敗 response 從 body 讀進記憶體的最大量(4KB)—— 避免惡意大 body。 +const errorBodyReadCap = 4 * 1024 + +// truncate 把 error body 截短(log / error message 用),避免超長 / 換行污染 log。 +func truncate(s string) string { + s = strings.ReplaceAll(strings.TrimSpace(s), "\n", " ") + const max = 256 + if len(s) > max { + return s[:max] + "...(truncated)" + } + return s +} + +// hashShort 對輸入做 SHA-256 取前 8 hex char,給 log 用(PII / object_key 保護)。 +// +// 不存原始 user_id / object_key 進 log,避免 log file 洩漏 OIDC sub 或 storage 路徑。 +// 對齊 internal/conversion.hashUserID 的遮罩慣例。 +func hashShort(s string) string { + if s == "" { + return "" + } + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:])[:8] +} diff --git a/visionA-backend/internal/fileaccess/client_test.go b/visionA-backend/internal/fileaccess/client_test.go new file mode 100644 index 0000000..e224711 --- /dev/null +++ b/visionA-backend/internal/fileaccess/client_test.go @@ -0,0 +1,360 @@ +package fileaccess + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" +) + +// ========================================================================== +// 測試輔助 +// ========================================================================== + +// newTestClient 用 httptest server base URL 建一個 *client(拿具體型別,方便測 cache)。 +func newTestClient(t *testing.T, baseURL string, ttlSec int) *client { + t.Helper() + iss, err := NewClient(Opts{ + MCBaseURL: baseURL, + ServiceClientID: "test-client-id", + ServiceClientSecret: "test-secret", + TenantID: "test-tenant", + DownloadTokenTTLSeconds: ttlSec, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + c, ok := iss.(*client) + if !ok { + t.Fatalf("NewClient did not return *client, got %T", iss) + } + return c +} + +// ========================================================================== +// NewClient +// ========================================================================== + +func TestNewClient_returnsErrWhenConfigIncomplete(t *testing.T) { + cases := map[string]Opts{ + "missing MCBaseURL": {ServiceClientID: "c", ServiceClientSecret: "s", TenantID: "t"}, + "missing clientID": {MCBaseURL: "http://mc", ServiceClientSecret: "s", TenantID: "t"}, + "missing clientSecret": {MCBaseURL: "http://mc", ServiceClientID: "c", TenantID: "t"}, + "missing tenant": {MCBaseURL: "http://mc", ServiceClientID: "c", ServiceClientSecret: "s"}, + } + for name, opts := range cases { + t.Run(name, func(t *testing.T) { + _, err := NewClient(opts) + if !errors.Is(err, ErrConfigIncomplete) { + t.Fatalf("want ErrConfigIncomplete, got %v", err) + } + }) + } +} + +func TestNewClient_defaultsTTLWhenNonPositive(t *testing.T) { + iss, err := NewClient(Opts{ + MCBaseURL: "http://mc", ServiceClientID: "c", ServiceClientSecret: "s", TenantID: "t", + DownloadTokenTTLSeconds: 0, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + if got := iss.(*client).ttlSeconds; got != defaultDownloadTokenTTLSeconds { + t.Fatalf("ttl default: want %d, got %d", defaultDownloadTokenTTLSeconds, got) + } +} + +// ========================================================================== +// GetServiceToken +// ========================================================================== + +func TestGetServiceToken_successAndSendsClientCredentials(t *testing.T) { + var gotGrant, gotScope, gotClientID, gotSecret string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != oauthTokenPath { + t.Errorf("unexpected path %s", r.URL.Path) + } + if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/x-www-form-urlencoded") { + t.Errorf("want form content-type, got %s", ct) + } + body, _ := io.ReadAll(r.Body) + form, _ := url.ParseQuery(string(body)) + gotGrant = form.Get("grant_type") + gotScope = form.Get("scope") + gotClientID = form.Get("client_id") + gotSecret = form.Get("client_secret") + writeJSON(w, http.StatusOK, oauthTokenResponse{ + AccessToken: "svc-access-token", TokenType: "Bearer", ExpiresIn: 3600, + Scope: downloadDelegateScope, + }) + })) + defer srv.Close() + + c := newTestClient(t, srv.URL, 120) + tok, err := c.GetServiceToken(context.Background()) + if err != nil { + t.Fatalf("GetServiceToken: %v", err) + } + if tok != "svc-access-token" { + t.Fatalf("token: want svc-access-token, got %s", tok) + } + if gotGrant != "client_credentials" { + t.Errorf("grant_type: want client_credentials, got %s", gotGrant) + } + if gotScope != downloadDelegateScope { + t.Errorf("scope: want %s, got %s", downloadDelegateScope, gotScope) + } + if gotClientID != "test-client-id" || gotSecret != "test-secret" { + t.Errorf("client creds not sent: id=%s secret=%s", gotClientID, gotSecret) + } +} + +func TestGetServiceToken_cachesTokenAcrossCalls(t *testing.T) { + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + writeJSON(w, http.StatusOK, oauthTokenResponse{ + AccessToken: "svc-access-token", TokenType: "Bearer", ExpiresIn: 3600, + }) + })) + defer srv.Close() + + c := newTestClient(t, srv.URL, 120) + for i := 0; i < 3; i++ { + if _, err := c.GetServiceToken(context.Background()); err != nil { + t.Fatalf("call %d: %v", i, err) + } + } + if got := atomic.LoadInt32(&hits); got != 1 { + t.Fatalf("want 1 oauth hit (cached), got %d", got) + } +} + +func TestGetServiceToken_refetchesWhenExpired(t *testing.T) { + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + // expires_in 短到一定要重拿 + writeJSON(w, http.StatusOK, oauthTokenResponse{ + AccessToken: "svc-access-token", TokenType: "Bearer", ExpiresIn: 10, + }) + })) + defer srv.Close() + + c := newTestClient(t, srv.URL, 120) + // 用可控時鐘:第一次 now=t0;第二次 now=t0+1h(必然過期) + base := time.Date(2026, 6, 7, 0, 0, 0, 0, time.UTC) + var cur time.Time = base + c.now = func() time.Time { return cur } + + if _, err := c.GetServiceToken(context.Background()); err != nil { + t.Fatalf("first call: %v", err) + } + cur = base.Add(time.Hour) + if _, err := c.GetServiceToken(context.Background()); err != nil { + t.Fatalf("second call: %v", err) + } + if got := atomic.LoadInt32(&hits); got != 2 { + t.Fatalf("want 2 oauth hits (refetch after expiry), got %d", got) + } +} + +func TestGetServiceToken_failsOnNon200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid_client"}`)) + })) + defer srv.Close() + + c := newTestClient(t, srv.URL, 120) + _, err := c.GetServiceToken(context.Background()) + if !errors.Is(err, ErrServiceTokenFailed) { + t.Fatalf("want ErrServiceTokenFailed, got %v", err) + } +} + +func TestGetServiceToken_failsOnMissingAccessToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, oauthTokenResponse{TokenType: "Bearer", ExpiresIn: 3600}) + })) + defer srv.Close() + + c := newTestClient(t, srv.URL, 120) + _, err := c.GetServiceToken(context.Background()) + if !errors.Is(err, ErrServiceTokenFailed) { + t.Fatalf("want ErrServiceTokenFailed, got %v", err) + } +} + +// ========================================================================== +// IssueDownloadToken +// ========================================================================== + +func TestIssueDownloadToken_successFullChain(t *testing.T) { + var issueAuth string + var issueBody issueRequest + expiresAt := time.Date(2026, 6, 7, 12, 2, 0, 0, time.UTC) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case oauthTokenPath: + writeJSON(w, http.StatusOK, oauthTokenResponse{ + AccessToken: "svc-access-token", TokenType: "Bearer", ExpiresIn: 3600, + }) + case issuePath: + issueAuth = r.Header.Get("Authorization") + _ = json.NewDecoder(r.Body).Decode(&issueBody) + writeJSON(w, http.StatusOK, issueResponse{ + Token: "fdt_abc123", TokenType: "file_download", + ExpiresAt: expiresAt.Format(time.RFC3339), Scope: "files:download.read", + }) + default: + t.Errorf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + c := newTestClient(t, srv.URL, 120) + res, err := c.IssueDownloadToken(context.Background(), "user-oidc-sub", "models/u/j.nef") + if err != nil { + t.Fatalf("IssueDownloadToken: %v", err) + } + + if res.Token != "fdt_abc123" { + t.Errorf("token: want fdt_abc123, got %s", res.Token) + } + if res.TokenType != "file_download" { + t.Errorf("token_type: want file_download, got %s", res.TokenType) + } + if res.Scope != "files:download.read" { + t.Errorf("scope: want files:download.read, got %s", res.Scope) + } + if !res.ExpiresAt.Equal(expiresAt) { + t.Errorf("expires_at: want %v, got %v", expiresAt, res.ExpiresAt) + } + // Issue 必須帶 service token 當 Bearer + if issueAuth != "Bearer svc-access-token" { + t.Errorf("issue auth header: want 'Bearer svc-access-token', got %q", issueAuth) + } + // Issue body 契約(ADR-017 §10) + if issueBody.TenantID != "test-tenant" { + t.Errorf("tenant_id: want test-tenant, got %s", issueBody.TenantID) + } + if issueBody.UserID != "user-oidc-sub" { + t.Errorf("user_id: want user-oidc-sub, got %s", issueBody.UserID) + } + if issueBody.ObjectKey != "models/u/j.nef" { + t.Errorf("object_key: want models/u/j.nef, got %s", issueBody.ObjectKey) + } + if issueBody.Method != "GET" { + t.Errorf("method: want GET, got %s", issueBody.Method) + } + if issueBody.ExpiresInSeconds != 120 { + t.Errorf("expires_in_seconds: want 120, got %d", issueBody.ExpiresInSeconds) + } +} + +func TestIssueDownloadToken_validatesArgs(t *testing.T) { + c := newTestClient(t, "http://unused", 120) + if _, err := c.IssueDownloadToken(context.Background(), "", "key"); !errors.Is(err, ErrIssueTokenFailed) { + t.Errorf("empty userID: want ErrIssueTokenFailed, got %v", err) + } + if _, err := c.IssueDownloadToken(context.Background(), "user", ""); !errors.Is(err, ErrIssueTokenFailed) { + t.Errorf("empty objectKey: want ErrIssueTokenFailed, got %v", err) + } +} + +func TestIssueDownloadToken_failsWhenServiceTokenFails(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // oauth 永遠 500 + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + c := newTestClient(t, srv.URL, 120) + _, err := c.IssueDownloadToken(context.Background(), "user", "key") + if !errors.Is(err, ErrServiceTokenFailed) { + t.Fatalf("want ErrServiceTokenFailed (propagated), got %v", err) + } +} + +func TestIssueDownloadToken_failsOnIssueNon200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case oauthTokenPath: + writeJSON(w, http.StatusOK, oauthTokenResponse{AccessToken: "tok", ExpiresIn: 3600}) + case issuePath: + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + } + })) + defer srv.Close() + + c := newTestClient(t, srv.URL, 120) + _, err := c.IssueDownloadToken(context.Background(), "user", "key") + if !errors.Is(err, ErrIssueTokenFailed) { + t.Fatalf("want ErrIssueTokenFailed, got %v", err) + } +} + +func TestIssueDownloadToken_failsOnMissingToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case oauthTokenPath: + writeJSON(w, http.StatusOK, oauthTokenResponse{AccessToken: "tok", ExpiresIn: 3600}) + case issuePath: + writeJSON(w, http.StatusOK, issueResponse{TokenType: "file_download"}) // 無 token + } + })) + defer srv.Close() + + c := newTestClient(t, srv.URL, 120) + _, err := c.IssueDownloadToken(context.Background(), "user", "key") + if !errors.Is(err, ErrIssueTokenFailed) { + t.Fatalf("want ErrIssueTokenFailed, got %v", err) + } +} + +func TestIssueDownloadToken_toleratesBadExpiresAt(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case oauthTokenPath: + writeJSON(w, http.StatusOK, oauthTokenResponse{AccessToken: "tok", ExpiresIn: 3600}) + case issuePath: + writeJSON(w, http.StatusOK, issueResponse{ + Token: "fdt_x", TokenType: "file_download", ExpiresAt: "not-a-date", + }) + } + })) + defer srv.Close() + + c := newTestClient(t, srv.URL, 120) + res, err := c.IssueDownloadToken(context.Background(), "user", "key") + if err != nil { + t.Fatalf("should tolerate bad expires_at, got err %v", err) + } + if res.Token != "fdt_x" { + t.Errorf("token: want fdt_x, got %s", res.Token) + } + if !res.ExpiresAt.IsZero() { + t.Errorf("bad expires_at should leave zero time, got %v", res.ExpiresAt) + } +} + +// ========================================================================== +// helpers(test-local) +// ========================================================================== + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/visionA-backend/internal/model/model.go b/visionA-backend/internal/model/model.go index c32af5f..cff3293 100644 --- a/visionA-backend/internal/model/model.go +++ b/visionA-backend/internal/model/model.go @@ -57,6 +57,18 @@ type Model struct { FileSize int64 `json:"fileSize"` FileChecksum string `json:"fileChecksum,omitempty"` // sha256 hex + // FAAObjectKey 是該 model 在 File Access Agent 上的 object key(ADR-017 (a) B1)。 + // + // 只有「轉檔→promote 進 FAA」類 model(Source=converted)有值——promote 時由 + // PromoteToModels 寫入(= converter promote 的 target_object_key,命名 models/{userID}/{jobID}.nef)。 + // 上傳類 model(Source=uploaded)只在 visionA 自己 storage、不在 FAA,此欄位留空。 + // + // model download endpoint(GET /api/models/:id/download)用此欄位(非 StorageKey)去 MC + // Issue download token + 組 FAA URL;留空時回 501(第一階段不支援上傳類 FAA 直連)。 + // + // nullable:DB 為 NULL(database.md §2.3 待補欄位,見回報);JSON `-` 完全不序列化到 API 回應,不向前端揭露 FAA 內部 object key。 + FAAObjectKey string `json:"-"` // 不對前端揭露(內部 storage key,ADR-017 決策 2 防曝露) + // 模型 metadata(可選) TargetChip string `json:"targetChip,omitempty"` InputShape []int `json:"inputShape,omitempty"`