Consolidate connector provider dispatch behind a typed *Registry

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>
This commit is contained in:
Aurélien Sibiril
2026-05-23 12:21:20 +02:00
parent f016e2cf06
commit e18ecdda8b
58 changed files with 2522 additions and 934 deletions

View File

@@ -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 {

View File

@@ -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

View File

@@ -1,63 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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]
}

View File

@@ -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)
}

View File

@@ -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"),
)
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -0,0 +1,78 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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
}

View File

@@ -0,0 +1,88 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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)
})
}
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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})
},
}
}

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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})
},
}
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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)
},
}
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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})
},
}
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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)
},
}
}

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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)
},
}
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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})
},
}
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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})
},
}
}

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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)
},
}
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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})
},
}
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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)
},
}
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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)
},
}
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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)
},
}
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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)
},
}
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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)
},
}
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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})
},
}
}

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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)
},
}
}

View File

@@ -0,0 +1,100 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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
},
}
}

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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")
})
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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)
},
}
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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)
},
}
}

View File

@@ -0,0 +1,134 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
// Package 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
}

View File

@@ -0,0 +1,156 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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"))
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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()
},
}
}

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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})
},
}
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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)
},
}
}

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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)
},
}
}

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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)
},
}
}

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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)
},
}
}

View File

@@ -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)
}
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
@@ -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)

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package 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")
})
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -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"`
}

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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{

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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"),
}