對齊 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>
428 lines
16 KiB
Go
428 lines
16 KiB
Go
// 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_<base64url>`(不是 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_<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 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]
|
||
}
|