Add Anthropic access-review driver and resolver

The driver lists organization members through the Anthropic Admin API
(GET /v1/organizations/users) with cursor pagination, mapping the role
and the RFC 3339 added_at timestamp. The name resolver reads the
organization name from /v1/organizations/me; a non-2xx response (for
example a revoked key) yields no name rather than making the
source-name worker retry forever. Both send the required
anthropic-version header.

Add a VCR test helper that injects the key via x-api-key so the
cassette stays recordable, and strip x-api-key on save. The cassette
holds synthetic members covering the user, developer, and admin roles.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
Aurélien Sibiril
2026-05-28 22:03:51 +02:00
parent f6510e25a1
commit 50093c9d19
5 changed files with 362 additions and 0 deletions

View File

@@ -0,0 +1,159 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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
}
}

View File

@@ -0,0 +1,76 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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))
})
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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}
}