Refine connector provider registry per review
Three follow-ups from review of the registry consolidation:
- Build Vercel's authorization URL with net/url instead of a
hand-rolled "{integration_slug}" placeholder resolved by
strings.ReplaceAll. The slug is escaped via url.PathEscape in a
per-provider Registration.BuildAuthURL closure, and the unused
AuthURLParams plumbing on Registration and OAuth2Connector is
removed (OAuth2Connector now carries a typed IntegrationSlug).
- Drop the SettingsInput union type and the per-provider
MarshalSettings closures. The create resolvers now build the typed
coredata.*ConnectorSettings directly from the gqlgen input, the
same way the OAuth callback path already does, so there is no
shared catch-all DTO and no stringly-typed boundary.
- Restore ConnectorProviders() to a plain ordered slice literal; the
intermediate map + slices.Sort added nondeterminism and a sort for
no benefit.
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
@@ -57,12 +57,13 @@ type (
|
|||||||
// to the authorize URL; CompleteWithState replays the verifier
|
// to the authorize URL; CompleteWithState replays the verifier
|
||||||
// on the token exchange.
|
// on the token exchange.
|
||||||
RequiresPKCE bool
|
RequiresPKCE bool
|
||||||
// AuthURLParams are operator-supplied placeholders substituted
|
// IntegrationSlug is an operator-supplied identifier used by
|
||||||
// into the static provider AuthURL by
|
// providers whose authorization URL embeds it as a path segment
|
||||||
// (*provider.Registry).ApplyOAuth2Defaults (for example
|
// (Vercel-style integrations). It is consumed by the provider's
|
||||||
// Vercel's "{integration_slug}"). Empty for the vast majority
|
// Registration.BuildAuthURL in
|
||||||
// of providers.
|
// (*provider.Registry).ApplyOAuth2Defaults. Empty for the vast
|
||||||
AuthURLParams map[string]string
|
// majority of providers.
|
||||||
|
IntegrationSlug string
|
||||||
|
|
||||||
// HTTPClient is used for the OAuth2 token-exchange request
|
// HTTPClient is used for the OAuth2 token-exchange request
|
||||||
// issued from CompleteWithState. It must be set by callers;
|
// issued from CompleteWithState. It must be set by callers;
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
package provider
|
package provider
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"go.gearno.de/kit/httpclient"
|
"go.gearno.de/kit/httpclient"
|
||||||
"go.probo.inc/probo/pkg/connector"
|
"go.probo.inc/probo/pkg/connector"
|
||||||
@@ -30,16 +30,16 @@ import (
|
|||||||
// pulled from r; only ClientID and ClientSecret come from deployment
|
// pulled from r; only ClientID and ClientSecret come from deployment
|
||||||
// config.
|
// config.
|
||||||
//
|
//
|
||||||
// Operator-supplied placeholders in the static AuthURL (e.g. Vercel's
|
// Providers whose authorization URL embeds an operator-supplied slug
|
||||||
// "{integration_slug}") are substituted from c.AuthURLParams; the
|
// (e.g. Vercel) derive it via Registration.BuildAuthURL from
|
||||||
// substitution is a no-op when no placeholders are configured.
|
// c.IntegrationSlug; this is a no-op when no slug is configured.
|
||||||
func (r *Registry) ApplyOAuth2Defaults(p string, redirectURI string, c *connector.OAuth2Connector) {
|
func (r *Registry) ApplyOAuth2Defaults(p string, redirectURI string, c *connector.OAuth2Connector) error {
|
||||||
c.RedirectURI = redirectURI
|
c.RedirectURI = redirectURI
|
||||||
c.HTTPClient = httpclient.DefaultClient(httpclient.WithSSRFProtection())
|
c.HTTPClient = httpclient.DefaultClient(httpclient.WithSSRFProtection())
|
||||||
|
|
||||||
reg, ok := r.Get(coredata.ConnectorProvider(p))
|
reg, ok := r.Get(coredata.ConnectorProvider(p))
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
c.AuthURL = reg.AuthURL
|
c.AuthURL = reg.AuthURL
|
||||||
@@ -57,13 +57,16 @@ func (r *Registry) ApplyOAuth2Defaults(p string, redirectURI string, c *connecto
|
|||||||
c.ExtraAuthParams = extra
|
c.ExtraAuthParams = extra
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve operator-supplied placeholders in the static AuthURL
|
if reg.BuildAuthURL != nil && c.IntegrationSlug != "" {
|
||||||
// (for example Vercel's "{integration_slug}"). Providers without
|
authURL, err := reg.BuildAuthURL(c.IntegrationSlug)
|
||||||
// placeholders are unaffected; the loop is a no-op when
|
if err != nil {
|
||||||
// AuthURLParams is empty.
|
return fmt.Errorf("cannot build %s auth URL: %w", p, err)
|
||||||
for k, v := range c.AuthURLParams {
|
}
|
||||||
c.AuthURL = strings.ReplaceAll(c.AuthURL, "{"+k+"}", v)
|
|
||||||
|
c.AuthURL = authURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProbeURL returns the registered probe URL for provider p, or the
|
// ProbeURL returns the registered probe URL for provider p, or the
|
||||||
|
|||||||
@@ -18,39 +18,51 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"go.probo.inc/probo/pkg/connector"
|
"go.probo.inc/probo/pkg/connector"
|
||||||
"go.probo.inc/probo/pkg/connector/provider"
|
"go.probo.inc/probo/pkg/connector/provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestApplyOAuth2Defaults_AuthURLTemplating verifies that operator-supplied
|
// TestApplyOAuth2Defaults_AuthURLFromSlug verifies that providers whose
|
||||||
// AuthURLParams (for example Vercel's "{integration_slug}") are substituted
|
// authorization URL embeds an operator-supplied slug (Vercel) build it
|
||||||
// into the static provider AuthURL when the connector is initialized.
|
// from c.IntegrationSlug via Registration.BuildAuthURL, with the slug
|
||||||
// Providers without placeholders are unaffected.
|
// percent-escaped. Providers without a BuildAuthURL are unaffected.
|
||||||
func TestApplyOAuth2Defaults_AuthURLTemplating(t *testing.T) {
|
func TestApplyOAuth2Defaults_AuthURLFromSlug(t *testing.T) {
|
||||||
t.Parallel()
|
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()
|
t.Parallel()
|
||||||
|
|
||||||
r := provider.NewBuiltinRegistry()
|
r := provider.NewBuiltinRegistry()
|
||||||
c := &connector.OAuth2Connector{
|
c := &connector.OAuth2Connector{
|
||||||
ClientID: "id",
|
ClientID: "id",
|
||||||
ClientSecret: "secret",
|
ClientSecret: "secret",
|
||||||
AuthURLParams: map[string]string{
|
IntegrationSlug: "acme",
|
||||||
"integration_slug": "acme",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// VERCEL uses a templated AuthURL with the
|
require.NoError(t, r.ApplyOAuth2Defaults("VERCEL", "https://example.com/cb", c))
|
||||||
// "{integration_slug}" placeholder.
|
|
||||||
r.ApplyOAuth2Defaults("VERCEL", "https://example.com/cb", c)
|
|
||||||
|
|
||||||
assert.Equal(t, "https://vercel.com/integrations/acme/new", c.AuthURL)
|
assert.Equal(t, "https://vercel.com/integrations/acme/new", c.AuthURL)
|
||||||
assert.Equal(t, "https://api.vercel.com/v2/oauth/access_token", c.TokenURL)
|
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()
|
t.Parallel()
|
||||||
|
|
||||||
r := provider.NewBuiltinRegistry()
|
r := provider.NewBuiltinRegistry()
|
||||||
@@ -59,12 +71,12 @@ func TestApplyOAuth2Defaults_AuthURLTemplating(t *testing.T) {
|
|||||||
ClientSecret: "secret",
|
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
|
// Vercel has no static AuthURL; without a slug there is nothing
|
||||||
// verbatim so a misconfiguration is visible at the
|
// to build, so the misconfiguration surfaces at the
|
||||||
// authorization step rather than silently masked.
|
// authorization step rather than being silently masked.
|
||||||
assert.Equal(t, "https://vercel.com/integrations/{integration_slug}/new", c.AuthURL)
|
assert.Empty(t, c.AuthURL)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +92,7 @@ func TestApplyOAuth2Defaults_PKCEDefaults(t *testing.T) {
|
|||||||
|
|
||||||
r := provider.NewBuiltinRegistry()
|
r := provider.NewBuiltinRegistry()
|
||||||
c := &connector.OAuth2Connector{ClientID: "id", ClientSecret: "secret"}
|
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,
|
assert.True(t, c.RequiresPKCE,
|
||||||
"provider %s must enable PKCE so Initiate generates a verifier", p)
|
"provider %s must enable PKCE so Initiate generates a verifier", p)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ package provider
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -37,13 +36,6 @@ func githubRegistration() *Registration {
|
|||||||
ExtraSettings: []ExtraSetting{
|
ExtraSettings: []ExtraSetting{
|
||||||
{Key: "organization", Label: "Organization", Required: true},
|
{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) {
|
NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) (drivers.Driver, error) {
|
||||||
s, err := coredata.ConnectorSettings[coredata.GitHubConnectorSettings](conn)
|
s, err := coredata.ConnectorSettings[coredata.GitHubConnectorSettings](conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ package provider
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
|
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/accessreview/drivers"
|
"go.probo.inc/probo/pkg/accessreview/drivers"
|
||||||
@@ -41,38 +39,8 @@ func onePasswordRegistration() *Registration {
|
|||||||
// 1Password has two settings shapes selected by protocol:
|
// 1Password has two settings shapes selected by protocol:
|
||||||
// - Client-credentials: AccountID + Region (Users API driver).
|
// - Client-credentials: AccountID + Region (Users API driver).
|
||||||
// - API key: SCIMBridgeURL (SCIM-bridge driver).
|
// - API key: SCIMBridgeURL (SCIM-bridge driver).
|
||||||
// MarshalSettings picks the shape based on which input fields
|
// The create resolvers build the matching settings; only one
|
||||||
// are populated. The resolvers ensure that only one path is
|
// path is possible for any given request.
|
||||||
// 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
|
|
||||||
},
|
|
||||||
NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
|
NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
|
||||||
// Client credentials grant uses the Users API driver; the
|
// Client credentials grant uses the Users API driver; the
|
||||||
// authorization-code grant uses the SCIM-bridge driver.
|
// authorization-code grant uses the SCIM-bridge driver.
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ package provider
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -37,13 +36,6 @@ func sentryRegistration() *Registration {
|
|||||||
ExtraSettings: []ExtraSetting{
|
ExtraSettings: []ExtraSetting{
|
||||||
{Key: "organizationSlug", Label: "Organization Slug", Required: true},
|
{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) {
|
NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
|
||||||
s, err := coredata.ConnectorSettings[coredata.SentryConnectorSettings](conn)
|
s, err := coredata.ConnectorSettings[coredata.SentryConnectorSettings](conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ package provider
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -34,13 +33,6 @@ func supabaseRegistration() *Registration {
|
|||||||
ExtraSettings: []ExtraSetting{
|
ExtraSettings: []ExtraSetting{
|
||||||
{Key: "organizationSlug", Label: "Organization Slug", Required: true},
|
{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) {
|
NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
|
||||||
s, err := coredata.ConnectorSettings[coredata.SupabaseConnectorSettings](conn)
|
s, err := coredata.ConnectorSettings[coredata.SupabaseConnectorSettings](conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ package provider
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -34,13 +33,6 @@ func tallyRegistration() *Registration {
|
|||||||
ExtraSettings: []ExtraSetting{
|
ExtraSettings: []ExtraSetting{
|
||||||
{Key: "organizationId", Label: "Organization ID", Required: true},
|
{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) {
|
NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
|
||||||
s, err := coredata.ConnectorSettings[coredata.TallyConnectorSettings](conn)
|
s, err := coredata.ConnectorSettings[coredata.TallyConnectorSettings](conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ package provider
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
@@ -47,10 +46,11 @@ type Registration struct {
|
|||||||
// request and replays the verifier on the token exchange. Default
|
// request and replays the verifier on the token exchange. Default
|
||||||
// false; non-PKCE providers are unaffected.
|
// false; non-PKCE providers are unaffected.
|
||||||
RequiresPKCE bool
|
RequiresPKCE bool
|
||||||
// AuthURLParams are operator-supplied placeholders substituted
|
// BuildAuthURL derives the authorization URL from an operator-supplied
|
||||||
// into the static provider AuthURL (e.g. Vercel's
|
// integration slug, for providers (e.g. Vercel) whose AuthURL embeds
|
||||||
// "{integration_slug}"). Empty for the vast majority of providers.
|
// it as a path segment. It must construct the URL with net/url and
|
||||||
AuthURLParams map[string]string
|
// escape the slug. Nil for providers with a fully static AuthURL.
|
||||||
|
BuildAuthURL func(slug string) (string, error)
|
||||||
|
|
||||||
// Protocol support / GraphQL surface.
|
// Protocol support / GraphQL surface.
|
||||||
SupportsAPIKey bool
|
SupportsAPIKey bool
|
||||||
@@ -61,35 +61,6 @@ type Registration struct {
|
|||||||
NewDriver func(context.Context, *http.Client, *coredata.Connector, *log.Logger) (drivers.Driver, error)
|
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
|
NewNameResolver func(context.Context, *http.Client, *coredata.Connector, *log.Logger) drivers.NameResolver
|
||||||
SetOrganizationSettings func(*coredata.Connector, string) error
|
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
|
// ExtraSetting describes one extra per-provider settings field
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/accessreview/drivers"
|
"go.probo.inc/probo/pkg/accessreview/drivers"
|
||||||
@@ -25,17 +26,25 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func vercelRegistration() *Registration {
|
func vercelRegistration() *Registration {
|
||||||
// Vercel uses a templated AuthURL: the operator supplies an
|
// Vercel's authorization URL embeds the operator's integration slug
|
||||||
// `integration-slug` config field which is resolved into the
|
// as a path segment; the operator supplies it via the
|
||||||
// "{integration_slug}" placeholder by ApplyOAuth2Defaults.
|
// `integration-slug` config field. BuildAuthURL constructs the URL
|
||||||
// Vercel does not use OAuth scopes — capabilities are pinned on
|
// with net/url so the slug is escaped. Vercel does not use OAuth
|
||||||
// the integration registration in the Vercel dashboard.
|
// scopes — capabilities are pinned on the integration registration
|
||||||
|
// in the Vercel dashboard.
|
||||||
return &Registration{
|
return &Registration{
|
||||||
Provider: coredata.ConnectorProviderVercel,
|
Provider: coredata.ConnectorProviderVercel,
|
||||||
DisplayName: "Vercel",
|
DisplayName: "Vercel",
|
||||||
AuthURL: "https://vercel.com/integrations/{integration_slug}/new",
|
|
||||||
TokenURL: "https://api.vercel.com/v2/oauth/access_token",
|
TokenURL: "https://api.vercel.com/v2/oauth/access_token",
|
||||||
ProbeURL: "https://api.vercel.com/v2/user",
|
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) {
|
NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
|
||||||
s, err := coredata.ConnectorSettings[coredata.VercelConnectorSettings](conn)
|
s, err := coredata.ConnectorSettings[coredata.VercelConnectorSettings](conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ package coredata
|
|||||||
import (
|
import (
|
||||||
"encoding"
|
"encoding"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ConnectorProvider string
|
type ConnectorProvider string
|
||||||
@@ -58,50 +57,35 @@ var (
|
|||||||
_ encoding.TextUnmarshaler = (*ConnectorProvider)(nil)
|
_ 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 {
|
func ConnectorProviders() []ConnectorProvider {
|
||||||
out := make([]ConnectorProvider, 0, len(providerStringMap))
|
return []ConnectorProvider{
|
||||||
for _, v := range providerStringMap {
|
ConnectorProviderSlack,
|
||||||
out = append(out, v)
|
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 {
|
func (v ConnectorProvider) IsValid() bool {
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ type (
|
|||||||
Protocol coredata.ConnectorProtocol
|
Protocol coredata.ConnectorProtocol
|
||||||
Connection connector.Connection
|
Connection connector.Connection
|
||||||
// RawSettings is the provider-specific settings payload as
|
// RawSettings is the provider-specific settings payload as
|
||||||
// already-marshalled JSON. The resolver builds this via the
|
// already-marshalled JSON. Callers build it from the typed
|
||||||
// per-provider MarshalSettings closure from the typed gqlgen
|
// gqlgen input (or OAuth callback metadata); the service layer
|
||||||
// input; the service layer never sees the typed structs.
|
// never sees the typed structs.
|
||||||
RawSettings json.RawMessage
|
RawSettings json.RawMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -288,7 +288,9 @@ func (impl *Implm) Run(
|
|||||||
|
|
||||||
for _, connectorCfg := range impl.cfg.Connectors {
|
for _, connectorCfg := range impl.cfg.Connectors {
|
||||||
if oauth2c, ok := connectorCfg.Config.(*connector.OAuth2Connector); ok {
|
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 {
|
if err := defaultConnectorRegistry.Register(connectorCfg.Provider, connectorCfg.Config); err != nil {
|
||||||
|
|||||||
@@ -35,11 +35,10 @@ type ConnectorConfig struct {
|
|||||||
type ConnectorConfigOAuth2 struct {
|
type ConnectorConfigOAuth2 struct {
|
||||||
ClientID string `json:"client-id"`
|
ClientID string `json:"client-id"`
|
||||||
ClientSecret string `json:"client-secret"`
|
ClientSecret string `json:"client-secret"`
|
||||||
// IntegrationSlug is an operator-supplied value substituted into
|
// IntegrationSlug is an operator-supplied value used by providers
|
||||||
// providers whose static AuthURL contains a "{integration_slug}"
|
// whose authorization URL embeds it as a path segment (Vercel-style
|
||||||
// placeholder (Vercel-style integrations). It is propagated onto
|
// integrations). It is propagated onto OAuth2Connector.IntegrationSlug
|
||||||
// OAuth2Connector.AuthURLParams and resolved by
|
// and resolved by (*provider.Registry).ApplyOAuth2Defaults.
|
||||||
// (*provider.Registry).ApplyOAuth2Defaults.
|
|
||||||
IntegrationSlug string `json:"integration-slug,omitempty"`
|
IntegrationSlug string `json:"integration-slug,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,11 +96,7 @@ func (c *ConnectorConfig) UnmarshalJSON(data []byte) error {
|
|||||||
ClientSecret: config.ClientSecret,
|
ClientSecret: config.ClientSecret,
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.IntegrationSlug != "" {
|
oauth2Connector.IntegrationSlug = config.IntegrationSlug
|
||||||
oauth2Connector.AuthURLParams = map[string]string{
|
|
||||||
"integration_slug": config.IntegrationSlug,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Config = &oauth2Connector
|
c.Config = &oauth2Connector
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
|
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/connector"
|
"go.probo.inc/probo/pkg/connector"
|
||||||
"go.probo.inc/probo/pkg/connector/provider"
|
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
|
"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},
|
Connection: &connector.APIKeyConnection{APIKey: input.APIKey},
|
||||||
}
|
}
|
||||||
|
|
||||||
in := &provider.SettingsInput{
|
raw, err := apiKeyConnectorSettings(input)
|
||||||
TallyOrganizationID: input.TallyOrganizationID,
|
if err != nil {
|
||||||
SentryOrganizationSlug: input.SentryOrganizationSlug,
|
return nil, gqlutils.Invalid(ctx, err)
|
||||||
SupabaseOrganizationSlug: input.SupabaseOrganizationSlug,
|
|
||||||
GitHubOrganization: input.GithubOrganization,
|
|
||||||
OnePasswordSCIMBridgeURL: input.OnePasswordScimBridgeURL,
|
|
||||||
}
|
}
|
||||||
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)
|
cnnctr, err := r.probo.Connectors.Create(ctx, scope, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -101,18 +91,12 @@ func (r *mutationResolver) CreateClientCredentialsConnector(ctx context.Context,
|
|||||||
Connection: oauth2Conn,
|
Connection: oauth2Conn,
|
||||||
}
|
}
|
||||||
|
|
||||||
in := &provider.SettingsInput{
|
raw, err := clientCredentialsConnectorSettings(input)
|
||||||
OnePasswordAccountID: input.OnePasswordAccountID,
|
if err != nil {
|
||||||
OnePasswordRegion: input.OnePasswordRegion,
|
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)
|
cnnctr, err := r.probo.Connectors.Create(ctx, scope, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
97
pkg/server/api/console/v1/connector_settings.go
Normal file
97
pkg/server/api/console/v1/connector_settings.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.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/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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user