feat(visionA-backend): Phase 0.9 模型庫存取 — FAA delegated download token(B1+B2)

對齊 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) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-06-07 04:06:09 +08:00
parent 88a8ddbd82
commit c63886a194
13 changed files with 1339 additions and 0 deletions

View File

@ -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 §10stage e2e 實測藍本)。
#
# 下載鏈visionA 用 service client 打 MC /oauth/tokenscope files:download.delegate
# → 打 MC POST /file-access/download-tokensIssue簽 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不帶結尾斜線
# stagehttps://stage-9527.innovedus.com:7850
VISIONA_FILE_ACCESS_MC_BASE_URL=
# Service clientclient_credentials grant打 MC /oauth/token + Issue download token
# ⚠️ 技術債:第一階段 PoC 共用 FAA service clientstage4242ba63099d4f318dd3f143d27ef4c5
VISIONA_FILE_ACCESS_SERVICE_CLIENT_ID=
# Service client secret
# ⚠️ 不可 commitprod 用 Secrets Manager / Vaultlog 永遠不印此值全文
# ⚠️ 技術債:第一階段 PoC 共用 FAA service client secret正式上線前換 visionA 專屬 client
VISIONA_FILE_ACCESS_SERVICE_CLIENT_SECRET=
# 簽 download token 時帶給 MC 的 tenant_id須與 FAA validate 的 tenant 一致)
# stage732270c0-449c-489c-bfad-321e9bf89b3d
VISIONA_FILE_ACCESS_TENANT_ID=
# File Access Agent 對外 base URL不帶結尾斜線— 組回給 Client 的 download_url 用
# stagehttps://stage-9527.innovedus.com:5081
VISIONA_FILE_ACCESS_FAA_BASE_URL=
# download token 有效期(秒)— ADR-017 Q2 區間 60300s預設 120
VISIONA_FILE_ACCESS_DOWNLOAD_TOKEN_TTL_SECONDS=120

View File

@ -20,6 +20,8 @@ go.work.sum
bin/
dist/
build/
# go build 產生的 api-server 二進位(根目錄錨定,避免誤排除其他同名路徑)
/api-server
# ---- 環境變數 / 密鑰 -----------------------------------------------------
.env

View File

@ -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) B1promote 寫入的 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,
}

View File

@ -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 自動回 501modelsDownloadHandler 處理)。
//
// ⚠️ 技術債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.8nil 時 /api/conversion/* 回 501
FileAccessIssuer: fileAccessIssuer, // Phase 0.9nil 時 /api/models/:id/download 回 501
FAABaseURL: cfg.FileAccess.FAABaseURL,
MaxUploadSizeMB: cfg.Model.MaxSizeMB,
CORSAllowedOrigins: cfg.CORS.AllowedOrigins,
RelayPublicURL: cfg.Server.RelayPublicURL,

View File

@ -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

View File

@ -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 dataADR-017 (a) 決策 2
//
// Clientlocal-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 tokenfdt_放 Authorization: Bearer。
Token string `json:"token"`
// ExpiresAt 是 token 到期時間RFC3339 / UTCMC 沒回填時為零值(前端不應依賴)。
ExpiresAt time.Time `json:"expires_at,omitempty"`
}
// modelsDownloadHandler 實作 GET /api/models/:id/downloadADR-017 (a) 模型庫直連 FAA 下載)。
//
// 流程(對齊 adr-017 §10.3 e2e 藍本 + 決策 2
// 1. ownership 驗(第一階段 owner-onlyB 分享是後續階段)
// 2. model 必須有 FAAObjectKey= 轉檔→promote 類);上傳類(空 key回 501Q7 範圍框死)
// 3. FileAccessIssuer 簽 MC download tokenfdt_
// 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-onlyB 分享後續階段);非 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 tokenfdt_。userID = OIDC subMC 真實 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}.nefuserID(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, "/")
}

View File

@ -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 必須拿到正確的 userIDOIDC sub+ objectKeyFAAObjectKey非 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")
}

View File

@ -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 §10stage 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 issuermodel 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不帶結尾斜線
// stagehttps://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 一致)。
// stage732270c0-449c-489c-bfad-321e9bf89b3d
// 對齊 VISIONA_FILE_ACCESS_TENANT_ID。
TenantID string
// FAABaseURL 是 File Access Agent 對外 base URL不帶結尾斜線用來組回給 Client 的
// download_url`{FAABaseURL}/files/{object_key}`)。
// stagehttps://stage-9527.innovedus.com:5081
// 對齊 VISIONA_FILE_ACCESS_FAA_BASE_URL。
FAABaseURL string
// DownloadTokenTTLSeconds 是簽 download token 時帶給 MC 的 expires_in_seconds。
// ADR-017 Q2 區間 60300s預設 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.1visionA 端撤回

View File

@ -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 clientADR-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),
},
}
}

View File

@ -84,6 +84,10 @@ type ModelRecord struct {
TargetChip string
Source string // 永遠 "converted"
SourceJobID string
// FAAObjectKey 是該 model 在 FAA 上的 object keyADR-017 (a) B1
// = converter promote 的 target_object_keybuildTargetObjectKeymodels/{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
//
// FAAObjectKeyADR-017 (a) B1promote 把 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,
}

View File

@ -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 tokenfdt_不負責真的打 FAA 下載——下載由 Clientlocal-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"
)
// ==========================================================================
// Errorssentinel
// ==========================================================================
var (
// ErrServiceTokenFailed 表示打 MC /oauth/token 拿 service token 失敗
// (網路 / 4xx / 5xx / 解析。callerhandler對外 mask 成 download_unavailable / 502。
ErrServiceTokenFailed = errors.New("fileaccess: service token request failed")
// ErrIssueTokenFailed 表示打 MC IssuePOST /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_<base64url>`(不是 JWTcaller 直接回給 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 真實 userOIDC subvisionA 登入走 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 為 optionalnil 自動填預設)— 方便 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 → 用 defaultDownloadTokenTTLSeconds120ADR-017 Q2
DownloadTokenTTLSeconds int
// HTTPClient 為 optionalnil 用預設timeout 10s。MC call 是輕量 JSON POST。
HTTPClient *http.Client
// Now 為 optionalnil 用 time.Nowservice token cache 過期判定用)。
Now func() time.Time
// Logger 為 optionalnil 用 slog.Default()。
Logger *slog.Logger
}
// ==========================================================================
// 內部常數
// ==========================================================================
const (
// downloadDelegateScope 是打 MC /oauth/token 時要的 scopeADR-017 §10.2/§10.3)。
downloadDelegateScope = "files:download.delegate"
// oauthTokenPath / issuePath 是 MC 的 endpoint pathADR-017 §10
oauthTokenPath = "/oauth/token"
issuePath = "/file-access/download-tokens"
// defaultDownloadTokenTTLSeconds 是 download token 預設有效期(秒)—— ADR-017 Q2傾向 120s
defaultDownloadTokenTTLSeconds = 120
// defaultHTTPTimeout 是 MC call 的整體 timeoutOAuth / Issue 都是輕量 JSON
defaultHTTPTimeout = 10 * time.Second
// serviceTokenRefreshSkew 是 service token cache 提前刷新的安全邊際——
// token 還有少於這個秒數就視為過期、重拿,避免「拿了正好過期的 token」。
serviceTokenRefreshSkew = 30 * time.Second
// issueMethod 是簽 download token 時帶給 MC 的 methodFAA 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 cacheclient_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/tokenclient_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。
//
// 帶簡單 cachetoken 未過期(含 serviceTokenRefreshSkew 安全邊際)直接回 cache
// 否則打 MC /oauth/token 重拿。goroutine-safemutex 保護 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 secretbody 可能含 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-tokensIssue簽 fdt_ token
// ==========================================================================
// issueRequest 是 MC Issue 的 request bodyADR-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 shapeADR-017 §10.3 實測)。
type issueResponse struct {
Token string `json:"token"` // fdt_<base64url>
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 tokenfdt_
//
// 流程ADR-017 §10.3 e2e 藍本):
// 1. GetServiceToken — 拿或重用MC service token
// 2. 帶該 token + tenant/user/object_key/method/expires_in 打 MC Issue
// 3. 回 IssuedDownloadTokenToken / 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 failtoken 本身可用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]
}

View File

@ -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)
}
}
// ==========================================================================
// helperstest-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)
}

View File

@ -57,6 +57,18 @@ type Model struct {
FileSize int64 `json:"fileSize"`
FileChecksum string `json:"fileChecksum,omitempty"` // sha256 hex
// FAAObjectKey 是該 model 在 File Access Agent 上的 object keyADR-017 (a) B1
//
// 只有「轉檔→promote 進 FAA」類 modelSource=converted有值——promote 時由
// PromoteToModels 寫入(= converter promote 的 target_object_key命名 models/{userID}/{jobID}.nef
// 上傳類 modelSource=uploaded只在 visionA 自己 storage、不在 FAA此欄位留空。
//
// model download endpointGET /api/models/:id/download用此欄位非 StorageKey去 MC
// Issue download token + 組 FAA URL留空時回 501第一階段不支援上傳類 FAA 直連)。
//
// nullableDB 為 NULLdatabase.md §2.3 待補欄位見回報JSON `-` 完全不序列化到 API 回應,不向前端揭露 FAA 內部 object key。
FAAObjectKey string `json:"-"` // 不對前端揭露(內部 storage keyADR-017 決策 2 防曝露)
// 模型 metadata可選
TargetChip string `json:"targetChip,omitempty"`
InputShape []int `json:"inputShape,omitempty"`