diff --git a/pkg/accessreview/drivers/anthropic.go b/pkg/accessreview/drivers/anthropic.go new file mode 100644 index 000000000..6223b2d69 --- /dev/null +++ b/pkg/accessreview/drivers/anthropic.go @@ -0,0 +1,159 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package drivers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "go.probo.inc/probo/pkg/coredata" +) + +type AnthropicDriver struct { + httpClient *http.Client +} + +var _ Driver = (*AnthropicDriver)(nil) + +const ( + anthropicUsersEndpoint = "https://api.anthropic.com/v1/organizations/users" + // anthropicAPIVersion is the required anthropic-version header value + // sent on every Admin API request. Shared with the name resolver. + anthropicAPIVersion = "2023-06-01" +) + +type anthropicUsersResponse struct { + Data []struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + AddedAt string `json:"added_at"` + } `json:"data"` + HasMore bool `json:"has_more"` + LastID string `json:"last_id"` +} + +func NewAnthropicDriver(httpClient *http.Client) *AnthropicDriver { + return &AnthropicDriver{ + httpClient: httpClient, + } +} + +func (d *AnthropicDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + var ( + records []AccountRecord + afterID string + ) + + for range maxPaginationPages { + resp, err := d.fetchUsers(ctx, afterID) + if err != nil { + return nil, err + } + + for _, u := range resp.Data { + record := AccountRecord{ + Email: u.Email, + FullName: u.Name, + Role: anthropicRole(u.Role), + IsAdmin: u.Role == "admin", + ExternalID: u.ID, + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessEntryAuthMethodUnknown, + AccountType: coredata.AccessEntryAccountTypeUser, + } + + // added_at is an RFC 3339 datetime string; ignore parse + // failures rather than dropping the record. + if u.AddedAt != "" { + if t, err := time.Parse(time.RFC3339, u.AddedAt); err == nil { + record.CreatedAt = &t + } + } + + if record.Email != "" { + records = append(records, record) + } + } + + if !resp.HasMore || resp.LastID == "" { + return records, nil + } + + afterID = resp.LastID + } + + return nil, fmt.Errorf("cannot list all anthropic accounts: %w", ErrPaginationLimitReached) +} + +func (d *AnthropicDriver) fetchUsers(ctx context.Context, afterID string) (*anthropicUsersResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, anthropicUsersEndpoint, nil) + if err != nil { + return nil, fmt.Errorf("cannot create anthropic users request: %w", err) + } + + q := req.URL.Query() + q.Set("limit", "100") + + if afterID != "" { + q.Set("after_id", afterID) + } + + req.URL.RawQuery = q.Encode() + + req.Header.Set("Accept", "application/json") + req.Header.Set("anthropic-version", anthropicAPIVersion) + + httpResp, err := d.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot execute anthropic users request: %w", err) + } + + defer func() { + _ = httpResp.Body.Close() + }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("cannot fetch anthropic users: unexpected status %d", httpResp.StatusCode) + } + + var resp anthropicUsersResponse + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return nil, fmt.Errorf("cannot decode anthropic users response: %w", err) + } + + return &resp, nil +} + +func anthropicRole(role string) string { + switch role { + case "admin": + return "Admin" + case "billing": + return "Billing" + case "developer": + return "Developer" + case "claude_code_user": + return "Claude Code User" + case "user": + return "User" + default: + return role + } +} diff --git a/pkg/accessreview/drivers/anthropic_test.go b/pkg/accessreview/drivers/anthropic_test.go new file mode 100644 index 000000000..c7f4f9c54 --- /dev/null +++ b/pkg/accessreview/drivers/anthropic_test.go @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package drivers + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAnthropicDriver(t *testing.T) { + t.Parallel() + + rec := newRecorder(t, "testdata/anthropic", "ANTHROPIC_ADMIN_TOKEN") + // Anthropic authenticates via x-api-key, not Authorization: Bearer. + client := newVCRClientWithHeader(rec, "x-api-key", os.Getenv("ANTHROPIC_ADMIN_TOKEN")) + + driver := NewAnthropicDriver(client) + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + assert.Len(t, records, 3) + + first := records[0] + assert.NotEmpty(t, first.Email) + assert.NotEmpty(t, first.FullName) + assert.NotEmpty(t, first.ExternalID) + assert.Equal(t, "User", first.Role) + assert.False(t, first.IsAdmin) + assert.NotNil(t, first.CreatedAt) + + assert.Equal(t, "Developer", records[1].Role) + assert.False(t, records[1].IsAdmin) + + admin := records[2] + assert.Equal(t, "Admin", admin.Role) + assert.True(t, admin.IsAdmin) +} + +func TestAnthropicRole(t *testing.T) { + t.Parallel() + + cases := []struct { + in string + want string + }{ + {"admin", "Admin"}, + {"billing", "Billing"}, + {"developer", "Developer"}, + {"claude_code_user", "Claude Code User"}, + {"user", "User"}, + {"unknown_future_role", "unknown_future_role"}, + } + + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, c.want, anthropicRole(c.in)) + }) + } +} diff --git a/pkg/accessreview/drivers/name_resolver.go b/pkg/accessreview/drivers/name_resolver.go index 974dc3349..06d990193 100644 --- a/pkg/accessreview/drivers/name_resolver.go +++ b/pkg/accessreview/drivers/name_resolver.go @@ -437,6 +437,55 @@ func (r *openaiNameResolver) ResolveInstanceName(ctx context.Context) (string, e return resp.Name, nil } +// anthropicNameResolver resolves the Anthropic organization name via the +// Admin API /v1/organizations/me endpoint, which returns the org an +// admin key belongs to. +type anthropicNameResolver struct { + httpClient *http.Client +} + +func NewAnthropicNameResolver(httpClient *http.Client) NameResolver { + return &anthropicNameResolver{httpClient: httpClient} +} + +func (r *anthropicNameResolver) ResolveInstanceName(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + "https://api.anthropic.com/v1/organizations/me", + nil, + ) + if err != nil { + return "", fmt.Errorf("cannot create anthropic organization request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("anthropic-version", anthropicAPIVersion) + + httpResp, err := r.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("cannot execute anthropic organization request: %w", err) + } + + defer func() { _ = httpResp.Body.Close() }() + + // Best-effort: a non-2xx (e.g. a revoked admin key) must not make the + // source-name worker retry forever. Give up gracefully and keep the + // generic source name; a dead key surfaces on the next ListAccounts. + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return "", nil + } + + var resp struct { + Name string `json:"name"` + } + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return "", fmt.Errorf("cannot decode anthropic organization response: %w", err) + } + + return resp.Name, nil +} + // sentryNameResolver resolves the Sentry organization name. type sentryNameResolver struct { httpClient *http.Client diff --git a/pkg/accessreview/drivers/testdata/anthropic.yaml b/pkg/accessreview/drivers/testdata/anthropic.yaml new file mode 100644 index 000000000..6fcb44487 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/anthropic.yaml @@ -0,0 +1,39 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.anthropic.com + form: + limit: + - "100" + headers: + Accept: + - application/json + Anthropic-Version: + - "2023-06-01" + url: https://api.anthropic.com/v1/organizations/users?limit=100 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"data":[{"id":"user_01WCz1FkmYMm4gnmykNKUu3Q","type":"user","email":"jane@example.com","name":"Jane Doe","role":"user","added_at":"2024-10-30T23:58:27.427722Z"},{"id":"user_01AbCdEfGhIjKlMnOpQrStUv","type":"user","email":"alex@example.com","name":"Alex Martin","role":"developer","added_at":"2025-02-14T09:12:03.001000Z"},{"id":"user_01ZyXwVuTsRqPoNmLkJiHgFe","type":"user","email":"john@example.com","name":"John Smith","role":"admin","added_at":"2026-01-05T16:40:51.500000Z"}],"has_more":false,"first_id":"user_01WCz1FkmYMm4gnmykNKUu3Q","last_id":"user_01ZyXwVuTsRqPoNmLkJiHgFe"}' + headers: + Content-Type: + - application/json + Date: + - Thu, 28 May 2026 12:55:02 GMT + Request-Id: + - req_011CRgSyntheticExampleId01 + Server: + - cloudflare + status: 200 OK + code: 200 + duration: 199.11175ms diff --git a/pkg/accessreview/drivers/vcr_test.go b/pkg/accessreview/drivers/vcr_test.go index 465eecb8c..b8ba7c3dc 100644 --- a/pkg/accessreview/drivers/vcr_test.go +++ b/pkg/accessreview/drivers/vcr_test.go @@ -50,6 +50,11 @@ func newRecorder(t *testing.T, cassettePath string, envVar string) *recorder.Rec )), recorder.WithHook(func(i *cassette.Interaction) error { i.Request.Headers.Del("Authorization") + // Providers like Anthropic authenticate via x-api-key rather + // than Authorization; strip it too so a re-record never + // persists a raw key. + i.Request.Headers.Del("X-Api-Key") + return nil }, recorder.BeforeSaveHook), ) @@ -110,3 +115,37 @@ func newVCRClient(rec *recorder.Recorder, authValue string) *http.Client { return &http.Client{Transport: transport} } + +// headerRoundTripper injects a value into an arbitrary request header. +// Used for providers (e.g. Anthropic) that authenticate with a custom +// header instead of Authorization. +type headerRoundTripper struct { + header string + value string + transport http.RoundTripper +} + +func (rt *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if rt.value != "" { + req.Header.Set(rt.header, rt.value) + } + + return rt.transport.RoundTrip(req) +} + +// newVCRClientWithHeader is like newVCRClient but injects the auth value +// into a named header (e.g. "x-api-key") instead of Authorization, for +// providers that do not use Bearer auth. The header is stripped from the +// cassette by newRecorder's BeforeSave hook. +func newVCRClientWithHeader(rec *recorder.Recorder, header, value string) *http.Client { + transport := rec.GetDefaultClient().Transport + if value != "" { + transport = &headerRoundTripper{ + header: header, + value: value, + transport: transport, + } + } + + return &http.Client{Transport: transport} +}