Wire post-OAuth pickers and 2-auto callback handling → Track e2e gap for new access-review connectors
- Wire post-OAuth pickers and 2-auto callback handling - Add 13 vendor logo components for new connectors - Wire access-review connectors into bootstrap config - Track e2e gap for new access-review connectors Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user