diff --git a/pkg/accessreview/drivers/sendgrid.go b/pkg/accessreview/drivers/sendgrid.go index a579661bb..f85d1deef 100644 --- a/pkg/accessreview/drivers/sendgrid.go +++ b/pkg/accessreview/drivers/sendgrid.go @@ -19,6 +19,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "strconv" "strings" @@ -32,12 +33,13 @@ type SendGridDriver struct { 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"` + 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"` + Scopes []string `json:"scopes"` } type sendGridTeammatesResponse struct { @@ -45,6 +47,10 @@ type sendGridTeammatesResponse struct { Results []sendGridTeammate `json:"results"` } +type sendGridTeammateResponse struct { + Result sendGridTeammate `json:"result"` +} + const ( sendGridTeammatesEndpoint = "https://api.sendgrid.com/v3/teammates" sendGridTeammatesPageLimit = 500 @@ -74,13 +80,21 @@ func (d *SendGridDriver) ListAccounts(ctx context.Context) ([]AccountRecord, err continue } + mfaStatus := sendGridMFAStatus(teammate.Scopes) + if mfaStatus == coredata.MFAStatusUnknown && teammate.Username != "" { + detailedTeammate, err := d.fetchTeammate(ctx, teammate.Username) + if err == nil { + mfaStatus = sendGridMFAStatus(detailedTeammate.Scopes) + } + } + 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, + MFAStatus: mfaStatus, AuthMethod: coredata.AccessEntryAuthMethodUnknown, AccountType: coredata.AccessEntryAccountTypeUser, }) @@ -133,6 +147,40 @@ func (d *SendGridDriver) fetchTeammates( return &resp, nil } +func (d *SendGridDriver) fetchTeammate(ctx context.Context, username string) (*sendGridTeammate, error) { + endpoint, err := url.JoinPath(sendGridTeammatesEndpoint, url.PathEscape(username)) + if err != nil { + return nil, fmt.Errorf("cannot build sendgrid teammate details url: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("cannot create sendgrid teammate details request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + httpResp, err := d.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot execute sendgrid teammate details request: %w", err) + } + + defer func() { + _ = httpResp.Body.Close() + }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("cannot fetch sendgrid teammate details: unexpected status %d", httpResp.StatusCode) + } + + var resp sendGridTeammateResponse + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return nil, fmt.Errorf("cannot decode sendgrid teammate details response: %w", err) + } + + return &resp.Result, nil +} + func sendGridResponseItems(resp *sendGridTeammatesResponse) []sendGridTeammate { if len(resp.Result) > 0 { return resp.Result @@ -163,3 +211,16 @@ func sendGridRole(userType string, isAdmin bool) string { return userType } } + +func sendGridMFAStatus(scopes []string) coredata.MFAStatus { + for _, scope := range scopes { + switch scope { + case "2fa_exempt": + return coredata.MFAStatusDisabled + case "2fa_required": + return coredata.MFAStatusEnabled + } + } + + return coredata.MFAStatusUnknown +} diff --git a/pkg/accessreview/drivers/sendgrid_test.go b/pkg/accessreview/drivers/sendgrid_test.go index 24c58860f..2598a0bbd 100644 --- a/pkg/accessreview/drivers/sendgrid_test.go +++ b/pkg/accessreview/drivers/sendgrid_test.go @@ -42,18 +42,21 @@ func TestSendGridDriver(t *testing.T) { assert.True(t, owner.IsAdmin) assert.Equal(t, "owner-user", owner.ExternalID) assert.Equal(t, coredata.AccessEntryAccountTypeUser, owner.AccountType) + assert.Equal(t, coredata.MFAStatusEnabled, owner.MFAStatus) 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) + assert.Equal(t, coredata.MFAStatusEnabled, admin.MFAStatus) 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) + assert.Equal(t, coredata.MFAStatusDisabled, teammate.MFAStatus) } func TestSendGridRole(t *testing.T) { @@ -113,3 +116,24 @@ func TestSendGridResponseItems(t *testing.T) { assert.Equal(t, "fallback@example.com", items[0].Email) }) } + +func TestSendGridMFAStatus(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + scopes []string + want coredata.MFAStatus + }{ + {name: "required", scopes: []string{"mail.send", "2fa_required"}, want: coredata.MFAStatusEnabled}, + {name: "exempt", scopes: []string{"mail.send", "2fa_exempt"}, want: coredata.MFAStatusDisabled}, + {name: "unknown", scopes: []string{"mail.send"}, want: coredata.MFAStatusUnknown}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, sendGridMFAStatus(tt.scopes)) + }) + } +} diff --git a/pkg/accessreview/drivers/testdata/sendgrid.yaml b/pkg/accessreview/drivers/testdata/sendgrid.yaml index 15dad7695..96fd45f0f 100644 --- a/pkg/accessreview/drivers/testdata/sendgrid.yaml +++ b/pkg/accessreview/drivers/testdata/sendgrid.yaml @@ -35,3 +35,90 @@ interactions: status: 200 OK code: 200 duration: 18ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.sendgrid.com + headers: + Accept: + - application/json + url: https://api.sendgrid.com/v3/teammates/owner-user + 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,"scopes":["mail.send","2fa_required"]}}' + headers: + Content-Type: + - application/json + Date: + - Fri, 29 May 2026 06:52:01 GMT + Server: + - nginx + status: 200 OK + code: 200 + duration: 16ms + - id: 2 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.sendgrid.com + headers: + Accept: + - application/json + url: https://api.sendgrid.com/v3/teammates/admin-user + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"result":{"username":"admin-user","email":"admin@example.com","first_name":"","last_name":"","user_type":"admin","is_admin":true,"scopes":["mail.send","2fa_required"]}}' + headers: + Content-Type: + - application/json + Date: + - Fri, 29 May 2026 06:52:02 GMT + Server: + - nginx + status: 200 OK + code: 200 + duration: 17ms + - id: 3 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.sendgrid.com + headers: + Accept: + - application/json + url: https://api.sendgrid.com/v3/teammates/teammate-user + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"result":{"username":"teammate-user","email":"teammate@example.com","first_name":"Taylor","last_name":"Teammate","user_type":"teammate","is_admin":false,"scopes":["mail.send","2fa_exempt"]}}' + headers: + Content-Type: + - application/json + Date: + - Fri, 29 May 2026 06:52:03 GMT + Server: + - nginx + status: 200 OK + code: 200 + duration: 17ms