diff --git a/packages/ui/src/Atoms/ThirdParties/SendGrid.tsx b/packages/ui/src/Atoms/ThirdParties/SendGrid.tsx new file mode 100644 index 000000000..7c125a8c7 --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/SendGrid.tsx @@ -0,0 +1,21 @@ +import type { ComponentProps } from "react"; + +export function SendGrid(props: ComponentProps<"svg">) { + return ( + + + + + + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx index 06dc03498..f3ee9f028 100644 --- a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx +++ b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx @@ -42,6 +42,7 @@ import { OpenAI } from "./OpenAI"; import { PagerDuty } from "./PagerDuty"; import { PostHog } from "./PostHog"; import { Resend } from "./Resend"; +import { SendGrid } from "./SendGrid"; import { Sentry } from "./Sentry"; import { Slack } from "./Slack"; import { Supabase } from "./Supabase"; @@ -81,6 +82,7 @@ const thirdParties: Record>> = { PAGERDUTY: PagerDuty, POSTHOG: PostHog, RESEND: Resend, + SENDGRID: SendGrid, SENTRY: Sentry, SLACK: Slack, SUPABASE: Supabase, diff --git a/packages/ui/src/Atoms/ThirdParties/index.ts b/packages/ui/src/Atoms/ThirdParties/index.ts index 4d9526c82..b12188d41 100644 --- a/packages/ui/src/Atoms/ThirdParties/index.ts +++ b/packages/ui/src/Atoms/ThirdParties/index.ts @@ -26,6 +26,7 @@ export { OpenAI } from "./OpenAI"; export { PagerDuty } from "./PagerDuty"; export { PostHog } from "./PostHog"; export { Resend } from "./Resend"; +export { SendGrid } from "./SendGrid"; export { Sentry } from "./Sentry"; export { Slack } from "./Slack"; export { Supabase } from "./Supabase"; diff --git a/pkg/accessreview/drivers/name_resolver.go b/pkg/accessreview/drivers/name_resolver.go index 1230935e2..c81bd0cac 100644 --- a/pkg/accessreview/drivers/name_resolver.go +++ b/pkg/accessreview/drivers/name_resolver.go @@ -486,6 +486,54 @@ func (r *anthropicNameResolver) ResolveInstanceName(ctx context.Context) (string return resp.Name, nil } +// sendGridNameResolver resolves the SendGrid account's company name from +// the user profile endpoint, used as the AccessSource instance label. +type sendGridNameResolver struct { + httpClient *http.Client +} + +func NewSendGridNameResolver(httpClient *http.Client) NameResolver { + return &sendGridNameResolver{httpClient: httpClient} +} + +func (r *sendGridNameResolver) ResolveInstanceName(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + "https://api.sendgrid.com/v3/user/profile", + nil, + ) + if err != nil { + return "", fmt.Errorf("cannot create sendgrid profile request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + httpResp, err := r.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("cannot execute sendgrid profile request: %w", err) + } + + defer func() { _ = httpResp.Body.Close() }() + + // Best-effort: a non-2xx (revoked key, or a key without the + // user.profile.read scope) 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 { + Company string `json:"company"` + } + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return "", fmt.Errorf("cannot decode sendgrid profile response: %w", err) + } + + return resp.Company, nil +} + // sentryNameResolver resolves the Sentry organization name. type sentryNameResolver struct { httpClient *http.Client diff --git a/pkg/accessreview/drivers/sendgrid.go b/pkg/accessreview/drivers/sendgrid.go index f85d1deef..bbd078e2c 100644 --- a/pkg/accessreview/drivers/sendgrid.go +++ b/pkg/accessreview/drivers/sendgrid.go @@ -212,15 +212,31 @@ func sendGridRole(userType string, isAdmin bool) string { } } +// sendGridMFAStatus derives a teammate's MFA status from the auto-set 2fa +// scopes SendGrid attaches to the teammate detail. A restricted teammate +// carries exactly one of them to reflect their real status. Full-access +// users (the account owner and full-access teammates) are the exception: +// their scope list is the entire catalog and therefore contains BOTH +// 2fa_exempt and 2fa_required, which says nothing about their actual MFA. +// Only report a definitive status when exactly one scope is present; +// both-or-neither is ambiguous, so report Unknown rather than guessing. func sendGridMFAStatus(scopes []string) coredata.MFAStatus { + var exempt, required bool for _, scope := range scopes { switch scope { case "2fa_exempt": - return coredata.MFAStatusDisabled + exempt = true case "2fa_required": - return coredata.MFAStatusEnabled + required = true } } - return coredata.MFAStatusUnknown + switch { + case required && !exempt: + return coredata.MFAStatusEnabled + case exempt && !required: + return coredata.MFAStatusDisabled + default: + return coredata.MFAStatusUnknown + } } diff --git a/pkg/accessreview/drivers/sendgrid_test.go b/pkg/accessreview/drivers/sendgrid_test.go index 2598a0bbd..ec013936c 100644 --- a/pkg/accessreview/drivers/sendgrid_test.go +++ b/pkg/accessreview/drivers/sendgrid_test.go @@ -33,30 +33,22 @@ func TestSendGridDriver(t *testing.T) { records, err := driver.ListAccounts(context.Background()) require.NoError(t, err) - require.Len(t, records, 3) + require.Len(t, records, 1) + // Recorded against a live SendGrid account that has only the owner. The + // list endpoint carries no scopes, so the driver fetches the teammate + // detail to read them. owner := records[0] assert.Equal(t, "owner@example.com", owner.Email) - assert.Equal(t, "Olivia Owner", owner.FullName) + assert.Empty(t, owner.FullName) assert.Equal(t, "Owner", owner.Role) assert.True(t, owner.IsAdmin) - assert.Equal(t, "owner-user", owner.ExternalID) + assert.Equal(t, "owner@example.com", 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) + // The owner is a full-access user whose scope catalog contains BOTH + // 2fa_exempt and 2fa_required, so the MFA signal is ambiguous and the + // driver reports Unknown rather than guessing from scope ordering. + assert.Equal(t, coredata.MFAStatusUnknown, owner.MFAStatus) } func TestSendGridRole(t *testing.T) { @@ -127,7 +119,8 @@ func TestSendGridMFAStatus(t *testing.T) { }{ {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}, + {name: "both is ambiguous", scopes: []string{"2fa_exempt", "2fa_required", "mail.send"}, want: coredata.MFAStatusUnknown}, + {name: "neither", scopes: []string{"mail.send"}, want: coredata.MFAStatusUnknown}, } for _, tt := range tests { diff --git a/pkg/accessreview/drivers/testdata/sendgrid.yaml b/pkg/accessreview/drivers/testdata/sendgrid.yaml index 96fd45f0f..b66223f84 100644 --- a/pkg/accessreview/drivers/testdata/sendgrid.yaml +++ b/pkg/accessreview/drivers/testdata/sendgrid.yaml @@ -19,22 +19,62 @@ interactions: url: https://api.sendgrid.com/v3/teammates?limit=500&offset=0 method: GET response: - proto: HTTP/2.0 - proto_major: 2 - proto_minor: 0 + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 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}]}' + body: | + {"result":[{"username":"owner@example.com","email":"owner@example.com","first_name":"","last_name":"","address":"","address2":"","city":"","state":"","zip":"","country":"","company":"","website":"","phone":"","is_admin":true,"is_sso":false,"user_type":"owner","is_unified":true,"is_partner_sso":false}]} headers: + Access-Control-Allow-Headers: + - AUTHORIZATION, Content-Type, On-behalf-of, x-sg-elas-acl, X-Recaptcha, X-Request-Source, Browser-Fingerprint + Access-Control-Allow-Methods: + - HEAD, GET, PUT, POST, DELETE, OPTIONS, PATCH + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - Link, Location + Access-Control-Max-Age: + - "21600" + Cache-Control: + - no-cache + Connection: + - keep-alive + Content-Length: + - "334" + Content-Security-Policy: + - default-src https://api.sendgrid.com; frame-src 'none'; object-src 'none' Content-Type: - - application/json + - application/json; charset=utf8 Date: - - Fri, 29 May 2026 06:52:00 GMT + - Thu, 04 Jun 2026 12:30:33 GMT + Link: + - ; rel="first"; title="1", ; rel="last"; title="1" + Powered-By: + - Mako + Referrer-Policy: + - strict-origin-when-cross-origin Server: - nginx + Strict-Transport-Security: + - max-age=31536000 + X-Client-Ff: + - "1000" + X-Content-Type-Options: + - nosniff + X-Envoy-Upstream-Service-Time: + - "231" + X-Ratelimit-Limit: + - "600" + X-Ratelimit-Remaining: + - "599" + X-Ratelimit-Reset: + - "1780576260" + X-Robots-Tag: + - noindex, nofollow status: 200 OK code: 200 - duration: 18ms + duration: 323.748208ms - id: 1 request: proto: HTTP/1.1 @@ -45,80 +85,61 @@ interactions: headers: Accept: - application/json - url: https://api.sendgrid.com/v3/teammates/owner-user + url: https://api.sendgrid.com/v3/teammates/owner@example.com 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 + transfer_encoding: + - chunked 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"]}}' + body: | + {"username":"owner@example.com","email":"owner@example.com","first_name":"","last_name":"","address":"","address2":"","city":"","state":"","zip":"","country":"","company":"","website":"","phone":"","is_admin":true,"is_sso":false,"user_type":"owner","scopes":["2fa_exempt","2fa_required","access_settings.activity.read","access_settings.whitelist.create","access_settings.whitelist.delete","access_settings.whitelist.read","access_settings.whitelist.update","alerts.create","alerts.delete","alerts.read","alerts.update","api_keys.create","api_keys.delete","api_keys.read","api_keys.update","asm.groups.create","asm.groups.delete","asm.groups.read","asm.groups.suppressions.create","asm.groups.suppressions.delete","asm.groups.suppressions.read","asm.groups.suppressions.update","asm.groups.update","asm.suppressions.global.create","asm.suppressions.global.delete","asm.suppressions.global.read","asm.suppressions.global.update","billing.create","billing.delete","billing.read","billing.update","browsers.stats.read","categories.create","categories.delete","categories.read","categories.stats.read","categories.stats.sums.read","categories.update","clients.desktop.stats.read","clients.phone.stats.read","clients.stats.read","clients.tablet.stats.read","clients.webmail.stats.read","credentials.create","credentials.delete","credentials.read","credentials.update","design_library.create","design_library.delete","design_library.read","design_library.update","devices.stats.read","email_testing.read","email_testing.write","geo.stats.read","ips.pools.ips.read","mail.batch.create","mail.batch.delete","mail.batch.read","mail.batch.update","mail.send","mail_settings.address_whitelist.create","mail_settings.address_whitelist.delete","mail_settings.address_whitelist.read","mail_settings.address_whitelist.update","mail_settings.bcc.create","mail_settings.bcc.delete","mail_settings.bcc.read","mail_settings.bcc.update","mail_settings.bounce_purge.create","mail_settings.bounce_purge.delete","mail_settings.bounce_purge.read","mail_settings.bounce_purge.update","mail_settings.footer.create","mail_settings.footer.delete","mail_settings.footer.read","mail_settings.footer.update","mail_settings.forward_bounce.create","mail_settings.forward_bounce.delete","mail_settings.forward_bounce.read","mail_settings.forward_bounce.update","mail_settings.forward_spam.create","mail_settings.forward_spam.delete","mail_settings.forward_spam.read","mail_settings.forward_spam.update","mail_settings.plain_content.create","mail_settings.plain_content.delete","mail_settings.plain_content.read","mail_settings.plain_content.update","mail_settings.read","mail_settings.spam_check.create","mail_settings.spam_check.delete","mail_settings.spam_check.read","mail_settings.spam_check.update","mail_settings.template.create","mail_settings.template.delete","mail_settings.template.read","mail_settings.template.update","mailbox_providers.stats.read","marketing.automation.read","marketing.read","messages.read","partner_settings.new_relic.create","partner_settings.new_relic.delete","partner_settings.new_relic.read","partner_settings.new_relic.update","partner_settings.read","partner_settings.sendwithus.create","partner_settings.sendwithus.delete","partner_settings.sendwithus.read","partner_settings.sendwithus.update","recipients.erasejob.create","recipients.erasejob.read","sender_verification_eligible","signup.trigger_confirmation","stats.global.read","stats.read","subusers.stats.monthly.read","subusers.stats.read","subusers.stats.sums.read","suppression.blocks.create","suppression.blocks.delete","suppression.blocks.read","suppression.blocks.update","suppression.bounces.create","suppression.bounces.delete","suppression.bounces.read","suppression.bounces.update","suppression.create","suppression.delete","suppression.invalid_emails.create","suppression.invalid_emails.delete","suppression.invalid_emails.read","suppression.invalid_emails.update","suppression.read","suppression.spam_reports.create","suppression.spam_reports.delete","suppression.spam_reports.read","suppression.spam_reports.update","suppression.unsubscribes.create","suppression.unsubscribes.delete","suppression.unsubscribes.read","suppression.unsubscribes.update","suppression.update","teammates.create","teammates.delete","teammates.read","teammates.update","templates.create","templates.delete","templates.read","templates.update","templates.versions.activate.create","templates.versions.activate.delete","templates.versions.activate.read","templates.versions.activate.update","templates.versions.create","templates.versions.delete","templates.versions.read","templates.versions.update","tracking_settings.click.create","tracking_settings.click.delete","tracking_settings.click.read","tracking_settings.click.update","tracking_settings.google_analytics.create","tracking_settings.google_analytics.delete","tracking_settings.google_analytics.read","tracking_settings.google_analytics.update","tracking_settings.open.create","tracking_settings.open.delete","tracking_settings.open.read","tracking_settings.open.update","tracking_settings.read","tracking_settings.subscription.create","tracking_settings.subscription.delete","tracking_settings.subscription.read","tracking_settings.subscription.update","ui.confirm_email","ui.provision","ui.signup_complete","user.account.read","user.credits.read","user.email.read","user.profile.create","user.profile.delete","user.profile.read","user.profile.update","user.scheduled_sends.create","user.scheduled_sends.delete","user.scheduled_sends.read","user.scheduled_sends.update","user.settings.enforced_tls.read","user.settings.enforced_tls.update","user.timezone.create","user.timezone.delete","user.timezone.read","user.timezone.update","user.username.read","user.webhooks.event.settings.create","user.webhooks.event.settings.delete","user.webhooks.event.settings.read","user.webhooks.event.settings.update","user.webhooks.event.test.create","user.webhooks.event.test.delete","user.webhooks.event.test.read","user.webhooks.event.test.update","user.webhooks.parse.settings.create","user.webhooks.parse.settings.delete","user.webhooks.parse.settings.read","user.webhooks.parse.settings.update","user.webhooks.parse.stats.read","whitelabel.create","whitelabel.delete","whitelabel.read","whitelabel.update"],"is_read_only":false,"is_unified":true,"is_partner_sso":false} headers: + Access-Control-Allow-Headers: + - AUTHORIZATION, Content-Type, On-behalf-of, x-sg-elas-acl, X-Recaptcha, X-Request-Source, Browser-Fingerprint + Access-Control-Allow-Methods: + - HEAD, GET, PUT, POST, DELETE, OPTIONS, PATCH + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - Link, Location + Access-Control-Max-Age: + - "21600" + Cache-Control: + - no-cache + Connection: + - keep-alive + Content-Security-Policy: + - default-src https://api.sendgrid.com; frame-src 'none'; object-src 'none' Content-Type: - - application/json + - application/json; charset=utf8 Date: - - Fri, 29 May 2026 06:52:02 GMT + - Thu, 04 Jun 2026 12:30:33 GMT + Powered-By: + - Mako + Referrer-Policy: + - strict-origin-when-cross-origin Server: - nginx + Strict-Transport-Security: + - max-age=31536000 + X-Client-Ff: + - "1000" + X-Content-Type-Options: + - nosniff + X-Envoy-Upstream-Service-Time: + - "264" + X-Ratelimit-Limit: + - "600" + X-Ratelimit-Remaining: + - "599" + X-Ratelimit-Reset: + - "1780576260" + X-Robots-Tag: + - noindex, nofollow 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 + duration: 283.823083ms diff --git a/pkg/connector/provider/sendgrid.go b/pkg/connector/provider/sendgrid.go index f5ae08898..6e75927ed 100644 --- a/pkg/connector/provider/sendgrid.go +++ b/pkg/connector/provider/sendgrid.go @@ -32,5 +32,8 @@ func sendgridRegistration() *Registration { NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { return drivers.NewSendGridDriver(c), nil }, + NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) drivers.NameResolver { + return drivers.NewSendGridNameResolver(c) + }, } } diff --git a/pkg/coredata/migrations/20260529T065300Z.sql b/pkg/coredata/migrations/20260529T713864Z.sql similarity index 100% rename from pkg/coredata/migrations/20260529T065300Z.sql rename to pkg/coredata/migrations/20260529T713864Z.sql