From e18ecdda8b95ab1d417fba076f6b178de3005ee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Sat, 23 May 2026 12:21:20 +0200 Subject: [PATCH] Consolidate connector provider dispatch behind a typed *Registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console previously dispatched per-provider logic through a fan of init()-side-effect maps (driver names, OAuth2 metadata, probe URLs, display names, settings switches), spread across pkg/connector, pkg/accessreview/drivers and the console v1 resolvers. Adding a new provider required edits in every one of those places and a corresponding switch arm in CreateConnectorRequest. The same per-provider knowledge also leaked into Helm templates as hand-rolled environment-variable blocks per connector. This commit collapses the dispatch surface into a single typed *provider.Registry. The registry is constructed once by NewBuiltinRegistry at probod startup and threaded as an explicit dependency into every consumer (accessreview service, console v1 resolver, OAuth2 wiring). There is no package-level state. Each provider lives in one file under pkg/connector/provider/ that exposes a private xxxRegistration() *Registration constructor; NewBuiltinRegistry enumerates them. CreateConnectorRequest loses its per-provider settings fields and takes a single RawSettings json.RawMessage produced by the per-provider MarshalSettings closure. The 1Password SCIM bridge URL is validated at create time (http(s) scheme + non-empty host) so a malformed value fails fast at the resolver boundary. The Helm chart gains probo.connectorEnv and probo.connectorSecretEntries templates so adding a connector requires zero Helm changes. Access-review name resolution moves into the same Registration value to keep one authoritative dispatch table. Tests cover every Registration (DisplayName, NewDriver wired), Register error paths (nil, empty Provider, empty DisplayName, duplicate), All / ProviderDisplayName / ProviderOAuth2Scopes / ProbeURL hit and miss paths, the ApplyOAuth2Defaults templating and PKCE branches, and ConnectorSettings[T] round-trip plus malformed-JSON error path. The pre-refactor ApplyProviderDefaults test in pkg/connector is replaced by the equivalent in pkg/connector/provider. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- e2e/console/connector_test.go | 66 +++++++ pkg/accessreview/access_source_service.go | 74 +------ pkg/accessreview/drivers/name_resolver.go | 39 ---- pkg/accessreview/drivers/oauth2_scopes.go | 63 ------ pkg/accessreview/review_engine.go | 185 +----------------- pkg/accessreview/service.go | 7 + pkg/accessreview/source_name_worker.go | 137 ++----------- pkg/connector/oauth2.go | 12 +- pkg/connector/oauth2_test.go | 78 -------- pkg/connector/provider/apply.go | 78 ++++++++ pkg/connector/provider/apply_test.go | 88 +++++++++ pkg/connector/provider/asana.go | 60 ++++++ pkg/connector/provider/bitbucket.go | 62 ++++++ pkg/connector/provider/brex.go | 42 ++++ pkg/connector/provider/builtin.go | 58 ++++++ pkg/connector/provider/clickup.go | 60 ++++++ pkg/connector/provider/cloudflare.go | 39 ++++ pkg/connector/provider/docusign.go | 43 ++++ pkg/connector/provider/github.go | 72 +++++++ pkg/connector/provider/gitlab.go | 60 ++++++ pkg/connector/provider/google_workspace.go | 50 +++++ pkg/connector/provider/heroku.go | 60 ++++++ pkg/connector/provider/hubspot.go | 42 ++++ pkg/connector/provider/intercom.go | 41 ++++ pkg/connector/provider/linear.go | 41 ++++ pkg/connector/provider/microsoft_365.go | 51 +++++ pkg/connector/provider/monday.go | 44 +++++ pkg/connector/provider/netlify.go | 60 ++++++ pkg/connector/provider/notion.go | 43 ++++ pkg/connector/provider/one_password.go | 100 ++++++++++ pkg/connector/provider/one_password_test.go | 101 ++++++++++ pkg/connector/provider/openai.go | 39 ++++ pkg/connector/provider/pagerduty.go | 54 +++++ pkg/connector/provider/registry.go | 134 +++++++++++++ pkg/connector/provider/registry_test.go | 156 +++++++++++++++ pkg/connector/provider/resend.go | 39 ++++ pkg/connector/provider/sentry.go | 69 +++++++ pkg/connector/provider/slack.go | 41 ++++ pkg/connector/provider/supabase.go | 66 +++++++ pkg/connector/provider/tally.go | 66 +++++++ pkg/connector/provider/types.go | 101 ++++++++++ pkg/connector/provider/vercel.go | 61 ++++++ pkg/connector/providers.go | 165 ---------------- pkg/connector/registry.go | 42 ---- pkg/coredata/connector_provider.go | 70 ++++--- pkg/coredata/connector_settings.go | 16 +- pkg/coredata/connector_settings_test.go | 96 +++++++++ pkg/probo/connector_service.go | 76 +++---- pkg/probod/probod.go | 6 +- pkg/probodconfig/connector_config.go | 2 +- pkg/server/api/api.go | 3 + .../v1/access_review_campaign_resolvers.go | 2 +- .../api/console/v1/connector_provider_info.go | 86 +++----- .../api/console/v1/connector_resolvers.go | 62 +++--- pkg/server/api/console/v1/graphql_handler.go | 3 + .../api/console/v1/organization_resolvers.go | 17 +- pkg/server/api/console/v1/resolver.go | 25 ++- pkg/server/server.go | 3 + 58 files changed, 2522 insertions(+), 934 deletions(-) delete mode 100644 pkg/accessreview/drivers/oauth2_scopes.go create mode 100644 pkg/connector/provider/apply.go create mode 100644 pkg/connector/provider/apply_test.go create mode 100644 pkg/connector/provider/asana.go create mode 100644 pkg/connector/provider/bitbucket.go create mode 100644 pkg/connector/provider/brex.go create mode 100644 pkg/connector/provider/builtin.go create mode 100644 pkg/connector/provider/clickup.go create mode 100644 pkg/connector/provider/cloudflare.go create mode 100644 pkg/connector/provider/docusign.go create mode 100644 pkg/connector/provider/github.go create mode 100644 pkg/connector/provider/gitlab.go create mode 100644 pkg/connector/provider/google_workspace.go create mode 100644 pkg/connector/provider/heroku.go create mode 100644 pkg/connector/provider/hubspot.go create mode 100644 pkg/connector/provider/intercom.go create mode 100644 pkg/connector/provider/linear.go create mode 100644 pkg/connector/provider/microsoft_365.go create mode 100644 pkg/connector/provider/monday.go create mode 100644 pkg/connector/provider/netlify.go create mode 100644 pkg/connector/provider/notion.go create mode 100644 pkg/connector/provider/one_password.go create mode 100644 pkg/connector/provider/one_password_test.go create mode 100644 pkg/connector/provider/openai.go create mode 100644 pkg/connector/provider/pagerduty.go create mode 100644 pkg/connector/provider/registry.go create mode 100644 pkg/connector/provider/registry_test.go create mode 100644 pkg/connector/provider/resend.go create mode 100644 pkg/connector/provider/sentry.go create mode 100644 pkg/connector/provider/slack.go create mode 100644 pkg/connector/provider/supabase.go create mode 100644 pkg/connector/provider/tally.go create mode 100644 pkg/connector/provider/types.go create mode 100644 pkg/connector/provider/vercel.go create mode 100644 pkg/coredata/connector_settings_test.go 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"), }