diff --git a/apps/console/src/pages/organizations/access-reviews/_components/AccessReviewSourceRow.tsx b/apps/console/src/pages/organizations/access-reviews/_components/AccessReviewSourceRow.tsx index d6af3c18e..654377d76 100644 --- a/apps/console/src/pages/organizations/access-reviews/_components/AccessReviewSourceRow.tsx +++ b/apps/console/src/pages/organizations/access-reviews/_components/AccessReviewSourceRow.tsx @@ -116,6 +116,8 @@ function sourceLabel(connectorProvider: string | null | undefined): string { return "Metabase"; case "SIGNOZ": return "SigNoz"; + case "CURSOR": + return "Cursor"; default: return connectorProvider; } @@ -231,6 +233,7 @@ export function AccessReviewSourceRow({ fKey, connectionId, organizationId }: Pr }; const showOrgSelector = accessSource.needsConfiguration || accessSource.selectedOrganization; + const canReconnect = (accessSource.connector?.oauth2Scopes.length ?? 0) > 0; return ( @@ -246,10 +249,14 @@ export function AccessReviewSourceRow({ fKey, connectionId, organizationId }: Pr )} {accessSource.connectionStatus === "DISCONNECTED" && (
- {__("Disconnected")} - + + {canReconnect ? __("Disconnected") : __("Invalid credentials")} + + {canReconnect && ( + + )}
)} diff --git a/pkg/connector/provider/anthropic.go b/pkg/connector/provider/anthropic.go index c599e4736..0c63b8558 100644 --- a/pkg/connector/provider/anthropic.go +++ b/pkg/connector/provider/anthropic.go @@ -35,10 +35,7 @@ func anthropicRegistration() *Registration { // third-party OAuth2 flow for the Admin API, so this is API-key // only and takes a single admin key (sk-ant-admin...) per org. APIKeyHeader: "x-api-key", - // ProbeURL is intentionally empty: the generic probe issues a - // header-less GET that cannot send the required anthropic-version - // header, so it would 400 regardless of token validity. A dead - // key surfaces on the first ListAccounts instead. + Probe: probeAnthropic, NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { return drivers.NewAnthropicDriver(c), nil }, diff --git a/pkg/connector/provider/clerk.go b/pkg/connector/provider/clerk.go index 680443b7c..c07b5fc25 100644 --- a/pkg/connector/provider/clerk.go +++ b/pkg/connector/provider/clerk.go @@ -36,11 +36,7 @@ func clerkRegistration() *Registration { // API. The secret key is bound to one Clerk instance, so there is // nothing to pick (Pattern 3): no settings struct, no picker, no // SetOrganizationSettings. - // - // ProbeURL is intentionally empty: the connection probe runs only - // for OAuth2 connections, so it would be dead config for an API-key - // provider; a dead key surfaces on the first ListAccounts instead. - // + ProbeURL: "https://api.clerk.com/v1/users?limit=1", // No NewNameResolver: the Backend API exposes no instance/application // name endpoint reachable with a secret key, so the source keeps its // generic name (the source-name worker degrades gracefully). diff --git a/pkg/connector/provider/cursor.go b/pkg/connector/provider/cursor.go index 9c664bdd8..dc131be49 100644 --- a/pkg/connector/provider/cursor.go +++ b/pkg/connector/provider/cursor.go @@ -23,6 +23,8 @@ import ( "go.probo.inc/probo/pkg/coredata" ) +const cursorMembersEndpoint = "https://api.cursor.com/teams/members" + func cursorRegistration() *Registration { return &Registration{ Provider: coredata.ConnectorProviderCursor, @@ -36,11 +38,7 @@ func cursorRegistration() *Registration { // there is nothing to pick (Pattern 3): no settings struct, no // picker, and no SetOrganizationSettings. APIKeyBasicAuth: true, - // ProbeURL is intentionally empty. API-key connectors skip the - // connection probe entirely (it runs only for OAuth2), so a probe - // URL would be dead config; a dead key surfaces on the first - // ListAccounts instead. - // + ProbeURL: cursorMembersEndpoint, // No NewNameResolver: the Admin API exposes no team/organization // name endpoint, so the source keeps its generic name (the // source-name worker degrades gracefully when no resolver is set). diff --git a/pkg/connector/provider/datadog.go b/pkg/connector/provider/datadog.go index b13404a1e..84f2c5b98 100644 --- a/pkg/connector/provider/datadog.go +++ b/pkg/connector/provider/datadog.go @@ -28,10 +28,9 @@ import ( func datadogRegistration() *Registration { // Datadog is multi-site: the customer's region drives the authorize // host (built at initiate from the region pick) and the token + API - // host (built at callback from Datadog's `domain` param). AuthURL, - // TokenURL, and ProbeURL are therefore empty — the closures build the - // per-customer hosts, and an empty probe is skipped (a dead token - // surfaces on the first ListAccounts). Confidential client + PKCE map + // host (built at callback from Datadog's `domain` param). AuthURL and + // TokenURL are empty — the closures build the per-customer hosts. + // BuildProbeURL targets the stored API domain. Confidential client + PKCE map // to the default post-form token-endpoint auth. return &Registration{ Provider: coredata.ConnectorProviderDatadog, @@ -40,6 +39,7 @@ func datadogRegistration() *Registration { RequiresPKCE: true, BuildAuthURLForSite: connector.DatadogAuthorizeURL, BuildTokenURLForDomain: connector.DatadogTokenURL, + BuildProbeURL: buildDatadogProbeURL, NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { s, err := coredata.ConnectorSettings[coredata.DatadogConnectorSettings](conn) if err != nil { diff --git a/pkg/connector/provider/grafana.go b/pkg/connector/provider/grafana.go index 29ef07b05..9381ec25d 100644 --- a/pkg/connector/provider/grafana.go +++ b/pkg/connector/provider/grafana.go @@ -31,6 +31,7 @@ func grafanaRegistration() *Registration { Provider: coredata.ConnectorProviderGrafana, DisplayName: "Grafana", SupportsAPIKey: true, + BuildProbeURL: buildGrafanaProbeURL, ExtraSettings: []ExtraSetting{ {Key: "baseUrl", Label: "Base URL", Required: true}, }, diff --git a/pkg/connector/provider/linear.go b/pkg/connector/provider/linear.go index f32422222..dc44735c4 100644 --- a/pkg/connector/provider/linear.go +++ b/pkg/connector/provider/linear.go @@ -29,7 +29,7 @@ func linearRegistration() *Registration { DisplayName: "Linear", AuthURL: "https://linear.app/oauth/authorize", TokenURL: "https://api.linear.app/oauth/token", - ProbeURL: "https://api.linear.app/graphql", + Probe: probeLinear, OAuth2Scopes: []string{"read"}, NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { return drivers.NewLinearDriver(c), nil diff --git a/pkg/connector/provider/metabase.go b/pkg/connector/provider/metabase.go index 6ab6fee0f..0de97939f 100644 --- a/pkg/connector/provider/metabase.go +++ b/pkg/connector/provider/metabase.go @@ -32,6 +32,7 @@ func metabaseRegistration() *Registration { DisplayName: "Metabase", SupportsAPIKey: true, APIKeyHeader: "x-api-key", + BuildProbeURL: buildMetabaseProbeURL, ExtraSettings: []ExtraSetting{ {Key: "instanceUrl", Label: "Instance URL", Required: true}, }, diff --git a/pkg/connector/provider/monday.go b/pkg/connector/provider/monday.go index 9f1b50acb..1aa05845f 100644 --- a/pkg/connector/provider/monday.go +++ b/pkg/connector/provider/monday.go @@ -24,15 +24,12 @@ import ( ) func mondayRegistration() *Registration { - // Monday.com's primary API is GraphQL POST, and the auth subdomain - // does not expose a Bearer-protected GET userinfo endpoint, so - // ProbeURL is empty. The probe handler skips empty entries; an - // invalid token surfaces at the next /v2 query. return &Registration{ Provider: coredata.ConnectorProviderMonday, DisplayName: "Monday.com", AuthURL: "https://auth.monday.com/oauth2/authorize", TokenURL: "https://auth.monday.com/oauth2/token", + Probe: probeMonday, OAuth2Scopes: []string{"users:read", "account:read"}, NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { return drivers.NewMondayDriver(c), nil diff --git a/pkg/connector/provider/neon.go b/pkg/connector/provider/neon.go index a3c84c3ae..1e9aa2717 100644 --- a/pkg/connector/provider/neon.go +++ b/pkg/connector/provider/neon.go @@ -35,10 +35,8 @@ func neonRegistration() *Registration { // can belong to several organizations; the operator supplies the // org ID (org-...) of the one to review. // - // ProbeURL is intentionally empty: the connection probe runs only - // for OAuth2 connections, so it would be dead config for an - // API-key provider; a dead key surfaces on the first ListAccounts. SupportsAPIKey: true, + BuildProbeURL: buildNeonProbeURL, ExtraSettings: []ExtraSetting{ {Key: "organizationId", Label: "Organization ID", Required: true}, }, diff --git a/pkg/connector/provider/okta.go b/pkg/connector/provider/okta.go index 3e88c2622..6f0cc263e 100644 --- a/pkg/connector/provider/okta.go +++ b/pkg/connector/provider/okta.go @@ -30,15 +30,14 @@ import ( // possible — it authenticates with a read-only API token presented under the // `SSWS` Authorization scheme (APIKeyAuthScheme), plus the customer's org // domain. The token + domain identify exactly one org, so there is no picker -// and no OAuth metadata. ProbeURL is empty because the API host is per-org and -// there is no static URL to probe; a dead token surfaces on the first -// ListAccounts. +// and no OAuth metadata. BuildProbeURL targets the org's own API host. func oktaRegistration() *Registration { return &Registration{ Provider: coredata.ConnectorProviderOkta, DisplayName: "Okta", SupportsAPIKey: true, APIKeyAuthScheme: "SSWS", + BuildProbeURL: buildOktaProbeURL, ExtraSettings: []ExtraSetting{ {Key: "domain", Label: "Okta Domain", Required: true}, }, diff --git a/pkg/connector/provider/posthog.go b/pkg/connector/provider/posthog.go index 88db8b50f..84de4a015 100644 --- a/pkg/connector/provider/posthog.go +++ b/pkg/connector/provider/posthog.go @@ -52,12 +52,8 @@ func posthogRegistration() *Registration { // organization_member:read applies org-wide and the org endpoints // resolve @current to the granted organization. ExtraAuthParams: map[string]string{"required_access_level": "organization"}, - // ProbeURL is intentionally empty: the data host varies per - // connection (the region-agnostic gateway for OAuth, us/eu for - // API-key), so a single static probe URL cannot match it. A dead - // token surfaces on the first ListAccounts. - - SupportsAPIKey: true, + Probe: probePostHog, + SupportsAPIKey: true, // API-key connections are either PostHog Cloud (a region, us/eu) or // self-hosted (an instance URL). The two are mutually exclusive, so // neither is individually Required; apiKeyConnectorSettings enforces diff --git a/pkg/connector/provider/probe.go b/pkg/connector/provider/probe.go new file mode 100644 index 000000000..537e08506 --- /dev/null +++ b/pkg/connector/provider/probe.go @@ -0,0 +1,444 @@ +// 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 ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "go.probo.inc/probo/pkg/connector" + "go.probo.inc/probo/pkg/coredata" +) + +const ( + anthropicAPIVersion = "2023-06-01" + anthropicUsersProbeURL = "https://api.anthropic.com/v1/organizations/users?limit=1" + linearGraphQLEndpoint = "https://api.linear.app/graphql" + mondayGraphQLEndpoint = "https://api.monday.com/v2" + posthogOrganizationPath = "/api/organizations/@current/" + posthogUSBaseURL = "https://us.posthog.com" + posthogEUBaseURL = "https://eu.posthog.com" +) + +// ProbeConnection verifies that the connector credential is accepted by the +// provider. It dispatches to a provider-specific Probe closure when +// registered, otherwise issues a lightweight GET against ProbeURL or +// BuildProbeURL. An empty probe URL means the check is skipped. +func (r *Registry) ProbeConnection( + ctx context.Context, + httpClient *http.Client, + conn *coredata.Connector, +) error { + reg, ok := r.Get(conn.Provider) + if !ok { + return nil + } + + if reg.Probe != nil { + return reg.Probe(ctx, httpClient, conn) + } + + probeURL := reg.ProbeURL + if reg.BuildProbeURL != nil { + built, err := reg.BuildProbeURL(conn) + if err != nil { + return fmt.Errorf("cannot build probe URL: %w", err) + } + + probeURL = built + } + + return probeGET(ctx, httpClient, probeURL) +} + +func probeGET(ctx context.Context, httpClient *http.Client, probeURL string) error { + if probeURL == "" { + return nil + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, probeURL, nil) + if err != nil { + return fmt.Errorf("cannot create probe request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + return doProbeRequest(httpClient, req) +} + +func probePOSTJSON( + ctx context.Context, + httpClient *http.Client, + probeURL string, + payload any, + extraHeaders map[string]string, +) error { + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("cannot marshal probe request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, probeURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("cannot create probe request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + for key, value := range extraHeaders { + req.Header.Set(key, value) + } + + return doProbeRequest(httpClient, req) +} + +func doProbeRequest(httpClient *http.Client, req *http.Request) error { + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("probe request failed: %w", err) + } + + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + }() + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("credential rejected: status %d", resp.StatusCode) + } + + return nil +} + +func buildDatadogProbeURL(conn *coredata.Connector) (string, error) { + s, err := coredata.ConnectorSettings[coredata.DatadogConnectorSettings](conn) + if err != nil { + return "", fmt.Errorf("cannot read datadog connector settings: %w", err) + } + + if !connector.IsValidDatadogDomain(s.Domain) { + return "", fmt.Errorf("invalid or missing datadog domain") + } + + q := url.Values{} + q.Set("page[size]", "1") + q.Set("page[number]", "0") + + endpoint := url.URL{ + Scheme: "https", + Host: "api." + s.Domain, + Path: "/api/v2/users", + RawQuery: q.Encode(), + } + + return endpoint.String(), nil +} + +func buildZendeskProbeURL(conn *coredata.Connector) (string, error) { + s, err := coredata.ConnectorSettings[coredata.ZendeskConnectorSettings](conn) + if err != nil { + return "", fmt.Errorf("cannot read zendesk connector settings: %w", err) + } + + if !connector.IsValidZendeskSubdomain(s.Subdomain) { + return "", fmt.Errorf("invalid or missing zendesk subdomain") + } + + q := url.Values{} + q.Set("page[size]", "1") + q.Add("role[]", "agent") + q.Add("role[]", "admin") + + endpoint := url.URL{ + Scheme: "https", + Host: s.Subdomain + ".zendesk.com", + Path: "/api/v2/users.json", + RawQuery: q.Encode(), + } + + return endpoint.String(), nil +} + +func buildOktaProbeURL(conn *coredata.Connector) (string, error) { + s, err := coredata.ConnectorSettings[coredata.OktaConnectorSettings](conn) + if err != nil { + return "", fmt.Errorf("cannot read okta connector settings: %w", err) + } + + if !connector.IsValidOktaDomain(s.Domain) { + return "", fmt.Errorf("invalid or missing okta domain") + } + + endpoint := url.URL{ + Scheme: "https", + Host: s.Domain, + Path: "/api/v1/users", + RawQuery: url.Values{"limit": {"1"}}.Encode(), + } + + return endpoint.String(), nil +} + +func buildNeonProbeURL(conn *coredata.Connector) (string, error) { + s, err := coredata.ConnectorSettings[coredata.NeonConnectorSettings](conn) + if err != nil { + return "", fmt.Errorf("cannot read neon connector settings: %w", err) + } + + if s.OrganizationID == "" { + return "", fmt.Errorf("missing neon organization_id") + } + + endpoint, err := url.JoinPath( + "https://console.neon.tech/api/v2", + "organizations", + url.PathEscape(s.OrganizationID), + "members", + ) + if err != nil { + return "", fmt.Errorf("cannot build neon probe URL: %w", err) + } + + q := url.Values{"limit": {"1"}} + + return endpoint + "?" + q.Encode(), nil +} + +func buildRenderProbeURL(conn *coredata.Connector) (string, error) { + s, err := coredata.ConnectorSettings[coredata.RenderConnectorSettings](conn) + if err != nil { + return "", fmt.Errorf("cannot read render connector settings: %w", err) + } + + if s.OwnerID == "" { + return "", fmt.Errorf("missing render owner_id") + } + + return url.JoinPath( + "https://api.render.com/v1", + "owners", + url.PathEscape(s.OwnerID), + "members", + ) +} + +func buildQoveryProbeURL(conn *coredata.Connector) (string, error) { + s, err := coredata.ConnectorSettings[coredata.QoveryConnectorSettings](conn) + if err != nil { + return "", fmt.Errorf("cannot read qovery connector settings: %w", err) + } + + if s.OrganizationID == "" { + return "", fmt.Errorf("missing qovery organization_id") + } + + return url.JoinPath( + "https://api.qovery.com", + "organization", + url.PathEscape(s.OrganizationID), + "member", + ) +} + +func buildGrafanaProbeURL(conn *coredata.Connector) (string, error) { + s, err := coredata.ConnectorSettings[coredata.GrafanaConnectorSettings](conn) + if err != nil { + return "", fmt.Errorf("cannot read grafana connector settings: %w", err) + } + + baseURL, err := normalizeGrafanaBaseURL(s.BaseURL) + if err != nil { + return "", err + } + + u, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("cannot parse grafana base URL: %w", err) + } + + u = u.JoinPath("api", "org", "users") + q := u.Query() + q.Set("perpage", "1") + q.Set("page", "1") + u.RawQuery = q.Encode() + + return u.String(), nil +} + +func buildMetabaseProbeURL(conn *coredata.Connector) (string, error) { + s, err := coredata.ConnectorSettings[coredata.MetabaseConnectorSettings](conn) + if err != nil { + return "", fmt.Errorf("cannot read metabase connector settings: %w", err) + } + + instanceURL := strings.TrimSpace(s.InstanceURL) + if instanceURL == "" { + return "", fmt.Errorf("missing metabase instance_url") + } + + if err := validateMetabaseInstanceURL(instanceURL); err != nil { + return "", err + } + + u, err := url.Parse(instanceURL) + if err != nil { + return "", fmt.Errorf("cannot parse metabase instance URL: %w", err) + } + + endpoint := u.JoinPath("api", "user") + q := endpoint.Query() + q.Set("status", "all") + q.Set("limit", "1") + q.Set("offset", "0") + endpoint.RawQuery = q.Encode() + + return endpoint.String(), nil +} + +func buildSigNozProbeURL(conn *coredata.Connector) (string, error) { + s, err := coredata.ConnectorSettings[coredata.SigNozConnectorSettings](conn) + if err != nil { + return "", fmt.Errorf("cannot read signoz connector settings: %w", err) + } + + baseURL, err := normalizeSigNozBaseURL(s.BaseURL) + if err != nil { + return "", err + } + + u, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("cannot parse signoz base URL: %w", err) + } + + return u.JoinPath("api", "v1", "user").String(), nil +} + +func buildPostHogProbeURL(conn *coredata.Connector) (string, error) { + s, err := coredata.ConnectorSettings[coredata.PostHogConnectorSettings](conn) + if err != nil { + return "", fmt.Errorf("cannot read posthog connector settings: %w", err) + } + + baseURL := strings.TrimSpace(s.BaseURL) + if baseURL == "" { + return "", nil + } + + return url.JoinPath(baseURL, posthogOrganizationPath) +} + +func probeLinear( + ctx context.Context, + httpClient *http.Client, + _ *coredata.Connector, +) error { + return probePOSTJSON( + ctx, + httpClient, + linearGraphQLEndpoint, + map[string]string{"query": "{ viewer { id } }"}, + nil, + ) +} + +func probeMonday( + ctx context.Context, + httpClient *http.Client, + _ *coredata.Connector, +) error { + return probePOSTJSON( + ctx, + httpClient, + mondayGraphQLEndpoint, + map[string]string{"query": "query { users(limit: 1) { id } }"}, + nil, + ) +} + +func probeAnthropic( + ctx context.Context, + httpClient *http.Client, + _ *coredata.Connector, +) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, anthropicUsersProbeURL, nil) + if err != nil { + return fmt.Errorf("cannot create probe request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("anthropic-version", anthropicAPIVersion) + + return doProbeRequest(httpClient, req) +} + +func probePostHog( + ctx context.Context, + httpClient *http.Client, + conn *coredata.Connector, +) error { + probeURL, err := buildPostHogProbeURL(conn) + if err != nil { + return err + } + + if probeURL != "" { + return probeGET(ctx, httpClient, probeURL) + } + + for _, host := range []string{posthogUSBaseURL, posthogEUBaseURL} { + endpoint, err := url.JoinPath(host, posthogOrganizationPath) + if err != nil { + continue + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + continue + } + + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + if ctx.Err() != nil { + return fmt.Errorf("cannot probe posthog region: %w", ctx.Err()) + } + + continue + } + + status := resp.StatusCode + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + + if status == http.StatusUnauthorized || status == http.StatusForbidden { + return fmt.Errorf("credential rejected: status %d", status) + } + + if status >= http.StatusOK && status < http.StatusMultipleChoices { + return nil + } + } + + return fmt.Errorf("credential rejected: no posthog region accepted the connection") +} diff --git a/pkg/connector/provider/probe_test.go b/pkg/connector/provider/probe_test.go new file mode 100644 index 000000000..7b030c398 --- /dev/null +++ b/pkg/connector/provider/probe_test.go @@ -0,0 +1,92 @@ +// 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 ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.probo.inc/probo/pkg/coredata" +) + +func TestBuiltinRegistry_ProbeCoverage(t *testing.T) { + t.Parallel() + + r := NewBuiltinRegistry() + + for _, reg := range r.All() { + hasProbe := reg.Probe != nil || reg.ProbeURL != "" || reg.BuildProbeURL != nil + assert.True(t, hasProbe, "provider %s has no connection probe configured", reg.Provider) + } +} + +func TestBuildDatadogProbeURL(t *testing.T) { + t.Parallel() + + conn := &coredata.Connector{Provider: coredata.ConnectorProviderDatadog} + require.NoError(t, conn.SetSettings(&coredata.DatadogConnectorSettings{ + Domain: "us3.datadoghq.com", + Region: "US3", + })) + + probeURL, err := buildDatadogProbeURL(conn) + require.NoError(t, err) + assert.Equal( + t, + "https://api.us3.datadoghq.com/api/v2/users?page%5Bnumber%5D=0&page%5Bsize%5D=1", + probeURL, + ) +} + +func TestBuildZendeskProbeURL(t *testing.T) { + t.Parallel() + + conn := &coredata.Connector{Provider: coredata.ConnectorProviderZendesk} + require.NoError(t, conn.SetSettings(&coredata.ZendeskConnectorSettings{ + Subdomain: "acme", + })) + + probeURL, err := buildZendeskProbeURL(conn) + require.NoError(t, err) + assert.Contains(t, probeURL, "https://acme.zendesk.com/api/v2/users.json") +} + +func TestBuildOktaProbeURL(t *testing.T) { + t.Parallel() + + conn := &coredata.Connector{Provider: coredata.ConnectorProviderOkta} + require.NoError(t, conn.SetSettings(&coredata.OktaConnectorSettings{ + Domain: "acme.okta.com", + })) + + probeURL, err := buildOktaProbeURL(conn) + require.NoError(t, err) + assert.Equal(t, "https://acme.okta.com/api/v1/users?limit=1", probeURL) +} + +func TestBuildPostHogProbeURL(t *testing.T) { + t.Parallel() + + conn := &coredata.Connector{Provider: coredata.ConnectorProviderPostHog} + require.NoError(t, conn.SetSettings(&coredata.PostHogConnectorSettings{ + BaseURL: "https://us.posthog.com", + })) + + probeURL, err := buildPostHogProbeURL(conn) + require.NoError(t, err) + assert.Equal(t, "https://us.posthog.com/api/organizations/@current/", probeURL) +} diff --git a/pkg/connector/provider/qovery.go b/pkg/connector/provider/qovery.go index f60df313a..89e0e9770 100644 --- a/pkg/connector/provider/qovery.go +++ b/pkg/connector/provider/qovery.go @@ -30,6 +30,7 @@ func qoveryRegistration() *Registration { DisplayName: "Qovery", SupportsAPIKey: true, APIKeyAuthScheme: "Token", + BuildProbeURL: buildQoveryProbeURL, ExtraSettings: []ExtraSetting{ {Key: "organizationId", Label: "Organization ID", Required: true}, }, diff --git a/pkg/connector/provider/render.go b/pkg/connector/provider/render.go index 46ee132d9..f7a2f2545 100644 --- a/pkg/connector/provider/render.go +++ b/pkg/connector/provider/render.go @@ -35,6 +35,7 @@ func renderRegistration() *Registration { Provider: coredata.ConnectorProviderRender, DisplayName: "Render", SupportsAPIKey: true, + BuildProbeURL: buildRenderProbeURL, ExtraSettings: []ExtraSetting{ {Key: "workspaceId", Label: "Workspace ID", Required: true}, }, diff --git a/pkg/connector/provider/signoz.go b/pkg/connector/provider/signoz.go index 7f8b31a49..5ae3a1226 100644 --- a/pkg/connector/provider/signoz.go +++ b/pkg/connector/provider/signoz.go @@ -32,6 +32,7 @@ func signozRegistration() *Registration { DisplayName: "SigNoz", SupportsAPIKey: true, APIKeyHeader: "SIGNOZ-API-KEY", + BuildProbeURL: buildSigNozProbeURL, ExtraSettings: []ExtraSetting{ {Key: "baseUrl", Label: "Base URL", Required: true}, }, diff --git a/pkg/connector/provider/tailscale.go b/pkg/connector/provider/tailscale.go index d668037de..a42f3db7e 100644 --- a/pkg/connector/provider/tailscale.go +++ b/pkg/connector/provider/tailscale.go @@ -28,6 +28,7 @@ func tailscaleRegistration() *Registration { Provider: coredata.ConnectorProviderTailscale, DisplayName: "Tailscale", SupportsAPIKey: true, + ProbeURL: "https://api.tailscale.com/api/v2/tailnet/-/users", NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { return drivers.NewTailscaleDriver(c), nil }, diff --git a/pkg/connector/provider/types.go b/pkg/connector/provider/types.go index d47ce6216..6cc070eec 100644 --- a/pkg/connector/provider/types.go +++ b/pkg/connector/provider/types.go @@ -104,6 +104,16 @@ type Registration struct { // APIKeyConnection. APIKeyAuthScheme string + // BuildProbeURL derives a per-connector probe URL when the API host or + // path depends on connector settings (e.g. a customer subdomain or + // instance URL). Nil for providers with a static ProbeURL. + BuildProbeURL func(*coredata.Connector) (string, error) + // Probe runs a provider-specific connection check when a plain GET + // against ProbeURL/BuildProbeURL is insufficient (e.g. GraphQL POST, + // extra headers, or multi-host region probing). Takes precedence over + // ProbeURL and BuildProbeURL when set. + Probe func(context.Context, *http.Client, *coredata.Connector) error + // Factory closures — wired by Stages 2 and 3. NewDriver func(context.Context, *http.Client, *coredata.Connector, *log.Logger) (drivers.Driver, error) NewNameResolver func(context.Context, *http.Client, *coredata.Connector, *log.Logger) drivers.NameResolver diff --git a/pkg/connector/provider/zendesk.go b/pkg/connector/provider/zendesk.go index 25636759d..c933c9802 100644 --- a/pkg/connector/provider/zendesk.go +++ b/pkg/connector/provider/zendesk.go @@ -33,10 +33,8 @@ func zendeskRegistration() *Registration { // 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 + // query param. AuthURL and TokenURL are empty: the closures build the + // per-customer hosts. BuildProbeURL targets the stored subdomain. The global confidential // client carries a client_secret, which both authenticates the token // exchange (default post-form) and signs the state. return &Registration{ @@ -45,6 +43,7 @@ func zendeskRegistration() *Registration { OAuth2Scopes: []string{"users:read"}, BuildAuthURLForSite: connector.ZendeskAuthorizeURL, BuildTokenURLForSite: connector.ZendeskTokenURL, + BuildProbeURL: buildZendeskProbeURL, 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 { diff --git a/pkg/server/api/console/v1/access_review_campaign_resolvers.go b/pkg/server/api/console/v1/access_review_campaign_resolvers.go index fd51f1d38..2278a3c44 100644 --- a/pkg/server/api/console/v1/access_review_campaign_resolvers.go +++ b/pkg/server/api/console/v1/access_review_campaign_resolvers.go @@ -508,15 +508,11 @@ func (r *accessReviewSourceResolver) ConnectionStatus(ctx context.Context, obj * return types.AccessReviewSourceConnectionStatusDisconnected, nil } - if dbConnector.Protocol != coredata.ConnectorProtocolOAuth2 { - return types.AccessReviewSourceConnectionStatusConnected, nil - } - - // Creating an HTTP client may succeed even with an expired token - // (e.g. no refresh token available). Make a lightweight probe - // request to verify the token is actually valid. - probeURL := r.providerRegistry.ProbeURL(string(dbConnector.Provider)) - if err := probeConnection(ctx, httpClient, probeURL); err != nil { + // Creating an HTTP client may succeed even with an expired or invalid + // credential (e.g. no refresh token available, or a dead API key). + // When the provider registers a probe, make a lightweight request to + // verify the credential is actually accepted. + if err := r.providerRegistry.ProbeConnection(ctx, httpClient, dbConnector); err != nil { return types.AccessReviewSourceConnectionStatusDisconnected, nil } diff --git a/pkg/server/api/console/v1/provider_organizations.go b/pkg/server/api/console/v1/provider_organizations.go deleted file mode 100644 index 7d30fbf5a..000000000 --- a/pkg/server/api/console/v1/provider_organizations.go +++ /dev/null @@ -1,54 +0,0 @@ -// 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 console_v1 - -import ( - "context" - "fmt" - "io" - "net/http" -) - -// probeConnection makes a lightweight API call to the given URL to verify -// the OAuth token is still valid. The probe URL is configured per -// connector in the connector registry. -func probeConnection(ctx context.Context, httpClient *http.Client, probeURL string) error { - if probeURL == "" { - return nil - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, probeURL, nil) - if err != nil { - return fmt.Errorf("cannot create probe request: %w", err) - } - - req.Header.Set("Accept", "application/json") - - resp, err := httpClient.Do(req) - if err != nil { - return fmt.Errorf("probe request failed: %w", err) - } - - defer func() { - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - }() - - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - return fmt.Errorf("token rejected: status %d", resp.StatusCode) - } - - return nil -}