diff --git a/pkg/connector/oauth2.go b/pkg/connector/oauth2.go index 34a070f56..283433ea5 100644 --- a/pkg/connector/oauth2.go +++ b/pkg/connector/oauth2.go @@ -57,12 +57,13 @@ type ( // to the authorize URL; CompleteWithState replays the verifier // on the token exchange. RequiresPKCE bool - // AuthURLParams are operator-supplied placeholders substituted - // into the static provider AuthURL by - // (*provider.Registry).ApplyOAuth2Defaults (for example - // Vercel's "{integration_slug}"). Empty for the vast majority - // of providers. - AuthURLParams map[string]string + // IntegrationSlug is an operator-supplied identifier used by + // providers whose authorization URL embeds it as a path segment + // (Vercel-style integrations). It is consumed by the provider's + // Registration.BuildAuthURL in + // (*provider.Registry).ApplyOAuth2Defaults. Empty for the vast + // majority of providers. + IntegrationSlug string // HTTPClient is used for the OAuth2 token-exchange request // issued from CompleteWithState. It must be set by callers; diff --git a/pkg/connector/provider/apply.go b/pkg/connector/provider/apply.go index 1016ccbd9..85ac9e9bb 100644 --- a/pkg/connector/provider/apply.go +++ b/pkg/connector/provider/apply.go @@ -15,8 +15,8 @@ package provider import ( + "fmt" "maps" - "strings" "go.gearno.de/kit/httpclient" "go.probo.inc/probo/pkg/connector" @@ -30,16 +30,16 @@ import ( // pulled from r; only ClientID and ClientSecret come from deployment // config. // -// Operator-supplied placeholders in the static AuthURL (e.g. Vercel's -// "{integration_slug}") are substituted from c.AuthURLParams; the -// substitution is a no-op when no placeholders are configured. -func (r *Registry) ApplyOAuth2Defaults(p string, redirectURI string, c *connector.OAuth2Connector) { +// Providers whose authorization URL embeds an operator-supplied slug +// (e.g. Vercel) derive it via Registration.BuildAuthURL from +// c.IntegrationSlug; this is a no-op when no slug is configured. +func (r *Registry) ApplyOAuth2Defaults(p string, redirectURI string, c *connector.OAuth2Connector) error { c.RedirectURI = redirectURI c.HTTPClient = httpclient.DefaultClient(httpclient.WithSSRFProtection()) reg, ok := r.Get(coredata.ConnectorProvider(p)) if !ok { - return + return nil } c.AuthURL = reg.AuthURL @@ -57,13 +57,16 @@ func (r *Registry) ApplyOAuth2Defaults(p string, redirectURI string, c *connecto c.ExtraAuthParams = extra } - // Resolve operator-supplied placeholders in the static AuthURL - // (for example Vercel's "{integration_slug}"). Providers without - // placeholders are unaffected; the loop is a no-op when - // AuthURLParams is empty. - for k, v := range c.AuthURLParams { - c.AuthURL = strings.ReplaceAll(c.AuthURL, "{"+k+"}", v) + if reg.BuildAuthURL != nil && c.IntegrationSlug != "" { + authURL, err := reg.BuildAuthURL(c.IntegrationSlug) + if err != nil { + return fmt.Errorf("cannot build %s auth URL: %w", p, err) + } + + c.AuthURL = authURL } + + return nil } // ProbeURL returns the registered probe URL for provider p, or the diff --git a/pkg/connector/provider/apply_test.go b/pkg/connector/provider/apply_test.go index 1f4093022..ca9927895 100644 --- a/pkg/connector/provider/apply_test.go +++ b/pkg/connector/provider/apply_test.go @@ -18,39 +18,51 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.probo.inc/probo/pkg/connector" "go.probo.inc/probo/pkg/connector/provider" ) -// TestApplyOAuth2Defaults_AuthURLTemplating verifies that operator-supplied -// AuthURLParams (for example Vercel's "{integration_slug}") are substituted -// into the static provider AuthURL when the connector is initialized. -// Providers without placeholders are unaffected. -func TestApplyOAuth2Defaults_AuthURLTemplating(t *testing.T) { +// TestApplyOAuth2Defaults_AuthURLFromSlug verifies that providers whose +// authorization URL embeds an operator-supplied slug (Vercel) build it +// from c.IntegrationSlug via Registration.BuildAuthURL, with the slug +// percent-escaped. Providers without a BuildAuthURL are unaffected. +func TestApplyOAuth2Defaults_AuthURLFromSlug(t *testing.T) { t.Parallel() - t.Run("placeholder is substituted when AuthURLParams is supplied", func(t *testing.T) { + t.Run("auth URL is built when an integration slug is supplied", func(t *testing.T) { t.Parallel() r := provider.NewBuiltinRegistry() c := &connector.OAuth2Connector{ - ClientID: "id", - ClientSecret: "secret", - AuthURLParams: map[string]string{ - "integration_slug": "acme", - }, + ClientID: "id", + ClientSecret: "secret", + IntegrationSlug: "acme", } - // VERCEL uses a templated AuthURL with the - // "{integration_slug}" placeholder. - r.ApplyOAuth2Defaults("VERCEL", "https://example.com/cb", c) + require.NoError(t, r.ApplyOAuth2Defaults("VERCEL", "https://example.com/cb", c)) assert.Equal(t, "https://vercel.com/integrations/acme/new", c.AuthURL) assert.Equal(t, "https://api.vercel.com/v2/oauth/access_token", c.TokenURL) }) - t.Run("placeholder remains literal when AuthURLParams is empty", func(t *testing.T) { + t.Run("slug with reserved characters is percent-escaped", func(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + c := &connector.OAuth2Connector{ + ClientID: "id", + ClientSecret: "secret", + IntegrationSlug: "a/b c", + } + + require.NoError(t, r.ApplyOAuth2Defaults("VERCEL", "https://example.com/cb", c)) + + assert.Equal(t, "https://vercel.com/integrations/a%2Fb%20c/new", c.AuthURL) + }) + + t.Run("auth URL is empty when no integration slug is supplied", func(t *testing.T) { t.Parallel() r := provider.NewBuiltinRegistry() @@ -59,12 +71,12 @@ func TestApplyOAuth2Defaults_AuthURLTemplating(t *testing.T) { ClientSecret: "secret", } - r.ApplyOAuth2Defaults("VERCEL", "https://example.com/cb", c) + require.NoError(t, r.ApplyOAuth2Defaults("VERCEL", "https://example.com/cb", c)) - // No substitution requested; the placeholder is preserved - // verbatim so a misconfiguration is visible at the - // authorization step rather than silently masked. - assert.Equal(t, "https://vercel.com/integrations/{integration_slug}/new", c.AuthURL) + // Vercel has no static AuthURL; without a slug there is nothing + // to build, so the misconfiguration surfaces at the + // authorization step rather than being silently masked. + assert.Empty(t, c.AuthURL) }) } @@ -80,7 +92,7 @@ func TestApplyOAuth2Defaults_PKCEDefaults(t *testing.T) { r := provider.NewBuiltinRegistry() c := &connector.OAuth2Connector{ClientID: "id", ClientSecret: "secret"} - r.ApplyOAuth2Defaults(p, "https://example.com/cb", c) + require.NoError(t, r.ApplyOAuth2Defaults(p, "https://example.com/cb", c)) assert.True(t, c.RequiresPKCE, "provider %s must enable PKCE so Initiate generates a verifier", p) }) diff --git a/pkg/connector/provider/github.go b/pkg/connector/provider/github.go index a3b5c9311..99cda5d70 100644 --- a/pkg/connector/provider/github.go +++ b/pkg/connector/provider/github.go @@ -16,7 +16,6 @@ package provider import ( "context" - "encoding/json" "fmt" "net/http" @@ -37,13 +36,6 @@ func githubRegistration() *Registration { ExtraSettings: []ExtraSetting{ {Key: "organization", Label: "Organization", Required: true}, }, - MarshalSettings: func(in *SettingsInput) (json.RawMessage, error) { - if in == nil || in.GitHubOrganization == nil || *in.GitHubOrganization == "" { - return nil, fmt.Errorf("cannot create github connector: githubOrganization is required") - } - - return json.Marshal(&coredata.GitHubConnectorSettings{Organization: *in.GitHubOrganization}) - }, NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) (drivers.Driver, error) { s, err := coredata.ConnectorSettings[coredata.GitHubConnectorSettings](conn) if err != nil { diff --git a/pkg/connector/provider/one_password.go b/pkg/connector/provider/one_password.go index 0a15b7623..242727ca1 100644 --- a/pkg/connector/provider/one_password.go +++ b/pkg/connector/provider/one_password.go @@ -16,10 +16,8 @@ package provider import ( "context" - "encoding/json" "fmt" "net/http" - "net/url" "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/accessreview/drivers" @@ -41,38 +39,8 @@ func onePasswordRegistration() *Registration { // 1Password has two settings shapes selected by protocol: // - Client-credentials: AccountID + Region (Users API driver). // - API key: SCIMBridgeURL (SCIM-bridge driver). - // MarshalSettings picks the shape based on which input fields - // are populated. The resolvers ensure that only one path is - // possible for any given request. - MarshalSettings: func(in *SettingsInput) (json.RawMessage, error) { - if in == nil { - return nil, nil - } - - if in.OnePasswordAccountID != nil && in.OnePasswordRegion != nil { - if *in.OnePasswordAccountID == "" || *in.OnePasswordRegion == "" { - return nil, fmt.Errorf("cannot create 1password connector: onePasswordAccountId and onePasswordRegion must be non-empty") - } - - return json.Marshal(&coredata.OnePasswordUsersAPISettings{ - AccountID: *in.OnePasswordAccountID, - Region: *in.OnePasswordRegion, - }) - } - - if in.OnePasswordSCIMBridgeURL != nil && *in.OnePasswordSCIMBridgeURL != "" { - u, err := url.Parse(*in.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: *in.OnePasswordSCIMBridgeURL, - }) - } - - return nil, nil - }, + // The create resolvers build the matching settings; only one + // path is possible for any given request. NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { // Client credentials grant uses the Users API driver; the // authorization-code grant uses the SCIM-bridge driver. diff --git a/pkg/connector/provider/sentry.go b/pkg/connector/provider/sentry.go index 34fe37297..9b5d7d63f 100644 --- a/pkg/connector/provider/sentry.go +++ b/pkg/connector/provider/sentry.go @@ -16,7 +16,6 @@ package provider import ( "context" - "encoding/json" "fmt" "net/http" @@ -37,13 +36,6 @@ func sentryRegistration() *Registration { ExtraSettings: []ExtraSetting{ {Key: "organizationSlug", Label: "Organization Slug", Required: true}, }, - MarshalSettings: func(in *SettingsInput) (json.RawMessage, error) { - if in == nil || in.SentryOrganizationSlug == nil || *in.SentryOrganizationSlug == "" { - return nil, fmt.Errorf("cannot create sentry connector: sentryOrganizationSlug is required") - } - - return json.Marshal(&coredata.SentryConnectorSettings{OrganizationSlug: *in.SentryOrganizationSlug}) - }, NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { s, err := coredata.ConnectorSettings[coredata.SentryConnectorSettings](conn) if err != nil { diff --git a/pkg/connector/provider/supabase.go b/pkg/connector/provider/supabase.go index b50715f13..d22898798 100644 --- a/pkg/connector/provider/supabase.go +++ b/pkg/connector/provider/supabase.go @@ -16,7 +16,6 @@ package provider import ( "context" - "encoding/json" "fmt" "net/http" @@ -34,13 +33,6 @@ func supabaseRegistration() *Registration { ExtraSettings: []ExtraSetting{ {Key: "organizationSlug", Label: "Organization Slug", Required: true}, }, - MarshalSettings: func(in *SettingsInput) (json.RawMessage, error) { - if in == nil || in.SupabaseOrganizationSlug == nil || *in.SupabaseOrganizationSlug == "" { - return nil, fmt.Errorf("cannot create supabase connector: supabaseOrganizationSlug is required") - } - - return json.Marshal(&coredata.SupabaseConnectorSettings{OrganizationSlug: *in.SupabaseOrganizationSlug}) - }, NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { s, err := coredata.ConnectorSettings[coredata.SupabaseConnectorSettings](conn) if err != nil { diff --git a/pkg/connector/provider/tally.go b/pkg/connector/provider/tally.go index 750d2bfe8..0d9ec5d44 100644 --- a/pkg/connector/provider/tally.go +++ b/pkg/connector/provider/tally.go @@ -16,7 +16,6 @@ package provider import ( "context" - "encoding/json" "fmt" "net/http" @@ -34,13 +33,6 @@ func tallyRegistration() *Registration { ExtraSettings: []ExtraSetting{ {Key: "organizationId", Label: "Organization ID", Required: true}, }, - MarshalSettings: func(in *SettingsInput) (json.RawMessage, error) { - if in == nil || in.TallyOrganizationID == nil || *in.TallyOrganizationID == "" { - return nil, fmt.Errorf("cannot create tally connector: tallyOrganizationId is required") - } - - return json.Marshal(&coredata.TallyConnectorSettings{OrganizationID: *in.TallyOrganizationID}) - }, NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { s, err := coredata.ConnectorSettings[coredata.TallyConnectorSettings](conn) if err != nil { diff --git a/pkg/connector/provider/types.go b/pkg/connector/provider/types.go index d4f713da1..3f9e66ac8 100644 --- a/pkg/connector/provider/types.go +++ b/pkg/connector/provider/types.go @@ -16,7 +16,6 @@ package provider import ( "context" - "encoding/json" "net/http" "go.gearno.de/kit/log" @@ -47,10 +46,11 @@ type Registration struct { // request and replays the verifier on the token exchange. Default // false; non-PKCE providers are unaffected. RequiresPKCE bool - // AuthURLParams are operator-supplied placeholders substituted - // into the static provider AuthURL (e.g. Vercel's - // "{integration_slug}"). Empty for the vast majority of providers. - AuthURLParams map[string]string + // BuildAuthURL derives the authorization URL from an operator-supplied + // integration slug, for providers (e.g. Vercel) whose AuthURL embeds + // it as a path segment. It must construct the URL with net/url and + // escape the slug. Nil for providers with a fully static AuthURL. + BuildAuthURL func(slug string) (string, error) // Protocol support / GraphQL surface. SupportsAPIKey bool @@ -61,35 +61,6 @@ type Registration struct { 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 SetOrganizationSettings func(*coredata.Connector, string) error - // MarshalSettings normalises the per-provider extra settings into - // the JSON blob persisted on coredata.Connector.RawSettings. - // - // SECURITY CONTRACT: returned errors are surfaced verbatim to the - // client via gqlutils.Invalid. They must contain only field names - // and structural information — never user-supplied values, - // secrets, or driver-internal details. Use a static string per - // validation failure ("sentryOrganizationSlug is required", - // "onePasswordRegion must be one of …"), never interpolate input. - MarshalSettings func(*SettingsInput) (json.RawMessage, error) -} - -// SettingsInput is the union of every optional per-provider field -// available on the GraphQL CreateAPIKeyConnectorInput and -// CreateClientCredentialsConnectorInput types. The resolver populates -// it once from the gqlgen input; each provider's MarshalSettings -// reads only the fields it cares about. -// -// Adding a new provider with extra settings: add the optional field -// here + the corresponding optional field on the GraphQL input + the -// read in the per-provider MarshalSettings closure. -type SettingsInput struct { - TallyOrganizationID *string - SentryOrganizationSlug *string - SupabaseOrganizationSlug *string - GitHubOrganization *string - OnePasswordSCIMBridgeURL *string - OnePasswordAccountID *string - OnePasswordRegion *string } // ExtraSetting describes one extra per-provider settings field diff --git a/pkg/connector/provider/vercel.go b/pkg/connector/provider/vercel.go index 30395ed65..12b5f0e91 100644 --- a/pkg/connector/provider/vercel.go +++ b/pkg/connector/provider/vercel.go @@ -18,6 +18,7 @@ import ( "context" "fmt" "net/http" + "net/url" "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/accessreview/drivers" @@ -25,17 +26,25 @@ import ( ) func vercelRegistration() *Registration { - // Vercel uses a templated AuthURL: the operator supplies an - // `integration-slug` config field which is resolved into the - // "{integration_slug}" placeholder by ApplyOAuth2Defaults. - // Vercel does not use OAuth scopes — capabilities are pinned on - // the integration registration in the Vercel dashboard. + // Vercel's authorization URL embeds the operator's integration slug + // as a path segment; the operator supplies it via the + // `integration-slug` config field. BuildAuthURL constructs the URL + // with net/url so the slug is escaped. Vercel does not use OAuth + // scopes — capabilities are pinned on the integration registration + // in the Vercel dashboard. return &Registration{ Provider: coredata.ConnectorProviderVercel, DisplayName: "Vercel", - AuthURL: "https://vercel.com/integrations/{integration_slug}/new", TokenURL: "https://api.vercel.com/v2/oauth/access_token", ProbeURL: "https://api.vercel.com/v2/user", + BuildAuthURL: func(slug string) (string, error) { + u, err := url.JoinPath("https://vercel.com/integrations", url.PathEscape(slug), "new") + if err != nil { + return "", fmt.Errorf("cannot build vercel auth URL: %w", err) + } + + return u, nil + }, NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { s, err := coredata.ConnectorSettings[coredata.VercelConnectorSettings](conn) if err != nil { diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index 7ba16409b..bd786753f 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -17,7 +17,6 @@ package coredata import ( "encoding" "fmt" - "slices" ) type ConnectorProvider string @@ -58,50 +57,35 @@ var ( _ encoding.TextUnmarshaler = (*ConnectorProvider)(nil) ) -// providerStringMap is the single source of truth that connects the -// wire/string form of a ConnectorProvider to its typed constant. Both -// ConnectorProviders() and Scan() read from it; adding a new provider -// is one constant + one map entry. -var providerStringMap = map[string]ConnectorProvider{ - "SLACK": ConnectorProviderSlack, - "GOOGLE_WORKSPACE": ConnectorProviderGoogleWorkspace, - "LINEAR": ConnectorProviderLinear, - "ONE_PASSWORD": ConnectorProviderOnePassword, - "HUBSPOT": ConnectorProviderHubSpot, - "DOCUSIGN": ConnectorProviderDocuSign, - "NOTION": ConnectorProviderNotion, - "BREX": ConnectorProviderBrex, - "TALLY": ConnectorProviderTally, - "CLOUDFLARE": ConnectorProviderCloudflare, - "OPENAI": ConnectorProviderOpenAI, - "SENTRY": ConnectorProviderSentry, - "SUPABASE": ConnectorProviderSupabase, - "GITHUB": ConnectorProviderGitHub, - "INTERCOM": ConnectorProviderIntercom, - "RESEND": ConnectorProviderResend, - "MICROSOFT_365": ConnectorProviderMicrosoft365, - "GITLAB": ConnectorProviderGitLab, - "BITBUCKET": ConnectorProviderBitbucket, - "HEROKU": ConnectorProviderHeroku, - "PAGERDUTY": ConnectorProviderPagerDuty, - "ASANA": ConnectorProviderAsana, - "NETLIFY": ConnectorProviderNetlify, - "CLICKUP": ConnectorProviderClickUp, - "VERCEL": ConnectorProviderVercel, - "MONDAY": ConnectorProviderMonday, -} - func ConnectorProviders() []ConnectorProvider { - out := make([]ConnectorProvider, 0, len(providerStringMap)) - for _, v := range providerStringMap { - out = append(out, v) + return []ConnectorProvider{ + ConnectorProviderSlack, + ConnectorProviderGoogleWorkspace, + ConnectorProviderLinear, + ConnectorProviderOnePassword, + ConnectorProviderHubSpot, + ConnectorProviderDocuSign, + ConnectorProviderNotion, + ConnectorProviderBrex, + ConnectorProviderTally, + ConnectorProviderCloudflare, + ConnectorProviderOpenAI, + ConnectorProviderSentry, + ConnectorProviderSupabase, + ConnectorProviderGitHub, + ConnectorProviderIntercom, + ConnectorProviderResend, + ConnectorProviderMicrosoft365, + ConnectorProviderGitLab, + ConnectorProviderBitbucket, + ConnectorProviderHeroku, + ConnectorProviderPagerDuty, + ConnectorProviderAsana, + ConnectorProviderNetlify, + ConnectorProviderClickUp, + ConnectorProviderVercel, + ConnectorProviderMonday, } - - // Map iteration order is nondeterministic; sort so callers (e.g. the - // connectorProviderInfos API/UI listing) get a stable order. - slices.Sort(out) - - return out } func (v ConnectorProvider) IsValid() bool { diff --git a/pkg/probo/connector_service.go b/pkg/probo/connector_service.go index aeafbb3d6..3f33de97b 100644 --- a/pkg/probo/connector_service.go +++ b/pkg/probo/connector_service.go @@ -54,9 +54,9 @@ type ( Protocol coredata.ConnectorProtocol Connection connector.Connection // RawSettings is the provider-specific settings payload as - // already-marshalled JSON. The resolver builds this via the - // per-provider MarshalSettings closure from the typed gqlgen - // input; the service layer never sees the typed structs. + // already-marshalled JSON. Callers build it from the typed + // gqlgen input (or OAuth callback metadata); the service layer + // never sees the typed structs. RawSettings json.RawMessage } diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 538a7e91e..3fd557952 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -288,7 +288,9 @@ func (impl *Implm) Run( for _, connectorCfg := range impl.cfg.Connectors { if oauth2c, ok := connectorCfg.Config.(*connector.OAuth2Connector); ok { - providerRegistry.ApplyOAuth2Defaults(connectorCfg.Provider, redirectURI, oauth2c) + if err := providerRegistry.ApplyOAuth2Defaults(connectorCfg.Provider, redirectURI, oauth2c); err != nil { + return fmt.Errorf("cannot apply oauth2 defaults: %w", err) + } } if err := defaultConnectorRegistry.Register(connectorCfg.Provider, connectorCfg.Config); err != nil { diff --git a/pkg/probodconfig/connector_config.go b/pkg/probodconfig/connector_config.go index bec2d41c5..c4c2c7591 100644 --- a/pkg/probodconfig/connector_config.go +++ b/pkg/probodconfig/connector_config.go @@ -35,11 +35,10 @@ type ConnectorConfig struct { type ConnectorConfigOAuth2 struct { ClientID string `json:"client-id"` ClientSecret string `json:"client-secret"` - // IntegrationSlug is an operator-supplied value substituted into - // providers whose static AuthURL contains a "{integration_slug}" - // placeholder (Vercel-style integrations). It is propagated onto - // OAuth2Connector.AuthURLParams and resolved by - // (*provider.Registry).ApplyOAuth2Defaults. + // IntegrationSlug is an operator-supplied value used by providers + // whose authorization URL embeds it as a path segment (Vercel-style + // integrations). It is propagated onto OAuth2Connector.IntegrationSlug + // and resolved by (*provider.Registry).ApplyOAuth2Defaults. IntegrationSlug string `json:"integration-slug,omitempty"` } @@ -97,11 +96,7 @@ func (c *ConnectorConfig) UnmarshalJSON(data []byte) error { ClientSecret: config.ClientSecret, } - if config.IntegrationSlug != "" { - oauth2Connector.AuthURLParams = map[string]string{ - "integration_slug": config.IntegrationSlug, - } - } + oauth2Connector.IntegrationSlug = config.IntegrationSlug c.Config = &oauth2Connector default: diff --git a/pkg/server/api/console/v1/connector_resolvers.go b/pkg/server/api/console/v1/connector_resolvers.go index feebfca7b..2b51a4241 100644 --- a/pkg/server/api/console/v1/connector_resolvers.go +++ b/pkg/server/api/console/v1/connector_resolvers.go @@ -12,7 +12,6 @@ import ( "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/connector" - "go.probo.inc/probo/pkg/connector/provider" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/server/api/console/v1/schema" @@ -44,21 +43,12 @@ func (r *mutationResolver) CreateAPIKeyConnector(ctx context.Context, input type Connection: &connector.APIKeyConnection{APIKey: input.APIKey}, } - in := &provider.SettingsInput{ - TallyOrganizationID: input.TallyOrganizationID, - SentryOrganizationSlug: input.SentryOrganizationSlug, - SupabaseOrganizationSlug: input.SupabaseOrganizationSlug, - GitHubOrganization: input.GithubOrganization, - OnePasswordSCIMBridgeURL: input.OnePasswordScimBridgeURL, + raw, err := apiKeyConnectorSettings(input) + if err != nil { + return nil, gqlutils.Invalid(ctx, err) } - if reg, ok := r.providerRegistry.Get(input.Provider); ok && reg.MarshalSettings != nil { - raw, err := reg.MarshalSettings(in) - if err != nil { - return nil, gqlutils.Invalid(ctx, err) - } - req.RawSettings = raw - } + req.RawSettings = raw cnnctr, err := r.probo.Connectors.Create(ctx, scope, req) if err != nil { @@ -101,18 +91,12 @@ func (r *mutationResolver) CreateClientCredentialsConnector(ctx context.Context, Connection: oauth2Conn, } - in := &provider.SettingsInput{ - OnePasswordAccountID: input.OnePasswordAccountID, - OnePasswordRegion: input.OnePasswordRegion, + raw, err := clientCredentialsConnectorSettings(input) + if err != nil { + return nil, gqlutils.Invalid(ctx, err) } - if reg, ok := r.providerRegistry.Get(input.Provider); ok && reg.MarshalSettings != nil { - raw, err := reg.MarshalSettings(in) - if err != nil { - return nil, gqlutils.Invalid(ctx, err) - } - req.RawSettings = raw - } + req.RawSettings = raw cnnctr, err := r.probo.Connectors.Create(ctx, scope, req) if err != nil { diff --git a/pkg/server/api/console/v1/connector_settings.go b/pkg/server/api/console/v1/connector_settings.go new file mode 100644 index 000000000..577616df5 --- /dev/null +++ b/pkg/server/api/console/v1/connector_settings.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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/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.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}) + } + + 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 +}