From 427af88f3ae1cda3fa3b9cf07c10bb824197df24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Thu, 28 May 2026 23:44:37 +0200 Subject: [PATCH] Add Cursor access-review driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fetch team members from the Cursor Admin API (GET /teams/members) and map them to access records. The endpoint is not paginated, so a single request returns the whole team; removed members are returned as inactive rather than dropped, and team owners are flagged as admins. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- pkg/accessreview/drivers/cursor.go | 128 ++++++++++++++++++ pkg/accessreview/drivers/cursor_test.go | 89 ++++++++++++ pkg/accessreview/drivers/testdata/cursor.yaml | 32 +++++ pkg/accessreview/drivers/vcr_test.go | 12 ++ 4 files changed, 261 insertions(+) create mode 100644 pkg/accessreview/drivers/cursor.go create mode 100644 pkg/accessreview/drivers/cursor_test.go create mode 100644 pkg/accessreview/drivers/testdata/cursor.yaml diff --git a/pkg/accessreview/drivers/cursor.go b/pkg/accessreview/drivers/cursor.go new file mode 100644 index 000000000..d7900f2af --- /dev/null +++ b/pkg/accessreview/drivers/cursor.go @@ -0,0 +1,128 @@ +// 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" + + "go.probo.inc/probo/pkg/coredata" +) + +type CursorDriver struct { + httpClient *http.Client +} + +var _ Driver = (*CursorDriver)(nil) + +// cursorMembersEndpoint lists every member of the team the admin API key +// belongs to. Cursor's Admin API authenticates with the key as the HTTP +// Basic auth username (handled by the connection transport) and exposes +// no pagination on this endpoint, so a single GET returns the full team. +const cursorMembersEndpoint = "https://api.cursor.com/teams/members" + +type cursorMembersResponse struct { + TeamMembers []struct { + // ID is the stable Cursor member identifier. The Admin API + // returns it as a JSON string (despite the docs labelling it a + // number), so it is decoded as a string and used verbatim. + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + IsRemoved bool `json:"isRemoved"` + } `json:"teamMembers"` +} + +func NewCursorDriver(httpClient *http.Client) *CursorDriver { + return &CursorDriver{ + httpClient: httpClient, + } +} + +func (d *CursorDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, cursorMembersEndpoint, nil) + if err != nil { + return nil, fmt.Errorf("cannot create cursor members request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + httpResp, err := d.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot execute cursor members request: %w", err) + } + + defer func() { + _ = httpResp.Body.Close() + }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("cannot fetch cursor members: unexpected status %d", httpResp.StatusCode) + } + + var resp cursorMembersResponse + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return nil, fmt.Errorf("cannot decode cursor members response: %w", err) + } + + records := make([]AccountRecord, 0, len(resp.TeamMembers)) + for _, m := range resp.TeamMembers { + if m.Email == "" { + continue + } + + // isRemoved is Cursor's only account-status signal, so Active is + // always populated (never nil): a removed member is reported + // inactive rather than dropped, per the AccountRecord contract. + active := !m.IsRemoved + + records = append(records, AccountRecord{ + Email: m.Email, + FullName: m.Name, + Role: cursorRole(m.Role), + Active: &active, + IsAdmin: cursorIsAdmin(m.Role), + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessEntryAuthMethodUnknown, + AccountType: coredata.AccessEntryAccountTypeUser, + ExternalID: m.ID, + }) + } + + return records, nil +} + +// cursorIsAdmin reports whether a Cursor team role carries team +// administration rights. Both paid ("owner") and free-tier +// ("free-owner") owners administer the team. +func cursorIsAdmin(role string) bool { + return role == "owner" || role == "free-owner" +} + +func cursorRole(role string) string { + switch role { + case "owner", "free-owner": + return "Owner" + case "member": + return "Member" + case "removed": + return "Removed" + default: + return role + } +} diff --git a/pkg/accessreview/drivers/cursor_test.go b/pkg/accessreview/drivers/cursor_test.go new file mode 100644 index 000000000..ca231e88b --- /dev/null +++ b/pkg/accessreview/drivers/cursor_test.go @@ -0,0 +1,89 @@ +// 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 TestCursorDriver(t *testing.T) { + t.Parallel() + + rec := newRecorder(t, "testdata/cursor", "CURSOR_ADMIN_TOKEN") + // Cursor authenticates via HTTP Basic auth (the admin key as the + // username). The cassette matcher ignores Authorization, so replay + // needs no auth; the value matters only when re-recording. + client := newVCRClient(rec, basicAuth(os.Getenv("CURSOR_ADMIN_TOKEN"))) + + driver := NewCursorDriver(client) + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + require.Len(t, records, 3) + + member := records[0] + assert.Equal(t, "jane@example.com", member.Email) + assert.Equal(t, "Jane Doe", member.FullName) + assert.Equal(t, "Member", member.Role) + assert.False(t, member.IsAdmin) + // The Cursor Admin API returns the member id as a string; it is used + // verbatim as the stable ExternalID. + assert.Equal(t, "10000001", member.ExternalID) + require.NotNil(t, member.Active) + assert.True(t, *member.Active) + + owner := records[1] + assert.Equal(t, "Owner", owner.Role) + assert.True(t, owner.IsAdmin) + require.NotNil(t, owner.Active) + assert.True(t, *owner.Active) + + // A removed member (role "removed", isRemoved true) is still returned, + // flagged inactive rather than dropped, per the AccountRecord contract. + removed := records[2] + assert.Equal(t, "Removed", removed.Role) + assert.False(t, removed.IsAdmin) + require.NotNil(t, removed.Active) + assert.False(t, *removed.Active) +} + +func TestCursorRole(t *testing.T) { + t.Parallel() + + cases := []struct { + in string + want string + isAdmin bool + }{ + {"owner", "Owner", true}, + {"free-owner", "Owner", true}, + {"member", "Member", false}, + {"removed", "Removed", false}, + {"unknown_future_role", "unknown_future_role", false}, + } + + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, c.want, cursorRole(c.in)) + assert.Equal(t, c.isAdmin, cursorIsAdmin(c.in)) + }) + } +} diff --git a/pkg/accessreview/drivers/testdata/cursor.yaml b/pkg/accessreview/drivers/testdata/cursor.yaml new file mode 100644 index 000000000..ca3a59728 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/cursor.yaml @@ -0,0 +1,32 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.cursor.com + headers: + Accept: + - application/json + url: https://api.cursor.com/teams/members + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"teamMembers":[{"id":"10000001","name":"Jane Doe","email":"jane@example.com","role":"member","isRemoved":false},{"id":"10000002","name":"Alex Martin","email":"alex@example.com","role":"owner","isRemoved":false},{"id":"10000003","name":"John Smith","email":"john@example.com","role":"removed","isRemoved":true}]}' + headers: + Content-Type: + - application/json + Date: + - Thu, 28 May 2026 12:55:02 GMT + Server: + - cloudflare + status: 200 OK + code: 200 + duration: 142.5ms diff --git a/pkg/accessreview/drivers/vcr_test.go b/pkg/accessreview/drivers/vcr_test.go index b8ba7c3dc..cb79f4815 100644 --- a/pkg/accessreview/drivers/vcr_test.go +++ b/pkg/accessreview/drivers/vcr_test.go @@ -15,6 +15,7 @@ package drivers import ( + "encoding/base64" "net/http" "os" "testing" @@ -100,6 +101,17 @@ func bearerAuth(token string) string { return "Bearer " + token } +// basicAuth returns the HTTP Basic auth header value for a username with +// an empty password ("Basic base64(:)"), or "" if the username +// is empty. Cursor presents its admin API key as the Basic auth username. +func basicAuth(username string) string { + if username == "" { + return "" + } + + return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":")) +} + // newVCRClient creates an *http.Client backed by the recorder's transport, // with an optional Authorization header injected into requests (for recording // mode). The authValue should be the complete header value, e.g.