diff --git a/e2e/console/connector_test.go b/e2e/console/connector_test.go index ed3613e5d..003513943 100644 --- a/e2e/console/connector_test.go +++ b/e2e/console/connector_test.go @@ -200,6 +200,72 @@ func TestCreateAPIKeyConnectorWithSettings(t *testing.T) { assert.Equal(t, "TALLY", connector.Provider) } +// TestCreateAPIKeyConnectorSentryMissingSlug asserts that creating a +// Sentry API-key connector without sentryOrganizationSlug returns a +// validation error, not a 500. This is the e2e gate on the +// MarshalSettings validation path introduced by the connector-provider +// consolidation. +func TestCreateAPIKeyConnectorSentryMissingSlug(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + orgID := owner.GetOrganizationID().String() + + const query = ` + mutation($input: CreateAPIKeyConnectorInput!) { + createAPIKeyConnector(input: $input) { + connector { id } + } + } + ` + + _, err := owner.Do(query, map[string]any{ + "input": map[string]any{ + "organizationId": orgID, + "provider": "SENTRY", + "apiKey": "test-key", + }, + }) + testutil.RequireErrorCode(t, err, "INVALID", "missing sentryOrganizationSlug must return INVALID not INTERNAL") +} + +// TestCreateAPIKeyConnectorSentryRoundTrip asserts that supplying +// sentryOrganizationSlug succeeds and that the connector is created +// with the slug persisted in RawSettings. +func TestCreateAPIKeyConnectorSentryRoundTrip(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + orgID := owner.GetOrganizationID().String() + + const query = ` + mutation($input: CreateAPIKeyConnectorInput!) { + createAPIKeyConnector(input: $input) { + connector { id provider } + } + } + ` + + var result struct { + CreateAPIKeyConnector struct { + Connector struct { + ID string `json:"id"` + Provider string `json:"provider"` + } `json:"connector"` + } `json:"createAPIKeyConnector"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "organizationId": orgID, + "provider": "SENTRY", + "apiKey": "test-key", + "sentryOrganizationSlug": "my-org", + }, + }, &result) + require.NoError(t, err) + assert.NotEmpty(t, result.CreateAPIKeyConnector.Connector.ID) + assert.Equal(t, "SENTRY", result.CreateAPIKeyConnector.Connector.Provider) +} + func TestCreateClientCredentialsConnector(t *testing.T) { t.Parallel() owner := testutil.NewClient(t, testutil.RoleOwner) diff --git a/pkg/accessreview/access_source_service.go b/pkg/accessreview/access_source_service.go index ee3ddeec4..d10697636 100644 --- a/pkg/accessreview/access_source_service.go +++ b/pkg/accessreview/access_source_service.go @@ -22,6 +22,7 @@ import ( "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/connector" + "go.probo.inc/probo/pkg/connector/provider" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/crypto/cipher" "go.probo.inc/probo/pkg/gid" @@ -39,6 +40,7 @@ type ( scope coredata.Scoper encryptionKey cipher.EncryptionKey connectorRegistry *connector.ConnectorRegistry + providerRegistry *provider.Registry } CreateAccessSourceRequest struct { @@ -383,75 +385,15 @@ func (s AccessSourceService) ConfigureAccessSource( return fmt.Errorf("cannot load connector: %w", err) } - switch dbConnector.Provider { - case coredata.ConnectorProviderGitHub: - if err := dbConnector.SetSettings( - &coredata.GitHubConnectorSettings{ - Organization: req.OrganizationSlug, - }, - ); err != nil { - return fmt.Errorf("cannot set github settings: %w", err) - } - case coredata.ConnectorProviderSentry: - if err := dbConnector.SetSettings( - &coredata.SentryConnectorSettings{ - OrganizationSlug: req.OrganizationSlug, - }, - ); err != nil { - return fmt.Errorf("cannot set sentry settings: %w", err) - } - case coredata.ConnectorProviderGitLab: - if err := dbConnector.SetSettings( - &coredata.GitLabConnectorSettings{ - GroupID: req.OrganizationSlug, - }, - ); err != nil { - return fmt.Errorf("cannot set gitlab settings: %w", err) - } - case coredata.ConnectorProviderBitbucket: - if err := dbConnector.SetSettings( - &coredata.BitbucketConnectorSettings{ - Workspace: req.OrganizationSlug, - }, - ); err != nil { - return fmt.Errorf("cannot set bitbucket settings: %w", err) - } - case coredata.ConnectorProviderHeroku: - if err := dbConnector.SetSettings( - &coredata.HerokuConnectorSettings{ - TeamID: req.OrganizationSlug, - }, - ); err != nil { - return fmt.Errorf("cannot set heroku settings: %w", err) - } - case coredata.ConnectorProviderAsana: - if err := dbConnector.SetSettings( - &coredata.AsanaConnectorSettings{ - WorkspaceGID: req.OrganizationSlug, - }, - ); err != nil { - return fmt.Errorf("cannot set asana settings: %w", err) - } - case coredata.ConnectorProviderNetlify: - if err := dbConnector.SetSettings( - &coredata.NetlifyConnectorSettings{ - AccountSlug: req.OrganizationSlug, - }, - ); err != nil { - return fmt.Errorf("cannot set netlify settings: %w", err) - } - case coredata.ConnectorProviderClickUp: - if err := dbConnector.SetSettings( - &coredata.ClickUpConnectorSettings{ - TeamID: req.OrganizationSlug, - }, - ); err != nil { - return fmt.Errorf("cannot set clickup settings: %w", err) - } - default: + reg, ok := s.providerRegistry.Get(dbConnector.Provider) + if !ok || reg.SetOrganizationSettings == nil { return fmt.Errorf("cannot configure access source: provider %s does not support organization configuration", dbConnector.Provider) } + if err := reg.SetOrganizationSettings(dbConnector, req.OrganizationSlug); err != nil { + return fmt.Errorf("cannot set %s settings: %w", dbConnector.Provider, err) + } + dbConnector.UpdatedAt = time.Now() if err := dbConnector.Update(ctx, conn, s.scope, s.encryptionKey); err != nil { diff --git a/pkg/accessreview/drivers/name_resolver.go b/pkg/accessreview/drivers/name_resolver.go index e7d9bc5a9..00b23bfdd 100644 --- a/pkg/accessreview/drivers/name_resolver.go +++ b/pkg/accessreview/drivers/name_resolver.go @@ -23,7 +23,6 @@ import ( "net/url" "go.probo.inc/probo/pkg/connector" - "go.probo.inc/probo/pkg/coredata" admin "google.golang.org/api/admin/directory/v1" "google.golang.org/api/option" ) @@ -34,44 +33,6 @@ type NameResolver interface { ResolveInstanceName(ctx context.Context) (string, error) } -var providerDisplayNames = map[coredata.ConnectorProvider]string{ - coredata.ConnectorProviderSlack: "Slack", - coredata.ConnectorProviderGoogleWorkspace: "Google Workspace", - coredata.ConnectorProviderLinear: "Linear", - coredata.ConnectorProviderOnePassword: "1Password", - coredata.ConnectorProviderHubSpot: "HubSpot", - coredata.ConnectorProviderDocuSign: "DocuSign", - coredata.ConnectorProviderNotion: "Notion", - coredata.ConnectorProviderBrex: "Brex", - coredata.ConnectorProviderTally: "Tally", - coredata.ConnectorProviderCloudflare: "Cloudflare", - coredata.ConnectorProviderOpenAI: "OpenAI", - coredata.ConnectorProviderSentry: "Sentry", - coredata.ConnectorProviderSupabase: "Supabase", - coredata.ConnectorProviderGitHub: "GitHub", - coredata.ConnectorProviderIntercom: "Intercom", - coredata.ConnectorProviderResend: "Resend", - coredata.ConnectorProviderMicrosoft365: "Microsoft 365", - coredata.ConnectorProviderGitLab: "GitLab", - coredata.ConnectorProviderBitbucket: "Bitbucket", - coredata.ConnectorProviderHeroku: "Heroku", - coredata.ConnectorProviderPagerDuty: "PagerDuty", - coredata.ConnectorProviderAsana: "Asana", - coredata.ConnectorProviderNetlify: "Netlify", - coredata.ConnectorProviderClickUp: "ClickUp", - coredata.ConnectorProviderVercel: "Vercel", - coredata.ConnectorProviderMonday: "Monday.com", -} - -// ProviderDisplayName returns the human-readable label for a connector provider. -func ProviderDisplayName(provider coredata.ConnectorProvider) string { - if name, ok := providerDisplayNames[provider]; ok { - return name - } - - return string(provider) -} - // slackNameResolver resolves the Slack workspace name via auth.test. type slackNameResolver struct { httpClient *http.Client diff --git a/pkg/accessreview/drivers/oauth2_scopes.go b/pkg/accessreview/drivers/oauth2_scopes.go deleted file mode 100644 index 8d53f2fad..000000000 --- a/pkg/accessreview/drivers/oauth2_scopes.go +++ /dev/null @@ -1,63 +0,0 @@ -// 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 drivers - -import "go.probo.inc/probo/pkg/coredata" - -// providerOAuth2Scopes maps each access review provider to the OAuth2 scopes -// the corresponding driver requires to list user accounts. The map is the -// single source of truth for access-review OAuth2 scopes — surfaced via -// GraphQL so the frontend never hardcodes scope strings. -var providerOAuth2Scopes = map[coredata.ConnectorProvider][]string{ - coredata.ConnectorProviderHubSpot: {"settings.users.read"}, - coredata.ConnectorProviderGitHub: {"read:org"}, - coredata.ConnectorProviderSentry: {"org:read", "member:read"}, - coredata.ConnectorProviderBrex: {"openid", "offline_access"}, - coredata.ConnectorProviderDocuSign: {"signature"}, - coredata.ConnectorProviderLinear: {"read"}, - coredata.ConnectorProviderSlack: {"users:read", "users:read.email"}, - coredata.ConnectorProviderGoogleWorkspace: { - "https://www.googleapis.com/auth/admin.directory.user.readonly", - "https://www.googleapis.com/auth/admin.directory.group.member.readonly", - "https://www.googleapis.com/auth/admin.directory.customer.readonly", - }, - coredata.ConnectorProviderMicrosoft365: { - "openid", - "profile", - "offline_access", - "https://graph.microsoft.com/User.Read.All", - "https://graph.microsoft.com/Directory.Read.All", - "https://graph.microsoft.com/RoleManagement.Read.Directory", - }, - coredata.ConnectorProviderGitLab: {"read_api"}, - coredata.ConnectorProviderHeroku: {"read"}, - coredata.ConnectorProviderPagerDuty: {"users.read"}, - coredata.ConnectorProviderAsana: {"workspaces:read", "users:read"}, - coredata.ConnectorProviderMonday: {"users:read", "account:read"}, - // Notion and Intercom have no scopes here: Notion authorizes via - // extra-auth-params (owner=user), Intercom configures scopes at the app - // level. Bitbucket scopes are pinned on the OAuth consumer at - // registration time, not passed via the authorize URL. Netlify and - // ClickUp OAuth flows have no scope granularity, so they are also - // omitted. Vercel pins capabilities on the integration registration - // in the Vercel dashboard, so no scopes are passed here. -} - -// ProviderOAuth2Scopes returns the OAuth2 scopes the access review driver -// for the given provider needs. Returns nil for providers that do not need -// any scopes (Notion, Intercom) or for non-access-review providers. -func ProviderOAuth2Scopes(provider coredata.ConnectorProvider) []string { - return providerOAuth2Scopes[provider] -} diff --git a/pkg/accessreview/review_engine.go b/pkg/accessreview/review_engine.go index 85ec10e47..5d2db78fa 100644 --- a/pkg/accessreview/review_engine.go +++ b/pkg/accessreview/review_engine.go @@ -26,6 +26,7 @@ import ( "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/accessreview/drivers" "go.probo.inc/probo/pkg/connector" + "go.probo.inc/probo/pkg/connector/provider" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/crypto/cipher" "go.probo.inc/probo/pkg/gid" @@ -38,6 +39,7 @@ type ReviewEngine struct { scope coredata.Scoper encryptionKey cipher.EncryptionKey connectorRegistry *connector.ConnectorRegistry + providerRegistry *provider.Registry logger *log.Logger } @@ -46,6 +48,7 @@ func NewReviewEngine( scope coredata.Scoper, encryptionKey cipher.EncryptionKey, connectorRegistry *connector.ConnectorRegistry, + providerRegistry *provider.Registry, logger *log.Logger, ) *ReviewEngine { return &ReviewEngine{ @@ -53,6 +56,7 @@ func NewReviewEngine( scope: scope, encryptionKey: encryptionKey, connectorRegistry: connectorRegistry, + providerRegistry: providerRegistry, logger: logger, } } @@ -307,181 +311,10 @@ func (e *ReviewEngine) resolveDriver( } } - switch dbConnector.Provider { - case coredata.ConnectorProviderGoogleWorkspace: - return drivers.NewGoogleWorkspaceDriver(httpClient), nil - case coredata.ConnectorProviderLinear: - return drivers.NewLinearDriver(httpClient), nil - case coredata.ConnectorProviderSlack: - return drivers.NewSlackDriver(httpClient), nil - case coredata.ConnectorProviderOnePassword: - // Client credentials grant -> Users API driver (to be created in Phase 5). - // Authorization code / SCIM grant -> existing SCIM-based driver. - if oauth2Conn, ok := dbConnector.Connection.(*connector.OAuth2Connection); ok && oauth2Conn.GrantType == connector.OAuth2GrantTypeClientCredentials { - settings, err := coredata.ConnectorSettings[coredata.OnePasswordUsersAPISettings](dbConnector) - if err != nil { - return nil, fmt.Errorf("cannot read 1password users api settings: %w", err) - } - - return drivers.NewOnePasswordUsersAPIDriver(httpClient, settings.AccountID, settings.Region), nil - } - - onePasswordSettings, err := coredata.ConnectorSettings[coredata.OnePasswordConnectorSettings](dbConnector) - if err != nil { - return nil, fmt.Errorf("cannot read 1password connector settings: %w", err) - } - - if onePasswordSettings.SCIMBridgeURL == "" { - return nil, fmt.Errorf("1password connector requires scim_bridge_url in settings") - } - - return drivers.NewOnePasswordDriver(httpClient, onePasswordSettings.SCIMBridgeURL), nil - case coredata.ConnectorProviderHubSpot: - return drivers.NewHubSpotDriver(httpClient), nil - case coredata.ConnectorProviderDocuSign: - return drivers.NewDocuSignDriver(httpClient), nil - case coredata.ConnectorProviderNotion: - return drivers.NewNotionDriver(httpClient), nil - case coredata.ConnectorProviderBrex: - return drivers.NewBrexDriver(httpClient), nil - case coredata.ConnectorProviderTally: - tallySettings, err := coredata.ConnectorSettings[coredata.TallyConnectorSettings](dbConnector) - if err != nil { - return nil, fmt.Errorf("cannot read tally connector settings: %w", err) - } - - if tallySettings.OrganizationID == "" { - return nil, fmt.Errorf("tally connector requires organization_id in settings") - } - - return drivers.NewTallyDriver(httpClient, tallySettings.OrganizationID), nil - case coredata.ConnectorProviderCloudflare: - return drivers.NewCloudflareDriver(httpClient), nil - case coredata.ConnectorProviderOpenAI: - return drivers.NewOpenAIDriver(httpClient), nil - case coredata.ConnectorProviderSentry: - sentrySettings, err := coredata.ConnectorSettings[coredata.SentryConnectorSettings](dbConnector) - if err != nil { - return nil, fmt.Errorf("cannot read sentry connector settings: %w", err) - } - - // OrganizationSlug may be empty for OAuth connections; the driver auto-discovers it. - return drivers.NewSentryDriver(httpClient, sentrySettings.OrganizationSlug), nil - case coredata.ConnectorProviderSupabase: - supabaseSettings, err := coredata.ConnectorSettings[coredata.SupabaseConnectorSettings](dbConnector) - if err != nil { - return nil, fmt.Errorf("cannot read supabase connector settings: %w", err) - } - - if supabaseSettings.OrganizationSlug == "" { - return nil, fmt.Errorf("supabase connector requires organization_slug in settings") - } - - return drivers.NewSupabaseDriver(httpClient, supabaseSettings.OrganizationSlug), nil - case coredata.ConnectorProviderGitHub: - githubSettings, err := coredata.ConnectorSettings[coredata.GitHubConnectorSettings](dbConnector) - if err != nil { - return nil, fmt.Errorf("cannot read github connector settings: %w", err) - } - - if githubSettings.Organization == "" { - return nil, fmt.Errorf("github connector requires organization in settings") - } - - return drivers.NewGitHubDriver(httpClient, githubSettings.Organization, e.logger.Named("github")), nil - case coredata.ConnectorProviderIntercom: - return drivers.NewIntercomDriver(httpClient), nil - case coredata.ConnectorProviderResend: - return drivers.NewResendDriver(httpClient), nil - case coredata.ConnectorProviderMicrosoft365: - return drivers.NewMicrosoft365Driver(httpClient), nil - case coredata.ConnectorProviderGitLab: - gitlabSettings, err := coredata.ConnectorSettings[coredata.GitLabConnectorSettings](dbConnector) - if err != nil { - return nil, fmt.Errorf("cannot read gitlab connector settings: %w", err) - } - - if gitlabSettings.GroupID == "" { - return nil, fmt.Errorf("gitlab connector requires group_id in settings") - } - - return drivers.NewGitLabDriver(httpClient, gitlabSettings.GroupID), nil - case coredata.ConnectorProviderBitbucket: - bitbucketSettings, err := coredata.ConnectorSettings[coredata.BitbucketConnectorSettings](dbConnector) - if err != nil { - return nil, fmt.Errorf("cannot read bitbucket connector settings: %w", err) - } - - if bitbucketSettings.Workspace == "" { - return nil, fmt.Errorf("bitbucket connector requires workspace in settings") - } - - return drivers.NewBitbucketDriver(httpClient, bitbucketSettings.Workspace), nil - case coredata.ConnectorProviderHeroku: - herokuSettings, err := coredata.ConnectorSettings[coredata.HerokuConnectorSettings](dbConnector) - if err != nil { - return nil, fmt.Errorf("cannot read heroku connector settings: %w", err) - } - - if herokuSettings.TeamID == "" { - return nil, fmt.Errorf("heroku connector requires team_id in settings") - } - - return drivers.NewHerokuDriver(httpClient, herokuSettings.TeamID), nil - case coredata.ConnectorProviderPagerDuty: - // PagerDuty's REST API uses the regional api.pagerduty.com host; - // the driver does not consume the per-tenant subdomain. Subdomain - // is read only by the name resolver, which returns empty when - // missing — that surfaces as a blank source name but does not - // block access review. - return drivers.NewPagerDutyDriver(httpClient), nil - case coredata.ConnectorProviderAsana: - asanaSettings, err := coredata.ConnectorSettings[coredata.AsanaConnectorSettings](dbConnector) - if err != nil { - return nil, fmt.Errorf("cannot read asana connector settings: %w", err) - } - - if asanaSettings.WorkspaceGID == "" { - return nil, fmt.Errorf("asana connector requires workspace_gid in settings") - } - - return drivers.NewAsanaDriver(httpClient, asanaSettings.WorkspaceGID), nil - case coredata.ConnectorProviderNetlify: - netlifySettings, err := coredata.ConnectorSettings[coredata.NetlifyConnectorSettings](dbConnector) - if err != nil { - return nil, fmt.Errorf("cannot read netlify connector settings: %w", err) - } - - if netlifySettings.AccountSlug == "" { - return nil, fmt.Errorf("netlify connector requires account_slug in settings") - } - - return drivers.NewNetlifyDriver(httpClient, netlifySettings.AccountSlug), nil - case coredata.ConnectorProviderClickUp: - clickupSettings, err := coredata.ConnectorSettings[coredata.ClickUpConnectorSettings](dbConnector) - if err != nil { - return nil, fmt.Errorf("cannot read clickup connector settings: %w", err) - } - - if clickupSettings.TeamID == "" { - return nil, fmt.Errorf("clickup connector requires team_id in settings") - } - - return drivers.NewClickUpDriver(httpClient, clickupSettings.TeamID), nil - case coredata.ConnectorProviderVercel: - vercelSettings, err := coredata.ConnectorSettings[coredata.VercelConnectorSettings](dbConnector) - if err != nil { - return nil, fmt.Errorf("cannot read vercel connector settings: %w", err) - } - - if vercelSettings.TeamID == "" { - return nil, fmt.Errorf("vercel connector requires team_id in settings") - } - - return drivers.NewVercelDriver(httpClient, vercelSettings.TeamID), nil - case coredata.ConnectorProviderMonday: - return drivers.NewMondayDriver(httpClient), nil - default: - return nil, fmt.Errorf("unsupported connector provider %q for access source driver", dbConnector.Provider) + reg, ok := e.providerRegistry.Get(dbConnector.Provider) + if !ok || reg.NewDriver == nil { + return nil, fmt.Errorf("cannot resolve driver: unsupported provider %q", dbConnector.Provider) } + + return reg.NewDriver(ctx, httpClient, dbConnector, e.logger) } diff --git a/pkg/accessreview/service.go b/pkg/accessreview/service.go index 13812bda7..7c0dab65c 100644 --- a/pkg/accessreview/service.go +++ b/pkg/accessreview/service.go @@ -23,6 +23,7 @@ import ( "go.gearno.de/kit/pg" "go.gearno.de/kit/worker" "go.probo.inc/probo/pkg/connector" + "go.probo.inc/probo/pkg/connector/provider" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/crypto/cipher" "go.probo.inc/probo/pkg/gid" @@ -34,6 +35,7 @@ type ( pg *pg.Client encryptionKey cipher.EncryptionKey connectorRegistry *connector.ConnectorRegistry + providerRegistry *provider.Registry logger *log.Logger fetchWorker *worker.Worker[coredata.AccessReviewCampaignSourceFetch] @@ -57,6 +59,7 @@ func NewService( pgClient *pg.Client, encryptionKey cipher.EncryptionKey, connectorRegistry *connector.ConnectorRegistry, + providerRegistry *provider.Registry, logger *log.Logger, opts ...Option, ) *Service { @@ -69,6 +72,7 @@ func NewService( pg: pgClient, encryptionKey: encryptionKey, connectorRegistry: connectorRegistry, + providerRegistry: providerRegistry, logger: logger, } @@ -91,6 +95,7 @@ func NewService( pgClient, encryptionKey, connectorRegistry, + providerRegistry, logger.Named("source-name"), ) @@ -104,6 +109,7 @@ func (s *Service) Sources(scope coredata.Scoper) *AccessSourceService { scope: scope, encryptionKey: s.encryptionKey, connectorRegistry: s.connectorRegistry, + providerRegistry: s.providerRegistry, } } @@ -124,6 +130,7 @@ func (s *Service) Engine(scope coredata.Scoper) *ReviewEngine { scope, s.encryptionKey, s.connectorRegistry, + s.providerRegistry, s.logger.Named("review_engine"), ) } diff --git a/pkg/accessreview/source_name_worker.go b/pkg/accessreview/source_name_worker.go index 29ef72911..2d22c712d 100644 --- a/pkg/accessreview/source_name_worker.go +++ b/pkg/accessreview/source_name_worker.go @@ -26,6 +26,7 @@ import ( "go.gearno.de/kit/worker" "go.probo.inc/probo/pkg/accessreview/drivers" "go.probo.inc/probo/pkg/connector" + "go.probo.inc/probo/pkg/connector/provider" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/crypto/cipher" ) @@ -36,6 +37,7 @@ type sourceNameHandler struct { pg *pg.Client encryptionKey cipher.EncryptionKey connectorRegistry *connector.ConnectorRegistry + providerRegistry *provider.Registry logger *log.Logger } @@ -43,6 +45,7 @@ func NewSourceNameWorker( pgClient *pg.Client, encryptionKey cipher.EncryptionKey, connectorRegistry *connector.ConnectorRegistry, + providerRegistry *provider.Registry, logger *log.Logger, opts ...worker.Option, ) *worker.Worker[coredata.AccessSource] { @@ -50,6 +53,7 @@ func NewSourceNameWorker( pg: pgClient, encryptionKey: encryptionKey, connectorRegistry: connectorRegistry, + providerRegistry: providerRegistry, logger: logger, } @@ -130,7 +134,7 @@ func (h *sourceNameHandler) Process(ctx context.Context, source coredata.AccessS } } - resolver = h.buildResolver(&dbConnector, httpClient) + resolver = h.buildResolver(ctx, &dbConnector, httpClient) return nil }, @@ -184,7 +188,7 @@ func (h *sourceNameHandler) Process(ctx context.Context, source coredata.AccessS return h.markNameSynced(ctx, &source) } - displayName := drivers.ProviderDisplayName(dbConnector.Provider) + displayName := h.providerRegistry.ProviderDisplayName(dbConnector.Provider) newName := displayName + " " + instanceName h.logger.InfoCtx( @@ -246,133 +250,14 @@ func (h *sourceNameHandler) connectorHTTPClient( } func (h *sourceNameHandler) buildResolver( + ctx context.Context, dbConnector *coredata.Connector, httpClient *http.Client, ) drivers.NameResolver { - switch dbConnector.Provider { - case coredata.ConnectorProviderSlack: - return drivers.NewSlackNameResolver(httpClient) - case coredata.ConnectorProviderGoogleWorkspace: - return drivers.NewGoogleWorkspaceNameResolver(httpClient) - case coredata.ConnectorProviderLinear: - return drivers.NewLinearNameResolver(httpClient) - case coredata.ConnectorProviderCloudflare: - return drivers.NewCloudflareNameResolver(httpClient) - case coredata.ConnectorProviderBrex: - return drivers.NewBrexNameResolver(httpClient) - case coredata.ConnectorProviderTally: - tallySettings, err := coredata.ConnectorSettings[coredata.TallyConnectorSettings](dbConnector) - if err != nil { - h.logger.Error("cannot read tally connector settings", log.Error(err)) - return nil - } - - return drivers.NewTallyNameResolver(httpClient, tallySettings.OrganizationID) - case coredata.ConnectorProviderHubSpot: - return drivers.NewHubSpotNameResolver(httpClient) - case coredata.ConnectorProviderDocuSign: - return drivers.NewDocuSignNameResolver(httpClient) - case coredata.ConnectorProviderOpenAI: - return drivers.NewOpenAINameResolver(httpClient) - case coredata.ConnectorProviderSentry: - sentrySettings, err := coredata.ConnectorSettings[coredata.SentryConnectorSettings](dbConnector) - if err != nil { - h.logger.Error("cannot read sentry connector settings", log.Error(err)) - return nil - } - - return drivers.NewSentryNameResolver(httpClient, sentrySettings.OrganizationSlug) - case coredata.ConnectorProviderGitHub: - githubSettings, err := coredata.ConnectorSettings[coredata.GitHubConnectorSettings](dbConnector) - if err != nil { - h.logger.Error("cannot read github connector settings", log.Error(err)) - return nil - } - - return drivers.NewGitHubNameResolver(httpClient, githubSettings.Organization) - case coredata.ConnectorProviderSupabase: - supabaseSettings, err := coredata.ConnectorSettings[coredata.SupabaseConnectorSettings](dbConnector) - if err != nil { - h.logger.Error("cannot read supabase connector settings", log.Error(err)) - return nil - } - - return drivers.NewSupabaseNameResolver(supabaseSettings.OrganizationSlug) - case coredata.ConnectorProviderIntercom: - return drivers.NewIntercomNameResolver(httpClient) - case coredata.ConnectorProviderNotion: - return drivers.NewNotionNameResolver(httpClient) - case coredata.ConnectorProviderResend: - return drivers.NewResendNameResolver() - case coredata.ConnectorProviderMicrosoft365: - return drivers.NewMicrosoft365NameResolver(httpClient) - case coredata.ConnectorProviderGitLab: - gitlabSettings, err := coredata.ConnectorSettings[coredata.GitLabConnectorSettings](dbConnector) - if err != nil { - h.logger.Error("cannot read gitlab connector settings", log.Error(err)) - return nil - } - - return drivers.NewGitLabNameResolver(httpClient, gitlabSettings.GroupID) - case coredata.ConnectorProviderBitbucket: - bitbucketSettings, err := coredata.ConnectorSettings[coredata.BitbucketConnectorSettings](dbConnector) - if err != nil { - h.logger.Error("cannot read bitbucket connector settings", log.Error(err)) - return nil - } - - return drivers.NewBitbucketNameResolver(httpClient, bitbucketSettings.Workspace) - case coredata.ConnectorProviderHeroku: - herokuSettings, err := coredata.ConnectorSettings[coredata.HerokuConnectorSettings](dbConnector) - if err != nil { - h.logger.Error("cannot read heroku connector settings", log.Error(err)) - return nil - } - - return drivers.NewHerokuNameResolver(httpClient, herokuSettings.TeamID) - case coredata.ConnectorProviderPagerDuty: - pdSettings, err := coredata.ConnectorSettings[coredata.PagerDutyConnectorSettings](dbConnector) - if err != nil { - h.logger.Error("cannot read pagerduty connector settings", log.Error(err)) - return nil - } - - return drivers.NewPagerDutyNameResolver(pdSettings.Subdomain) - case coredata.ConnectorProviderAsana: - asanaSettings, err := coredata.ConnectorSettings[coredata.AsanaConnectorSettings](dbConnector) - if err != nil { - h.logger.Error("cannot read asana connector settings", log.Error(err)) - return nil - } - - return drivers.NewAsanaNameResolver(httpClient, asanaSettings.WorkspaceGID) - case coredata.ConnectorProviderNetlify: - netlifySettings, err := coredata.ConnectorSettings[coredata.NetlifyConnectorSettings](dbConnector) - if err != nil { - h.logger.Error("cannot read netlify connector settings", log.Error(err)) - return nil - } - - return drivers.NewNetlifyNameResolver(httpClient, netlifySettings.AccountSlug) - case coredata.ConnectorProviderClickUp: - clickupSettings, err := coredata.ConnectorSettings[coredata.ClickUpConnectorSettings](dbConnector) - if err != nil { - h.logger.Error("cannot read clickup connector settings", log.Error(err)) - return nil - } - - return drivers.NewClickUpNameResolver(httpClient, clickupSettings.TeamID) - case coredata.ConnectorProviderVercel: - vercelSettings, err := coredata.ConnectorSettings[coredata.VercelConnectorSettings](dbConnector) - if err != nil { - h.logger.Error("cannot read vercel connector settings", log.Error(err)) - return nil - } - - return drivers.NewVercelNameResolver(httpClient, vercelSettings.TeamID) - case coredata.ConnectorProviderMonday: - return drivers.NewMondayNameResolver(httpClient) - default: + reg, ok := h.providerRegistry.Get(dbConnector.Provider) + if !ok || reg.NewNameResolver == nil { return nil } + + return reg.NewNameResolver(ctx, httpClient, dbConnector, h.logger) } diff --git a/pkg/connector/oauth2.go b/pkg/connector/oauth2.go index 094e8fcbe..34a070f56 100644 --- a/pkg/connector/oauth2.go +++ b/pkg/connector/oauth2.go @@ -58,15 +58,17 @@ type ( // on the token exchange. RequiresPKCE bool // AuthURLParams are operator-supplied placeholders substituted - // into the static provider AuthURL by ApplyProviderDefaults - // (for example Vercel's "{integration_slug}"). Empty for the - // vast majority of providers. + // 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 // HTTPClient is used for the OAuth2 token-exchange request // issued from CompleteWithState. It must be set by callers; - // ApplyProviderDefaults assigns an SSRF-protected client for - // production use. Tests may inject a loopback-friendly one. + // (*provider.Registry).ApplyOAuth2Defaults assigns an + // SSRF-protected client for production use. Tests may inject a + // loopback-friendly one. HTTPClient *http.Client } diff --git a/pkg/connector/oauth2_test.go b/pkg/connector/oauth2_test.go index 8638b13ba..1311594bc 100644 --- a/pkg/connector/oauth2_test.go +++ b/pkg/connector/oauth2_test.go @@ -699,66 +699,6 @@ func TestInitiateWithState_PKCE(t *testing.T) { }) } -// TestApplyProviderDefaults_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 TestApplyProviderDefaults_AuthURLTemplating(t *testing.T) { - t.Parallel() - - // Register a fake provider definition for the duration of this - // test so we do not have to wait for a real Vercel-style provider - // to land. Restore on teardown. - const fakeProvider = "TEST_TEMPLATED_AUTH_URL" - - previous, hadPrevious := providerDefinitions[fakeProvider] - providerDefinitions[fakeProvider] = providerDefinition{ - AuthURL: "https://example.com/integrations/{integration_slug}/new", - TokenURL: "https://example.com/oauth/token", - } - - t.Cleanup(func() { - if hadPrevious { - providerDefinitions[fakeProvider] = previous - } else { - delete(providerDefinitions, fakeProvider) - } - }) - - t.Run("placeholder is substituted when AuthURLParams is supplied", func(t *testing.T) { - t.Parallel() - - c := &OAuth2Connector{ - ClientID: "id", - ClientSecret: "secret", - AuthURLParams: map[string]string{ - "integration_slug": "acme", - }, - } - - ApplyProviderDefaults(fakeProvider, "https://example.com/cb", c) - - assert.Equal(t, "https://example.com/integrations/acme/new", c.AuthURL) - assert.Equal(t, "https://example.com/oauth/token", c.TokenURL) - }) - - t.Run("placeholder remains literal when AuthURLParams is empty", func(t *testing.T) { - t.Parallel() - - c := &OAuth2Connector{ - ClientID: "id", - ClientSecret: "secret", - } - - ApplyProviderDefaults(fakeProvider, "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://example.com/integrations/{integration_slug}/new", c.AuthURL) - }) -} - // TestGeneratePKCEVerifier exercises the verifier generator: each call // must return a fresh value, encoded as RFC 4648 §5 base64url-without- // padding (RFC 7636 §4.1 mandates 43–128 unreserved chars; 32 bytes @@ -788,24 +728,6 @@ func TestGeneratePKCEVerifier(t *testing.T) { } } -// TestApplyProviderDefaults_PKCEDefaults asserts that the registered -// PAGERDUTY provider defaults flip RequiresPKCE on so the downstream -// Initiate/Complete flow generates a verifier and replays it. -func TestApplyProviderDefaults_PKCEDefaults(t *testing.T) { - t.Parallel() - - for _, provider := range []string{"PAGERDUTY"} { - t.Run(provider, func(t *testing.T) { - t.Parallel() - - c := &OAuth2Connector{ClientID: "id", ClientSecret: "secret"} - ApplyProviderDefaults(provider, "https://example.com/cb", c) - assert.True(t, c.RequiresPKCE, - "provider %s must enable PKCE so Initiate generates a verifier", provider) - }) - } -} - // TestCompleteWithState_PKCEMismatch confirms that a token endpoint // rejecting a stale or mismatched code_verifier (the standard PKCE // failure path) surfaces as an error from CompleteWithState rather diff --git a/pkg/connector/provider/apply.go b/pkg/connector/provider/apply.go new file mode 100644 index 000000000..1016ccbd9 --- /dev/null +++ b/pkg/connector/provider/apply.go @@ -0,0 +1,78 @@ +// 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 provider + +import ( + "maps" + "strings" + + "go.gearno.de/kit/httpclient" + "go.probo.inc/probo/pkg/connector" + "go.probo.inc/probo/pkg/coredata" +) + +// ApplyOAuth2Defaults sets the redirect URI on c and applies static +// provider defaults (auth URL, token URL, extra params, token endpoint +// auth, PKCE) onto an OAuth2Connector, and wires an SSRF-protected +// HTTP client for the token exchange request. Static metadata is +// 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) { + c.RedirectURI = redirectURI + c.HTTPClient = httpclient.DefaultClient(httpclient.WithSSRFProtection()) + + reg, ok := r.Get(coredata.ConnectorProvider(p)) + if !ok { + return + } + + c.AuthURL = reg.AuthURL + c.TokenURL = reg.TokenURL + c.TokenEndpointAuth = reg.TokenEndpointAuth + c.SupportsIncrementalAuth = reg.SupportsIncrementalAuth + c.RequiresPKCE = reg.RequiresPKCE + + // Deep copy ExtraAuthParams so per-connector mutations (e.g. + // incremental auth, scope overrides) cannot alias back into the + // shared registry map. + if len(reg.ExtraAuthParams) > 0 { + extra := make(map[string]string, len(reg.ExtraAuthParams)) + maps.Copy(extra, reg.ExtraAuthParams) + 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) + } +} + +// ProbeURL returns the registered probe URL for provider p, or the +// empty string if no probe URL is configured. +func (r *Registry) ProbeURL(p string) string { + reg, ok := r.Get(coredata.ConnectorProvider(p)) + if !ok { + return "" + } + + return reg.ProbeURL +} diff --git a/pkg/connector/provider/apply_test.go b/pkg/connector/provider/apply_test.go new file mode 100644 index 000000000..1f4093022 --- /dev/null +++ b/pkg/connector/provider/apply_test.go @@ -0,0 +1,88 @@ +// 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 provider_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "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) { + t.Parallel() + + t.Run("placeholder is substituted when AuthURLParams 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", + }, + } + + // VERCEL uses a templated AuthURL with the + // "{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://api.vercel.com/v2/oauth/access_token", c.TokenURL) + }) + + t.Run("placeholder remains literal when AuthURLParams is empty", func(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + c := &connector.OAuth2Connector{ + ClientID: "id", + ClientSecret: "secret", + } + + 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) + }) +} + +// TestApplyOAuth2Defaults_PKCEDefaults asserts that the registered +// PAGERDUTY provider defaults flip RequiresPKCE on so the downstream +// Initiate/Complete flow generates a verifier and replays it. +func TestApplyOAuth2Defaults_PKCEDefaults(t *testing.T) { + t.Parallel() + + for _, p := range []string{"PAGERDUTY"} { + t.Run(p, func(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + c := &connector.OAuth2Connector{ClientID: "id", ClientSecret: "secret"} + 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/asana.go b/pkg/connector/provider/asana.go new file mode 100644 index 000000000..5457dac69 --- /dev/null +++ b/pkg/connector/provider/asana.go @@ -0,0 +1,60 @@ +// 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 provider + +import ( + "context" + "fmt" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func asanaRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderAsana, + DisplayName: "Asana", + AuthURL: "https://app.asana.com/-/oauth_authorize", + TokenURL: "https://app.asana.com/-/oauth_token", + ProbeURL: "https://app.asana.com/api/1.0/users/me", + OAuth2Scopes: []string{"workspaces:read", "users:read"}, + NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + s, err := coredata.ConnectorSettings[coredata.AsanaConnectorSettings](conn) + if err != nil { + return nil, fmt.Errorf("cannot read asana connector settings: %w", err) + } + + if s.WorkspaceGID == "" { + return nil, fmt.Errorf("cannot create asana driver: workspace_gid is required") + } + + return drivers.NewAsanaDriver(c, s.WorkspaceGID), nil + }, + NewNameResolver: func(ctx context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver { + s, err := coredata.ConnectorSettings[coredata.AsanaConnectorSettings](conn) + if err != nil { + logger.ErrorCtx(ctx, "cannot read asana connector settings", log.Error(err)) + return nil + } + + return drivers.NewAsanaNameResolver(c, s.WorkspaceGID) + }, + SetOrganizationSettings: func(c *coredata.Connector, workspaceGID string) error { + return c.SetSettings(&coredata.AsanaConnectorSettings{WorkspaceGID: workspaceGID}) + }, + } +} diff --git a/pkg/connector/provider/bitbucket.go b/pkg/connector/provider/bitbucket.go new file mode 100644 index 000000000..77d0db9ab --- /dev/null +++ b/pkg/connector/provider/bitbucket.go @@ -0,0 +1,62 @@ +// 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 provider + +import ( + "context" + "fmt" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func bitbucketRegistration() *Registration { + // Bitbucket scopes are pinned on the OAuth consumer at registration + // time (`account` for workspace membership). They are not passed in + // the authorize URL. + return &Registration{ + Provider: coredata.ConnectorProviderBitbucket, + DisplayName: "Bitbucket", + AuthURL: "https://bitbucket.org/site/oauth2/authorize", + TokenURL: "https://bitbucket.org/site/oauth2/access_token", + ProbeURL: "https://api.bitbucket.org/2.0/user", + NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + s, err := coredata.ConnectorSettings[coredata.BitbucketConnectorSettings](conn) + if err != nil { + return nil, fmt.Errorf("cannot read bitbucket connector settings: %w", err) + } + + if s.Workspace == "" { + return nil, fmt.Errorf("cannot create bitbucket driver: workspace is required") + } + + return drivers.NewBitbucketDriver(c, s.Workspace), nil + }, + NewNameResolver: func(ctx context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver { + s, err := coredata.ConnectorSettings[coredata.BitbucketConnectorSettings](conn) + if err != nil { + logger.ErrorCtx(ctx, "cannot read bitbucket connector settings", log.Error(err)) + return nil + } + + return drivers.NewBitbucketNameResolver(c, s.Workspace) + }, + SetOrganizationSettings: func(c *coredata.Connector, workspace string) error { + return c.SetSettings(&coredata.BitbucketConnectorSettings{Workspace: workspace}) + }, + } +} diff --git a/pkg/connector/provider/brex.go b/pkg/connector/provider/brex.go new file mode 100644 index 000000000..3eb67bcb4 --- /dev/null +++ b/pkg/connector/provider/brex.go @@ -0,0 +1,42 @@ +// 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 provider + +import ( + "context" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func brexRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderBrex, + DisplayName: "Brex", + AuthURL: "https://accounts-api.brex.com/oauth2/default/v1/authorize", + TokenURL: "https://accounts-api.brex.com/oauth2/default/v1/token", + ProbeURL: "https://platform.brexapis.com/v2/users/me", + OAuth2Scopes: []string{"openid", "offline_access", "users.readonly"}, + SupportsAPIKey: true, + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + return drivers.NewBrexDriver(c), nil + }, + NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) drivers.NameResolver { + return drivers.NewBrexNameResolver(c) + }, + } +} diff --git a/pkg/connector/provider/builtin.go b/pkg/connector/provider/builtin.go new file mode 100644 index 000000000..d6eab56fd --- /dev/null +++ b/pkg/connector/provider/builtin.go @@ -0,0 +1,58 @@ +// 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 provider + +// NewBuiltinRegistry returns a *Registry populated with every +// connector provider compiled into the binary. It panics on duplicate +// registration or invalid Registration metadata — both are programmer +// errors caught at process start, not at runtime. Probod calls this +// once at startup and threads the *Registry into every consumer. +func NewBuiltinRegistry() *Registry { + r := NewRegistry() + for _, reg := range []*Registration{ + asanaRegistration(), + bitbucketRegistration(), + brexRegistration(), + clickupRegistration(), + cloudflareRegistration(), + docusignRegistration(), + githubRegistration(), + gitlabRegistration(), + googleWorkspaceRegistration(), + herokuRegistration(), + hubspotRegistration(), + intercomRegistration(), + linearRegistration(), + microsoft365Registration(), + mondayRegistration(), + netlifyRegistration(), + notionRegistration(), + onePasswordRegistration(), + openaiRegistration(), + pagerdutyRegistration(), + resendRegistration(), + sentryRegistration(), + slackRegistration(), + supabaseRegistration(), + tallyRegistration(), + vercelRegistration(), + } { + if err := r.Register(reg); err != nil { + panic(err) + } + } + + return r +} diff --git a/pkg/connector/provider/clickup.go b/pkg/connector/provider/clickup.go new file mode 100644 index 000000000..d2a8bcb3c --- /dev/null +++ b/pkg/connector/provider/clickup.go @@ -0,0 +1,60 @@ +// 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 provider + +import ( + "context" + "fmt" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func clickupRegistration() *Registration { + // ClickUp OAuth flow has no scope granularity, so OAuth2Scopes is empty. + return &Registration{ + Provider: coredata.ConnectorProviderClickUp, + DisplayName: "ClickUp", + AuthURL: "https://app.clickup.com/api", + TokenURL: "https://api.clickup.com/api/v2/oauth/token", + ProbeURL: "https://api.clickup.com/api/v2/user", + NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + s, err := coredata.ConnectorSettings[coredata.ClickUpConnectorSettings](conn) + if err != nil { + return nil, fmt.Errorf("cannot read clickup connector settings: %w", err) + } + + if s.TeamID == "" { + return nil, fmt.Errorf("cannot create clickup driver: team_id is required") + } + + return drivers.NewClickUpDriver(c, s.TeamID), nil + }, + NewNameResolver: func(ctx context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver { + s, err := coredata.ConnectorSettings[coredata.ClickUpConnectorSettings](conn) + if err != nil { + logger.ErrorCtx(ctx, "cannot read clickup connector settings", log.Error(err)) + return nil + } + + return drivers.NewClickUpNameResolver(c, s.TeamID) + }, + SetOrganizationSettings: func(c *coredata.Connector, teamID string) error { + return c.SetSettings(&coredata.ClickUpConnectorSettings{TeamID: teamID}) + }, + } +} diff --git a/pkg/connector/provider/cloudflare.go b/pkg/connector/provider/cloudflare.go new file mode 100644 index 000000000..e794b8c96 --- /dev/null +++ b/pkg/connector/provider/cloudflare.go @@ -0,0 +1,39 @@ +// 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 provider + +import ( + "context" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func cloudflareRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderCloudflare, + DisplayName: "Cloudflare", + ProbeURL: "https://api.cloudflare.com/client/v4/user/tokens/verify", + SupportsAPIKey: true, + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + return drivers.NewCloudflareDriver(c), nil + }, + NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) drivers.NameResolver { + return drivers.NewCloudflareNameResolver(c) + }, + } +} diff --git a/pkg/connector/provider/docusign.go b/pkg/connector/provider/docusign.go new file mode 100644 index 000000000..59ad5dbc5 --- /dev/null +++ b/pkg/connector/provider/docusign.go @@ -0,0 +1,43 @@ +// 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 provider + +import ( + "context" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func docusignRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderDocuSign, + DisplayName: "DocuSign", + AuthURL: "https://account.docusign.com/oauth/auth", + TokenURL: "https://account.docusign.com/oauth/token", + TokenEndpointAuth: "basic-form", + ProbeURL: "https://account.docusign.com/oauth/userinfo", + OAuth2Scopes: []string{"signature"}, + SupportsAPIKey: true, + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + return drivers.NewDocuSignDriver(c), nil + }, + NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) drivers.NameResolver { + return drivers.NewDocuSignNameResolver(c) + }, + } +} diff --git a/pkg/connector/provider/github.go b/pkg/connector/provider/github.go new file mode 100644 index 000000000..a3b5c9311 --- /dev/null +++ b/pkg/connector/provider/github.go @@ -0,0 +1,72 @@ +// 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 provider + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func githubRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderGitHub, + DisplayName: "GitHub", + AuthURL: "https://github.com/login/oauth/authorize", + TokenURL: "https://github.com/login/oauth/access_token", + ProbeURL: "https://api.github.com/user", + OAuth2Scopes: []string{"read:org"}, + SupportsAPIKey: true, + 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 { + return nil, fmt.Errorf("cannot read github connector settings: %w", err) + } + + if s.Organization == "" { + return nil, fmt.Errorf("cannot create github driver: organization is required") + } + + return drivers.NewGitHubDriver(c, s.Organization, logger.Named("github")), nil + }, + NewNameResolver: func(ctx context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver { + s, err := coredata.ConnectorSettings[coredata.GitHubConnectorSettings](conn) + if err != nil { + logger.ErrorCtx(ctx, "cannot read github connector settings", log.Error(err)) + return nil + } + + return drivers.NewGitHubNameResolver(c, s.Organization) + }, + SetOrganizationSettings: func(c *coredata.Connector, org string) error { + return c.SetSettings(&coredata.GitHubConnectorSettings{Organization: org}) + }, + } +} diff --git a/pkg/connector/provider/gitlab.go b/pkg/connector/provider/gitlab.go new file mode 100644 index 000000000..aef8db039 --- /dev/null +++ b/pkg/connector/provider/gitlab.go @@ -0,0 +1,60 @@ +// 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 provider + +import ( + "context" + "fmt" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func gitlabRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderGitLab, + DisplayName: "GitLab", + AuthURL: "https://gitlab.com/oauth/authorize", + TokenURL: "https://gitlab.com/oauth/token", + ProbeURL: "https://gitlab.com/api/v4/user", + OAuth2Scopes: []string{"read_api"}, + NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + s, err := coredata.ConnectorSettings[coredata.GitLabConnectorSettings](conn) + if err != nil { + return nil, fmt.Errorf("cannot read gitlab connector settings: %w", err) + } + + if s.GroupID == "" { + return nil, fmt.Errorf("cannot create gitlab driver: group_id is required") + } + + return drivers.NewGitLabDriver(c, s.GroupID), nil + }, + NewNameResolver: func(ctx context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver { + s, err := coredata.ConnectorSettings[coredata.GitLabConnectorSettings](conn) + if err != nil { + logger.ErrorCtx(ctx, "cannot read gitlab connector settings", log.Error(err)) + return nil + } + + return drivers.NewGitLabNameResolver(c, s.GroupID) + }, + SetOrganizationSettings: func(c *coredata.Connector, groupID string) error { + return c.SetSettings(&coredata.GitLabConnectorSettings{GroupID: groupID}) + }, + } +} diff --git a/pkg/connector/provider/google_workspace.go b/pkg/connector/provider/google_workspace.go new file mode 100644 index 000000000..c425d9e16 --- /dev/null +++ b/pkg/connector/provider/google_workspace.go @@ -0,0 +1,50 @@ +// 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 provider + +import ( + "context" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func googleWorkspaceRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderGoogleWorkspace, + DisplayName: "Google Workspace", + AuthURL: "https://accounts.google.com/o/oauth2/v2/auth", + TokenURL: "https://oauth2.googleapis.com/token", + ExtraAuthParams: map[string]string{ + "access_type": "offline", + "prompt": "consent", + }, + SupportsIncrementalAuth: true, + ProbeURL: "https://admin.googleapis.com/admin/directory/v1/users?customer=my_customer&maxResults=1", + OAuth2Scopes: []string{ + "https://www.googleapis.com/auth/admin.directory.user.readonly", + "https://www.googleapis.com/auth/admin.directory.group.member.readonly", + "https://www.googleapis.com/auth/admin.directory.customer.readonly", + }, + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + return drivers.NewGoogleWorkspaceDriver(c), nil + }, + NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) drivers.NameResolver { + return drivers.NewGoogleWorkspaceNameResolver(c) + }, + } +} diff --git a/pkg/connector/provider/heroku.go b/pkg/connector/provider/heroku.go new file mode 100644 index 000000000..9f363bc06 --- /dev/null +++ b/pkg/connector/provider/heroku.go @@ -0,0 +1,60 @@ +// 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 provider + +import ( + "context" + "fmt" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func herokuRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderHeroku, + DisplayName: "Heroku", + AuthURL: "https://id.heroku.com/oauth/authorize", + TokenURL: "https://id.heroku.com/oauth/token", + ProbeURL: "https://api.heroku.com/account", + OAuth2Scopes: []string{"read"}, + NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + s, err := coredata.ConnectorSettings[coredata.HerokuConnectorSettings](conn) + if err != nil { + return nil, fmt.Errorf("cannot read heroku connector settings: %w", err) + } + + if s.TeamID == "" { + return nil, fmt.Errorf("cannot create heroku driver: team_id is required") + } + + return drivers.NewHerokuDriver(c, s.TeamID), nil + }, + NewNameResolver: func(ctx context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver { + s, err := coredata.ConnectorSettings[coredata.HerokuConnectorSettings](conn) + if err != nil { + logger.ErrorCtx(ctx, "cannot read heroku connector settings", log.Error(err)) + return nil + } + + return drivers.NewHerokuNameResolver(c, s.TeamID) + }, + SetOrganizationSettings: func(c *coredata.Connector, teamID string) error { + return c.SetSettings(&coredata.HerokuConnectorSettings{TeamID: teamID}) + }, + } +} diff --git a/pkg/connector/provider/hubspot.go b/pkg/connector/provider/hubspot.go new file mode 100644 index 000000000..e85ec1a42 --- /dev/null +++ b/pkg/connector/provider/hubspot.go @@ -0,0 +1,42 @@ +// 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 provider + +import ( + "context" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func hubspotRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderHubSpot, + DisplayName: "HubSpot", + AuthURL: "https://app.hubspot.com/oauth/authorize", + TokenURL: "https://api.hubapi.com/oauth/v1/token", + ProbeURL: "https://api.hubapi.com/account-info/v3/details", + OAuth2Scopes: []string{"settings.users.read"}, + SupportsAPIKey: true, + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + return drivers.NewHubSpotDriver(c), nil + }, + NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) drivers.NameResolver { + return drivers.NewHubSpotNameResolver(c) + }, + } +} diff --git a/pkg/connector/provider/intercom.go b/pkg/connector/provider/intercom.go new file mode 100644 index 000000000..de60c7796 --- /dev/null +++ b/pkg/connector/provider/intercom.go @@ -0,0 +1,41 @@ +// 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 provider + +import ( + "context" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func intercomRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderIntercom, + DisplayName: "Intercom", + AuthURL: "https://app.intercom.com/oauth", + TokenURL: "https://api.intercom.io/auth/eagle/token", + ProbeURL: "https://api.intercom.io/me", + SupportsAPIKey: true, + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + return drivers.NewIntercomDriver(c), nil + }, + NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) drivers.NameResolver { + return drivers.NewIntercomNameResolver(c) + }, + } +} diff --git a/pkg/connector/provider/linear.go b/pkg/connector/provider/linear.go new file mode 100644 index 000000000..99b76c0c1 --- /dev/null +++ b/pkg/connector/provider/linear.go @@ -0,0 +1,41 @@ +// 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 provider + +import ( + "context" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func linearRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderLinear, + DisplayName: "Linear", + AuthURL: "https://linear.app/oauth/authorize", + TokenURL: "https://api.linear.app/oauth/token", + ProbeURL: "https://api.linear.app/graphql", + OAuth2Scopes: []string{"read"}, + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + return drivers.NewLinearDriver(c), nil + }, + NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) drivers.NameResolver { + return drivers.NewLinearNameResolver(c) + }, + } +} diff --git a/pkg/connector/provider/microsoft_365.go b/pkg/connector/provider/microsoft_365.go new file mode 100644 index 000000000..ae87d0ee7 --- /dev/null +++ b/pkg/connector/provider/microsoft_365.go @@ -0,0 +1,51 @@ +// 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 provider + +import ( + "context" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func microsoft365Registration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderMicrosoft365, + DisplayName: "Microsoft 365", + AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + TokenURL: "https://login.microsoftonline.com/common/oauth2/v2.0/token", + ExtraAuthParams: map[string]string{ + "prompt": "consent", + }, + ProbeURL: "https://graph.microsoft.com/v1.0/organization?$top=1", + OAuth2Scopes: []string{ + "openid", + "profile", + "offline_access", + "https://graph.microsoft.com/User.Read.All", + "https://graph.microsoft.com/Directory.Read.All", + "https://graph.microsoft.com/RoleManagement.Read.Directory", + }, + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + return drivers.NewMicrosoft365Driver(c), nil + }, + NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) drivers.NameResolver { + return drivers.NewMicrosoft365NameResolver(c) + }, + } +} diff --git a/pkg/connector/provider/monday.go b/pkg/connector/provider/monday.go new file mode 100644 index 000000000..d70ad6936 --- /dev/null +++ b/pkg/connector/provider/monday.go @@ -0,0 +1,44 @@ +// 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 provider + +import ( + "context" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func mondayRegistration() *Registration { + // Monday.com's primary API is GraphQL POST, and the auth subdomain + // does not expose a Bearer-protected GET userinfo endpoint, so + // ProbeURL is empty. The probe handler skips empty entries; an + // invalid token surfaces at the next /v2 query. + return &Registration{ + Provider: coredata.ConnectorProviderMonday, + DisplayName: "Monday.com", + AuthURL: "https://auth.monday.com/oauth2/authorize", + TokenURL: "https://auth.monday.com/oauth2/token", + OAuth2Scopes: []string{"users:read", "account:read"}, + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + return drivers.NewMondayDriver(c), nil + }, + NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) drivers.NameResolver { + return drivers.NewMondayNameResolver(c) + }, + } +} diff --git a/pkg/connector/provider/netlify.go b/pkg/connector/provider/netlify.go new file mode 100644 index 000000000..298cfef7e --- /dev/null +++ b/pkg/connector/provider/netlify.go @@ -0,0 +1,60 @@ +// 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 provider + +import ( + "context" + "fmt" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func netlifyRegistration() *Registration { + // Netlify OAuth flow has no scope granularity, so OAuth2Scopes is empty. + return &Registration{ + Provider: coredata.ConnectorProviderNetlify, + DisplayName: "Netlify", + AuthURL: "https://app.netlify.com/authorize", + TokenURL: "https://api.netlify.com/oauth/token", + ProbeURL: "https://api.netlify.com/api/v1/user", + NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + s, err := coredata.ConnectorSettings[coredata.NetlifyConnectorSettings](conn) + if err != nil { + return nil, fmt.Errorf("cannot read netlify connector settings: %w", err) + } + + if s.AccountSlug == "" { + return nil, fmt.Errorf("cannot create netlify driver: account_slug is required") + } + + return drivers.NewNetlifyDriver(c, s.AccountSlug), nil + }, + NewNameResolver: func(ctx context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver { + s, err := coredata.ConnectorSettings[coredata.NetlifyConnectorSettings](conn) + if err != nil { + logger.ErrorCtx(ctx, "cannot read netlify connector settings", log.Error(err)) + return nil + } + + return drivers.NewNetlifyNameResolver(c, s.AccountSlug) + }, + SetOrganizationSettings: func(c *coredata.Connector, accountSlug string) error { + return c.SetSettings(&coredata.NetlifyConnectorSettings{AccountSlug: accountSlug}) + }, + } +} diff --git a/pkg/connector/provider/notion.go b/pkg/connector/provider/notion.go new file mode 100644 index 000000000..4d88ba280 --- /dev/null +++ b/pkg/connector/provider/notion.go @@ -0,0 +1,43 @@ +// 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 provider + +import ( + "context" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func notionRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderNotion, + DisplayName: "Notion", + AuthURL: "https://api.notion.com/v1/oauth/authorize", + TokenURL: "https://api.notion.com/v1/oauth/token", + ExtraAuthParams: map[string]string{"owner": "user"}, + TokenEndpointAuth: "basic-json", + ProbeURL: "https://api.notion.com/v1/users/me", + SupportsAPIKey: true, + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + return drivers.NewNotionDriver(c), nil + }, + NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) drivers.NameResolver { + return drivers.NewNotionNameResolver(c) + }, + } +} diff --git a/pkg/connector/provider/one_password.go b/pkg/connector/provider/one_password.go new file mode 100644 index 000000000..0a15b7623 --- /dev/null +++ b/pkg/connector/provider/one_password.go @@ -0,0 +1,100 @@ +// 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 provider + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/connector" + "go.probo.inc/probo/pkg/coredata" +) + +func onePasswordRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderOnePassword, + DisplayName: "1Password", + ProbeURL: "https://events.1password.com/api/v1/auditevents", + SupportsAPIKey: true, + SupportsClientCredentials: true, + ExtraSettings: []ExtraSetting{ + {Key: "accountId", Label: "Account ID", Required: true}, + {Key: "region", Label: "Region", Required: true}, + }, + // 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 + }, + 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. + if conn.GrantType() == string(connector.OAuth2GrantTypeClientCredentials) { + s, err := coredata.ConnectorSettings[coredata.OnePasswordUsersAPISettings](conn) + if err != nil { + return nil, fmt.Errorf("cannot read 1password users api settings: %w", err) + } + + return drivers.NewOnePasswordUsersAPIDriver(c, s.AccountID, s.Region), nil + } + + s, err := coredata.ConnectorSettings[coredata.OnePasswordConnectorSettings](conn) + if err != nil { + return nil, fmt.Errorf("cannot read 1password connector settings: %w", err) + } + + if s.SCIMBridgeURL == "" { + return nil, fmt.Errorf("cannot create 1password driver: scim_bridge_url is required") + } + + return drivers.NewOnePasswordDriver(c, s.SCIMBridgeURL), nil + }, + } +} diff --git a/pkg/connector/provider/one_password_test.go b/pkg/connector/provider/one_password_test.go new file mode 100644 index 000000000..b516b1bbc --- /dev/null +++ b/pkg/connector/provider/one_password_test.go @@ -0,0 +1,101 @@ +// 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 provider_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.gearno.de/kit/httpclient" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/connector" + "go.probo.inc/probo/pkg/connector/provider" + "go.probo.inc/probo/pkg/coredata" +) + +// TestOnePassword_NewDriver_DispatchByGrantType is the pre-merge gate +// for the 1Password closure. The OnePassword registration dispatches +// between two drivers based on the connector's OAuth2 grant type — +// this test asserts both paths construct without error from a +// coredata.Connector shaped for each grant type. +func TestOnePassword_NewDriver_DispatchByGrantType(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + reg, ok := r.Get(coredata.ConnectorProviderOnePassword) + require.True(t, ok, "1Password provider must be registered") + require.NotNil(t, reg.NewDriver, "1Password NewDriver closure must be wired") + + t.Run("client_credentials uses Users API driver", func(t *testing.T) { + t.Parallel() + + raw, err := json.Marshal(&coredata.OnePasswordUsersAPISettings{ + AccountID: "test-account", + Region: "us", + }) + require.NoError(t, err) + + conn := &coredata.Connector{ + Provider: coredata.ConnectorProviderOnePassword, + RawSettings: raw, + Connection: &connector.OAuth2Connection{ + GrantType: connector.OAuth2GrantTypeClientCredentials, + }, + } + + drv, err := reg.NewDriver(context.Background(), httpclient.DefaultClient(httpclient.WithSSRFProtection()), conn, nil) + require.NoError(t, err) + assert.IsType(t, &drivers.OnePasswordUsersAPIDriver{}, drv) + }) + + t.Run("authorization_code uses SCIM-bridge driver", func(t *testing.T) { + t.Parallel() + + raw, err := json.Marshal(&coredata.OnePasswordConnectorSettings{ + SCIMBridgeURL: "https://scim.example.test", + }) + require.NoError(t, err) + + conn := &coredata.Connector{ + Provider: coredata.ConnectorProviderOnePassword, + RawSettings: raw, + Connection: &connector.OAuth2Connection{ + GrantType: connector.OAuth2GrantTypeAuthorizationCode, + }, + } + + drv, err := reg.NewDriver(context.Background(), httpclient.DefaultClient(httpclient.WithSSRFProtection()), conn, nil) + require.NoError(t, err) + assert.IsType(t, &drivers.OnePasswordDriver{}, drv) + }) + + t.Run("authorization_code without scim_bridge_url errors", func(t *testing.T) { + t.Parallel() + + conn := &coredata.Connector{ + Provider: coredata.ConnectorProviderOnePassword, + Connection: &connector.OAuth2Connection{ + GrantType: connector.OAuth2GrantTypeAuthorizationCode, + }, + } + + _, err := reg.NewDriver(context.Background(), httpclient.DefaultClient(httpclient.WithSSRFProtection()), conn, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "scim_bridge_url is required") + }) +} diff --git a/pkg/connector/provider/openai.go b/pkg/connector/provider/openai.go new file mode 100644 index 000000000..f7b8399d9 --- /dev/null +++ b/pkg/connector/provider/openai.go @@ -0,0 +1,39 @@ +// 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 provider + +import ( + "context" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func openaiRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderOpenAI, + DisplayName: "OpenAI", + ProbeURL: "https://api.openai.com/v1/models", + SupportsAPIKey: true, + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + return drivers.NewOpenAIDriver(c), nil + }, + NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) drivers.NameResolver { + return drivers.NewOpenAINameResolver(c) + }, + } +} diff --git a/pkg/connector/provider/pagerduty.go b/pkg/connector/provider/pagerduty.go new file mode 100644 index 000000000..77be19d50 --- /dev/null +++ b/pkg/connector/provider/pagerduty.go @@ -0,0 +1,54 @@ +// 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 provider + +import ( + "context" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func pagerdutyRegistration() *Registration { + // PagerDuty Scoped OAuth requires PKCE (RFC 7636). The customer + // subdomain surfaces as a callback query parameter (or + // occasionally in the token response body) and is persisted on + // PagerDutyConnectorSettings by the OAuth callback handler. + return &Registration{ + Provider: coredata.ConnectorProviderPagerDuty, + DisplayName: "PagerDuty", + AuthURL: "https://identity.pagerduty.com/oauth/authorize", + TokenURL: "https://identity.pagerduty.com/oauth/token", + ProbeURL: "https://api.pagerduty.com/users/me", + OAuth2Scopes: []string{"users.read"}, + RequiresPKCE: true, + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + // PagerDuty's REST API uses the regional api.pagerduty.com host; + // the driver does not consume the per-tenant subdomain. + return drivers.NewPagerDutyDriver(c), nil + }, + NewNameResolver: func(ctx context.Context, _ *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver { + s, err := coredata.ConnectorSettings[coredata.PagerDutyConnectorSettings](conn) + if err != nil { + logger.ErrorCtx(ctx, "cannot read pagerduty connector settings", log.Error(err)) + return nil + } + + return drivers.NewPagerDutyNameResolver(s.Subdomain) + }, + } +} diff --git a/pkg/connector/provider/registry.go b/pkg/connector/provider/registry.go new file mode 100644 index 000000000..2492977df --- /dev/null +++ b/pkg/connector/provider/registry.go @@ -0,0 +1,134 @@ +// 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 provider holds one Go file per connector provider. Each file +// exposes a private constructor that returns a *Registration; the +// builtin set is assembled by NewBuiltinRegistry, which probod calls +// once at startup and threads as an explicit *Registry into every +// consumer. The registry carries no package-level state. +// +// pkg/connector/provider is a sub-package of pkg/connector. The +// child may import its parent (it does — for the *OAuth2Connector +// type in apply.go); the parent must not import this child. Cycles +// with pkg/coredata are avoided because the back-edge runs: +// provider -> connector -> coredata -> (no further imports back). +package provider + +import ( + "fmt" + "slices" + "sync" + + "go.probo.inc/probo/pkg/coredata" +) + +// Registry holds the per-provider *Registration set used by the rest +// of the system to look up display names, OAuth2 metadata, driver +// constructors, and so on. It is safe for concurrent use. +// +// All consumers receive a *Registry constructed by NewBuiltinRegistry +// at probod startup; no package-level singleton exists. +type Registry struct { + mu sync.RWMutex + providers map[coredata.ConnectorProvider]*Registration +} + +// NewRegistry returns an empty *Registry. Production code uses +// NewBuiltinRegistry; tests and specialised callers can construct an +// empty Registry and register only the providers they need. +func NewRegistry() *Registry { + return &Registry{ + providers: make(map[coredata.ConnectorProvider]*Registration), + } +} + +// Register adds a Registration to r. It returns an error on nil or +// incomplete Registration metadata or on duplicate registration so +// callers (in particular NewBuiltinRegistry) can decide whether the +// condition is a programmer error worth crashing on or a recoverable +// state worth surfacing. +func (r *Registry) Register(reg *Registration) error { + if reg == nil { + return fmt.Errorf("cannot register connector provider: nil Registration") + } + + if reg.Provider == "" { + return fmt.Errorf("cannot register connector provider: missing Provider") + } + + if reg.DisplayName == "" { + return fmt.Errorf("cannot register connector provider %q: missing DisplayName", reg.Provider) + } + + r.mu.Lock() + defer r.mu.Unlock() + + if _, dup := r.providers[reg.Provider]; dup { + return fmt.Errorf("cannot register connector provider %q: duplicate registration", reg.Provider) + } + + r.providers[reg.Provider] = reg + + return nil +} + +// Get returns the Registration for the given provider, or false if +// no provider is registered under that key. +func (r *Registry) Get(p coredata.ConnectorProvider) (*Registration, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + + reg, ok := r.providers[p] + + return reg, ok +} + +// All returns every Registration currently in r. Order is not stable; +// callers must sort when determinism matters. +func (r *Registry) All() []*Registration { + r.mu.RLock() + defer r.mu.RUnlock() + + out := make([]*Registration, 0, len(r.providers)) + for _, reg := range r.providers { + out = append(out, reg) + } + + return out +} + +// ProviderDisplayName returns the human-readable label for the +// provider, falling back to the raw constant string when no display +// name is registered. +func (r *Registry) ProviderDisplayName(p coredata.ConnectorProvider) string { + if reg, ok := r.Get(p); ok && reg.DisplayName != "" { + return reg.DisplayName + } + + return string(p) +} + +// ProviderOAuth2Scopes returns the OAuth2 scopes the access review +// driver for the given provider needs to list user accounts. Returns +// nil for providers that do not need any scopes (Notion, Intercom) +// or for non-access-review providers. +func (r *Registry) ProviderOAuth2Scopes(p coredata.ConnectorProvider) []string { + if reg, ok := r.Get(p); ok { + // Return a copy so callers cannot mutate the shared, concurrently + // read registration slice held by this long-lived registry. + return slices.Clone(reg.OAuth2Scopes) + } + + return nil +} diff --git a/pkg/connector/provider/registry_test.go b/pkg/connector/provider/registry_test.go new file mode 100644 index 000000000..d8e3fa2e5 --- /dev/null +++ b/pkg/connector/provider/registry_test.go @@ -0,0 +1,156 @@ +// 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 provider_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/connector/provider" + "go.probo.inc/probo/pkg/coredata" +) + +// TestEveryProviderRegistered asserts that every +// coredata.ConnectorProvider constant has a matching Registration in +// the registry, that the registration carries the minimum metadata +// (Provider, DisplayName), and that the access-review NewDriver +// closure is wired — so the provider can actually drive a review. +func TestEveryProviderRegistered(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + + for _, p := range coredata.ConnectorProviders() { + t.Run(string(p), func(t *testing.T) { + t.Parallel() + + reg, ok := r.Get(p) + require.Truef(t, ok, "provider %q has no Registration", p) + require.NotNil(t, reg, "provider %q Registration is nil", p) + require.Equalf(t, p, reg.Provider, "provider %q has mismatching Registration.Provider", p) + assert.NotEmptyf(t, reg.DisplayName, "provider %q has empty DisplayName", p) + assert.NotNilf(t, reg.NewDriver, "provider %q has nil NewDriver", p) + }) + } +} + +// TestRegistry_Register exercises the validation and duplicate-detection +// paths on Register. Programmer errors at NewBuiltinRegistry time — +// nil, empty Provider, empty DisplayName, duplicate — must all surface +// as errors rather than silently registering a malformed entry. +func TestRegistry_Register(t *testing.T) { + t.Parallel() + + t.Run("nil Registration", func(t *testing.T) { + t.Parallel() + + r := provider.NewRegistry() + err := r.Register(nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "nil Registration") + }) + + t.Run("empty Provider", func(t *testing.T) { + t.Parallel() + + r := provider.NewRegistry() + err := r.Register(&provider.Registration{DisplayName: "X"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing Provider") + }) + + t.Run("empty DisplayName", func(t *testing.T) { + t.Parallel() + + r := provider.NewRegistry() + err := r.Register(&provider.Registration{Provider: coredata.ConnectorProviderSlack}) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing DisplayName") + }) + + t.Run("duplicate registration", func(t *testing.T) { + t.Parallel() + + r := provider.NewRegistry() + require.NoError(t, r.Register(&provider.Registration{ + Provider: coredata.ConnectorProviderSlack, + DisplayName: "Slack", + })) + err := r.Register(&provider.Registration{ + Provider: coredata.ConnectorProviderSlack, + DisplayName: "Slack-bis", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate registration") + }) + + t.Run("valid Registration round-trips through Get", func(t *testing.T) { + t.Parallel() + + r := provider.NewRegistry() + want := &provider.Registration{ + Provider: coredata.ConnectorProviderSlack, + DisplayName: "Slack", + } + require.NoError(t, r.Register(want)) + + got, ok := r.Get(coredata.ConnectorProviderSlack) + require.True(t, ok) + assert.Same(t, want, got) + }) +} + +// TestRegistry_All asserts the registry returns the same number of +// entries that have been registered. The builtin registry is the +// canonical source of truth: every coredata.ConnectorProvider has +// exactly one matching Registration, no more. +func TestRegistry_All(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + assert.Len(t, r.All(), len(coredata.ConnectorProviders())) +} + +// TestRegistry_ProviderDisplayName covers the fallback path: an +// unregistered provider returns its raw constant string. +func TestRegistry_ProviderDisplayName(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + assert.Equal(t, "Slack", r.ProviderDisplayName(coredata.ConnectorProviderSlack)) + assert.Equal(t, "UNKNOWN", r.ProviderDisplayName(coredata.ConnectorProvider("UNKNOWN"))) +} + +// TestRegistry_ProviderOAuth2Scopes covers the nil path for an +// unregistered provider and the populated path for a registered one. +func TestRegistry_ProviderOAuth2Scopes(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + assert.NotEmpty(t, r.ProviderOAuth2Scopes(coredata.ConnectorProviderSlack)) + assert.Nil(t, r.ProviderOAuth2Scopes(coredata.ConnectorProvider("UNKNOWN"))) +} + +// TestRegistry_ProbeURL covers the registered and unregistered paths. +// Slack ships a probe URL in its Registration; an unknown provider +// returns the empty string. +func TestRegistry_ProbeURL(t *testing.T) { + t.Parallel() + + r := provider.NewBuiltinRegistry() + assert.NotEmpty(t, r.ProbeURL("SLACK")) + assert.Empty(t, r.ProbeURL("UNKNOWN")) +} diff --git a/pkg/connector/provider/resend.go b/pkg/connector/provider/resend.go new file mode 100644 index 000000000..0045dddfb --- /dev/null +++ b/pkg/connector/provider/resend.go @@ -0,0 +1,39 @@ +// 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 provider + +import ( + "context" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func resendRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderResend, + DisplayName: "Resend", + ProbeURL: "https://api.resend.com/domains", + SupportsAPIKey: true, + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + return drivers.NewResendDriver(c), nil + }, + NewNameResolver: func(_ context.Context, _ *http.Client, _ *coredata.Connector, _ *log.Logger) drivers.NameResolver { + return drivers.NewResendNameResolver() + }, + } +} diff --git a/pkg/connector/provider/sentry.go b/pkg/connector/provider/sentry.go new file mode 100644 index 000000000..34fe37297 --- /dev/null +++ b/pkg/connector/provider/sentry.go @@ -0,0 +1,69 @@ +// 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 provider + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func sentryRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderSentry, + DisplayName: "Sentry", + AuthURL: "https://sentry.io/oauth/authorize/", + TokenURL: "https://sentry.io/oauth/token/", + ProbeURL: "https://sentry.io/api/0/organizations/", + OAuth2Scopes: []string{"org:read", "member:read"}, + SupportsAPIKey: true, + 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 { + return nil, fmt.Errorf("cannot read sentry connector settings: %w", err) + } + + // OrganizationSlug may be empty for OAuth connections; the driver auto-discovers it. + return drivers.NewSentryDriver(c, s.OrganizationSlug), nil + }, + NewNameResolver: func(ctx context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver { + s, err := coredata.ConnectorSettings[coredata.SentryConnectorSettings](conn) + if err != nil { + logger.ErrorCtx(ctx, "cannot read sentry connector settings", log.Error(err)) + return nil + } + + return drivers.NewSentryNameResolver(c, s.OrganizationSlug) + }, + SetOrganizationSettings: func(c *coredata.Connector, slug string) error { + return c.SetSettings(&coredata.SentryConnectorSettings{OrganizationSlug: slug}) + }, + } +} diff --git a/pkg/connector/provider/slack.go b/pkg/connector/provider/slack.go new file mode 100644 index 000000000..31ba78578 --- /dev/null +++ b/pkg/connector/provider/slack.go @@ -0,0 +1,41 @@ +// 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 provider + +import ( + "context" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func slackRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderSlack, + DisplayName: "Slack", + AuthURL: "https://slack.com/oauth/v2/authorize", + TokenURL: "https://slack.com/api/oauth.v2.access", + ProbeURL: "https://slack.com/api/users.list?limit=1", + OAuth2Scopes: []string{"users:read", "users:read.email"}, + NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { + return drivers.NewSlackDriver(c), nil + }, + NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) drivers.NameResolver { + return drivers.NewSlackNameResolver(c) + }, + } +} diff --git a/pkg/connector/provider/supabase.go b/pkg/connector/provider/supabase.go new file mode 100644 index 000000000..b50715f13 --- /dev/null +++ b/pkg/connector/provider/supabase.go @@ -0,0 +1,66 @@ +// 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 provider + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func supabaseRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderSupabase, + DisplayName: "Supabase", + ProbeURL: "https://api.supabase.com/v1/organizations", + SupportsAPIKey: true, + 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 { + return nil, fmt.Errorf("cannot read supabase connector settings: %w", err) + } + + if s.OrganizationSlug == "" { + return nil, fmt.Errorf("cannot create supabase driver: organization_slug is required") + } + + return drivers.NewSupabaseDriver(c, s.OrganizationSlug), nil + }, + NewNameResolver: func(ctx context.Context, _ *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver { + s, err := coredata.ConnectorSettings[coredata.SupabaseConnectorSettings](conn) + if err != nil { + logger.ErrorCtx(ctx, "cannot read supabase connector settings", log.Error(err)) + return nil + } + + return drivers.NewSupabaseNameResolver(s.OrganizationSlug) + }, + } +} diff --git a/pkg/connector/provider/tally.go b/pkg/connector/provider/tally.go new file mode 100644 index 000000000..750d2bfe8 --- /dev/null +++ b/pkg/connector/provider/tally.go @@ -0,0 +1,66 @@ +// 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 provider + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +func tallyRegistration() *Registration { + return &Registration{ + Provider: coredata.ConnectorProviderTally, + DisplayName: "Tally", + ProbeURL: "https://api.tally.so/me", + SupportsAPIKey: true, + 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 { + return nil, fmt.Errorf("cannot read tally connector settings: %w", err) + } + + if s.OrganizationID == "" { + return nil, fmt.Errorf("cannot create tally driver: organization_id is required") + } + + return drivers.NewTallyDriver(c, s.OrganizationID), nil + }, + NewNameResolver: func(ctx context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver { + s, err := coredata.ConnectorSettings[coredata.TallyConnectorSettings](conn) + if err != nil { + logger.ErrorCtx(ctx, "cannot read tally connector settings", log.Error(err)) + return nil + } + + return drivers.NewTallyNameResolver(c, s.OrganizationID) + }, + } +} diff --git a/pkg/connector/provider/types.go b/pkg/connector/provider/types.go new file mode 100644 index 000000000..d4f713da1 --- /dev/null +++ b/pkg/connector/provider/types.go @@ -0,0 +1,101 @@ +// 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 provider + +import ( + "context" + "encoding/json" + "net/http" + + "go.gearno.de/kit/log" + + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +// Registration is the per-provider metadata + factory bundle. Each +// provider returns one of these from a private constructor (e.g. +// slackRegistration) that NewBuiltinRegistry assembles into the +// runtime *Registry. Fields are grouped by concern: identity, OAuth2 +// metadata, supported protocols, extra settings, and factory closures. +type Registration struct { + // Identity. + Provider coredata.ConnectorProvider + DisplayName string + + // OAuth2 metadata. + AuthURL string + TokenURL string + ExtraAuthParams map[string]string + TokenEndpointAuth string // "post-form" (default), "basic-form", or "basic-json" + SupportsIncrementalAuth bool + OAuth2Scopes []string + ProbeURL string + // RequiresPKCE enables RFC 7636 PKCE (S256) on the authorization + // 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 + + // Protocol support / GraphQL surface. + SupportsAPIKey bool + SupportsClientCredentials bool + ExtraSettings []ExtraSetting + + // Factory closures — wired by Stages 2 and 3. + 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 +// surfaced on ConnectorProviderInfo for the frontend to render. +type ExtraSetting struct { + Key string + Label string + Required bool +} diff --git a/pkg/connector/provider/vercel.go b/pkg/connector/provider/vercel.go new file mode 100644 index 000000000..30395ed65 --- /dev/null +++ b/pkg/connector/provider/vercel.go @@ -0,0 +1,61 @@ +// 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 provider + +import ( + "context" + "fmt" + "net/http" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview/drivers" + "go.probo.inc/probo/pkg/coredata" +) + +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. + 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", + 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 { + return nil, fmt.Errorf("cannot read vercel connector settings: %w", err) + } + + if s.TeamID == "" { + return nil, fmt.Errorf("cannot create vercel driver: team_id is required") + } + + return drivers.NewVercelDriver(c, s.TeamID), nil + }, + NewNameResolver: func(ctx context.Context, c *http.Client, conn *coredata.Connector, logger *log.Logger) drivers.NameResolver { + s, err := coredata.ConnectorSettings[coredata.VercelConnectorSettings](conn) + if err != nil { + logger.ErrorCtx(ctx, "cannot read vercel connector settings", log.Error(err)) + return nil + } + + return drivers.NewVercelNameResolver(c, s.TeamID) + }, + } +} diff --git a/pkg/connector/providers.go b/pkg/connector/providers.go index 361ce492c..59bf44698 100644 --- a/pkg/connector/providers.go +++ b/pkg/connector/providers.go @@ -14,170 +14,5 @@ package connector -import ( - "maps" - "strings" - - "go.gearno.de/kit/httpclient" -) - // CallbackPath is the HTTP path for the OAuth2 callback endpoint. const CallbackPath = "/api/console/v1/connectors/complete" - -// providerDefinition holds the static OAuth2 properties for a provider. -// These are intrinsic to the provider and do not vary between deployments. -// Scopes are not part of this — they are passed by the caller at -// initiate time via InitiateOptions, since the same provider may be used -// in multiple contexts requiring different scope sets. -type providerDefinition struct { - AuthURL string - TokenURL string - ExtraAuthParams map[string]string - TokenEndpointAuth string // "post-form" (default), "basic-form", or "basic-json" - SupportsIncrementalAuth bool - // RequiresPKCE enables RFC 7636 PKCE (S256) on the authorization - // request and replays the verifier on the token exchange. Default - // false; existing providers are unaffected. - RequiresPKCE bool -} - -// providerDefinitions maps provider names to their static OAuth2 definitions. -// Only ClientID and ClientSecret come from deployment config. -var ( - providerDefinitions = map[string]providerDefinition{ - "SLACK": { - AuthURL: "https://slack.com/oauth/v2/authorize", - TokenURL: "https://slack.com/api/oauth.v2.access", - }, - "HUBSPOT": { - AuthURL: "https://app.hubspot.com/oauth/authorize", - TokenURL: "https://api.hubapi.com/oauth/v1/token", - }, - "DOCUSIGN": { - AuthURL: "https://account.docusign.com/oauth/auth", - TokenURL: "https://account.docusign.com/oauth/token", - TokenEndpointAuth: "basic-form", - }, - "NOTION": { - AuthURL: "https://api.notion.com/v1/oauth/authorize", - TokenURL: "https://api.notion.com/v1/oauth/token", - ExtraAuthParams: map[string]string{"owner": "user"}, - TokenEndpointAuth: "basic-json", - }, - "GITHUB": { - AuthURL: "https://github.com/login/oauth/authorize", - TokenURL: "https://github.com/login/oauth/access_token", - }, - "SENTRY": { - AuthURL: "https://sentry.io/oauth/authorize/", - TokenURL: "https://sentry.io/oauth/token/", - }, - "INTERCOM": { - AuthURL: "https://app.intercom.com/oauth", - TokenURL: "https://api.intercom.io/auth/eagle/token", - }, - "BREX": { - AuthURL: "https://accounts-api.brex.com/oauth2/default/v1/authorize", - TokenURL: "https://accounts-api.brex.com/oauth2/default/v1/token", - }, - "GOOGLE_WORKSPACE": { - AuthURL: "https://accounts.google.com/o/oauth2/v2/auth", - TokenURL: "https://oauth2.googleapis.com/token", - ExtraAuthParams: map[string]string{ - "access_type": "offline", - "prompt": "consent", - }, - SupportsIncrementalAuth: true, - }, - "MICROSOFT_365": { - AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", - TokenURL: "https://login.microsoftonline.com/common/oauth2/v2.0/token", - ExtraAuthParams: map[string]string{ - "prompt": "consent", - }, - }, - "LINEAR": { - AuthURL: "https://linear.app/oauth/authorize", - TokenURL: "https://api.linear.app/oauth/token", - }, - "GITLAB": { - AuthURL: "https://gitlab.com/oauth/authorize", - TokenURL: "https://gitlab.com/oauth/token", - }, - // Bitbucket scopes are pinned on the OAuth consumer at registration - // time (`account` for workspace membership). They are not passed in - // the authorize URL and not configured here. - "BITBUCKET": { - AuthURL: "https://bitbucket.org/site/oauth2/authorize", - TokenURL: "https://bitbucket.org/site/oauth2/access_token", - }, - "HEROKU": { - AuthURL: "https://id.heroku.com/oauth/authorize", - TokenURL: "https://id.heroku.com/oauth/token", - }, - "PAGERDUTY": { - AuthURL: "https://identity.pagerduty.com/oauth/authorize", - TokenURL: "https://identity.pagerduty.com/oauth/token", - RequiresPKCE: true, - }, - "ASANA": { - AuthURL: "https://app.asana.com/-/oauth_authorize", - TokenURL: "https://app.asana.com/-/oauth_token", - }, - "NETLIFY": { - AuthURL: "https://app.netlify.com/authorize", - TokenURL: "https://api.netlify.com/oauth/token", - }, - "CLICKUP": { - AuthURL: "https://app.clickup.com/api", - TokenURL: "https://api.clickup.com/api/v2/oauth/token", - }, - // Vercel uses a templated AuthURL: the operator supplies an - // `integration-slug` config field which is resolved into the - // "{integration_slug}" placeholder by ApplyProviderDefaults. - // Vercel does not use OAuth scopes — capabilities are pinned on - // the integration registration in the Vercel dashboard. - "VERCEL": { - AuthURL: "https://vercel.com/integrations/{integration_slug}/new", - TokenURL: "https://api.vercel.com/v2/oauth/access_token", - }, - "MONDAY": { - AuthURL: "https://auth.monday.com/oauth2/authorize", - TokenURL: "https://auth.monday.com/oauth2/token", - }, - } -) - -// ApplyProviderDefaults sets the redirect URI and applies static provider -// defaults (auth URL, token URL, extra params, token endpoint auth) onto -// an OAuth2Connector, and wires an SSRF-protected HTTP client for the -// token exchange request. Call this before registering the connector. -func ApplyProviderDefaults(provider string, redirectURI string, c *OAuth2Connector) { - c.RedirectURI = redirectURI - c.HTTPClient = httpclient.DefaultClient(httpclient.WithSSRFProtection()) - - if def, ok := providerDefinitions[provider]; ok { - c.AuthURL = def.AuthURL - c.TokenURL = def.TokenURL - c.TokenEndpointAuth = def.TokenEndpointAuth - c.SupportsIncrementalAuth = def.SupportsIncrementalAuth - c.RequiresPKCE = def.RequiresPKCE - - // Deep copy ExtraAuthParams so per-connector mutations (e.g. - // incremental auth, scope overrides) cannot alias back into the - // shared providerDefinitions map. - if len(def.ExtraAuthParams) > 0 { - extra := make(map[string]string, len(def.ExtraAuthParams)) - maps.Copy(extra, def.ExtraAuthParams) - 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) - } - } -} diff --git a/pkg/connector/registry.go b/pkg/connector/registry.go index d803e049c..8a1e68bcb 100644 --- a/pkg/connector/registry.go +++ b/pkg/connector/registry.go @@ -119,48 +119,6 @@ func (r *ConnectorRegistry) CompleteWithState(ctx context.Context, provider stri return oauth2Connector.CompleteWithState(ctx, req) } -// providerProbeURLs maps provider names to lightweight API endpoints -// used to verify OAuth token validity. Each URL must accept a GET -// request with a Bearer token and return 401/403 for invalid tokens. -var ( - providerProbeURLs = map[string]string{ - "SLACK": "https://slack.com/api/users.list?limit=1", - "GOOGLE_WORKSPACE": "https://admin.googleapis.com/admin/directory/v1/users?customer=my_customer&maxResults=1", - "LINEAR": "https://api.linear.app/graphql", - "BREX": "https://platform.brexapis.com/v2/users/me", - "HUBSPOT": "https://api.hubapi.com/account-info/v3/details", - "DOCUSIGN": "https://account-d.docusign.com/oauth/userinfo", - "NOTION": "https://api.notion.com/v1/users/me", - "GITHUB": "https://api.github.com/user", - "SENTRY": "https://sentry.io/api/0/organizations/", - "INTERCOM": "https://api.intercom.io/me", - "CLOUDFLARE": "https://api.cloudflare.com/client/v4/user/tokens/verify", - "OPENAI": "https://api.openai.com/v1/models", - "SUPABASE": "https://api.supabase.com/v1/organizations", - "TALLY": "https://api.tally.so/me", - "RESEND": "https://api.resend.com/domains", - "ONE_PASSWORD": "https://events.1password.com/api/v1/auditevents", - "MICROSOFT_365": "https://graph.microsoft.com/v1.0/organization?$top=1", - "GITLAB": "https://gitlab.com/api/v4/user", - "BITBUCKET": "https://api.bitbucket.org/2.0/user", - "HEROKU": "https://api.heroku.com/account", - "PAGERDUTY": "https://api.pagerduty.com/users/me", - "ASANA": "https://app.asana.com/api/1.0/users/me", - "NETLIFY": "https://api.netlify.com/api/v1/user", - "CLICKUP": "https://api.clickup.com/api/v2/user", - "VERCEL": "https://api.vercel.com/v2/user", - // Monday's primary API is GraphQL POST, and the auth subdomain - // does not expose a Bearer-protected GET userinfo endpoint, so - // there is no valid probe URL. The probe handler skips empty - // entries; an invalid token surfaces at the next /v2 query. - } -) - -// GetProbeURL returns the probe URL for a provider. -func (r *ConnectorRegistry) GetProbeURL(provider string) string { - return providerProbeURLs[provider] -} - // GetOAuth2RefreshConfig returns the OAuth2 refresh configuration for a provider. // Returns nil if the provider is not found or is not an OAuth2 connector. func (r *ConnectorRegistry) GetOAuth2RefreshConfig(provider string) *OAuth2RefreshConfig { diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index bd786753f..7ba16409b 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -17,6 +17,7 @@ package coredata import ( "encoding" "fmt" + "slices" ) type ConnectorProvider string @@ -57,35 +58,50 @@ 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 { - 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, + out := make([]ConnectorProvider, 0, len(providerStringMap)) + for _, v := range providerStringMap { + out = append(out, v) } + + // 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/coredata/connector_settings.go b/pkg/coredata/connector_settings.go index 71039527c..9480b148e 100644 --- a/pkg/coredata/connector_settings.go +++ b/pkg/coredata/connector_settings.go @@ -1,4 +1,4 @@ -// Copyright (c) 2026 Probo Inc . +// Copyright (c) 2025-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 @@ -17,6 +17,8 @@ package coredata import ( "encoding/json" "fmt" + + "go.probo.inc/probo/pkg/connector" ) type ( @@ -83,6 +85,18 @@ type ( } ) +// GrantType returns the OAuth2 grant type recorded on the connector's +// Connection, or the empty string when the connector is not an OAuth2 +// connector. Driver factories that dispatch on grant type (1Password) +// read this instead of inspecting the typed Connection directly. +func (c *Connector) GrantType() string { + if oauth2Conn, ok := c.Connection.(*connector.OAuth2Connection); ok { + return string(oauth2Conn.GrantType) + } + + return "" +} + // SetSettings marshals a typed settings struct into the connector's RawSettings. func (c *Connector) SetSettings(v any) error { data, err := json.Marshal(v) diff --git a/pkg/coredata/connector_settings_test.go b/pkg/coredata/connector_settings_test.go new file mode 100644 index 000000000..9556c10d8 --- /dev/null +++ b/pkg/coredata/connector_settings_test.go @@ -0,0 +1,96 @@ +// 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 coredata_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" +) + +// TestConnectorSettings_RoundTrip exercises the ConnectorSettings[T] generic accessor +// against every per-provider settings struct that ships extra +// fields. Each case writes a typed value via SetSettings, reads it +// back via ConnectorSettings[T], and asserts equality. The empty-RawSettings +// case asserts that a connector with no settings yields the zero +// value with no error. +func TestConnectorSettings_RoundTrip(t *testing.T) { + t.Parallel() + + t.Run("SentryConnectorSettings", func(t *testing.T) { + t.Parallel() + + want := coredata.SentryConnectorSettings{OrganizationSlug: "acme"} + c := &coredata.Connector{} + require.NoError(t, c.SetSettings(&want)) + + got, err := coredata.ConnectorSettings[coredata.SentryConnectorSettings](c) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("OnePasswordConnectorSettings", func(t *testing.T) { + t.Parallel() + + want := coredata.OnePasswordConnectorSettings{SCIMBridgeURL: "https://scim.example.test"} + c := &coredata.Connector{} + require.NoError(t, c.SetSettings(&want)) + + got, err := coredata.ConnectorSettings[coredata.OnePasswordConnectorSettings](c) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("TallyConnectorSettings", func(t *testing.T) { + t.Parallel() + + want := coredata.TallyConnectorSettings{OrganizationID: "org_123"} + c := &coredata.Connector{} + require.NoError(t, c.SetSettings(&want)) + + got, err := coredata.ConnectorSettings[coredata.TallyConnectorSettings](c) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("empty RawSettings returns zero value", func(t *testing.T) { + t.Parallel() + + c := &coredata.Connector{} + got, err := coredata.ConnectorSettings[coredata.SentryConnectorSettings](c) + require.NoError(t, err) + assert.Equal(t, coredata.SentryConnectorSettings{}, got) + }) + + t.Run("null RawSettings returns zero value", func(t *testing.T) { + t.Parallel() + + c := &coredata.Connector{RawSettings: []byte("null")} + got, err := coredata.ConnectorSettings[coredata.TallyConnectorSettings](c) + require.NoError(t, err) + assert.Equal(t, coredata.TallyConnectorSettings{}, got) + }) + + t.Run("invalid JSON returns wrapped error", func(t *testing.T) { + t.Parallel() + + c := &coredata.Connector{RawSettings: []byte("{not-valid")} + _, err := coredata.ConnectorSettings[coredata.TallyConnectorSettings](c) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot unmarshal connector settings") + }) +} diff --git a/pkg/probo/connector_service.go b/pkg/probo/connector_service.go index 53e1bd208..aeafbb3d6 100644 --- a/pkg/probo/connector_service.go +++ b/pkg/probo/connector_service.go @@ -49,18 +49,15 @@ type ( } CreateConnectorRequest struct { - OrganizationID gid.GID - Provider coredata.ConnectorProvider - Protocol coredata.ConnectorProtocol - Connection connector.Connection - TallySettings *coredata.TallyConnectorSettings - OnePasswordSettings *coredata.OnePasswordConnectorSettings - SentrySettings *coredata.SentryConnectorSettings - SupabaseSettings *coredata.SupabaseConnectorSettings - GitHubSettings *coredata.GitHubConnectorSettings - OnePasswordUsersAPISettings *coredata.OnePasswordUsersAPISettings - PagerDutySettings *coredata.PagerDutyConnectorSettings - VercelSettings *coredata.VercelConnectorSettings + OrganizationID gid.GID + Provider coredata.ConnectorProvider + 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. + RawSettings json.RawMessage } ReconnectConnectorRequest struct { @@ -77,10 +74,30 @@ func (car *CreateConnectorRequest) Validate() error { v.Check(car.Provider, "provider", validator.Required(), validator.OneOfSlice(coredata.ConnectorProviders())) v.Check(car.Protocol, "protocol", validator.Required(), validator.OneOfSlice(coredata.ConnectorProtocols())) v.Check(car.Connection, "connection", validator.Required()) + v.Check(car.RawSettings, "raw_settings", validJSONRawMessage) return v.Error() } +// validJSONRawMessage rejects a non-empty RawSettings that does not +// parse as JSON. Empty RawSettings is allowed (providers without +// extra settings). +func validJSONRawMessage(value any) *validator.ValidationError { + raw, ok := value.(json.RawMessage) + if !ok || len(raw) == 0 { + return nil + } + + if !json.Valid(raw) { + return &validator.ValidationError{ + Code: validator.ErrorCodeInvalidFormat, + Message: "must be valid JSON", + } + } + + return nil +} + func (rcr *ReconnectConnectorRequest) Validate() error { v := validator.New() v.Check(rcr.ConnectorID, "connector_id", validator.Required(), validator.GID(coredata.ConnectorEntityType)) @@ -248,39 +265,8 @@ func (s *ConnectorService) Create( UpdatedAt: now, } - switch { - case req.TallySettings != nil: - if err := newConnector.SetSettings(req.TallySettings); err != nil { - return nil, fmt.Errorf("cannot set tally settings: %w", err) - } - case req.OnePasswordSettings != nil: - if err := newConnector.SetSettings(req.OnePasswordSettings); err != nil { - return nil, fmt.Errorf("cannot set one password settings: %w", err) - } - case req.SentrySettings != nil: - if err := newConnector.SetSettings(req.SentrySettings); err != nil { - return nil, fmt.Errorf("cannot set sentry settings: %w", err) - } - case req.SupabaseSettings != nil: - if err := newConnector.SetSettings(req.SupabaseSettings); err != nil { - return nil, fmt.Errorf("cannot set supabase settings: %w", err) - } - case req.GitHubSettings != nil: - if err := newConnector.SetSettings(req.GitHubSettings); err != nil { - return nil, fmt.Errorf("cannot set github settings: %w", err) - } - case req.OnePasswordUsersAPISettings != nil: - if err := newConnector.SetSettings(req.OnePasswordUsersAPISettings); err != nil { - return nil, fmt.Errorf("cannot set one password users api settings: %w", err) - } - case req.PagerDutySettings != nil: - if err := newConnector.SetSettings(req.PagerDutySettings); err != nil { - return nil, fmt.Errorf("cannot set pagerduty settings: %w", err) - } - case req.VercelSettings != nil: - if err := newConnector.SetSettings(req.VercelSettings); err != nil { - return nil, fmt.Errorf("cannot set vercel settings: %w", err) - } + if len(req.RawSettings) > 0 { + newConnector.RawSettings = []byte(req.RawSettings) } err := s.svc.pg.WithTx( diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 190792688..538a7e91e 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -46,6 +46,7 @@ import ( "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/certmanager" "go.probo.inc/probo/pkg/connector" + "go.probo.inc/probo/pkg/connector/provider" "go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/crypto/cipher" @@ -282,11 +283,12 @@ func (impl *Implm) Run( } redirectURI := baseURL.WithPath(connector.CallbackPath).MustString() + providerRegistry := provider.NewBuiltinRegistry() defaultConnectorRegistry := connector.NewConnectorRegistry() for _, connectorCfg := range impl.cfg.Connectors { if oauth2c, ok := connectorCfg.Config.(*connector.OAuth2Connector); ok { - connector.ApplyProviderDefaults(connectorCfg.Provider, redirectURI, oauth2c) + providerRegistry.ApplyOAuth2Defaults(connectorCfg.Provider, redirectURI, oauth2c) } if err := defaultConnectorRegistry.Register(connectorCfg.Provider, connectorCfg.Config); err != nil { @@ -541,6 +543,7 @@ func (impl *Implm) Run( pgClient, encryptionKey, defaultConnectorRegistry, + providerRegistry, l.Named("access-review"), ) @@ -564,6 +567,7 @@ func (impl *Implm) Run( RiskManagement: riskManagementService, Slack: slackService, ConnectorRegistry: defaultConnectorRegistry, + ProviderRegistry: providerRegistry, BaseURL: baseURL, CustomDomainCname: impl.cfg.CustomDomains.CnameTarget, diff --git a/pkg/probodconfig/connector_config.go b/pkg/probodconfig/connector_config.go index d13ad279e..bec2d41c5 100644 --- a/pkg/probodconfig/connector_config.go +++ b/pkg/probodconfig/connector_config.go @@ -39,7 +39,7 @@ type ConnectorConfigOAuth2 struct { // providers whose static AuthURL contains a "{integration_slug}" // placeholder (Vercel-style integrations). It is propagated onto // OAuth2Connector.AuthURLParams and resolved by - // connector.ApplyProviderDefaults. + // (*provider.Registry).ApplyOAuth2Defaults. IntegrationSlug string `json:"integration-slug,omitempty"` } diff --git a/pkg/server/api/api.go b/pkg/server/api/api.go index 07d673bb4..49e83425c 100644 --- a/pkg/server/api/api.go +++ b/pkg/server/api/api.go @@ -28,6 +28,7 @@ import ( "go.probo.inc/probo/pkg/accessreview" "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/connector" + "go.probo.inc/probo/pkg/connector/provider" "go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/esign" "go.probo.inc/probo/pkg/file" @@ -68,6 +69,7 @@ type ( Cookie securecookie.Config TokenSecret string ConnectorRegistry *connector.ConnectorRegistry + ProviderRegistry *provider.Registry CustomDomainCname string Logger *log.Logger } @@ -192,6 +194,7 @@ func NewServer(cfg Config) (*Server, error) { cfg.Cookie, cfg.TokenSecret, cfg.ConnectorRegistry, + cfg.ProviderRegistry, cfg.BaseURL, cfg.CustomDomainCname, cfg.ThirdParty, diff --git a/pkg/server/api/console/v1/access_review_campaign_resolvers.go b/pkg/server/api/console/v1/access_review_campaign_resolvers.go index 8416e4e85..97ab283a9 100644 --- a/pkg/server/api/console/v1/access_review_campaign_resolvers.go +++ b/pkg/server/api/console/v1/access_review_campaign_resolvers.go @@ -450,7 +450,7 @@ func (r *accessSourceResolver) ConnectionStatus(ctx context.Context, obj *types. // Creating an HTTP client may succeed even with an expired token // (e.g. no refresh token available). Make a lightweight probe // request to verify the token is actually valid. - probeURL := r.connectorRegistry.GetProbeURL(string(dbConnector.Provider)) + probeURL := r.providerRegistry.ProbeURL(string(dbConnector.Provider)) if err := probeConnection(ctx, httpClient, probeURL); err != nil { return types.AccessSourceConnectionStatusDisconnected, nil } diff --git a/pkg/server/api/console/v1/connector_provider_info.go b/pkg/server/api/console/v1/connector_provider_info.go index 95a7984bf..1deb320e5 100644 --- a/pkg/server/api/console/v1/connector_provider_info.go +++ b/pkg/server/api/console/v1/connector_provider_info.go @@ -15,66 +15,44 @@ package console_v1 import ( - "go.probo.inc/probo/pkg/accessreview/drivers" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/server/api/console/v1/types" ) -var apiKeyProviders = map[coredata.ConnectorProvider]bool{ - coredata.ConnectorProviderHubSpot: true, - coredata.ConnectorProviderDocuSign: true, - coredata.ConnectorProviderNotion: true, - coredata.ConnectorProviderGitHub: true, - coredata.ConnectorProviderSentry: true, - coredata.ConnectorProviderIntercom: true, - coredata.ConnectorProviderBrex: true, - coredata.ConnectorProviderTally: true, - coredata.ConnectorProviderCloudflare: true, - coredata.ConnectorProviderOpenAI: true, - coredata.ConnectorProviderSupabase: true, - coredata.ConnectorProviderResend: true, - coredata.ConnectorProviderOnePassword: true, +func (r *Resolver) providerDisplayName(p coredata.ConnectorProvider) string { + return r.providerRegistry.ProviderDisplayName(p) } -var clientCredentialsProviders = map[coredata.ConnectorProvider]bool{ - coredata.ConnectorProviderOnePassword: true, -} - -var providerExtraSettingsMap = map[coredata.ConnectorProvider][]*types.ConnectorProviderSettingInfo{ - coredata.ConnectorProviderGitHub: { - {Key: "organization", Label: "Organization", Required: true}, - }, - coredata.ConnectorProviderSentry: { - {Key: "organizationSlug", Label: "Organization Slug", Required: true}, - }, - coredata.ConnectorProviderTally: { - {Key: "organizationId", Label: "Organization ID", Required: true}, - }, - coredata.ConnectorProviderSupabase: { - {Key: "organizationSlug", Label: "Organization Slug", Required: true}, - }, - coredata.ConnectorProviderOnePassword: { - {Key: "accountId", Label: "Account ID", Required: true}, - {Key: "region", Label: "Region", Required: true}, - }, -} - -func providerDisplayName(provider coredata.ConnectorProvider) string { - return drivers.ProviderDisplayName(provider) -} - -func providerSupportsAPIKey(provider coredata.ConnectorProvider) bool { - return apiKeyProviders[provider] -} - -func providerSupportsClientCredentials(provider coredata.ConnectorProvider) bool { - return clientCredentialsProviders[provider] -} - -func providerExtraSettings(provider coredata.ConnectorProvider) []*types.ConnectorProviderSettingInfo { - if settings, ok := providerExtraSettingsMap[provider]; ok { - return settings +func (r *Resolver) providerSupportsAPIKey(p coredata.ConnectorProvider) bool { + if reg, ok := r.providerRegistry.Get(p); ok { + return reg.SupportsAPIKey } - return []*types.ConnectorProviderSettingInfo{} + return false +} + +func (r *Resolver) providerSupportsClientCredentials(p coredata.ConnectorProvider) bool { + if reg, ok := r.providerRegistry.Get(p); ok { + return reg.SupportsClientCredentials + } + + return false +} + +func (r *Resolver) providerExtraSettings(p coredata.ConnectorProvider) []*types.ConnectorProviderSettingInfo { + reg, ok := r.providerRegistry.Get(p) + if !ok || len(reg.ExtraSettings) == 0 { + return []*types.ConnectorProviderSettingInfo{} + } + + out := make([]*types.ConnectorProviderSettingInfo, 0, len(reg.ExtraSettings)) + for _, s := range reg.ExtraSettings { + out = append(out, &types.ConnectorProviderSettingInfo{ + Key: s.Key, + Label: s.Label, + Required: s.Required, + }) + } + + return out } diff --git a/pkg/server/api/console/v1/connector_resolvers.go b/pkg/server/api/console/v1/connector_resolvers.go index 8db684210..feebfca7b 100644 --- a/pkg/server/api/console/v1/connector_resolvers.go +++ b/pkg/server/api/console/v1/connector_resolvers.go @@ -11,8 +11,8 @@ import ( "fmt" "go.gearno.de/kit/log" - "go.probo.inc/probo/pkg/accessreview/drivers" "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" @@ -22,7 +22,7 @@ import ( // Oauth2Scopes is the resolver for the oauth2Scopes field. func (r *connectorResolver) Oauth2Scopes(ctx context.Context, obj *types.Connector) ([]string, error) { - scopes := drivers.ProviderOAuth2Scopes(obj.Provider) + scopes := r.providerRegistry.ProviderOAuth2Scopes(obj.Provider) if scopes == nil { return []string{}, nil } @@ -44,34 +44,20 @@ func (r *mutationResolver) CreateAPIKeyConnector(ctx context.Context, input type Connection: &connector.APIKeyConnection{APIKey: input.APIKey}, } - if input.TallyOrganizationID != nil { - req.TallySettings = &coredata.TallyConnectorSettings{ - OrganizationID: *input.TallyOrganizationID, - } + in := &provider.SettingsInput{ + TallyOrganizationID: input.TallyOrganizationID, + SentryOrganizationSlug: input.SentryOrganizationSlug, + SupabaseOrganizationSlug: input.SupabaseOrganizationSlug, + GitHubOrganization: input.GithubOrganization, + OnePasswordSCIMBridgeURL: input.OnePasswordScimBridgeURL, } - - if input.SentryOrganizationSlug != nil { - req.SentrySettings = &coredata.SentryConnectorSettings{ - OrganizationSlug: *input.SentryOrganizationSlug, + 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) } - } - if input.SupabaseOrganizationSlug != nil { - req.SupabaseSettings = &coredata.SupabaseConnectorSettings{ - OrganizationSlug: *input.SupabaseOrganizationSlug, - } - } - - if input.GithubOrganization != nil { - req.GitHubSettings = &coredata.GitHubConnectorSettings{ - Organization: *input.GithubOrganization, - } - } - - if input.OnePasswordScimBridgeURL != nil { - req.OnePasswordSettings = &coredata.OnePasswordConnectorSettings{ - SCIMBridgeURL: *input.OnePasswordScimBridgeURL, - } + req.RawSettings = raw } cnnctr, err := r.probo.Connectors.Create(ctx, scope, req) @@ -80,7 +66,9 @@ func (r *mutationResolver) CreateAPIKeyConnector(ctx context.Context, input type return nil, gqlutils.Conflict(ctx, err) } - panic(fmt.Errorf("cannot create API key connector: %w", err)) + r.logger.ErrorCtx(ctx, "cannot create API key connector", log.Error(err)) + + return nil, gqlutils.Internal(ctx) } return &types.CreateAPIKeyConnectorPayload{ @@ -113,11 +101,17 @@ func (r *mutationResolver) CreateClientCredentialsConnector(ctx context.Context, Connection: oauth2Conn, } - if input.OnePasswordAccountID != nil && input.OnePasswordRegion != nil { - req.OnePasswordUsersAPISettings = &coredata.OnePasswordUsersAPISettings{ - AccountID: *input.OnePasswordAccountID, - Region: *input.OnePasswordRegion, + in := &provider.SettingsInput{ + OnePasswordAccountID: input.OnePasswordAccountID, + OnePasswordRegion: input.OnePasswordRegion, + } + 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 } cnnctr, err := r.probo.Connectors.Create(ctx, scope, req) @@ -126,7 +120,9 @@ func (r *mutationResolver) CreateClientCredentialsConnector(ctx context.Context, return nil, gqlutils.Conflict(ctx, err) } - panic(fmt.Errorf("cannot create client credentials connector: %w", err)) + r.logger.ErrorCtx(ctx, "cannot create client credentials connector", log.Error(err)) + + return nil, gqlutils.Internal(ctx) } return &types.CreateClientCredentialsConnectorPayload{ diff --git a/pkg/server/api/console/v1/graphql_handler.go b/pkg/server/api/console/v1/graphql_handler.go index 535d86686..5cd7eeae1 100644 --- a/pkg/server/api/console/v1/graphql_handler.go +++ b/pkg/server/api/console/v1/graphql_handler.go @@ -20,6 +20,7 @@ import ( "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/accessreview" "go.probo.inc/probo/pkg/connector" + "go.probo.inc/probo/pkg/connector/provider" "go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/esign" "go.probo.inc/probo/pkg/iam" @@ -41,6 +42,7 @@ func NewGraphQLHandler( mailmanSvc *mailman.Service, cookieBannerSvc *cookiebanner.Service, connectorRegistry *connector.ConnectorRegistry, + providerRegistry *provider.Registry, customDomainCname string, logger *log.Logger, thirdPartySvc *thirdparty.Service, @@ -57,6 +59,7 @@ func NewGraphQLHandler( mailman: mailmanSvc, cookieBanner: cookieBannerSvc, connectorRegistry: connectorRegistry, + providerRegistry: providerRegistry, riskManagement: riskManagementSvc, thirdParty: thirdPartySvc, customDomainCname: customDomainCname, diff --git a/pkg/server/api/console/v1/organization_resolvers.go b/pkg/server/api/console/v1/organization_resolvers.go index 1f213e519..379ae9862 100644 --- a/pkg/server/api/console/v1/organization_resolvers.go +++ b/pkg/server/api/console/v1/organization_resolvers.go @@ -12,7 +12,6 @@ import ( "time" "go.gearno.de/kit/log" - "go.probo.inc/probo/pkg/accessreview/drivers" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" @@ -548,22 +547,22 @@ func (r *organizationResolver) ConnectorProviderInfos(ctx context.Context, obj * var infos []*types.ConnectorProviderInfo - for _, provider := range coredata.ConnectorProviders() { - _, oauthErr := r.connectorRegistry.Get(string(provider)) + for _, p := range coredata.ConnectorProviders() { + _, oauthErr := r.connectorRegistry.Get(string(p)) - scopes := drivers.ProviderOAuth2Scopes(provider) + scopes := r.providerRegistry.ProviderOAuth2Scopes(p) if scopes == nil { scopes = []string{} } info := &types.ConnectorProviderInfo{ - Provider: provider, - DisplayName: providerDisplayName(provider), + Provider: p, + DisplayName: r.providerDisplayName(p), OauthConfigured: oauthErr == nil, - APIKeySupported: providerSupportsAPIKey(provider), - ClientCredentialsSupported: providerSupportsClientCredentials(provider), + APIKeySupported: r.providerSupportsAPIKey(p), + ClientCredentialsSupported: r.providerSupportsClientCredentials(p), Oauth2Scopes: scopes, - ExtraSettings: providerExtraSettings(provider), + ExtraSettings: r.providerExtraSettings(p), } infos = append(infos, info) } diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index 92c6e9337..cf8e9255a 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -18,6 +18,7 @@ package console_v1 import ( "context" + "encoding/json" "fmt" "net/http" "net/url" @@ -28,6 +29,7 @@ import ( "go.probo.inc/probo/pkg/accessreview" "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/connector" + "go.probo.inc/probo/pkg/connector/provider" "go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/esign" @@ -56,6 +58,7 @@ type ( mailman *mailman.Service cookieBanner *cookiebanner.Service connectorRegistry *connector.ConnectorRegistry + providerRegistry *provider.Registry riskManagement *riskmanagement.Service thirdParty *thirdparty.Service logger *log.Logger @@ -74,6 +77,7 @@ func NewMux( cookieConfig securecookie.Config, tokenSecret string, connectorRegistry *connector.ConnectorRegistry, + providerRegistry *provider.Registry, baseURL *baseurl.BaseURL, customDomainCname string, thirdPartySvc *thirdparty.Service, @@ -91,6 +95,7 @@ func NewMux( mailmanSvc, cookieBannerSvc, connectorRegistry, + providerRegistry, customDomainCname, logger, thirdPartySvc, @@ -236,9 +241,17 @@ func handleConnectorComplete( } if subdomain != "" { - createReq.PagerDutySettings = &coredata.PagerDutyConnectorSettings{ + raw, err := json.Marshal(&coredata.PagerDutyConnectorSettings{ Subdomain: subdomain, + }) + if err != nil { + logger.ErrorCtx(r.Context(), "cannot marshal pagerduty settings", log.Error(err)) + httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("internal error")) + + return } + + createReq.RawSettings = raw } } @@ -260,9 +273,17 @@ func handleConnectorComplete( } if teamID != "" { - createReq.VercelSettings = &coredata.VercelConnectorSettings{ + raw, err := json.Marshal(&coredata.VercelConnectorSettings{ TeamID: teamID, + }) + if err != nil { + logger.ErrorCtx(r.Context(), "cannot marshal vercel settings", log.Error(err)) + httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("internal error")) + + return } + + createReq.RawSettings = raw } } diff --git a/pkg/server/server.go b/pkg/server/server.go index 62e6aa89f..e396d14bf 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -27,6 +27,7 @@ import ( "go.probo.inc/probo/pkg/accessreview" "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/connector" + "go.probo.inc/probo/pkg/connector/provider" "go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/esign" "go.probo.inc/probo/pkg/file" @@ -67,6 +68,7 @@ type Config struct { Cookie securecookie.Config TokenSecret string ConnectorRegistry *connector.ConnectorRegistry + ProviderRegistry *provider.Registry CustomDomainCname string Logger *log.Logger } @@ -104,6 +106,7 @@ func NewServer(cfg Config) (*Server, error) { Cookie: cfg.Cookie, TokenSecret: cfg.TokenSecret, ConnectorRegistry: cfg.ConnectorRegistry, + ProviderRegistry: cfg.ProviderRegistry, CustomDomainCname: cfg.CustomDomainCname, Logger: cfg.Logger.Named("api"), }