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