mirror of
https://github.com/netbirdio/dex.git
synced 2026-05-22 18:43:53 -07:00
fix: migrate Bitbucket Cloud connector to current workspace API (#4687)
Signed-off-by: Nick Nikolakakis <nonicked@protonmail.com>
This commit is contained in:
@@ -21,37 +21,44 @@ import (
|
||||
|
||||
const (
|
||||
apiURL = "https://api.bitbucket.org/2.0"
|
||||
// Switch to API v2.0 when the Atlassian platform services are fully available in Bitbucket
|
||||
legacyAPIURL = "https://api.bitbucket.org/1.0"
|
||||
// Bitbucket requires this scope to access '/user' API endpoints.
|
||||
scopeAccount = "account"
|
||||
// Bitbucket requires this scope to access '/user/emails' API endpoints.
|
||||
scopeEmail = "email"
|
||||
// Bitbucket requires this scope to access '/teams' API endpoints
|
||||
// which are used when a client includes the 'groups' scope.
|
||||
scopeTeams = "team"
|
||||
)
|
||||
|
||||
// Config holds configuration options for Bitbucket logins.
|
||||
type Config struct {
|
||||
ClientID string `json:"clientID"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
RedirectURI string `json:"redirectURI"`
|
||||
Teams []string `json:"teams"`
|
||||
IncludeTeamGroups bool `json:"includeTeamGroups,omitempty"`
|
||||
ClientID string `json:"clientID"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
RedirectURI string `json:"redirectURI"`
|
||||
Teams []string `json:"teams"`
|
||||
|
||||
// Deprecated: The Bitbucket 1.0 API (/1.0/groups/{team}) that this feature
|
||||
// relied on has been removed by Atlassian. This option is ignored; if set,
|
||||
// a warning is logged at startup. Consider using getWorkspacePermissions.
|
||||
IncludeTeamGroups bool `json:"includeTeamGroups,omitempty"`
|
||||
|
||||
// When enabled, appends workspace permission suffixes (e.g. "workspace:owner",
|
||||
// "workspace:member") to the groups claim, similar to GitLab's getGroupsPermission.
|
||||
GetWorkspacePermissions bool `json:"getWorkspacePermissions,omitempty"`
|
||||
}
|
||||
|
||||
// Open returns a strategy for logging in through Bitbucket.
|
||||
func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, error) {
|
||||
if c.IncludeTeamGroups {
|
||||
logger.Warn("bitbucket: includeTeamGroups is deprecated and has no effect; " +
|
||||
"the Bitbucket 1.0 API it relied on has been removed by Atlassian")
|
||||
}
|
||||
|
||||
b := bitbucketConnector{
|
||||
redirectURI: c.RedirectURI,
|
||||
teams: c.Teams,
|
||||
clientID: c.ClientID,
|
||||
clientSecret: c.ClientSecret,
|
||||
includeTeamGroups: c.IncludeTeamGroups,
|
||||
apiURL: apiURL,
|
||||
legacyAPIURL: legacyAPIURL,
|
||||
logger: logger.With(slog.Group("connector", "type", "bitbucketcloud", "id", id)),
|
||||
redirectURI: c.RedirectURI,
|
||||
teams: c.Teams,
|
||||
clientID: c.ClientID,
|
||||
clientSecret: c.ClientSecret,
|
||||
getWorkspacePermissions: c.GetWorkspacePermissions,
|
||||
apiURL: apiURL,
|
||||
logger: logger.With(slog.Group("connector", "type", "bitbucketcloud", "id", id)),
|
||||
}
|
||||
|
||||
return &b, nil
|
||||
@@ -69,31 +76,26 @@ var (
|
||||
)
|
||||
|
||||
type bitbucketConnector struct {
|
||||
redirectURI string
|
||||
teams []string
|
||||
clientID string
|
||||
clientSecret string
|
||||
logger *slog.Logger
|
||||
apiURL string
|
||||
legacyAPIURL string
|
||||
redirectURI string
|
||||
teams []string
|
||||
clientID string
|
||||
clientSecret string
|
||||
logger *slog.Logger
|
||||
apiURL string
|
||||
getWorkspacePermissions bool
|
||||
|
||||
// the following are used only for tests
|
||||
hostName string
|
||||
httpClient *http.Client
|
||||
|
||||
includeTeamGroups bool
|
||||
}
|
||||
|
||||
// groupsRequired returns whether dex requires Bitbucket's 'team' scope.
|
||||
// groupsRequired returns whether dex needs to fetch Bitbucket workspace membership.
|
||||
func (b *bitbucketConnector) groupsRequired(groupScope bool) bool {
|
||||
return len(b.teams) > 0 || groupScope
|
||||
}
|
||||
|
||||
func (b *bitbucketConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config {
|
||||
bitbucketScopes := []string{scopeAccount, scopeEmail}
|
||||
if b.groupsRequired(scopes.Groups) {
|
||||
bitbucketScopes = append(bitbucketScopes, scopeTeams)
|
||||
}
|
||||
|
||||
endpoint := bitbucket.Endpoint
|
||||
if b.hostName != "" {
|
||||
@@ -344,6 +346,7 @@ func (b *bitbucketConnector) userEmail(ctx context.Context, client *http.Client)
|
||||
if response.Next == nil {
|
||||
break
|
||||
}
|
||||
apiURL = *response.Next
|
||||
}
|
||||
|
||||
return "", errors.New("bitbucket: user has no confirmed, primary email")
|
||||
@@ -369,29 +372,33 @@ func (b *bitbucketConnector) getGroups(ctx context.Context, client *http.Client,
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type workspaceSlug struct {
|
||||
type workspaceRef struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
type workspace struct {
|
||||
Workspace workspaceSlug `json:"workspace"`
|
||||
type workspaceAccess struct {
|
||||
Workspace workspaceRef `json:"workspace"`
|
||||
}
|
||||
|
||||
type userWorkspacesResponse struct {
|
||||
type workspacesResponse struct {
|
||||
pagedResponse
|
||||
Values []workspace `json:"values"`
|
||||
Values []workspaceAccess `json:"values"`
|
||||
}
|
||||
|
||||
type workspacePermission struct {
|
||||
Permission string `json:"permission"`
|
||||
}
|
||||
|
||||
func (b *bitbucketConnector) userWorkspaces(ctx context.Context, client *http.Client) ([]string, error) {
|
||||
var teams []string
|
||||
apiURL := b.apiURL + "/user/permissions/workspaces"
|
||||
apiURL := b.apiURL + "/user/workspaces"
|
||||
|
||||
for {
|
||||
// https://developer.atlassian.com/cloud/bitbucket/rest/api-group-workspaces/#api-workspaces-get
|
||||
var response userWorkspacesResponse
|
||||
// https://developer.atlassian.com/cloud/bitbucket/rest/api-group-user/#api-user-workspaces-get
|
||||
var response workspacesResponse
|
||||
|
||||
if err := get(ctx, client, apiURL, &response); err != nil {
|
||||
return nil, fmt.Errorf("bitbucket: get user teams: %v", err)
|
||||
return nil, fmt.Errorf("bitbucket: get user workspaces: %v", err)
|
||||
}
|
||||
|
||||
for _, value := range response.Values {
|
||||
@@ -401,39 +408,33 @@ func (b *bitbucketConnector) userWorkspaces(ctx context.Context, client *http.Cl
|
||||
if response.Next == nil {
|
||||
break
|
||||
}
|
||||
apiURL = *response.Next
|
||||
}
|
||||
|
||||
if b.includeTeamGroups {
|
||||
if b.getWorkspacePermissions {
|
||||
var permissionGroups []string
|
||||
for _, team := range teams {
|
||||
teamGroups, err := b.userTeamGroups(ctx, client, team)
|
||||
perm, err := b.userWorkspacePermission(ctx, client, team)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bitbucket: %v", err)
|
||||
b.logger.Warn("bitbucket: failed to get permission for workspace, skipping permission suffix",
|
||||
"workspace", team, "error", err)
|
||||
continue
|
||||
}
|
||||
teams = append(teams, teamGroups...)
|
||||
permissionGroups = append(permissionGroups, team+":"+perm)
|
||||
}
|
||||
teams = append(teams, permissionGroups...)
|
||||
}
|
||||
|
||||
return teams, nil
|
||||
}
|
||||
|
||||
type group struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
func (b *bitbucketConnector) userTeamGroups(ctx context.Context, client *http.Client, teamName string) ([]string, error) {
|
||||
apiURL := b.legacyAPIURL + "/groups/" + teamName
|
||||
|
||||
var response []group
|
||||
func (b *bitbucketConnector) userWorkspacePermission(ctx context.Context, client *http.Client, workspaceSlug string) (string, error) {
|
||||
apiURL := b.apiURL + "/user/workspaces/" + workspaceSlug + "/permission"
|
||||
var response workspacePermission
|
||||
if err := get(ctx, client, apiURL, &response); err != nil {
|
||||
return nil, fmt.Errorf("get user team %q groups: %v", teamName, err)
|
||||
return "", fmt.Errorf("get workspace %q permission: %v", workspaceSlug, err)
|
||||
}
|
||||
|
||||
teamGroups := make([]string, 0, len(response))
|
||||
for _, group := range response {
|
||||
teamGroups = append(teamGroups, teamName+"/"+group.Slug)
|
||||
}
|
||||
|
||||
return teamGroups, nil
|
||||
return response.Permission, nil
|
||||
}
|
||||
|
||||
// get creates a "GET `apiURL`" request with context, sends the request using
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
package bitbucketcloud
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dexidp/dex/connector"
|
||||
)
|
||||
|
||||
func TestUserGroups(t *testing.T) {
|
||||
teamsResponse := userWorkspacesResponse{
|
||||
workspacesResponse := workspacesResponse{
|
||||
pagedResponse: pagedResponse{
|
||||
Size: 3,
|
||||
Page: 1,
|
||||
PageLen: 10,
|
||||
},
|
||||
Values: []workspace{
|
||||
{Workspace: workspaceSlug{Slug: "team-1"}},
|
||||
{Workspace: workspaceSlug{Slug: "team-2"}},
|
||||
{Workspace: workspaceSlug{Slug: "team-3"}},
|
||||
Values: []workspaceAccess{
|
||||
{Workspace: workspaceRef{Slug: "team-1"}},
|
||||
{Workspace: workspaceRef{Slug: "team-2"}},
|
||||
{Workspace: workspaceRef{Slug: "team-3"}},
|
||||
},
|
||||
}
|
||||
|
||||
s := newTestServer(map[string]interface{}{
|
||||
"/user/permissions/workspaces": teamsResponse,
|
||||
"/groups/team-1": []group{{Slug: "administrators"}, {Slug: "members"}},
|
||||
"/groups/team-2": []group{{Slug: "everyone"}},
|
||||
"/groups/team-3": []group{},
|
||||
"/user/workspaces": workspacesResponse,
|
||||
})
|
||||
|
||||
connector := bitbucketConnector{apiURL: s.URL, legacyAPIURL: s.URL}
|
||||
connector := bitbucketConnector{apiURL: s.URL}
|
||||
groups, err := connector.userWorkspaces(context.Background(), newClient())
|
||||
|
||||
expectNil(t, err)
|
||||
@@ -44,25 +44,12 @@ func TestUserGroups(t *testing.T) {
|
||||
"team-3",
|
||||
})
|
||||
|
||||
connector.includeTeamGroups = true
|
||||
groups, err = connector.userWorkspaces(context.Background(), newClient())
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, groups, []string{
|
||||
"team-1",
|
||||
"team-2",
|
||||
"team-3",
|
||||
"team-1/administrators",
|
||||
"team-1/members",
|
||||
"team-2/everyone",
|
||||
})
|
||||
|
||||
s.Close()
|
||||
}
|
||||
|
||||
func TestUserWithoutTeams(t *testing.T) {
|
||||
s := newTestServer(map[string]interface{}{
|
||||
"/user/permissions/workspaces": userWorkspacesResponse{},
|
||||
"/user/workspaces": workspacesResponse{},
|
||||
})
|
||||
|
||||
connector := bitbucketConnector{apiURL: s.URL}
|
||||
@@ -74,6 +61,58 @@ func TestUserWithoutTeams(t *testing.T) {
|
||||
s.Close()
|
||||
}
|
||||
|
||||
func TestUserGroupsWithPermissions(t *testing.T) {
|
||||
workspacesResp := workspacesResponse{
|
||||
pagedResponse: pagedResponse{
|
||||
Size: 2,
|
||||
Page: 1,
|
||||
PageLen: 10,
|
||||
},
|
||||
Values: []workspaceAccess{
|
||||
{Workspace: workspaceRef{Slug: "team-1"}},
|
||||
{Workspace: workspaceRef{Slug: "team-2"}},
|
||||
},
|
||||
}
|
||||
|
||||
s := newTestServer(map[string]interface{}{
|
||||
"/user/workspaces": workspacesResp,
|
||||
"/user/workspaces/team-1/permission": workspacePermission{Permission: "owner"},
|
||||
"/user/workspaces/team-2/permission": workspacePermission{Permission: "member"},
|
||||
})
|
||||
defer s.Close()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
|
||||
c := bitbucketConnector{apiURL: s.URL, getWorkspacePermissions: true, logger: logger}
|
||||
groups, err := c.userWorkspaces(context.Background(), newClient())
|
||||
|
||||
expectNil(t, err)
|
||||
expectEquals(t, groups, []string{
|
||||
"team-1",
|
||||
"team-2",
|
||||
"team-1:owner",
|
||||
"team-2:member",
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeprecatedIncludeTeamGroups(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&buf, nil))
|
||||
|
||||
cfg := Config{
|
||||
ClientID: "id",
|
||||
ClientSecret: "secret",
|
||||
RedirectURI: "http://localhost",
|
||||
IncludeTeamGroups: true,
|
||||
}
|
||||
|
||||
_, err := cfg.Open("test", logger)
|
||||
expectNil(t, err)
|
||||
|
||||
if !strings.Contains(buf.String(), "includeTeamGroups is deprecated") {
|
||||
t.Fatal("expected deprecation warning for includeTeamGroups")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsernameIncludedInFederatedIdentity(t *testing.T) {
|
||||
s := newTestServer(map[string]interface{}{
|
||||
"/user": user{Username: "some-login"},
|
||||
|
||||
Reference in New Issue
Block a user