jim800121chen c63886a194 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>
2026-06-07 04:06:09 +08:00

428 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

// Package 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]
}