Probe every access review connector on status check
Bad API keys and expired OAuth tokens showed Connected because probes ran only for OAuth2 and many providers had no ProbeURL. Add a registry ProbeConnection dispatcher with static, dynamic, and custom probes so all 41 providers are checked on demand. Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
@@ -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 (
|
||||
<Tr>
|
||||
@@ -246,10 +249,14 @@ export function AccessReviewSourceRow({ fKey, connectionId, organizationId }: Pr
|
||||
)}
|
||||
{accessSource.connectionStatus === "DISCONNECTED" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="danger" size="sm">{__("Disconnected")}</Badge>
|
||||
<Button variant="secondary" onClick={handleReconnect}>
|
||||
{__("Reconnect")}
|
||||
</Button>
|
||||
<Badge variant="danger" size="sm">
|
||||
{canReconnect ? __("Disconnected") : __("Invalid credentials")}
|
||||
</Badge>
|
||||
{canReconnect && (
|
||||
<Button variant="secondary" onClick={handleReconnect}>
|
||||
{__("Reconnect")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Td>
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
444
pkg/connector/provider/probe.go
Normal file
444
pkg/connector/provider/probe.go
Normal file
@@ -0,0 +1,444 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
92
pkg/connector/provider/probe_test.go
Normal file
92
pkg/connector/provider/probe_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
@@ -30,6 +30,7 @@ func qoveryRegistration() *Registration {
|
||||
DisplayName: "Qovery",
|
||||
SupportsAPIKey: true,
|
||||
APIKeyAuthScheme: "Token",
|
||||
BuildProbeURL: buildQoveryProbeURL,
|
||||
ExtraSettings: []ExtraSetting{
|
||||
{Key: "organizationId", Label: "Organization ID", Required: true},
|
||||
},
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user