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:
Bryan Frimin
2026-06-11 16:47:19 +02:00
parent 26d64a970f
commit 8d8a5ebb26
22 changed files with 587 additions and 106 deletions

View File

@@ -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>

View File

@@ -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
},

View File

@@ -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).

View File

@@ -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).

View File

@@ -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 {

View File

@@ -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},
},

View File

@@ -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

View File

@@ -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},
},

View File

@@ -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

View File

@@ -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},
},

View File

@@ -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},
},

View File

@@ -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

View 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")
}

View 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)
}

View File

@@ -30,6 +30,7 @@ func qoveryRegistration() *Registration {
DisplayName: "Qovery",
SupportsAPIKey: true,
APIKeyAuthScheme: "Token",
BuildProbeURL: buildQoveryProbeURL,
ExtraSettings: []ExtraSetting{
{Key: "organizationId", Label: "Organization ID", Required: true},
},

View File

@@ -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},
},

View File

@@ -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},
},

View File

@@ -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
},

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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
}