From 65561166018b2546f1f83894483fe9f5c80403a9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 29 May 2026 06:51:44 +0000 Subject: [PATCH] Add SendGrid access review driver Implement a SendGrid access-review driver that fetches teammates from the SendGrid API and maps them into AccountRecord values. Register SendGrid as a connector provider, expose it through the connector provider enum, and add a migration that extends the connector_provider type with SENDGRID. Cover the new driver with a VCR-backed fixture test and helper tests for role and response-shape handling to keep parsing robust. Signed-off-by: Cursor Agent Co-authored-by: Bryan FRIMIN --- pkg/accessreview/drivers/sendgrid.go | 165 ++++++++++++++++++ pkg/accessreview/drivers/sendgrid_test.go | 115 ++++++++++++ .../drivers/testdata/sendgrid.yaml | 37 ++++ pkg/connector/provider/builtin.go | 1 + pkg/connector/provider/sendgrid.go | 36 ++++ pkg/coredata/connector_provider.go | 3 + pkg/coredata/migrations/20260529T065300Z.sql | 15 ++ .../api/console/v1/graphql/connector.graphql | 2 + 8 files changed, 374 insertions(+) create mode 100644 pkg/accessreview/drivers/sendgrid.go create mode 100644 pkg/accessreview/drivers/sendgrid_test.go create mode 100644 pkg/accessreview/drivers/testdata/sendgrid.yaml create mode 100644 pkg/connector/provider/sendgrid.go create mode 100644 pkg/coredata/migrations/20260529T065300Z.sql diff --git a/pkg/accessreview/drivers/sendgrid.go b/pkg/accessreview/drivers/sendgrid.go new file mode 100644 index 000000000..a579661bb --- /dev/null +++ b/pkg/accessreview/drivers/sendgrid.go @@ -0,0 +1,165 @@ +// 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" + "strconv" + "strings" + + "go.probo.inc/probo/pkg/coredata" +) + +type SendGridDriver struct { + httpClient *http.Client +} + +var _ Driver = (*SendGridDriver)(nil) + +type sendGridTeammate struct { + Username string `json:"username"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + UserType string `json:"user_type"` + IsAdmin bool `json:"is_admin"` +} + +type sendGridTeammatesResponse struct { + Result []sendGridTeammate `json:"result"` + Results []sendGridTeammate `json:"results"` +} + +const ( + sendGridTeammatesEndpoint = "https://api.sendgrid.com/v3/teammates" + sendGridTeammatesPageLimit = 500 +) + +func NewSendGridDriver(httpClient *http.Client) *SendGridDriver { + return &SendGridDriver{ + httpClient: httpClient, + } +} + +func (d *SendGridDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + var ( + records []AccountRecord + offset int + ) + + for range maxPaginationPages { + resp, err := d.fetchTeammates(ctx, offset) + if err != nil { + return nil, err + } + + teammates := sendGridResponseItems(resp) + for _, teammate := range teammates { + if teammate.Email == "" { + continue + } + + records = append(records, AccountRecord{ + Email: teammate.Email, + FullName: sendGridFullName(teammate.FirstName, teammate.LastName), + Role: sendGridRole(teammate.UserType, teammate.IsAdmin), + IsAdmin: teammate.IsAdmin, + ExternalID: strings.TrimSpace(teammate.Username), + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessEntryAuthMethodUnknown, + AccountType: coredata.AccessEntryAccountTypeUser, + }) + } + + if len(teammates) < sendGridTeammatesPageLimit { + return records, nil + } + + offset += len(teammates) + } + + return nil, fmt.Errorf("cannot list all sendgrid teammates: %w", ErrPaginationLimitReached) +} + +func (d *SendGridDriver) fetchTeammates( + ctx context.Context, + offset int, +) (*sendGridTeammatesResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, sendGridTeammatesEndpoint, nil) + if err != nil { + return nil, fmt.Errorf("cannot create sendgrid teammates request: %w", err) + } + + q := req.URL.Query() + q.Set("limit", strconv.Itoa(sendGridTeammatesPageLimit)) + q.Set("offset", strconv.Itoa(offset)) + req.URL.RawQuery = q.Encode() + + req.Header.Set("Accept", "application/json") + + httpResp, err := d.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot execute sendgrid teammates request: %w", err) + } + + defer func() { + _ = httpResp.Body.Close() + }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("cannot fetch sendgrid teammates: unexpected status %d", httpResp.StatusCode) + } + + var resp sendGridTeammatesResponse + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return nil, fmt.Errorf("cannot decode sendgrid teammates response: %w", err) + } + + return &resp, nil +} + +func sendGridResponseItems(resp *sendGridTeammatesResponse) []sendGridTeammate { + if len(resp.Result) > 0 { + return resp.Result + } + + return resp.Results +} + +func sendGridFullName(firstName, lastName string) string { + return strings.TrimSpace(strings.Join([]string{firstName, lastName}, " ")) +} + +func sendGridRole(userType string, isAdmin bool) string { + switch userType { + case "owner": + return "Owner" + case "admin": + return "Admin" + case "teammate": + return "Teammate" + case "": + if isAdmin { + return "Admin" + } + + return "Teammate" + default: + return userType + } +} diff --git a/pkg/accessreview/drivers/sendgrid_test.go b/pkg/accessreview/drivers/sendgrid_test.go new file mode 100644 index 000000000..24c58860f --- /dev/null +++ b/pkg/accessreview/drivers/sendgrid_test.go @@ -0,0 +1,115 @@ +// 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" + "go.probo.inc/probo/pkg/coredata" +) + +func TestSendGridDriver(t *testing.T) { + t.Parallel() + + rec := newRecorder(t, "testdata/sendgrid", "SENDGRID_API_KEY") + client := newVCRClient(rec, bearerAuth(os.Getenv("SENDGRID_API_KEY"))) + driver := NewSendGridDriver(client) + + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + require.Len(t, records, 3) + + owner := records[0] + assert.Equal(t, "owner@example.com", owner.Email) + assert.Equal(t, "Olivia Owner", owner.FullName) + assert.Equal(t, "Owner", owner.Role) + assert.True(t, owner.IsAdmin) + assert.Equal(t, "owner-user", owner.ExternalID) + assert.Equal(t, coredata.AccessEntryAccountTypeUser, owner.AccountType) + + admin := records[1] + assert.Equal(t, "admin@example.com", admin.Email) + assert.Equal(t, "Admin", admin.Role) + assert.True(t, admin.IsAdmin) + assert.Equal(t, "admin-user", admin.ExternalID) + + teammate := records[2] + assert.Equal(t, "teammate@example.com", teammate.Email) + assert.Equal(t, "Teammate", teammate.Role) + assert.False(t, teammate.IsAdmin) + assert.Equal(t, "teammate-user", teammate.ExternalID) +} + +func TestSendGridRole(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + userType string + isAdmin bool + want string + }{ + {name: "owner", userType: "owner", isAdmin: true, want: "Owner"}, + {name: "admin", userType: "admin", isAdmin: true, want: "Admin"}, + {name: "teammate", userType: "teammate", isAdmin: false, want: "Teammate"}, + {name: "empty admin", userType: "", isAdmin: true, want: "Admin"}, + {name: "empty teammate", userType: "", isAdmin: false, want: "Teammate"}, + {name: "unknown", userType: "custom-role", isAdmin: false, want: "custom-role"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, sendGridRole(tt.userType, tt.isAdmin)) + }) + } +} + +func TestSendGridResponseItems(t *testing.T) { + t.Parallel() + + t.Run("prefers result", func(t *testing.T) { + t.Parallel() + + items := sendGridResponseItems(&sendGridTeammatesResponse{ + Result: []sendGridTeammate{ + {Email: "owner@example.com"}, + }, + Results: []sendGridTeammate{ + {Email: "fallback@example.com"}, + }, + }) + + require.Len(t, items, 1) + assert.Equal(t, "owner@example.com", items[0].Email) + }) + + t.Run("falls back to results", func(t *testing.T) { + t.Parallel() + + items := sendGridResponseItems(&sendGridTeammatesResponse{ + Results: []sendGridTeammate{ + {Email: "fallback@example.com"}, + }, + }) + + require.Len(t, items, 1) + assert.Equal(t, "fallback@example.com", items[0].Email) + }) +} diff --git a/pkg/accessreview/drivers/testdata/sendgrid.yaml b/pkg/accessreview/drivers/testdata/sendgrid.yaml new file mode 100644 index 000000000..15dad7695 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/sendgrid.yaml @@ -0,0 +1,37 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.sendgrid.com + form: + limit: + - "500" + offset: + - "0" + headers: + Accept: + - application/json + url: https://api.sendgrid.com/v3/teammates?limit=500&offset=0 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"result":[{"username":"owner-user","email":"owner@example.com","first_name":"Olivia","last_name":"Owner","user_type":"owner","is_admin":true},{"username":"admin-user","email":"admin@example.com","first_name":"","last_name":"","user_type":"admin","is_admin":true},{"username":"teammate-user","email":"teammate@example.com","first_name":"Taylor","last_name":"Teammate","user_type":"teammate","is_admin":false},{"username":"missing-email","email":"","first_name":"Missing","last_name":"Email","user_type":"teammate","is_admin":false}]}' + headers: + Content-Type: + - application/json + Date: + - Fri, 29 May 2026 06:52:00 GMT + Server: + - nginx + status: 200 OK + code: 200 + duration: 18ms diff --git a/pkg/connector/provider/builtin.go b/pkg/connector/provider/builtin.go index 97c7b5269..bd6a42754 100644 --- a/pkg/connector/provider/builtin.go +++ b/pkg/connector/provider/builtin.go @@ -49,6 +49,7 @@ func NewBuiltinRegistry() *Registry { posthogRegistration(), pagerdutyRegistration(), resendRegistration(), + sendgridRegistration(), sentryRegistration(), slackRegistration(), supabaseRegistration(), diff --git a/pkg/connector/provider/sendgrid.go b/pkg/connector/provider/sendgrid.go new file mode 100644 index 000000000..f5ae08898 --- /dev/null +++ b/pkg/connector/provider/sendgrid.go @@ -0,0 +1,36 @@ +// 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 provider + +import ( + "context" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func sendgridRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderSendGrid, + DisplayName: "SendGrid", + ProbeURL: "https://api.sendgrid.com/v3/teammates?limit=1&offset=0", + SupportsAPIKey: true, + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + return drivers.NewSendGridDriver(c), nil + }, + } +} diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index 0193cdab0..d365e11d4 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -41,6 +41,7 @@ const ( ConnectorProviderGitHub ConnectorProvider = "GITHUB" ConnectorProviderIntercom ConnectorProvider = "INTERCOM" ConnectorProviderResend ConnectorProvider = "RESEND" + ConnectorProviderSendGrid ConnectorProvider = "SENDGRID" ConnectorProviderMicrosoft365 ConnectorProvider = "MICROSOFT_365" ConnectorProviderGitLab ConnectorProvider = "GITLAB" ConnectorProviderBitbucket ConnectorProvider = "BITBUCKET" @@ -84,6 +85,7 @@ func ConnectorProviders() []ConnectorProvider { ConnectorProviderGitHub, ConnectorProviderIntercom, ConnectorProviderResend, + ConnectorProviderSendGrid, ConnectorProviderMicrosoft365, ConnectorProviderGitLab, ConnectorProviderBitbucket, @@ -123,6 +125,7 @@ func (v ConnectorProvider) IsValid() bool { ConnectorProviderGitHub, ConnectorProviderIntercom, ConnectorProviderResend, + ConnectorProviderSendGrid, ConnectorProviderMicrosoft365, ConnectorProviderGitLab, ConnectorProviderBitbucket, diff --git a/pkg/coredata/migrations/20260529T065300Z.sql b/pkg/coredata/migrations/20260529T065300Z.sql new file mode 100644 index 000000000..23e64c0f8 --- /dev/null +++ b/pkg/coredata/migrations/20260529T065300Z.sql @@ -0,0 +1,15 @@ +-- 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. + +ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'SENDGRID'; diff --git a/pkg/server/api/console/v1/graphql/connector.graphql b/pkg/server/api/console/v1/graphql/connector.graphql index 65f4362fe..8eeff71ad 100644 --- a/pkg/server/api/console/v1/graphql/connector.graphql +++ b/pkg/server/api/console/v1/graphql/connector.graphql @@ -30,6 +30,8 @@ enum ConnectorProvider INTERCOM @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderIntercom") RESEND @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderResend") + SENDGRID + @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSendGrid") MICROSOFT_365 @goEnum( value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderMicrosoft365"