diff --git a/.env.example b/.env.example index c096c8a7b..9aab20142 100644 --- a/.env.example +++ b/.env.example @@ -98,6 +98,33 @@ # CONNECTOR_DOCUSIGN_CLIENT_SECRET= # CONNECTOR_BREX_CLIENT_ID= # CONNECTOR_BREX_CLIENT_SECRET= +# CONNECTOR_GITLAB_CLIENT_ID= +# CONNECTOR_GITLAB_CLIENT_SECRET= +# CONNECTOR_BITBUCKET_CLIENT_ID= +# CONNECTOR_BITBUCKET_CLIENT_SECRET= +# CONNECTOR_HEROKU_CLIENT_ID= +# CONNECTOR_HEROKU_CLIENT_SECRET= +# CONNECTOR_PAGERDUTY_CLIENT_ID= +# CONNECTOR_PAGERDUTY_CLIENT_SECRET= +# CONNECTOR_ASANA_CLIENT_ID= +# CONNECTOR_ASANA_CLIENT_SECRET= +# CONNECTOR_SNYK_CLIENT_ID= +# CONNECTOR_SNYK_CLIENT_SECRET= +# CONNECTOR_NETLIFY_CLIENT_ID= +# CONNECTOR_NETLIFY_CLIENT_SECRET= +# CONNECTOR_RAMP_CLIENT_ID= +# CONNECTOR_RAMP_CLIENT_SECRET= +# CONNECTOR_CLICKUP_CLIENT_ID= +# CONNECTOR_CLICKUP_CLIENT_SECRET= +# CONNECTOR_VERCEL_CLIENT_ID= +# CONNECTOR_VERCEL_CLIENT_SECRET= +# CONNECTOR_VERCEL_INTEGRATION_SLUG= +# CONNECTOR_MONDAY_CLIENT_ID= +# CONNECTOR_MONDAY_CLIENT_SECRET= +# CONNECTOR_LEVER_CLIENT_ID= +# CONNECTOR_LEVER_CLIENT_SECRET= +# CONNECTOR_DEEL_CLIENT_ID= +# CONNECTOR_DEEL_CLIENT_SECRET= # ── Custom domains (Pebble ACME via compose) ────────────────────────── # CUSTOM_DOMAINS_CNAME_TARGET=custom.getprobo.com diff --git a/contrib/helm/charts/probo/values-production.yaml.example b/contrib/helm/charts/probo/values-production.yaml.example index 6e1b60660..45cbabd2d 100644 --- a/contrib/helm/charts/probo/values-production.yaml.example +++ b/contrib/helm/charts/probo/values-production.yaml.example @@ -181,6 +181,15 @@ probo: insecureTls: false # External OAuth2 connectors (optional) + # + # Supported providers (set name to one of): + # slack, hubspot, docusign, notion, github, sentry, intercom, brex, + # google_workspace, gitlab, bitbucket, heroku, pagerduty, asana, + # snyk, netlify, ramp, clickup, vercel, monday, lever, deel + # + # Vercel additionally requires "integration-slug" so the templated + # AuthURL ("https://vercel.com/integrations/{integration_slug}/new") + # resolves to the operator's registered integration. connectors: - name: "github" type: "oauth2" @@ -190,6 +199,12 @@ probo: redirect-uri: "https://probo.example.com/api/console/v1/connectors/complete" auth-url: "https://github.com/login/oauth/authorize" token-url: "https://github.com/login/oauth/access_token" + # - name: "vercel" + # type: "oauth2" + # config: + # client-id: "CHANGE_ME_VERCEL_CLIENT_ID" + # client-secret: "CHANGE_ME_VERCEL_CLIENT_SECRET" + # integration-slug: "CHANGE_ME_VERCEL_INTEGRATION_SLUG" # PostgreSQL configuration (REQUIRED) # For production: Use external managed PostgreSQL (AWS RDS, GCP Cloud SQL, etc.) diff --git a/e2e/console/access_review_test.go b/e2e/console/access_review_test.go index 23d95239c..54365e51f 100644 --- a/e2e/console/access_review_test.go +++ b/e2e/console/access_review_test.go @@ -26,6 +26,14 @@ import ( const testCsvData = "email,full_name,role,job_title,is_admin,mfa_status,auth_method,last_login,account_created_at,external_id\njane@example.com,Jane Smith,admin,CTO,true,ENABLED,SSO,2026-01-15T00:00:00Z,2024-06-01T00:00:00Z,ext-jane" +// TODO(access-review-connectors): add e2e tests for the new GitLab, +// Bitbucket, Heroku, PagerDuty, Asana, Snyk, Netlify, Ramp, ClickUp, +// Vercel, Monday.com, Lever, and Deel access-source connectors once the +// e2e factory grows OAuth2-connector seed support for arbitrary +// providers. For now the per-driver VCR tests under +// pkg/accessreview/drivers cover the fetch path; this e2e suite still +// exercises CSV + Slack which were the providers wired before these +// sprints. func TestAccessSource_Create(t *testing.T) { t.Parallel() owner := testutil.NewClient(t, testutil.RoleOwner) diff --git a/packages/ui/src/Atoms/ThirdParties/Asana.tsx b/packages/ui/src/Atoms/ThirdParties/Asana.tsx new file mode 100644 index 000000000..773488b02 --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Asana.tsx @@ -0,0 +1,12 @@ +import type { ComponentProps } from "react"; + +export function Asana(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/Bitbucket.tsx b/packages/ui/src/Atoms/ThirdParties/Bitbucket.tsx new file mode 100644 index 000000000..605bdb147 --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Bitbucket.tsx @@ -0,0 +1,12 @@ +import type { ComponentProps } from "react"; + +export function Bitbucket(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/ClickUp.tsx b/packages/ui/src/Atoms/ThirdParties/ClickUp.tsx new file mode 100644 index 000000000..9fc2cf763 --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/ClickUp.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; + +export function ClickUp(props: ComponentProps<"svg">) { + return ( + + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/Deel.tsx b/packages/ui/src/Atoms/ThirdParties/Deel.tsx new file mode 100644 index 000000000..2dc9b2e42 --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Deel.tsx @@ -0,0 +1,12 @@ +import type { ComponentProps } from "react"; + +export function Deel(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/GitLab.tsx b/packages/ui/src/Atoms/ThirdParties/GitLab.tsx new file mode 100644 index 000000000..7f888a699 --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/GitLab.tsx @@ -0,0 +1,12 @@ +import type { ComponentProps } from "react"; + +export function GitLab(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/Heroku.tsx b/packages/ui/src/Atoms/ThirdParties/Heroku.tsx new file mode 100644 index 000000000..b9ec123bf --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Heroku.tsx @@ -0,0 +1,12 @@ +import type { ComponentProps } from "react"; + +export function Heroku(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/Lever.tsx b/packages/ui/src/Atoms/ThirdParties/Lever.tsx new file mode 100644 index 000000000..9cfb6f929 --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Lever.tsx @@ -0,0 +1,12 @@ +import type { ComponentProps } from "react"; + +export function Lever(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/Monday.tsx b/packages/ui/src/Atoms/ThirdParties/Monday.tsx new file mode 100644 index 000000000..9f65ea2b5 --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Monday.tsx @@ -0,0 +1,12 @@ +import type { ComponentProps } from "react"; + +export function Monday(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/Netlify.tsx b/packages/ui/src/Atoms/ThirdParties/Netlify.tsx new file mode 100644 index 000000000..7fc36ca89 --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Netlify.tsx @@ -0,0 +1,12 @@ +import type { ComponentProps } from "react"; + +export function Netlify(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/PagerDuty.tsx b/packages/ui/src/Atoms/ThirdParties/PagerDuty.tsx new file mode 100644 index 000000000..b579a56df --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/PagerDuty.tsx @@ -0,0 +1,12 @@ +import type { ComponentProps } from "react"; + +export function PagerDuty(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/Ramp.tsx b/packages/ui/src/Atoms/ThirdParties/Ramp.tsx new file mode 100644 index 000000000..deede6ff9 --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Ramp.tsx @@ -0,0 +1,12 @@ +import type { ComponentProps } from "react"; + +export function Ramp(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/Snyk.tsx b/packages/ui/src/Atoms/ThirdParties/Snyk.tsx new file mode 100644 index 000000000..83b422a49 --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Snyk.tsx @@ -0,0 +1,12 @@ +import type { ComponentProps } from "react"; + +export function Snyk(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx index ce420de7d..8f2550d93 100644 --- a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx +++ b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx @@ -14,47 +14,73 @@ import type { ComponentProps, FC } from "react"; +import { Asana } from "./Asana"; +import { Bitbucket } from "./Bitbucket"; import { Brex } from "./Brex"; +import { ClickUp } from "./ClickUp"; import { Cloudflare } from "./Cloudflare"; +import { Deel } from "./Deel"; import { DocuSign } from "./DocuSign"; import { Figma } from "./Figma"; import { GitHub } from "./GitHub"; +import { GitLab } from "./GitLab"; import { Google } from "./Google"; +import { Heroku } from "./Heroku"; import { HubSpot } from "./HubSpot"; import { Intercom } from "./Intercom"; +import { Lever } from "./Lever"; import { Linear } from "./Linear"; import { Microsoft } from "./Microsoft"; +import { Monday } from "./Monday"; +import { Netlify } from "./Netlify"; import { Notion } from "./Notion"; import { OnePassword } from "./OnePassword"; import { OpenAI } from "./OpenAI"; +import { PagerDuty } from "./PagerDuty"; +import { Ramp } from "./Ramp"; import { Resend } from "./Resend"; import { Sentry } from "./Sentry"; import { Slack } from "./Slack"; +import { Snyk } from "./Snyk"; import { Supabase } from "./Supabase"; import { Tally } from "./Tally"; +import { Vercel } from "./Vercel"; const thirdParties: Record>> = { + ASANA: Asana, + BITBUCKET: Bitbucket, BREX: Brex, + CLICKUP: ClickUp, CLOUDFLARE: Cloudflare, + DEEL: Deel, DOCUSIGN: DocuSign, FIGMA: Figma, GITHUB: GitHub, + GITLAB: GitLab, GOOGLE: Google, GOOGLE_WORKSPACE: Google, + HEROKU: Heroku, HUBSPOT: HubSpot, INTERCOM: Intercom, + LEVER: Lever, LINEAR: Linear, MICROSOFT: Microsoft, MICROSOFT_365: Microsoft, + MONDAY: Monday, + NETLIFY: Netlify, NOTION: Notion, ONE_PASSWORD: OnePassword, ONEPASSWORD: OnePassword, OPENAI: OpenAI, + PAGERDUTY: PagerDuty, + RAMP: Ramp, RESEND: Resend, SENTRY: Sentry, SLACK: Slack, + SNYK: Snyk, SUPABASE: Supabase, TALLY: Tally, + VERCEL: Vercel, }; type ThirdPartyLogoProps = ComponentProps<"svg"> & { @@ -72,7 +98,9 @@ export function ThirdPartyLogo({ thirdParty, tint, ...props }: ThirdPartyLogoPro return ( ); } diff --git a/packages/ui/src/Atoms/ThirdParties/Vercel.tsx b/packages/ui/src/Atoms/ThirdParties/Vercel.tsx new file mode 100644 index 000000000..619e23d40 --- /dev/null +++ b/packages/ui/src/Atoms/ThirdParties/Vercel.tsx @@ -0,0 +1,9 @@ +import type { ComponentProps } from "react"; + +export function Vercel(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/ThirdParties/index.ts b/packages/ui/src/Atoms/ThirdParties/index.ts index 26e572fbe..61c9ede04 100644 --- a/packages/ui/src/Atoms/ThirdParties/index.ts +++ b/packages/ui/src/Atoms/ThirdParties/index.ts @@ -1,19 +1,32 @@ +export { Asana } from "./Asana"; +export { Bitbucket } from "./Bitbucket"; export { Brex } from "./Brex"; +export { ClickUp } from "./ClickUp"; export { Cloudflare } from "./Cloudflare"; +export { Deel } from "./Deel"; export { DocuSign } from "./DocuSign"; export { Figma } from "./Figma"; export { GitHub } from "./GitHub"; +export { GitLab } from "./GitLab"; export { Google } from "./Google"; +export { Heroku } from "./Heroku"; export { HubSpot } from "./HubSpot"; export { Intercom } from "./Intercom"; +export { Lever } from "./Lever"; export { Linear } from "./Linear"; export { Microsoft } from "./Microsoft"; +export { Monday } from "./Monday"; +export { Netlify } from "./Netlify"; export { Notion } from "./Notion"; export { OnePassword } from "./OnePassword"; export { OpenAI } from "./OpenAI"; +export { PagerDuty } from "./PagerDuty"; +export { Ramp } from "./Ramp"; export { Resend } from "./Resend"; export { Sentry } from "./Sentry"; export { Slack } from "./Slack"; +export { Snyk } from "./Snyk"; export { Supabase } from "./Supabase"; export { Tally } from "./Tally"; export { ThirdPartyLogo } from "./ThirdPartyLogo"; +export { Vercel } from "./Vercel"; diff --git a/pkg/bootstrap/builder.go b/pkg/bootstrap/builder.go index b2cc64c99..8b3584746 100644 --- a/pkg/bootstrap/builder.go +++ b/pkg/bootstrap/builder.go @@ -349,6 +349,48 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) { }) } + for _, provider := range []string{ + "GITLAB", + "BITBUCKET", + "HEROKU", + "PAGERDUTY", + "ASANA", + "SNYK", + "NETLIFY", + "RAMP", + "CLICKUP", + "MONDAY", + "LEVER", + "DEEL", + } { + clientID := b.getEnv("CONNECTOR_" + provider + "_CLIENT_ID") + if clientID == "" { + continue + } + cfg.Probod.Connectors = append(cfg.Probod.Connectors, probodconfig.ConnectorConfig{ + Provider: provider, + Protocol: "oauth2", + RawConfig: probodconfig.ConnectorConfigOAuth2{ + ClientID: clientID, + ClientSecret: b.getEnv("CONNECTOR_" + provider + "_CLIENT_SECRET"), + }, + }) + } + + // Vercel needs the operator-supplied integration slug to resolve the + // templated AuthURL ("https://vercel.com/integrations/{integration_slug}/new"). + if vercelClientID := b.getEnv("CONNECTOR_VERCEL_CLIENT_ID"); vercelClientID != "" { + cfg.Probod.Connectors = append(cfg.Probod.Connectors, probodconfig.ConnectorConfig{ + Provider: "VERCEL", + Protocol: "oauth2", + RawConfig: probodconfig.ConnectorConfigOAuth2{ + ClientID: vercelClientID, + ClientSecret: b.getEnv("CONNECTOR_VERCEL_CLIENT_SECRET"), + IntegrationSlug: b.getEnv("CONNECTOR_VERCEL_INTEGRATION_SLUG"), + }, + }) + } + return cfg, nil } diff --git a/pkg/bootstrap/builder_test.go b/pkg/bootstrap/builder_test.go index 2d15356c1..996a5032e 100644 --- a/pkg/bootstrap/builder_test.go +++ b/pkg/bootstrap/builder_test.go @@ -435,6 +435,64 @@ func TestBuilder_Build_Microsoft365Connector(t *testing.T) { assert.Equal(t, "ms365-client-secret", rawConfig.ClientSecret) } +func TestBuilder_Build_AccessReviewConnectors(t *testing.T) { + env := requiredEnv() + env["CONNECTOR_GITLAB_CLIENT_ID"] = "gitlab-id" + env["CONNECTOR_GITLAB_CLIENT_SECRET"] = "gitlab-secret" + env["CONNECTOR_BITBUCKET_CLIENT_ID"] = "bitbucket-id" + env["CONNECTOR_BITBUCKET_CLIENT_SECRET"] = "bitbucket-secret" + env["CONNECTOR_PAGERDUTY_CLIENT_ID"] = "pagerduty-id" + env["CONNECTOR_PAGERDUTY_CLIENT_SECRET"] = "pagerduty-secret" + env["CONNECTOR_DEEL_CLIENT_ID"] = "deel-id" + env["CONNECTOR_DEEL_CLIENT_SECRET"] = "deel-secret" + + b := NewBuilder(mockEnv(env)) + b.samlCertificate = "test-cert" + b.samlPrivateKey = "test-key" + + cfg, err := b.Build() + require.NoError(t, err) + + require.Len(t, cfg.Probod.Connectors, 4) + byProvider := make(map[string]probodconfig.ConnectorConfig, len(cfg.Probod.Connectors)) + for _, c := range cfg.Probod.Connectors { + byProvider[c.Provider] = c + } + + for _, provider := range []string{"GITLAB", "BITBUCKET", "PAGERDUTY", "DEEL"} { + c, ok := byProvider[provider] + require.True(t, ok, "missing %s connector", provider) + assert.Equal(t, "oauth2", string(c.Protocol)) + raw := c.RawConfig.(probodconfig.ConnectorConfigOAuth2) + assert.NotEmpty(t, raw.ClientID, "%s client-id", provider) + assert.NotEmpty(t, raw.ClientSecret, "%s client-secret", provider) + assert.Empty(t, raw.IntegrationSlug, "%s should not carry integration-slug", provider) + } +} + +func TestBuilder_Build_VercelConnector(t *testing.T) { + env := requiredEnv() + env["CONNECTOR_VERCEL_CLIENT_ID"] = "vercel-id" + env["CONNECTOR_VERCEL_CLIENT_SECRET"] = "vercel-secret" + env["CONNECTOR_VERCEL_INTEGRATION_SLUG"] = "probo-app" + + b := NewBuilder(mockEnv(env)) + b.samlCertificate = "test-cert" + b.samlPrivateKey = "test-key" + + cfg, err := b.Build() + require.NoError(t, err) + + require.Len(t, cfg.Probod.Connectors, 1) + c := cfg.Probod.Connectors[0] + assert.Equal(t, "VERCEL", c.Provider) + assert.Equal(t, "oauth2", string(c.Protocol)) + raw := c.RawConfig.(probodconfig.ConnectorConfigOAuth2) + assert.Equal(t, "vercel-id", raw.ClientID) + assert.Equal(t, "vercel-secret", raw.ClientSecret) + assert.Equal(t, "probo-app", raw.IntegrationSlug) +} + func TestBuilder_Build_SlackConnector(t *testing.T) { env := requiredEnv() env["CONNECTOR_SLACK_CLIENT_ID"] = "slack-client-id" diff --git a/pkg/probo/connector_service.go b/pkg/probo/connector_service.go index 4fedf5618..795b818eb 100644 --- a/pkg/probo/connector_service.go +++ b/pkg/probo/connector_service.go @@ -59,6 +59,8 @@ type ( SupabaseSettings *coredata.SupabaseConnectorSettings GitHubSettings *coredata.GitHubConnectorSettings OnePasswordUsersAPISettings *coredata.OnePasswordUsersAPISettings + PagerDutySettings *coredata.PagerDutyConnectorSettings + VercelSettings *coredata.VercelConnectorSettings } ReconnectConnectorRequest struct { @@ -270,6 +272,14 @@ func (s *ConnectorService) Create( 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) + } } err := s.svc.pg.WithTx( diff --git a/pkg/server/api/console/v1/access_review_campaign_resolvers.go b/pkg/server/api/console/v1/access_review_campaign_resolvers.go index 2b154e285..90314d6a1 100644 --- a/pkg/server/api/console/v1/access_review_campaign_resolvers.go +++ b/pkg/server/api/console/v1/access_review_campaign_resolvers.go @@ -411,6 +411,57 @@ func (r *accessSourceResolver) ProviderOrganizations(ctx context.Context, obj *t return nil, fmt.Errorf("cannot fetch sentry organizations: %w", err) } return orgs, nil + case coredata.ConnectorProviderGitLab: + orgs, err := fetchGitLabOrganizations(ctx, httpClient) + if err != nil { + return nil, fmt.Errorf("cannot fetch gitlab organizations: %w", err) + } + return orgs, nil + case coredata.ConnectorProviderBitbucket: + orgs, err := fetchBitbucketOrganizations(ctx, httpClient) + if err != nil { + return nil, fmt.Errorf("cannot fetch bitbucket organizations: %w", err) + } + return orgs, nil + case coredata.ConnectorProviderHeroku: + orgs, err := fetchHerokuOrganizations(ctx, httpClient) + if err != nil { + return nil, fmt.Errorf("cannot fetch heroku organizations: %w", err) + } + return orgs, nil + case coredata.ConnectorProviderAsana: + orgs, err := fetchAsanaOrganizations(ctx, httpClient) + if err != nil { + return nil, fmt.Errorf("cannot fetch asana organizations: %w", err) + } + return orgs, nil + case coredata.ConnectorProviderPagerDuty: + // PagerDuty uses Pattern 2-auto: the subdomain is captured during + // the OAuth callback, no picker UI is required. + return []*types.ProviderOrganization{}, nil + case coredata.ConnectorProviderSnyk: + orgs, err := fetchSnykOrganizations(ctx, httpClient) + if err != nil { + return nil, fmt.Errorf("cannot fetch snyk organizations: %w", err) + } + return orgs, nil + case coredata.ConnectorProviderNetlify: + orgs, err := fetchNetlifyOrganizations(ctx, httpClient) + if err != nil { + return nil, fmt.Errorf("cannot fetch netlify organizations: %w", err) + } + return orgs, nil + case coredata.ConnectorProviderClickUp: + orgs, err := fetchClickUpOrganizations(ctx, httpClient) + if err != nil { + return nil, fmt.Errorf("cannot fetch clickup organizations: %w", err) + } + return orgs, nil + case coredata.ConnectorProviderVercel: + // Vercel uses Pattern 2-auto: the team_id is captured during the + // OAuth callback (or synthesized from /v2/user for personal + // accounts), no picker UI is required. + return []*types.ProviderOrganization{}, nil default: return []*types.ProviderOrganization{}, nil } @@ -439,6 +490,38 @@ func (r *accessSourceResolver) NeedsConfiguration(ctx context.Context, obj *type case coredata.ConnectorProviderSentry: settings, _ := dbConnector.SentrySettings() return settings.OrganizationSlug == "", nil + case coredata.ConnectorProviderGitLab: + settings, _ := dbConnector.GitLabSettings() + return settings.GroupID == "", nil + case coredata.ConnectorProviderBitbucket: + settings, _ := dbConnector.BitbucketSettings() + return settings.Workspace == "", nil + case coredata.ConnectorProviderHeroku: + settings, _ := dbConnector.HerokuSettings() + return settings.TeamID == "", nil + case coredata.ConnectorProviderAsana: + settings, _ := dbConnector.AsanaSettings() + return settings.WorkspaceGID == "", nil + case coredata.ConnectorProviderPagerDuty: + // 2-auto: subdomain is set during the OAuth callback. If for any + // reason it is missing, we still return false so the configure + // mutation does not surface — there is no manual picker. + return false, nil + case coredata.ConnectorProviderSnyk: + settings, _ := dbConnector.SnykSettings() + return settings.OrgID == "", nil + case coredata.ConnectorProviderNetlify: + settings, _ := dbConnector.NetlifySettings() + return settings.AccountSlug == "", nil + case coredata.ConnectorProviderClickUp: + settings, _ := dbConnector.ClickUpSettings() + return settings.TeamID == "", nil + case coredata.ConnectorProviderVercel: + // 2-auto: team_id is set during the OAuth callback (or synthesized + // from /v2/user for personal accounts). If for any reason it is + // missing, return false so the configure mutation does not surface + // — there is no manual picker. + return false, nil default: return false, nil } @@ -502,6 +585,51 @@ func (r *accessSourceResolver) SelectedOrganization(ctx context.Context, obj *ty if settings.OrganizationSlug != "" { return &settings.OrganizationSlug, nil } + case coredata.ConnectorProviderGitLab: + settings, _ := dbConnector.GitLabSettings() + if settings.GroupID != "" { + return &settings.GroupID, nil + } + case coredata.ConnectorProviderBitbucket: + settings, _ := dbConnector.BitbucketSettings() + if settings.Workspace != "" { + return &settings.Workspace, nil + } + case coredata.ConnectorProviderHeroku: + settings, _ := dbConnector.HerokuSettings() + if settings.TeamID != "" { + return &settings.TeamID, nil + } + case coredata.ConnectorProviderPagerDuty: + settings, _ := dbConnector.PagerDutySettings() + if settings.Subdomain != "" { + return &settings.Subdomain, nil + } + case coredata.ConnectorProviderAsana: + settings, _ := dbConnector.AsanaSettings() + if settings.WorkspaceGID != "" { + return &settings.WorkspaceGID, nil + } + case coredata.ConnectorProviderSnyk: + settings, _ := dbConnector.SnykSettings() + if settings.OrgID != "" { + return &settings.OrgID, nil + } + case coredata.ConnectorProviderNetlify: + settings, _ := dbConnector.NetlifySettings() + if settings.AccountSlug != "" { + return &settings.AccountSlug, nil + } + case coredata.ConnectorProviderClickUp: + settings, _ := dbConnector.ClickUpSettings() + if settings.TeamID != "" { + return &settings.TeamID, nil + } + case coredata.ConnectorProviderVercel: + settings, _ := dbConnector.VercelSettings() + if settings.TeamID != "" { + return &settings.TeamID, nil + } } return nil, nil diff --git a/pkg/server/api/console/v1/provider_organizations.go b/pkg/server/api/console/v1/provider_organizations.go index 096e07a36..572bc0da4 100644 --- a/pkg/server/api/console/v1/provider_organizations.go +++ b/pkg/server/api/console/v1/provider_organizations.go @@ -20,6 +20,7 @@ import ( "fmt" "io" "net/http" + "strconv" "go.probo.inc/probo/pkg/server/api/console/v1/types" ) @@ -108,6 +109,348 @@ func fetchSentryOrganizations(ctx context.Context, httpClient *http.Client) ([]* return result, nil } +// fetchGitLabOrganizations fetches the list of groups the authenticated +// GitLab user owns. Group IDs are numeric int64 values; we surface them +// as strings so they fit the ProviderOrganization.Slug shape. +func fetchGitLabOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + "https://gitlab.com/api/v4/groups?min_access_level=50&per_page=100", + nil, + ) + if err != nil { + return nil, fmt.Errorf("cannot create gitlab organizations request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot fetch gitlab organizations: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("cannot fetch gitlab organizations: status %d", resp.StatusCode) + } + + var groups []struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullPath string `json:"full_path"` + } + if err := json.NewDecoder(resp.Body).Decode(&groups); err != nil { + return nil, fmt.Errorf("cannot decode gitlab organizations response: %w", err) + } + + result := make([]*types.ProviderOrganization, len(groups)) + for i, g := range groups { + displayName := g.Name + if displayName == "" { + displayName = g.FullPath + } + result[i] = &types.ProviderOrganization{ + Slug: strconv.FormatInt(g.ID, 10), + DisplayName: displayName, + } + } + + return result, nil +} + +// fetchBitbucketOrganizations fetches the list of workspaces the +// authenticated Bitbucket user belongs to. +func fetchBitbucketOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + "https://api.bitbucket.org/2.0/workspaces?role=member&pagelen=100", + nil, + ) + if err != nil { + return nil, fmt.Errorf("cannot create bitbucket organizations request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot fetch bitbucket organizations: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("cannot fetch bitbucket organizations: status %d", resp.StatusCode) + } + + var body struct { + Values []struct { + Slug string `json:"slug"` + Name string `json:"name"` + } `json:"values"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("cannot decode bitbucket organizations response: %w", err) + } + + result := make([]*types.ProviderOrganization, len(body.Values)) + for i, w := range body.Values { + displayName := w.Name + if displayName == "" { + displayName = w.Slug + } + result[i] = &types.ProviderOrganization{ + Slug: w.Slug, + DisplayName: displayName, + } + } + + return result, nil +} + +// fetchHerokuOrganizations fetches the list of teams the authenticated +// Heroku user belongs to. +func fetchHerokuOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.heroku.com/teams", nil) + if err != nil { + return nil, fmt.Errorf("cannot create heroku organizations request: %w", err) + } + req.Header.Set("Accept", "application/vnd.heroku+json; version=3") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot fetch heroku organizations: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("cannot fetch heroku organizations: status %d", resp.StatusCode) + } + + var teams []struct { + ID string `json:"id"` + Name string `json:"name"` + } + if err := json.NewDecoder(resp.Body).Decode(&teams); err != nil { + return nil, fmt.Errorf("cannot decode heroku organizations response: %w", err) + } + + result := make([]*types.ProviderOrganization, len(teams)) + for i, t := range teams { + displayName := t.Name + if displayName == "" { + displayName = t.ID + } + result[i] = &types.ProviderOrganization{ + Slug: t.ID, + DisplayName: displayName, + } + } + + return result, nil +} + +// fetchAsanaOrganizations fetches the list of workspaces the +// authenticated Asana user belongs to. +func fetchAsanaOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + "https://app.asana.com/api/1.0/workspaces?limit=100", + nil, + ) + if err != nil { + return nil, fmt.Errorf("cannot create asana organizations request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot fetch asana organizations: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("cannot fetch asana organizations: status %d", resp.StatusCode) + } + + var body struct { + Data []struct { + GID string `json:"gid"` + Name string `json:"name"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("cannot decode asana organizations response: %w", err) + } + + result := make([]*types.ProviderOrganization, len(body.Data)) + for i, w := range body.Data { + displayName := w.Name + if displayName == "" { + displayName = w.GID + } + result[i] = &types.ProviderOrganization{ + Slug: w.GID, + DisplayName: displayName, + } + } + + return result, nil +} + +// fetchSnykOrganizations fetches the list of Snyk organizations the +// authenticated user belongs to. The Snyk REST API is JSON:API style; +// the org id is the unique identifier surfaced as the slug. +func fetchSnykOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + "https://api.snyk.io/rest/orgs?version=2024-10-15&limit=100", + nil, + ) + if err != nil { + return nil, fmt.Errorf("cannot create snyk organizations request: %w", err) + } + req.Header.Set("Accept", "application/vnd.api+json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot fetch snyk organizations: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("cannot fetch snyk organizations: status %d", resp.StatusCode) + } + + var body struct { + Data []struct { + ID string `json:"id"` + Attributes struct { + Slug string `json:"slug"` + Name string `json:"name"` + } `json:"attributes"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("cannot decode snyk organizations response: %w", err) + } + + result := make([]*types.ProviderOrganization, len(body.Data)) + for i, org := range body.Data { + displayName := org.Attributes.Name + if displayName == "" { + displayName = org.Attributes.Slug + } + if displayName == "" { + displayName = org.ID + } + result[i] = &types.ProviderOrganization{ + Slug: org.ID, + DisplayName: displayName, + } + } + + return result, nil +} + +// fetchNetlifyOrganizations fetches the list of Netlify accounts the +// authenticated user belongs to. +func fetchNetlifyOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + "https://api.netlify.com/api/v1/accounts", + nil, + ) + if err != nil { + return nil, fmt.Errorf("cannot create netlify organizations request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot fetch netlify organizations: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("cannot fetch netlify organizations: status %d", resp.StatusCode) + } + + var accounts []struct { + Slug string `json:"slug"` + Name string `json:"name"` + Type string `json:"type"` + } + if err := json.NewDecoder(resp.Body).Decode(&accounts); err != nil { + return nil, fmt.Errorf("cannot decode netlify organizations response: %w", err) + } + + result := make([]*types.ProviderOrganization, len(accounts)) + for i, a := range accounts { + displayName := a.Name + if displayName == "" { + displayName = a.Slug + } + result[i] = &types.ProviderOrganization{ + Slug: a.Slug, + DisplayName: displayName, + } + } + + return result, nil +} + +// fetchClickUpOrganizations fetches the list of ClickUp teams (workspaces) +// the authenticated user belongs to. +func fetchClickUpOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + "https://api.clickup.com/api/v2/team", + nil, + ) + if err != nil { + return nil, fmt.Errorf("cannot create clickup organizations request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot fetch clickup organizations: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("cannot fetch clickup organizations: status %d", resp.StatusCode) + } + + var body struct { + Teams []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"teams"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("cannot decode clickup organizations response: %w", err) + } + + result := make([]*types.ProviderOrganization, len(body.Teams)) + for i, t := range body.Teams { + displayName := t.Name + if displayName == "" { + displayName = t.ID + } + result[i] = &types.ProviderOrganization{ + Slug: t.ID, + DisplayName: displayName, + } + } + + return result, nil +} + // probeConnection makes a lightweight API call to the given URL to verify // the OAuth token is still valid. The probe URL is configured per connector // in the connector registry. diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index 3fd91eff4..d8f8751ba 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -18,11 +18,14 @@ package console_v1 import ( "context" + "encoding/json" "fmt" "net/http" "net/url" + "time" "github.com/go-chi/chi/v5" + "go.gearno.de/kit/httpclient" "go.gearno.de/kit/httpserver" "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/accessreview" @@ -195,15 +198,49 @@ func handleConnectorComplete( return } } else { - cnnctr, err = svc.Connectors.Create( - r.Context(), - probo.CreateConnectorRequest{ - OrganizationID: organizationID, - Provider: connectorProvider, - Protocol: coredata.ConnectorProtocol(connection.Type()), - Connection: connection, - }, - ) + createReq := probo.CreateConnectorRequest{ + OrganizationID: organizationID, + Provider: connectorProvider, + Protocol: coredata.ConnectorProtocol(connection.Type()), + Connection: connection, + } + + // PagerDuty Scoped OAuth surfaces the customer's subdomain + // in the token response body; CompleteWithState parsed it + // into state.ProviderMetadata. Persist it on the connector + // settings so the driver and name resolver can read it. + if connectorProvider == coredata.ConnectorProviderPagerDuty { + if subdomain := state.ProviderMetadata["subdomain"]; subdomain != "" { + createReq.PagerDutySettings = &coredata.PagerDutyConnectorSettings{ + Subdomain: subdomain, + } + } + } + + // Vercel surfaces the customer's team_id as an OAuth callback + // query parameter (not in the token response body). When the + // install targets a personal account no team_id is sent — fall + // back to /v2/user.id as a synthetic TeamID; the v3 members + // endpoint accepts personal-account UIDs. + if connectorProvider == coredata.ConnectorProviderVercel { + teamID := query.Get("team_id") + if teamID == "" { + if oauth2Conn, ok := connection.(*connector.OAuth2Connection); ok && oauth2Conn.AccessToken != "" { + if uid, err := fetchVercelUserID(r.Context(), oauth2Conn.AccessToken); err == nil { + teamID = uid + } else { + logger.WarnCtx(r.Context(), "cannot fetch vercel user id for personal-account fallback", log.Error(err)) + } + } + } + if teamID != "" { + createReq.VercelSettings = &coredata.VercelConnectorSettings{ + TeamID: teamID, + } + } + } + + cnnctr, err = svc.Connectors.Create(r.Context(), createReq) if err != nil { logger.ErrorCtx(r.Context(), "cannot create connector", log.Error(err)) httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("internal error")) @@ -271,6 +308,43 @@ func handleConnectorOAuth2Error( safeRedirect.Redirect(w, r, parsedURL.String(), "/", http.StatusSeeOther) } +// fetchVercelUserID calls Vercel's /v2/user with the freshly-minted access +// token to retrieve the user's UID. This is used as a synthetic TeamID +// when the OAuth callback omits team_id (personal-account installs). +func fetchVercelUserID(ctx context.Context, accessToken string) (string, error) { + reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, "https://api.vercel.com/v2/user", nil) + if err != nil { + return "", fmt.Errorf("cannot create vercel user request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := httpclient.DefaultClient(httpclient.WithSSRFProtection()) + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("cannot execute vercel user request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("cannot fetch vercel user: unexpected status %d", resp.StatusCode) + } + + var body struct { + User struct { + ID string `json:"id"` + } `json:"user"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", fmt.Errorf("cannot decode vercel user response: %w", err) + } + + return body.User.ID, nil +} + func (r *Resolver) ProboService(ctx context.Context, tenantID gid.TenantID) *probo.TenantService { return r.probo.WithTenant(tenantID) }