feat(ai): authenticate to the Claude API with Workload Identity Federation
정적 API 키 대신 Workload Identity Federation을 쓴다. 에이전트는 AI VM의 Google 서명 ID 토큰(메타데이터 서버, audience https://api.anthropic.com, format=full)을 POST /v1/oauth/token(jwt-bearer)으로 교환해 단기 access token을 받고 Authorization: Bearer로 호출한다. 토큰은 캐시하고 만료 120초 전에 재교환하며, 401이면 한 번 재교환한다. 규칙·조직·서비스 계정·워크스페이스 ID 4개는 비밀이 아니므로 ai.env 환경 변수로 받고, 비어 있으면 기존 키 파일 경로로 동작한다. /selfcheck가 auth 모드와 실제 교환 결과를 보고해 캠페인 없이 설정을 검증할 수 있다. 테스트: 교환 1회 재사용·401 재교환·만료 전 갱신·거부 응답·불완전 ID 무시.
This commit is contained in:
1 parent
53ff243183
commit
684e70ba60
4 files changed
+387
-13
No files matched your search
@@ -35,6 +35,7 @@ type Client struct {
|
||||
BaseURL string
|
||||
HTTP *http.Client
|
||||
keyFn func() (string, error)
|
||||
fed *Federation // nil = API key from keyFn; set = Workload Identity Federation (takes precedence)
|
||||
|
||||
mu sync.Mutex
|
||||
conv map[string]conversation // response id -> messages (and system prompt) up to and including that assistant turn
|
||||
@@ -60,6 +61,48 @@ func (c *Client) KeyPresent() bool {
|
||||
return err == nil && k != ""
|
||||
}
|
||||
|
||||
// SetFederation switches the client to Workload Identity Federation (ignored when the ids are incomplete).
|
||||
func (c *Client) SetFederation(f *Federation) {
|
||||
if f.Configured() {
|
||||
c.fed = f
|
||||
}
|
||||
}
|
||||
|
||||
// AuthMode is "federation", "api_key" or "none" (reported by /selfcheck).
|
||||
func (c *Client) AuthMode() string {
|
||||
if c.fed != nil {
|
||||
return "federation"
|
||||
}
|
||||
if c.KeyPresent() {
|
||||
return "api_key"
|
||||
}
|
||||
return "none"
|
||||
}
|
||||
|
||||
// Federation returns the active federation config (nil when the API key path is used).
|
||||
func (c *Client) Federation() *Federation { return c.fed }
|
||||
|
||||
// authorize sets the credential header: a federated bearer token, or the API key.
|
||||
func (c *Client) authorize(ctx context.Context, req *http.Request) error {
|
||||
if c.fed != nil {
|
||||
tok, err := c.fed.AccessToken(ctx, c.BaseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("anthropic federation: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
return nil
|
||||
}
|
||||
key, err := c.keyFn()
|
||||
if err != nil {
|
||||
return fmt.Errorf("anthropic key: %w", err)
|
||||
}
|
||||
if key == "" {
|
||||
return fmt.Errorf("anthropic api key is empty (secret selfheal-anthropic-api-key not registered yet) and federation is not configured")
|
||||
}
|
||||
req.Header.Set("x-api-key", key)
|
||||
return nil
|
||||
}
|
||||
|
||||
type message struct {
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"` // string or []block
|
||||
@@ -114,12 +157,12 @@ const forcedNudge = "You must call the submit_diagnosis tool now with your final
|
||||
|
||||
// CreateResponse translates one loop turn to a Messages API call and the answer back to Responses-API items.
|
||||
func (c *Client) CreateResponse(ctx context.Context, req openai.Request) (*openai.Response, error) {
|
||||
key, err := c.keyFn()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("anthropic key: %w", err)
|
||||
}
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("anthropic api key is empty (secret selfheal-anthropic-api-key not registered yet)")
|
||||
if c.fed == nil {
|
||||
if key, err := c.keyFn(); err != nil {
|
||||
return nil, fmt.Errorf("anthropic key: %w", err)
|
||||
} else if key == "" {
|
||||
return nil, fmt.Errorf("anthropic api key is empty (secret selfheal-anthropic-api-key not registered yet) and federation is not configured")
|
||||
}
|
||||
}
|
||||
var msgs []message
|
||||
var system []string
|
||||
@@ -187,16 +230,28 @@ func (c *Client) CreateResponse(ctx context.Context, req openai.Request) (*opena
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(body)
|
||||
hreq, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"/v1/messages", bytes.NewReader(b))
|
||||
send := func() (*http.Response, error) {
|
||||
hreq, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"/v1/messages", bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.authorize(ctx, hreq); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hreq.Header.Set("anthropic-version", apiVersion)
|
||||
hreq.Header.Set("Content-Type", "application/json")
|
||||
return c.HTTP.Do(hreq)
|
||||
}
|
||||
resp, err := send()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hreq.Header.Set("x-api-key", key)
|
||||
hreq.Header.Set("anthropic-version", apiVersion)
|
||||
hreq.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.HTTP.Do(hreq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if resp.StatusCode == 401 && c.fed != nil { // token revoked/expired early: exchange once more
|
||||
resp.Body.Close()
|
||||
c.fed.Invalidate()
|
||||
if resp, err = send(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
|
||||
Reference in new issue
Block a user