Files
probo/pkg/connector/provider/probe.go
Bryan Frimin 8d8a5ebb26 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>
2026-06-15 16:49:07 +02:00

445 lines
11 KiB
Go

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