Two OAuth2 and two API-key connectors:
- Google Analytics (GA4): OAuth2 with both analytics.readonly and
analytics.manage.users.readonly (readonly alone 403s on the accounts
list); v1alpha accessBindings enumerated at account and property level
and merged by email; manual account picker (Pattern 1) with a
per-connection probe and name resolver; distinct from Google Workspace.
- Dotfile: API key in the X-DOTFILE-API-KEY header (Pattern 3); GET
/v1/users (owner/admin, suspended_at) with a static probe.
- Segment (Twilio): Public API token as Bearer with a required Region
setting (US or EU) mapped to the regional host; GET /users plus per-user
GET /users/{id} for roles and /invites for pending members; per-connection
BuildProbeURL.
- Square: OAuth2 (EMPLOYEES_READ) or a personal access token (Pattern 3);
POST /v2/team-members/search returns email/status/is_owner directly, so no
role resolution; custom probe and name resolver.
Google Analytics and Square are confidential OAuth clients, wired into the
bootstrap OAuth provider list and .env.example. Segment carries a required
extra setting, so the console add-source dialog maps region onto its
segmentRegion API-key input; without that mapping the value is silently
dropped and the create is rejected.
Cassette-backed driver tests plus unit tests for the Segment probe URL and
the bootstrap OAuth provider list.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
391 lines
17 KiB
Go
391 lines
17 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// SOFTWARE.
|
|
|
|
package console_v1
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"go.gearno.de/kit/log"
|
|
"go.probo.inc/probo/pkg/accessreview/drivers"
|
|
"go.probo.inc/probo/pkg/connector"
|
|
"go.probo.inc/probo/pkg/coredata"
|
|
"go.probo.inc/probo/pkg/server/api/console/v1/types"
|
|
"go.probo.inc/probo/pkg/server/gqlutils"
|
|
)
|
|
|
|
// These helpers live outside connector_resolvers.go because that file is
|
|
// regenerated by gqlgen, which does not preserve standalone functions.
|
|
|
|
// resolveAPIKeyConnectorCredential returns the API key to persist on a new
|
|
// API-key connection. For ManagedAPIKey providers (Model B, e.g. Crisp) it
|
|
// persists NOTHING (empty string): the Probo-held key is injected fresh at
|
|
// use time by (*provider.Registry).ApplyManagedAPIKey, so it survives key
|
|
// rotation and is not duplicated across tenant rows. It still requires the
|
|
// key to be configured, which is what keeps the provider deactivated, and
|
|
// ignores any client-supplied value. For all other providers it requires
|
|
// the client-supplied key. The returned error is surfaced verbatim to the
|
|
// client via gqlutils.Invalid, so it contains only provider/field names,
|
|
// never the key itself.
|
|
func (r *Resolver) resolveAPIKeyConnectorCredential(provider coredata.ConnectorProvider, clientKey *string) (string, error) {
|
|
if reg, ok := r.providerRegistry.Get(provider); ok && reg.ManagedAPIKey {
|
|
if _, ok := r.providerRegistry.ManagedAPIKey(provider); !ok {
|
|
return "", fmt.Errorf("connector is not configured for this deployment")
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
if clientKey == nil || *clientKey == "" {
|
|
return "", fmt.Errorf("apiKey is required")
|
|
}
|
|
|
|
return *clientKey, nil
|
|
}
|
|
|
|
// newAPIKeyConnection builds an API-key connection for provider, filling the auth
|
|
// presentation (header, basic-auth mode, scheme) from the provider registry and
|
|
// using key as the credential. Both CreateAPIKeyConnector (the persisted
|
|
// connection) and verifyCrispOwnership (the ownership-check client) construct
|
|
// their connection through it, so the verification client authenticates exactly
|
|
// as the persisted connector will: a new auth flag cannot be added to one path
|
|
// and silently missed on the other.
|
|
func (r *Resolver) newAPIKeyConnection(provider coredata.ConnectorProvider, key string) *connector.APIKeyConnection {
|
|
return &connector.APIKeyConnection{
|
|
APIKey: key,
|
|
Header: r.providerRegistry.APIKeyHeader(provider),
|
|
BasicAuth: r.providerRegistry.APIKeyUsesBasicAuth(provider),
|
|
BasicAuthUserPass: r.providerRegistry.APIKeyUsesBasicAuthUserPass(provider),
|
|
Scheme: r.providerRegistry.APIKeyAuthScheme(provider),
|
|
}
|
|
}
|
|
|
|
// crispSettingsFetcher reads a Crisp plugin's per-website subscription settings.
|
|
// It matches drivers.GetCrispSubscriptionSettings: verifyCrispOwnership injects
|
|
// the real fetch, and tests substitute a fake so the branch wiring (the security
|
|
// polarity and the Invalid-versus-Internal error mapping) is exercised without a
|
|
// live Crisp API.
|
|
type crispSettingsFetcher func(ctx context.Context, httpClient *http.Client, websiteID, pluginID string) (*drivers.CrispSubscriptionSettings, error)
|
|
|
|
// verifyCrispOwnership proves the connecting organization controls the Crisp
|
|
// website before a connection is created (the #1b ownership check). It reads the
|
|
// Probo plugin's per-website settings through the managed plugin token and
|
|
// requires probo_verification_code to equal the code Probo showed for this exact
|
|
// (organization, website) pair. Because the code is bound to both, one
|
|
// organization cannot bind another organization's website, and only someone with
|
|
// dashboard access to the website could have written the setting. It runs only
|
|
// for Crisp/managed providers; nothing is persisted before it returns, so a
|
|
// failed check creates no row. Returned Invalid errors are surfaced to the
|
|
// client and contain only guidance, never the code or the token.
|
|
func (r *Resolver) verifyCrispOwnership(ctx context.Context, input types.CreateAPIKeyConnectorInput) error {
|
|
return r.verifyCrispOwnershipWith(ctx, input, drivers.GetCrispSubscriptionSettings)
|
|
}
|
|
|
|
// verifyCrispOwnershipWith is verifyCrispOwnership with the settings fetch
|
|
// injected, so its branch wiring can be unit-tested without reaching the live
|
|
// Crisp API. verifyCrispOwnership passes the real
|
|
// drivers.GetCrispSubscriptionSettings.
|
|
func (r *Resolver) verifyCrispOwnershipWith(ctx context.Context, input types.CreateAPIKeyConnectorInput, fetch crispSettingsFetcher) error {
|
|
if input.CrispWebsiteID == nil || strings.TrimSpace(*input.CrispWebsiteID) == "" {
|
|
return gqlutils.Invalidf(ctx, "crispWebsiteId is required")
|
|
}
|
|
|
|
websiteID := strings.TrimSpace(*input.CrispWebsiteID)
|
|
|
|
// The managed plugin token gates the connector's visibility, so it is set
|
|
// here; treat its absence as an internal error rather than client input.
|
|
managedKey, ok := r.providerRegistry.ManagedAPIKey(input.Provider)
|
|
if !ok {
|
|
r.logger.ErrorCtx(ctx, "crisp managed api key not configured")
|
|
|
|
return gqlutils.Internal(ctx)
|
|
}
|
|
|
|
// The plugin ID is a separate managed value the per-website plugin API
|
|
// needs; the bootstrap requires it alongside the token, so its absence is a
|
|
// deployment misconfiguration.
|
|
pluginID, ok := r.providerRegistry.ManagedResourceID(input.Provider)
|
|
if !ok {
|
|
r.logger.ErrorCtx(ctx, "crisp plugin id not configured")
|
|
|
|
return gqlutils.Internal(ctx)
|
|
}
|
|
|
|
conn := r.newAPIKeyConnection(input.Provider, managedKey)
|
|
|
|
httpClient, err := conn.Client(ctx)
|
|
if err != nil {
|
|
r.logger.ErrorCtx(ctx, "cannot build crisp verification client", log.Error(err))
|
|
|
|
return gqlutils.Internal(ctx)
|
|
}
|
|
|
|
settings, err := fetch(ctx, httpClient, websiteID, pluginID)
|
|
|
|
switch {
|
|
case errors.Is(err, drivers.ErrCrispPluginNotSubscribed):
|
|
return gqlutils.Invalidf(ctx, "install and configure the Probo plugin on this Crisp website, then retry")
|
|
case err != nil:
|
|
r.logger.ErrorCtx(ctx, "cannot read crisp subscription settings", log.Error(err))
|
|
|
|
return gqlutils.Internal(ctx)
|
|
}
|
|
|
|
if !verifyCrispVerificationCode(r.tokenSecret, input.OrganizationID.String(), websiteID, settings.ProboVerificationCode) {
|
|
return gqlutils.Invalidf(ctx, "verification code mismatch: paste the code shown in Probo into the plugin settings, then retry")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// apiKeyConnectorSettings marshals the provider-specific extra settings
|
|
// for an API-key connector from the typed gqlgen input into the JSON
|
|
// blob persisted on coredata.Connector.RawSettings. It returns (nil,
|
|
// nil) for providers without extra settings.
|
|
//
|
|
// Returned errors are surfaced verbatim to the client via
|
|
// gqlutils.Invalid: they must contain only field names and structural
|
|
// information, never user-supplied values.
|
|
func apiKeyConnectorSettings(input types.CreateAPIKeyConnectorInput) (json.RawMessage, error) {
|
|
switch input.Provider {
|
|
case coredata.ConnectorProviderTally:
|
|
if input.TallyOrganizationID == nil || *input.TallyOrganizationID == "" {
|
|
return nil, fmt.Errorf("cannot create tally connector: tallyOrganizationId is required")
|
|
}
|
|
|
|
return json.Marshal(&coredata.TallyConnectorSettings{OrganizationID: *input.TallyOrganizationID})
|
|
case coredata.ConnectorProviderSentry:
|
|
if input.SentryOrganizationSlug == nil || *input.SentryOrganizationSlug == "" {
|
|
return nil, fmt.Errorf("cannot create sentry connector: sentryOrganizationSlug is required")
|
|
}
|
|
|
|
return json.Marshal(&coredata.SentryConnectorSettings{OrganizationSlug: *input.SentryOrganizationSlug})
|
|
case coredata.ConnectorProviderSupabase:
|
|
if input.SupabaseOrganizationSlug == nil || *input.SupabaseOrganizationSlug == "" {
|
|
return nil, fmt.Errorf("cannot create supabase connector: supabaseOrganizationSlug is required")
|
|
}
|
|
|
|
return json.Marshal(&coredata.SupabaseConnectorSettings{OrganizationSlug: *input.SupabaseOrganizationSlug})
|
|
case coredata.ConnectorProviderGitHub:
|
|
if input.GithubOrganization == nil || *input.GithubOrganization == "" {
|
|
return nil, fmt.Errorf("cannot create github connector: githubOrganization is required")
|
|
}
|
|
|
|
return json.Marshal(&coredata.GitHubConnectorSettings{Organization: *input.GithubOrganization})
|
|
case coredata.ConnectorProviderGrafana:
|
|
if input.GrafanaBaseURL == nil || *input.GrafanaBaseURL == "" {
|
|
return nil, fmt.Errorf("cannot create grafana connector: grafanaBaseUrl is required")
|
|
}
|
|
|
|
u, err := url.Parse(*input.GrafanaBaseURL)
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
|
return nil, fmt.Errorf("cannot create grafana connector: grafanaBaseUrl must be an http(s) URL")
|
|
}
|
|
|
|
return json.Marshal(&coredata.GrafanaConnectorSettings{BaseURL: *input.GrafanaBaseURL})
|
|
case coredata.ConnectorProviderSigNoz:
|
|
if input.SignozBaseURL == nil || *input.SignozBaseURL == "" {
|
|
return nil, fmt.Errorf("cannot create signoz connector: signozBaseUrl is required")
|
|
}
|
|
|
|
u, err := url.Parse(*input.SignozBaseURL)
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
|
return nil, fmt.Errorf("cannot create signoz connector: signozBaseUrl must be an http(s) URL")
|
|
}
|
|
|
|
return json.Marshal(&coredata.SigNozConnectorSettings{BaseURL: *input.SignozBaseURL})
|
|
case coredata.ConnectorProviderOnePassword:
|
|
if input.OnePasswordScimBridgeURL == nil || *input.OnePasswordScimBridgeURL == "" {
|
|
return nil, fmt.Errorf("cannot create 1password connector: onePasswordScimBridgeURL is required")
|
|
}
|
|
|
|
u, err := url.Parse(*input.OnePasswordScimBridgeURL)
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
|
return nil, fmt.Errorf("cannot create 1password connector: onePasswordScimBridgeURL must be an http(s) URL")
|
|
}
|
|
|
|
return json.Marshal(&coredata.OnePasswordConnectorSettings{SCIMBridgeURL: *input.OnePasswordScimBridgeURL})
|
|
case coredata.ConnectorProviderMetabase:
|
|
if input.MetabaseInstanceURL == nil || *input.MetabaseInstanceURL == "" {
|
|
return nil, fmt.Errorf("cannot create metabase connector: metabaseInstanceUrl is required")
|
|
}
|
|
|
|
u, err := url.Parse(*input.MetabaseInstanceURL)
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
|
return nil, fmt.Errorf("cannot create metabase connector: metabaseInstanceUrl must be an http(s) URL")
|
|
}
|
|
|
|
return json.Marshal(&coredata.MetabaseConnectorSettings{InstanceURL: *input.MetabaseInstanceURL})
|
|
case coredata.ConnectorProviderPostHog:
|
|
region := ""
|
|
if input.PosthogRegion != nil {
|
|
region = *input.PosthogRegion
|
|
}
|
|
|
|
instanceURL := ""
|
|
if input.PosthogInstanceURL != nil {
|
|
instanceURL = *input.PosthogInstanceURL
|
|
}
|
|
|
|
// Cloud (region) and self-hosted (instance URL) are mutually
|
|
// exclusive; exactly one identifies the connection's data host.
|
|
switch {
|
|
case region != "" && instanceURL != "":
|
|
return nil, fmt.Errorf("cannot create posthog connector: set either posthogRegion or posthogInstanceUrl, not both")
|
|
case region != "":
|
|
baseURL, ok := drivers.PostHogRegionBaseURL(region)
|
|
if !ok {
|
|
return nil, fmt.Errorf("cannot create posthog connector: posthogRegion must be US or EU")
|
|
}
|
|
|
|
return json.Marshal(&coredata.PostHogConnectorSettings{BaseURL: baseURL})
|
|
case instanceURL != "":
|
|
u, err := url.Parse(instanceURL)
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
|
return nil, fmt.Errorf("cannot create posthog connector: posthogInstanceUrl must be an http(s) URL")
|
|
}
|
|
|
|
return json.Marshal(&coredata.PostHogConnectorSettings{BaseURL: instanceURL})
|
|
default:
|
|
return nil, fmt.Errorf("cannot create posthog connector: posthogRegion or posthogInstanceUrl is required")
|
|
}
|
|
case coredata.ConnectorProviderOkta:
|
|
if input.OktaDomain == nil || *input.OktaDomain == "" {
|
|
return nil, fmt.Errorf("cannot create okta connector: oktaDomain is required")
|
|
}
|
|
|
|
// NormalizeOktaDomain strips scheme/path and validates the host; the
|
|
// stored value is the bare org domain (e.g. "acme.okta.com"). Use a
|
|
// static error message — this string is surfaced verbatim to the
|
|
// client and must never echo the operator-supplied input.
|
|
domain, err := connector.NormalizeOktaDomain(*input.OktaDomain)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot create okta connector: oktaDomain must be a valid Okta domain (e.g. acme.okta.com)")
|
|
}
|
|
|
|
return json.Marshal(&coredata.OktaConnectorSettings{Domain: domain})
|
|
case coredata.ConnectorProviderBetterStack:
|
|
if input.BetterStackTeamName == nil || *input.BetterStackTeamName == "" {
|
|
return nil, fmt.Errorf("cannot create better stack connector: betterStackTeamName is required")
|
|
}
|
|
|
|
return json.Marshal(&coredata.BetterStackConnectorSettings{TeamName: *input.BetterStackTeamName})
|
|
case coredata.ConnectorProviderQovery:
|
|
if input.QoveryOrganizationID == nil || *input.QoveryOrganizationID == "" {
|
|
return nil, fmt.Errorf("cannot create qovery connector: qoveryOrganizationId is required")
|
|
}
|
|
|
|
return json.Marshal(&coredata.QoveryConnectorSettings{OrganizationID: *input.QoveryOrganizationID})
|
|
case coredata.ConnectorProviderRender:
|
|
if input.RenderWorkspaceID == nil || *input.RenderWorkspaceID == "" {
|
|
return nil, fmt.Errorf("cannot create render connector: renderWorkspaceId is required")
|
|
}
|
|
|
|
return json.Marshal(&coredata.RenderConnectorSettings{OwnerID: *input.RenderWorkspaceID})
|
|
case coredata.ConnectorProviderNeon:
|
|
if input.NeonOrganizationID == nil || *input.NeonOrganizationID == "" {
|
|
return nil, fmt.Errorf("cannot create neon connector: neonOrganizationId is required")
|
|
}
|
|
|
|
return json.Marshal(&coredata.NeonConnectorSettings{OrganizationID: *input.NeonOrganizationID})
|
|
case coredata.ConnectorProviderLangfuse:
|
|
if input.LangfuseBaseURL == nil || *input.LangfuseBaseURL == "" {
|
|
return nil, fmt.Errorf("cannot create langfuse connector: langfuseBaseUrl is required")
|
|
}
|
|
|
|
u, err := url.Parse(*input.LangfuseBaseURL)
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
|
return nil, fmt.Errorf("cannot create langfuse connector: langfuseBaseUrl must be an http(s) URL")
|
|
}
|
|
|
|
return json.Marshal(&coredata.LangfuseConnectorSettings{BaseURL: *input.LangfuseBaseURL})
|
|
case coredata.ConnectorProviderScaleway:
|
|
if input.ScalewayOrganizationID == nil || *input.ScalewayOrganizationID == "" {
|
|
return nil, fmt.Errorf("cannot create scaleway connector: scalewayOrganizationId is required")
|
|
}
|
|
|
|
return json.Marshal(&coredata.ScalewayConnectorSettings{OrganizationID: *input.ScalewayOrganizationID})
|
|
case coredata.ConnectorProviderSegment:
|
|
region := ""
|
|
if input.SegmentRegion != nil {
|
|
region = strings.ToUpper(strings.TrimSpace(*input.SegmentRegion))
|
|
}
|
|
|
|
// The region selects the API host and is not discoverable from the
|
|
// token; resolve it to a base URL against a fixed allow-list so an
|
|
// unexpected value cannot reach the driver.
|
|
var baseURL string
|
|
|
|
switch region {
|
|
case "US":
|
|
baseURL = "https://api.segmentapis.com"
|
|
case "EU":
|
|
baseURL = "https://eu1.api.segmentapis.com"
|
|
default:
|
|
return nil, fmt.Errorf("cannot create segment connector: segmentRegion must be US or EU")
|
|
}
|
|
|
|
return json.Marshal(&coredata.SegmentConnectorSettings{BaseURL: baseURL})
|
|
case coredata.ConnectorProviderCrisp:
|
|
websiteID := ""
|
|
if input.CrispWebsiteID != nil {
|
|
websiteID = strings.TrimSpace(*input.CrispWebsiteID)
|
|
}
|
|
|
|
if websiteID == "" {
|
|
return nil, fmt.Errorf("cannot create crisp connector: crispWebsiteId is required")
|
|
}
|
|
|
|
// Persist the same trimmed value that verifyCrispOwnership proved and the
|
|
// crispVerificationCode query minted the code against, so the stored,
|
|
// verified, and displayed website are identical (a padded value would
|
|
// verify then break the driver's URL).
|
|
return json.Marshal(&coredata.CrispConnectorSettings{WebsiteID: websiteID})
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// clientCredentialsConnectorSettings marshals the provider-specific
|
|
// extra settings for a client-credentials connector. See
|
|
// apiKeyConnectorSettings for the error contract.
|
|
func clientCredentialsConnectorSettings(input types.CreateClientCredentialsConnectorInput) (json.RawMessage, error) {
|
|
switch input.Provider {
|
|
case coredata.ConnectorProviderOnePassword:
|
|
if input.OnePasswordAccountID == nil || *input.OnePasswordAccountID == "" ||
|
|
input.OnePasswordRegion == nil || *input.OnePasswordRegion == "" {
|
|
return nil, fmt.Errorf("cannot create 1password connector: onePasswordAccountId and onePasswordRegion are required")
|
|
}
|
|
|
|
return json.Marshal(&coredata.OnePasswordUsersAPISettings{
|
|
AccountID: *input.OnePasswordAccountID,
|
|
Region: *input.OnePasswordRegion,
|
|
})
|
|
}
|
|
|
|
return nil, nil
|
|
}
|