// Copyright (c) 2026 Probo Inc . // // 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 }