From dbd920dc24bc960b5f0fbe71217643d06c4566de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:32:55 +0200 Subject: [PATCH] Add Zendesk access-review connector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zendesk is a multi-tenant OAuth connector keyed by the customer subdomain. The customer enters it at connect time; it rides the signed state to the callback, is re-validated, and is stored on the connector settings to build the API host. List staff (agents and admins) via GET /api/v2/users.json with cursor pagination, mapping role, active/suspended, and 2FA status; end-users are excluded. The subdomain is validated as a single DNS label at every trust boundary to close the SSRF vector, and the data client keeps the SSRF-protected transport. Zendesk OAuth across customer subdomains requires a Zendesk-approved global OAuth client; the connector goes live once those credentials are supplied via bootstrap. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- .env.example | 3 + pkg/accessreview/drivers/name_resolver.go | 17 ++ .../drivers/testdata/zendesk.yaml | 82 +++++++ pkg/accessreview/drivers/zendesk.go | 218 ++++++++++++++++++ pkg/accessreview/drivers/zendesk_test.go | 93 ++++++++ pkg/bootstrap/builder.go | 2 + pkg/bootstrap/builder_test.go | 1 + pkg/connector/oauth2_test.go | 127 ++++++++++ pkg/connector/provider/apply_test.go | 34 +++ pkg/connector/provider/builtin.go | 1 + pkg/connector/provider/zendesk.go | 75 ++++++ pkg/connector/zendesk.go | 79 +++++++ pkg/connector/zendesk_test.go | 81 +++++++ pkg/coredata/connector_provider.go | 5 +- pkg/coredata/connector_settings.go | 10 + pkg/coredata/migrations/20260604T372815Z.sql | 15 ++ .../v1/access_source_provider_config.go | 8 + .../api/console/v1/graphql/connector.graphql | 1 + pkg/server/api/console/v1/resolver.go | 53 +++-- 19 files changed, 891 insertions(+), 14 deletions(-) create mode 100644 pkg/accessreview/drivers/testdata/zendesk.yaml create mode 100644 pkg/accessreview/drivers/zendesk.go create mode 100644 pkg/accessreview/drivers/zendesk_test.go create mode 100644 pkg/connector/provider/zendesk.go create mode 100644 pkg/connector/zendesk.go create mode 100644 pkg/connector/zendesk_test.go create mode 100644 pkg/coredata/migrations/20260604T372815Z.sql diff --git a/.env.example b/.env.example index 453e71eb6..cd343a843 100644 --- a/.env.example +++ b/.env.example @@ -124,6 +124,9 @@ # CONNECTOR_MONDAY_CLIENT_SECRET= # CONNECTOR_DATADOG_CLIENT_ID= # CONNECTOR_DATADOG_CLIENT_SECRET= +# Zendesk OAuth requires a Zendesk-approved global OAuth client (Marketplace). +# CONNECTOR_ZENDESK_CLIENT_ID= +# CONNECTOR_ZENDESK_CLIENT_SECRET= # PostHog Cloud (US + EU) OAuth needs NO config: it uses the CIMD public-client # flow (no app registration, no client_secret), auto-enabled when this # deployment is publicly reachable at PROBOD_BASE_URL. Self-hosted PostHog uses diff --git a/pkg/accessreview/drivers/name_resolver.go b/pkg/accessreview/drivers/name_resolver.go index 769d65a9f..ff8c40943 100644 --- a/pkg/accessreview/drivers/name_resolver.go +++ b/pkg/accessreview/drivers/name_resolver.go @@ -951,6 +951,23 @@ func (r *oktaNameResolver) ResolveInstanceName(ctx context.Context) (string, err return resp.Subdomain, nil } +// zendeskNameResolver returns the Zendesk subdomain stored in connector +// settings (e.g. "acme" for acme.zendesk.com), captured at connect time. No +// HTTP call is required; the AccessSource title becomes "Zendesk ". +// Account-name resolution is intentionally omitted to keep the scope to +// users:read (Zendesk exposes no human account name on that scope). +type zendeskNameResolver struct { + subdomain string +} + +func NewZendeskNameResolver(subdomain string) NameResolver { + return &zendeskNameResolver{subdomain: subdomain} +} + +func (r *zendeskNameResolver) ResolveInstanceName(_ context.Context) (string, error) { + return r.subdomain, nil +} + // asanaNameResolver resolves the Asana workspace name. type asanaNameResolver struct { httpClient *http.Client diff --git a/pkg/accessreview/drivers/testdata/zendesk.yaml b/pkg/accessreview/drivers/testdata/zendesk.yaml new file mode 100644 index 000000000..1836f8ab2 --- /dev/null +++ b/pkg/accessreview/drivers/testdata/zendesk.yaml @@ -0,0 +1,82 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: acme.zendesk.com + remote_addr: "" + request_uri: "" + body: "" + form: + page[size]: + - "100" + role[]: + - agent + - admin + headers: + Accept: + - application/json + url: https://acme.zendesk.com/api/v2/users.json?page%5Bsize%5D=100&role%5B%5D=agent&role%5B%5D=admin + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: true + body: | + { + "users": [ + { + "id": 12345, + "email": "alice@example.com", + "name": "Alice Example", + "role": "admin", + "suspended": false, + "active": true, + "two_factor_auth_enabled": true, + "last_login_at": "2025-06-01T10:00:00Z", + "created_at": "2025-01-02T03:04:05Z" + }, + { + "id": 67890, + "email": "bob@example.com", + "name": "Bob Example", + "role": "agent", + "suspended": false, + "active": true, + "two_factor_auth_enabled": false, + "last_login_at": null, + "created_at": "2025-02-01T00:00:00Z" + }, + { + "id": 99999, + "email": "carol@example.com", + "name": "Carol Customer", + "role": "end-user", + "suspended": false, + "active": true, + "two_factor_auth_enabled": false, + "last_login_at": null, + "created_at": "2025-03-01T00:00:00Z" + } + ], + "meta": { + "has_more": false, + "after_cursor": null + } + } + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 1ms diff --git a/pkg/accessreview/drivers/zendesk.go b/pkg/accessreview/drivers/zendesk.go new file mode 100644 index 000000000..6c077c9a7 --- /dev/null +++ b/pkg/accessreview/drivers/zendesk.go @@ -0,0 +1,218 @@ +// 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" + "net/url" + "strconv" + "time" + + "go.probo.inc/probo/pkg/coredata" +) + +// ZendeskDriver lists a Zendesk account's staff (agents and admins) via GET +// /api/v2/users.json. The API host is per-customer (.zendesk.com), +// captured at connect time and stored on the connector settings. End-users +// (ticket submitters) are excluded — they are customers, not access subjects. +type ZendeskDriver struct { + httpClient *http.Client + subdomain string // e.g. "acme" for acme.zendesk.com +} + +var _ Driver = (*ZendeskDriver)(nil) + +// NewZendeskDriver wraps the connection's SSRF-protected transport with a +// retrying transport for transient 5xx, matching the canonical sibling +// drivers (datadog.go, heroku.go). The caller's *http.Client is not mutated. +func NewZendeskDriver(httpClient *http.Client, subdomain string) *ZendeskDriver { + return &ZendeskDriver{ + httpClient: &http.Client{ + Transport: &retryRoundTripper{ + next: httpClient.Transport, + maxRetries: 3, + }, + }, + subdomain: subdomain, + } +} + +const zendeskPageSize = 100 + +type zendeskUser struct { + ID int64 `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` // "end-user", "agent", "admin" + Suspended bool `json:"suspended"` + Active bool `json:"active"` + TwoFactorAuthEnabled *bool `json:"two_factor_auth_enabled"` + LastLoginAt *string `json:"last_login_at"` + CreatedAt string `json:"created_at"` +} + +// zendeskUsersResponse is the GET /api/v2/users.json payload. Zendesk uses +// cursor pagination: meta.has_more signals more pages and meta.after_cursor is +// the token for the next page. +type zendeskUsersResponse struct { + Users []zendeskUser `json:"users"` + Meta struct { + HasMore bool `json:"has_more"` + AfterCursor string `json:"after_cursor"` + } `json:"meta"` +} + +func (d *ZendeskDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + var ( + records []AccountRecord + afterCursor string + ) + + for range maxPaginationPages { + resp, err := d.queryUsers(ctx, afterCursor) + if err != nil { + return nil, err + } + + for _, u := range resp.Users { + // The query already filters to agents + admins, but guard here + // too: end-users are ticket submitters, not staff with access. + if u.Role == "end-user" { + continue + } + + records = append(records, zendeskRecord(u)) + } + + if !resp.Meta.HasMore || resp.Meta.AfterCursor == "" { + return records, nil + } + + afterCursor = resp.Meta.AfterCursor + } + + return nil, fmt.Errorf("cannot list all zendesk users: %w", ErrPaginationLimitReached) +} + +func zendeskRecord(u zendeskUser) AccountRecord { + active := u.Active && !u.Suspended + isAdmin := u.Role == "admin" + + // Zendesk reports 2FA per user; map it to the MFA status. A null value + // (absent on some plans) stays unknown rather than asserting "disabled". + mfaStatus := coredata.MFAStatusUnknown + + if u.TwoFactorAuthEnabled != nil { + if *u.TwoFactorAuthEnabled { + mfaStatus = coredata.MFAStatusEnabled + } else { + mfaStatus = coredata.MFAStatusDisabled + } + } + + lastLogin := "" + if u.LastLoginAt != nil { + lastLogin = *u.LastLoginAt + } + + return AccountRecord{ + Email: u.Email, + FullName: u.Name, + Role: zendeskRole(u.Role), + Active: &active, + IsAdmin: isAdmin, + MFAStatus: mfaStatus, + // Zendesk's users API does not expose the sign-in method + // (password / SSO / social), so the auth method is unknown. + AuthMethod: coredata.AccessEntryAuthMethodUnknown, + AccountType: coredata.AccessEntryAccountTypeUser, + ExternalID: strconv.FormatInt(u.ID, 10), + LastLogin: parseZendeskTime(lastLogin), + CreatedAt: parseZendeskTime(u.CreatedAt), + } +} + +// zendeskRole title-cases the two staff roles for display; any other value +// (custom role names) passes through unchanged. +func zendeskRole(role string) string { + switch role { + case "admin": + return "Admin" + case "agent": + return "Agent" + default: + return role + } +} + +func (d *ZendeskDriver) queryUsers(ctx context.Context, afterCursor string) (*zendeskUsersResponse, error) { + q := url.Values{} + q.Set("page[size]", strconv.Itoa(zendeskPageSize)) + // Restrict to staff (agents + admins); end-users are ticket submitters. + q.Add("role[]", "agent") + q.Add("role[]", "admin") + + if afterCursor != "" { + q.Set("page[after]", afterCursor) + } + + endpoint := url.URL{ + Scheme: "https", + Host: d.subdomain + ".zendesk.com", + Path: "/api/v2/users.json", + RawQuery: q.Encode(), + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, fmt.Errorf("cannot create zendesk users request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + resp, err := d.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot list zendesk users: %w", err) + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("cannot list zendesk users: unexpected status %d", resp.StatusCode) + } + + var out zendeskUsersResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("cannot decode zendesk users response: %w", err) + } + + return &out, nil +} + +func parseZendeskTime(s string) *time.Time { + if s == "" { + return nil + } + + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return nil + } + + return &t +} diff --git a/pkg/accessreview/drivers/zendesk_test.go b/pkg/accessreview/drivers/zendesk_test.go new file mode 100644 index 000000000..263dff8be --- /dev/null +++ b/pkg/accessreview/drivers/zendesk_test.go @@ -0,0 +1,93 @@ +// 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 TestZendeskDriver(t *testing.T) { + t.Parallel() + + rec := newRecorder(t, "testdata/zendesk", "ZENDESK_TOKEN") + client := newVCRClient(rec, bearerAuth(os.Getenv("ZENDESK_TOKEN"))) + + driver := NewZendeskDriver(client, "acme") + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + // The page holds three users; the end-user (carol) is filtered out so + // only the two staff members remain. + assert.Len(t, records, 2) + + r := records[0] + assert.Equal(t, "alice@example.com", r.Email) + assert.Equal(t, "Alice Example", r.FullName) + assert.Equal(t, "12345", r.ExternalID) + require.NotNil(t, r.Active) + assert.True(t, *r.Active) + assert.True(t, r.IsAdmin) + assert.Equal(t, "Admin", r.Role) + assert.Equal(t, coredata.AccessEntryAccountTypeUser, r.AccountType) + assert.Equal(t, coredata.MFAStatusEnabled, r.MFAStatus) + assert.Equal(t, coredata.AccessEntryAuthMethodUnknown, r.AuthMethod) + require.NotNil(t, r.LastLogin) + require.NotNil(t, r.CreatedAt) + + // Second record exercises the agent (non-admin), MFA-disabled, and + // never-logged-in (null last_login_at) branches. + r2 := records[1] + assert.Equal(t, "bob@example.com", r2.Email) + assert.Equal(t, "67890", r2.ExternalID) + require.NotNil(t, r2.Active) + assert.True(t, *r2.Active) + assert.False(t, r2.IsAdmin) + assert.Equal(t, "Agent", r2.Role) + assert.Equal(t, coredata.MFAStatusDisabled, r2.MFAStatus) + assert.Nil(t, r2.LastLogin) +} + +// TestZendeskRecord_FieldMapping covers the field-mapping edge cases that the +// cassette does not: a null 2FA flag stays unknown (not "disabled"), a +// suspended user is inactive even when active is true, and a custom role name +// passes through verbatim. +func TestZendeskRecord_FieldMapping(t *testing.T) { + t.Parallel() + + rec := zendeskRecord(zendeskUser{ + ID: 42, + Email: "dana@example.com", + Name: "Dana Example", + Role: "Light agent", + Suspended: true, + Active: true, + }) + + assert.Equal(t, "dana@example.com", rec.Email) + assert.Equal(t, "Dana Example", rec.FullName) + require.NotNil(t, rec.Active) + assert.False(t, *rec.Active, "a suspended user must be inactive") + assert.Equal(t, coredata.MFAStatusUnknown, rec.MFAStatus, "null 2FA must stay unknown") + assert.Equal(t, "Light agent", rec.Role, "custom role names pass through") + assert.False(t, rec.IsAdmin) + assert.Equal(t, "42", rec.ExternalID) + assert.Nil(t, rec.LastLogin) + assert.Nil(t, rec.CreatedAt) +} diff --git a/pkg/bootstrap/builder.go b/pkg/bootstrap/builder.go index 998d58a93..42a729e38 100644 --- a/pkg/bootstrap/builder.go +++ b/pkg/bootstrap/builder.go @@ -428,6 +428,7 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) { "CLICKUP", "MONDAY", "DATADOG", + "ZENDESK", } { clientID := b.getEnv("CONNECTOR_" + provider + "_CLIENT_ID") if clientID == "" { @@ -520,6 +521,7 @@ func (b *Builder) validateRequired() error { {"CONNECTOR_CLICKUP", []string{"CLIENT_SECRET"}}, {"CONNECTOR_MONDAY", []string{"CLIENT_SECRET"}}, {"CONNECTOR_DATADOG", []string{"CLIENT_SECRET"}}, + {"CONNECTOR_ZENDESK", []string{"CLIENT_SECRET"}}, {"CONNECTOR_VERCEL", []string{"CLIENT_SECRET", "INTEGRATION_SLUG"}}, } diff --git a/pkg/bootstrap/builder_test.go b/pkg/bootstrap/builder_test.go index 20a3ff69f..352948942 100644 --- a/pkg/bootstrap/builder_test.go +++ b/pkg/bootstrap/builder_test.go @@ -522,6 +522,7 @@ func TestBuilder_Build_AccessReviewConnectors(t *testing.T) { providers := []string{ "GITLAB", "BITBUCKET", "HEROKU", "PAGERDUTY", "ASANA", "NETLIFY", "CLICKUP", "MONDAY", "DATADOG", + "ZENDESK", } env := requiredEnv() diff --git a/pkg/connector/oauth2_test.go b/pkg/connector/oauth2_test.go index 1744f5ecb..c7aae7809 100644 --- a/pkg/connector/oauth2_test.go +++ b/pkg/connector/oauth2_test.go @@ -858,6 +858,133 @@ func TestCompleteWithState_InvalidDomainRejected(t *testing.T) { require.Error(t, err) } +// TestInitiateWithState_PersistsSiteInState verifies that opts.Site is signed +// into the state token so it survives the round-trip to the callback — the +// mechanism multi-site providers (e.g. Zendesk) rely on when the provider does +// not echo the host back. +func TestInitiateWithState_PersistsSiteInState(t *testing.T) { + t.Parallel() + + c := &OAuth2Connector{ + ClientID: "cid", + ClientSecret: "secret", + RedirectURI: "https://probo.example/cb", + BuildAuthURLForSite: func(site string) (string, error) { + return "https://" + site + ".zendesk.com/oauth/authorizations/new", nil + }, + } + + authURL, err := c.InitiateWithState(context.Background(), + OAuth2State{OrganizationID: "org", Provider: ZendeskProvider}, + InitiateOptions{Site: "acme"}, + ) + require.NoError(t, err) + + u, err := url.Parse(authURL) + require.NoError(t, err) + assert.Equal(t, "acme.zendesk.com", u.Host) + + payload, err := DecodeOAuth2StatePayload(u.Query().Get("state")) + require.NoError(t, err) + assert.Equal(t, "acme", payload.Data.Site) +} + +// TestCompleteWithState_PerSiteTokenURL exercises the site-carried-in-state +// token-URL path: the subdomain comes from the signed state (no callback +// param), and the per-connection token URL is persisted for refresh. +func TestCompleteWithState_PerSiteTokenURL(t *testing.T) { + t.Parallel() + + var gotPath string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"at","token_type":"Bearer"}`)) + })) + defer srv.Close() + + c := &OAuth2Connector{ + ClientID: "cid", + ClientSecret: "secret", + RedirectURI: "https://probo.example/cb", + HTTPClient: httpclient.DefaultClient(httpclient.WithSSRFProtection(), httpclient.WithSSRFAllowLoopback()), + BuildTokenURLForSite: func(site string) (string, error) { + if site != "acme" { + return "", fmt.Errorf("unknown site") + } + + return srv.URL + "/oauth/tokens", nil + }, + } + + state, err := statelesstoken.NewToken(c.ClientSecret, OAuth2TokenType, OAuth2TokenTTL, + OAuth2State{OrganizationID: validOrgGID(t), Provider: ZendeskProvider, Site: "acme"}) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, + "https://probo.example/cb?code=abc&state="+state, nil) + + conn, _, err := c.CompleteWithState(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, "/oauth/tokens", gotPath) + + oc, ok := conn.(*OAuth2Connection) + require.True(t, ok) + assert.Equal(t, srv.URL+"/oauth/tokens", oc.TokenURL) +} + +// TestCompleteWithState_MissingSiteForSiteTokenURL ensures a multi-site +// provider whose state carries no site fails before any credential POST. +func TestCompleteWithState_MissingSiteForSiteTokenURL(t *testing.T) { + t.Parallel() + + c := &OAuth2Connector{ + ClientID: "cid", + ClientSecret: "secret", + RedirectURI: "https://probo.example/cb", + HTTPClient: httpclient.DefaultClient(httpclient.WithSSRFProtection(), httpclient.WithSSRFAllowLoopback()), + BuildTokenURLForSite: func(string) (string, error) { return "", fmt.Errorf("unused") }, + } + + state, err := statelesstoken.NewToken(c.ClientSecret, OAuth2TokenType, OAuth2TokenTTL, + OAuth2State{OrganizationID: validOrgGID(t), Provider: ZendeskProvider}) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, + "https://probo.example/cb?code=abc&state="+state, nil) + + _, _, err = c.CompleteWithState(context.Background(), req) + require.Error(t, err) +} + +// TestCompleteWithState_InvalidSiteRejected exercises the SSRF guard on the +// site-in-state path: a signed state carrying a malformed subdomain must fail +// (ZendeskTokenURL rejects it) before any credential POST. Mirrors +// TestCompleteWithState_InvalidDomainRejected for Datadog. +func TestCompleteWithState_InvalidSiteRejected(t *testing.T) { + t.Parallel() + + c := &OAuth2Connector{ + ClientID: "cid", + ClientSecret: "secret", + RedirectURI: "https://probo.example/cb", + HTTPClient: httpclient.DefaultClient(httpclient.WithSSRFProtection(), httpclient.WithSSRFAllowLoopback()), + BuildTokenURLForSite: ZendeskTokenURL, + } + + state, err := statelesstoken.NewToken(c.ClientSecret, OAuth2TokenType, OAuth2TokenTTL, + OAuth2State{OrganizationID: validOrgGID(t), Provider: ZendeskProvider, Site: "evil.example"}) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, + "https://probo.example/cb?code=abc&state="+state, nil) + + _, _, err = c.CompleteWithState(context.Background(), req) + require.Error(t, err) +} + func TestRefreshableClient_PrefersConnectionTokenURL(t *testing.T) { t.Parallel() diff --git a/pkg/connector/provider/apply_test.go b/pkg/connector/provider/apply_test.go index d57101a78..74f88a529 100644 --- a/pkg/connector/provider/apply_test.go +++ b/pkg/connector/provider/apply_test.go @@ -152,3 +152,37 @@ func TestApplyOAuth2Defaults_CopiesSiteClosures(t *testing.T) { require.NoError(t, err) assert.Equal(t, "https://api.us3.datadoghq.com/oauth2/v1/token", tokenURL) } + +// TestApplyOAuth2Defaults_CopiesTokenURLForSiteClosure verifies the +// site-carried-in-state token-URL closure (BuildTokenURLForSite) is copied +// from the Registration onto the OAuth2Connector — the Zendesk shape, where +// both the authorize and token hosts are the customer subdomain. +func TestApplyOAuth2Defaults_CopiesTokenURLForSiteClosure(t *testing.T) { + t.Parallel() + + r := provider.NewRegistry() + require.NoError(t, r.Register(&provider.Registration{ + Provider: coredata.ConnectorProviderZendesk, + DisplayName: "Zendesk", + OAuth2Scopes: []string{"users:read"}, + BuildAuthURLForSite: connector.ZendeskAuthorizeURL, + BuildTokenURLForSite: connector.ZendeskTokenURL, + NewDriver: func(context.Context, *http.Client, *coredata.Connector, *log.Logger) (drivers.Driver, error) { + return nil, nil + }, + })) + + var c connector.OAuth2Connector + require.NoError(t, r.ApplyOAuth2Defaults("ZENDESK", "https://probo.example/cb", &c)) + require.NotNil(t, c.BuildAuthURLForSite) + require.NotNil(t, c.BuildTokenURLForSite) + require.Nil(t, c.BuildTokenURLForDomain) + + authURL, err := c.BuildAuthURLForSite("acme") + require.NoError(t, err) + assert.Equal(t, "https://acme.zendesk.com/oauth/authorizations/new", authURL) + + tokenURL, err := c.BuildTokenURLForSite("acme") + require.NoError(t, err) + assert.Equal(t, "https://acme.zendesk.com/oauth/tokens", tokenURL) +} diff --git a/pkg/connector/provider/builtin.go b/pkg/connector/provider/builtin.go index 93fc5ad7b..ec33a42c3 100644 --- a/pkg/connector/provider/builtin.go +++ b/pkg/connector/provider/builtin.go @@ -58,6 +58,7 @@ func NewBuiltinRegistry() *Registry { tailscaleRegistration(), tallyRegistration(), vercelRegistration(), + zendeskRegistration(), } { if err := r.Register(reg); err != nil { panic(err) diff --git a/pkg/connector/provider/zendesk.go b/pkg/connector/provider/zendesk.go new file mode 100644 index 000000000..4b41f4423 --- /dev/null +++ b/pkg/connector/provider/zendesk.go @@ -0,0 +1,75 @@ +// 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" + "fmt" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/connector" + "go.probo.inc/probo/pkg/coredata" +) + +func zendeskRegistration() *Registration { + // Zendesk is multi-tenant via per-customer subdomain + // (.zendesk.com). The subdomain is collected at initiate (the + // customer types it; it drives the authorize host) and rides the signed + // OAuth state to the callback, where it builds the token host and is + // persisted on the connector settings for the driver's API host. Unlike + // Datadog, Zendesk does NOT echo a host back on the callback, so + // BuildTokenURLForSite reads the subdomain from the state rather than a + // query param. AuthURL, TokenURL, and ProbeURL are therefore empty: the + // closures build the per-customer hosts, and a static probe URL is + // impossible for a per-subdomain host (an empty probe is skipped; a dead + // token surfaces on the first ListAccounts). The global confidential + // client carries a client_secret, which both authenticates the token + // exchange (default post-form) and signs the state. + return &Registration{ + Provider: coredata.ConnectorProviderZendesk, + DisplayName: "Zendesk", + OAuth2Scopes: []string{"users:read"}, + BuildAuthURLForSite: connector.ZendeskAuthorizeURL, + BuildTokenURLForSite: connector.ZendeskTokenURL, + NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + s, err := coredata.ConnectorSettings[coredata.ZendeskConnectorSettings](conn) + if err != nil { + return nil, fmt.Errorf("cannot read zendesk connector settings: %w", err) + } + + // Re-validate the stored subdomain at the construction site + // (defense-in-depth). The OAuth callback validates on write, but + // pinning the SSRF invariant here keeps the driver safe regardless + // of how the connector row was populated. An empty subdomain also + // fails this check. + if !connector.IsValidZendeskSubdomain(s.Subdomain) { + return nil, fmt.Errorf("cannot create zendesk driver: invalid or missing subdomain") + } + + return drivers.NewZendeskDriver(c, s.Subdomain), nil + }, + NewNameResolver: func(ctx context.Context, _ *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver { + s, err := coredata.ConnectorSettings[coredata.ZendeskConnectorSettings](conn) + if err != nil { + logger.ErrorCtx(ctx, "cannot read zendesk connector settings", log.Error(err)) + return nil + } + + return drivers.NewZendeskNameResolver(s.Subdomain) + }, + } +} diff --git a/pkg/connector/zendesk.go b/pkg/connector/zendesk.go new file mode 100644 index 000000000..e4d0d7632 --- /dev/null +++ b/pkg/connector/zendesk.go @@ -0,0 +1,79 @@ +// 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 connector + +import ( + "fmt" + "net/url" +) + +const ZendeskProvider = "ZENDESK" + +// IsValidZendeskSubdomain reports whether s is a single DNS label safe to use +// as the host component of .zendesk.com. The subdomain is +// customer-supplied and feeds a URL host on every authorize, token, and API +// request, so it MUST be validated before use to close an SSRF vector: only +// ASCII letters, digits, and interior hyphens are allowed (no dots, slashes, +// colons, '@', or whitespace that could escape the host position), bounded to +// a 63-character DNS label. DNS is case-insensitive, so mixed case is accepted +// and used verbatim. +func IsValidZendeskSubdomain(s string) bool { + if len(s) == 0 || len(s) > 63 { + return false + } + + // i is a byte offset; because every accepted character is single-byte + // ASCII, i equals the character position, so the i < len(s)-1 bound below + // correctly identifies the final character. A non-ASCII rune falls through + // to the default case and is rejected before that assumption matters. + for i, c := range s { + switch { + case c >= 'a' && c <= 'z': + case c >= 'A' && c <= 'Z': + case c >= '0' && c <= '9': + case c == '-' && i > 0 && i < len(s)-1: + default: + return false + } + } + + return true +} + +// ZendeskAuthorizeURL returns the OAuth2 authorize endpoint for a Zendesk +// customer subdomain (e.g. "acme"). It errors on any subdomain that is not a +// valid single DNS label. +func ZendeskAuthorizeURL(subdomain string) (string, error) { + if !IsValidZendeskSubdomain(subdomain) { + return "", fmt.Errorf("cannot build authorize URL: invalid zendesk subdomain") + } + + u := url.URL{Scheme: "https", Host: subdomain + ".zendesk.com", Path: "/oauth/authorizations/new"} + + return u.String(), nil +} + +// ZendeskTokenURL returns the OAuth2 token endpoint for a Zendesk customer +// subdomain (e.g. "acme"). It errors on any subdomain that is not a valid +// single DNS label. +func ZendeskTokenURL(subdomain string) (string, error) { + if !IsValidZendeskSubdomain(subdomain) { + return "", fmt.Errorf("cannot build token URL: invalid zendesk subdomain") + } + + u := url.URL{Scheme: "https", Host: subdomain + ".zendesk.com", Path: "/oauth/tokens"} + + return u.String(), nil +} diff --git a/pkg/connector/zendesk_test.go b/pkg/connector/zendesk_test.go new file mode 100644 index 000000000..b701c7610 --- /dev/null +++ b/pkg/connector/zendesk_test.go @@ -0,0 +1,81 @@ +// 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 connector_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/connector" +) + +func TestZendeskAuthorizeURL(t *testing.T) { + t.Parallel() + + got, err := connector.ZendeskAuthorizeURL("acme") + require.NoError(t, err) + assert.Equal(t, "https://acme.zendesk.com/oauth/authorizations/new", got) + + // An invalid subdomain must error rather than build a host. + _, err = connector.ZendeskAuthorizeURL("evil.example") + require.Error(t, err) +} + +func TestZendeskTokenURL(t *testing.T) { + t.Parallel() + + got, err := connector.ZendeskTokenURL("acme") + require.NoError(t, err) + assert.Equal(t, "https://acme.zendesk.com/oauth/tokens", got) + + _, err = connector.ZendeskTokenURL("acme/../evil") + require.Error(t, err) +} + +// TestIsValidZendeskSubdomain exercises the SSRF guard that gates every Zendesk +// URL host. Anything that could escape the host position of +// .zendesk.com must be rejected. +func TestIsValidZendeskSubdomain(t *testing.T) { + t.Parallel() + + for _, s := range []string{ + "acme", + "my-company", + "a", // single label + "ABC123", // DNS is case-insensitive + "a--b", // interior double hyphen is allowed + strings.Repeat("a", 63), // max DNS label length + } { + assert.True(t, connector.IsValidZendeskSubdomain(s), s) + } + + for _, s := range []string{ + "", // empty + strings.Repeat("a", 64), // one over the 63-char label cap + "-acme", // leading hyphen + "acme-", // trailing hyphen + "acme.evil", // dot — would add a host segment + "acme/evil", // slash — path escape + "acme:1234", // colon — port/authority escape + "acme@evil", // userinfo escape + "acme evil", // whitespace + "acme_evil", // underscore is not a DNS label char + "acmé", // non-ASCII + } { + assert.False(t, connector.IsValidZendeskSubdomain(s), s) + } +} diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index a862150ae..58691b93d 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -59,6 +59,7 @@ const ( ConnectorProviderCursor ConnectorProvider = "CURSOR" ConnectorProviderDatadog ConnectorProvider = "DATADOG" ConnectorProviderOkta ConnectorProvider = "OKTA" + ConnectorProviderZendesk ConnectorProvider = "ZENDESK" ) var ( @@ -105,6 +106,7 @@ func ConnectorProviders() []ConnectorProvider { ConnectorProviderCursor, ConnectorProviderDatadog, ConnectorProviderOkta, + ConnectorProviderZendesk, } } @@ -146,7 +148,8 @@ func (v ConnectorProvider) IsValid() bool { ConnectorProviderAnthropic, ConnectorProviderCursor, ConnectorProviderDatadog, - ConnectorProviderOkta: + ConnectorProviderOkta, + ConnectorProviderZendesk: return true } diff --git a/pkg/coredata/connector_settings.go b/pkg/coredata/connector_settings.go index 7450ba690..423acfa88 100644 --- a/pkg/coredata/connector_settings.go +++ b/pkg/coredata/connector_settings.go @@ -122,6 +122,16 @@ type ( OktaConnectorSettings struct { Domain string `json:"domain"` } + + // ZendeskConnectorSettings holds the per-customer Zendesk subdomain + // captured at connect time (the customer types it before the OAuth + // redirect, and it rides the signed state token to the callback — + // Zendesk does not echo it back). Subdomain is the part of + // .zendesk.com, used by the driver to build the API host and + // by the name resolver for the AccessSource title. + ZendeskConnectorSettings struct { + Subdomain string `json:"subdomain"` + } ) // GrantType returns the OAuth2 grant type recorded on the connector's diff --git a/pkg/coredata/migrations/20260604T372815Z.sql b/pkg/coredata/migrations/20260604T372815Z.sql new file mode 100644 index 000000000..6b2758486 --- /dev/null +++ b/pkg/coredata/migrations/20260604T372815Z.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 'ZENDESK'; diff --git a/pkg/server/api/console/v1/access_source_provider_config.go b/pkg/server/api/console/v1/access_source_provider_config.go index f498a026d..557dd38a8 100644 --- a/pkg/server/api/console/v1/access_source_provider_config.go +++ b/pkg/server/api/console/v1/access_source_provider_config.go @@ -131,4 +131,12 @@ var providerOrgConfigs = map[coredata.ConnectorProvider]providerOrgConfig{ return s.Domain }, }, + // Pattern 2-auto: the subdomain is collected at initiate and persisted + // from the signed OAuth state on the callback; no picker UI. + coredata.ConnectorProviderZendesk: { + SelectedSlug: func(c *coredata.Connector) string { + s, _ := coredata.ConnectorSettings[coredata.ZendeskConnectorSettings](c) + return s.Subdomain + }, + }, } diff --git a/pkg/server/api/console/v1/graphql/connector.graphql b/pkg/server/api/console/v1/graphql/connector.graphql index dd41a6258..ac97f4630 100644 --- a/pkg/server/api/console/v1/graphql/connector.graphql +++ b/pkg/server/api/console/v1/graphql/connector.graphql @@ -61,6 +61,7 @@ enum ConnectorProvider CURSOR @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderCursor") DATADOG @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderDatadog") OKTA @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderOkta") + ZENDESK @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderZendesk") } type ConnectorProviderInfo { diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index 0fb8c29b1..7208c5eda 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -189,12 +189,13 @@ func handleConnectorComplete( var cnnctr *coredata.Connector - // Datadog returns the customer's API domain as a `domain` query - // parameter on every OAuth callback; it drives the driver's API - // host, so capture and validate it on both the create and the - // reconnect path (a reconnect from a different site must refresh - // the stored domain). - var datadogRawSettings json.RawMessage + // Some providers persist per-customer settings on the connector, + // captured here for both the create and the reconnect path: Datadog + // echoes its API domain as a `domain` callback param; Zendesk's + // subdomain rode the signed OAuth state from initiate (it is not + // echoed back). Both become a URL host, so each is re-validated + // before use. At most one block applies per callback. + var rawSettings json.RawMessage if connectorProvider == coredata.ConnectorProviderDatadog { domain := query.Get("domain") @@ -220,7 +221,33 @@ func handleConnectorComplete( return } - datadogRawSettings = raw + rawSettings = raw + } + + if connectorProvider == coredata.ConnectorProviderZendesk { + // The subdomain is HMAC-signed in the state (untamperable) and was + // validated at initiate, but re-validate it here too — it becomes + // a URL host on every API call (defense-in-depth). + if !connector.IsValidZendeskSubdomain(state.Site) { + logger.WarnCtx(r.Context(), "rejecting invalid zendesk subdomain", + log.String("provider", string(connectorProvider)), + ) + httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("invalid subdomain")) + + return + } + + raw, err := json.Marshal(&coredata.ZendeskConnectorSettings{ + Subdomain: state.Site, + }) + if err != nil { + logger.ErrorCtx(r.Context(), "cannot marshal zendesk settings", log.Error(err)) + httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("internal error")) + + return + } + + rawSettings = raw } // If a connector_id was passed in the state, this is a @@ -240,7 +267,7 @@ func handleConnectorComplete( OrganizationID: organizationID, Provider: connectorProvider, Connection: connection, - RawSettings: datadogRawSettings, + RawSettings: rawSettings, }, ) if err != nil { @@ -328,11 +355,11 @@ func handleConnectorComplete( } } - // Datadog's per-customer settings were captured and validated - // above (the same block also feeds the reconnect path); apply - // them to the create request. - if datadogRawSettings != nil { - createReq.RawSettings = datadogRawSettings + // Per-customer settings captured above (Datadog's callback domain + // or Zendesk's state subdomain) apply to the create request; at + // most one provider populates them per callback. + if rawSettings != nil { + createReq.RawSettings = rawSettings } cnnctr, err = svc.Connectors.Create(r.Context(), scope, createReq)