Register Neon as a connector provider and add a new access review driver that fetches organization members from the Neon API with cursor-based pagination. Neon's OAuth is partner-gated, so the connector is API-key only (Bearer, the default scheme). A personal or organization API key can belong to several organizations; the operator supplies the ID of the one to review. The members endpoint exposes per-user MFA state (has_mfa) and deactivation, which map to the access entry MFA status and active flag; the stable account UUID (user_id) is used as the external ID over the membership ID. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
205 lines
9.1 KiB
Go
205 lines
9.1 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 console_v1
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
|
|
"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"
|
|
)
|
|
|
|
// These helpers live outside connector_resolvers.go because that file is
|
|
// regenerated by gqlgen, which does not preserve standalone functions.
|
|
|
|
// 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})
|
|
}
|
|
|
|
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
|
|
}
|