Skip to content

Commit 830fca9

Browse files
fix: migrate Bitbucket Cloud connector to current workspace API (#4687)
Signed-off-by: Nick Nikolakakis <nonicked@protonmail.com>
1 parent d4807b6 commit 830fca9

File tree

2 files changed

+124
-84
lines changed

2 files changed

+124
-84
lines changed

connector/bitbucketcloud/bitbucketcloud.go

Lines changed: 61 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -21,37 +21,44 @@ import (
2121

2222
const (
2323
apiURL = "https://api.bitbucket.org/2.0"
24-
// Switch to API v2.0 when the Atlassian platform services are fully available in Bitbucket
25-
legacyAPIURL = "https://api.bitbucket.org/1.0"
2624
// Bitbucket requires this scope to access '/user' API endpoints.
2725
scopeAccount = "account"
2826
// Bitbucket requires this scope to access '/user/emails' API endpoints.
2927
scopeEmail = "email"
30-
// Bitbucket requires this scope to access '/teams' API endpoints
31-
// which are used when a client includes the 'groups' scope.
32-
scopeTeams = "team"
3328
)
3429

3530
// Config holds configuration options for Bitbucket logins.
3631
type Config struct {
37-
ClientID string `json:"clientID"`
38-
ClientSecret string `json:"clientSecret"`
39-
RedirectURI string `json:"redirectURI"`
40-
Teams []string `json:"teams"`
41-
IncludeTeamGroups bool `json:"includeTeamGroups,omitempty"`
32+
ClientID string `json:"clientID"`
33+
ClientSecret string `json:"clientSecret"`
34+
RedirectURI string `json:"redirectURI"`
35+
Teams []string `json:"teams"`
36+
37+
// Deprecated: The Bitbucket 1.0 API (/1.0/groups/{team}) that this feature
38+
// relied on has been removed by Atlassian. This option is ignored; if set,
39+
// a warning is logged at startup. Consider using getWorkspacePermissions.
40+
IncludeTeamGroups bool `json:"includeTeamGroups,omitempty"`
41+
42+
// When enabled, appends workspace permission suffixes (e.g. "workspace:owner",
43+
// "workspace:member") to the groups claim, similar to GitLab's getGroupsPermission.
44+
GetWorkspacePermissions bool `json:"getWorkspacePermissions,omitempty"`
4245
}
4346

4447
// Open returns a strategy for logging in through Bitbucket.
4548
func (c *Config) Open(id string, logger *slog.Logger) (connector.Connector, error) {
49+
if c.IncludeTeamGroups {
50+
logger.Warn("bitbucket: includeTeamGroups is deprecated and has no effect; " +
51+
"the Bitbucket 1.0 API it relied on has been removed by Atlassian")
52+
}
53+
4654
b := bitbucketConnector{
47-
redirectURI: c.RedirectURI,
48-
teams: c.Teams,
49-
clientID: c.ClientID,
50-
clientSecret: c.ClientSecret,
51-
includeTeamGroups: c.IncludeTeamGroups,
52-
apiURL: apiURL,
53-
legacyAPIURL: legacyAPIURL,
54-
logger: logger.With(slog.Group("connector", "type", "bitbucketcloud", "id", id)),
55+
redirectURI: c.RedirectURI,
56+
teams: c.Teams,
57+
clientID: c.ClientID,
58+
clientSecret: c.ClientSecret,
59+
getWorkspacePermissions: c.GetWorkspacePermissions,
60+
apiURL: apiURL,
61+
logger: logger.With(slog.Group("connector", "type", "bitbucketcloud", "id", id)),
5562
}
5663

5764
return &b, nil
@@ -69,31 +76,26 @@ var (
6976
)
7077

7178
type bitbucketConnector struct {
72-
redirectURI string
73-
teams []string
74-
clientID string
75-
clientSecret string
76-
logger *slog.Logger
77-
apiURL string
78-
legacyAPIURL string
79+
redirectURI string
80+
teams []string
81+
clientID string
82+
clientSecret string
83+
logger *slog.Logger
84+
apiURL string
85+
getWorkspacePermissions bool
7986

8087
// the following are used only for tests
8188
hostName string
8289
httpClient *http.Client
83-
84-
includeTeamGroups bool
8590
}
8691

87-
// groupsRequired returns whether dex requires Bitbucket's 'team' scope.
92+
// groupsRequired returns whether dex needs to fetch Bitbucket workspace membership.
8893
func (b *bitbucketConnector) groupsRequired(groupScope bool) bool {
8994
return len(b.teams) > 0 || groupScope
9095
}
9196

9297
func (b *bitbucketConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config {
9398
bitbucketScopes := []string{scopeAccount, scopeEmail}
94-
if b.groupsRequired(scopes.Groups) {
95-
bitbucketScopes = append(bitbucketScopes, scopeTeams)
96-
}
9799

98100
endpoint := bitbucket.Endpoint
99101
if b.hostName != "" {
@@ -344,6 +346,7 @@ func (b *bitbucketConnector) userEmail(ctx context.Context, client *http.Client)
344346
if response.Next == nil {
345347
break
346348
}
349+
apiURL = *response.Next
347350
}
348351

349352
return "", errors.New("bitbucket: user has no confirmed, primary email")
@@ -369,29 +372,33 @@ func (b *bitbucketConnector) getGroups(ctx context.Context, client *http.Client,
369372
return nil, nil
370373
}
371374

372-
type workspaceSlug struct {
375+
type workspaceRef struct {
373376
Slug string `json:"slug"`
374377
}
375378

376-
type workspace struct {
377-
Workspace workspaceSlug `json:"workspace"`
379+
type workspaceAccess struct {
380+
Workspace workspaceRef `json:"workspace"`
378381
}
379382

380-
type userWorkspacesResponse struct {
383+
type workspacesResponse struct {
381384
pagedResponse
382-
Values []workspace `json:"values"`
385+
Values []workspaceAccess `json:"values"`
386+
}
387+
388+
type workspacePermission struct {
389+
Permission string `json:"permission"`
383390
}
384391

385392
func (b *bitbucketConnector) userWorkspaces(ctx context.Context, client *http.Client) ([]string, error) {
386393
var teams []string
387-
apiURL := b.apiURL + "/user/permissions/workspaces"
394+
apiURL := b.apiURL + "/user/workspaces"
388395

389396
for {
390-
// https://developer.atlassian.com/cloud/bitbucket/rest/api-group-workspaces/#api-workspaces-get
391-
var response userWorkspacesResponse
397+
// https://developer.atlassian.com/cloud/bitbucket/rest/api-group-user/#api-user-workspaces-get
398+
var response workspacesResponse
392399

393400
if err := get(ctx, client, apiURL, &response); err != nil {
394-
return nil, fmt.Errorf("bitbucket: get user teams: %v", err)
401+
return nil, fmt.Errorf("bitbucket: get user workspaces: %v", err)
395402
}
396403

397404
for _, value := range response.Values {
@@ -401,39 +408,33 @@ func (b *bitbucketConnector) userWorkspaces(ctx context.Context, client *http.Cl
401408
if response.Next == nil {
402409
break
403410
}
411+
apiURL = *response.Next
404412
}
405413

406-
if b.includeTeamGroups {
414+
if b.getWorkspacePermissions {
415+
var permissionGroups []string
407416
for _, team := range teams {
408-
teamGroups, err := b.userTeamGroups(ctx, client, team)
417+
perm, err := b.userWorkspacePermission(ctx, client, team)
409418
if err != nil {
410-
return nil, fmt.Errorf("bitbucket: %v", err)
419+
b.logger.Warn("bitbucket: failed to get permission for workspace, skipping permission suffix",
420+
"workspace", team, "error", err)
421+
continue
411422
}
412-
teams = append(teams, teamGroups...)
423+
permissionGroups = append(permissionGroups, team+":"+perm)
413424
}
425+
teams = append(teams, permissionGroups...)
414426
}
415427

416428
return teams, nil
417429
}
418430

419-
type group struct {
420-
Slug string `json:"slug"`
421-
}
422-
423-
func (b *bitbucketConnector) userTeamGroups(ctx context.Context, client *http.Client, teamName string) ([]string, error) {
424-
apiURL := b.legacyAPIURL + "/groups/" + teamName
425-
426-
var response []group
431+
func (b *bitbucketConnector) userWorkspacePermission(ctx context.Context, client *http.Client, workspaceSlug string) (string, error) {
432+
apiURL := b.apiURL + "/user/workspaces/" + workspaceSlug + "/permission"
433+
var response workspacePermission
427434
if err := get(ctx, client, apiURL, &response); err != nil {
428-
return nil, fmt.Errorf("get user team %q groups: %v", teamName, err)
435+
return "", fmt.Errorf("get workspace %q permission: %v", workspaceSlug, err)
429436
}
430-
431-
teamGroups := make([]string, 0, len(response))
432-
for _, group := range response {
433-
teamGroups = append(teamGroups, teamName+"/"+group.Slug)
434-
}
435-
436-
return teamGroups, nil
437+
return response.Permission, nil
437438
}
438439

439440
// get creates a "GET `apiURL`" request with context, sends the request using

connector/bitbucketcloud/bitbucketcloud_test.go

Lines changed: 63 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,40 @@
11
package bitbucketcloud
22

33
import (
4+
"bytes"
45
"context"
56
"crypto/tls"
67
"encoding/json"
8+
"log/slog"
79
"net/http"
810
"net/http/httptest"
911
"net/url"
1012
"reflect"
13+
"strings"
1114
"testing"
1215

1316
"github.com/dexidp/dex/connector"
1417
)
1518

1619
func TestUserGroups(t *testing.T) {
17-
teamsResponse := userWorkspacesResponse{
20+
workspacesResponse := workspacesResponse{
1821
pagedResponse: pagedResponse{
1922
Size: 3,
2023
Page: 1,
2124
PageLen: 10,
2225
},
23-
Values: []workspace{
24-
{Workspace: workspaceSlug{Slug: "team-1"}},
25-
{Workspace: workspaceSlug{Slug: "team-2"}},
26-
{Workspace: workspaceSlug{Slug: "team-3"}},
26+
Values: []workspaceAccess{
27+
{Workspace: workspaceRef{Slug: "team-1"}},
28+
{Workspace: workspaceRef{Slug: "team-2"}},
29+
{Workspace: workspaceRef{Slug: "team-3"}},
2730
},
2831
}
2932

3033
s := newTestServer(map[string]interface{}{
31-
"/user/permissions/workspaces": teamsResponse,
32-
"/groups/team-1": []group{{Slug: "administrators"}, {Slug: "members"}},
33-
"/groups/team-2": []group{{Slug: "everyone"}},
34-
"/groups/team-3": []group{},
34+
"/user/workspaces": workspacesResponse,
3535
})
3636

37-
connector := bitbucketConnector{apiURL: s.URL, legacyAPIURL: s.URL}
37+
connector := bitbucketConnector{apiURL: s.URL}
3838
groups, err := connector.userWorkspaces(context.Background(), newClient())
3939

4040
expectNil(t, err)
@@ -44,25 +44,12 @@ func TestUserGroups(t *testing.T) {
4444
"team-3",
4545
})
4646

47-
connector.includeTeamGroups = true
48-
groups, err = connector.userWorkspaces(context.Background(), newClient())
49-
50-
expectNil(t, err)
51-
expectEquals(t, groups, []string{
52-
"team-1",
53-
"team-2",
54-
"team-3",
55-
"team-1/administrators",
56-
"team-1/members",
57-
"team-2/everyone",
58-
})
59-
6047
s.Close()
6148
}
6249

6350
func TestUserWithoutTeams(t *testing.T) {
6451
s := newTestServer(map[string]interface{}{
65-
"/user/permissions/workspaces": userWorkspacesResponse{},
52+
"/user/workspaces": workspacesResponse{},
6653
})
6754

6855
connector := bitbucketConnector{apiURL: s.URL}
@@ -74,6 +61,58 @@ func TestUserWithoutTeams(t *testing.T) {
7461
s.Close()
7562
}
7663

64+
func TestUserGroupsWithPermissions(t *testing.T) {
65+
workspacesResp := workspacesResponse{
66+
pagedResponse: pagedResponse{
67+
Size: 2,
68+
Page: 1,
69+
PageLen: 10,
70+
},
71+
Values: []workspaceAccess{
72+
{Workspace: workspaceRef{Slug: "team-1"}},
73+
{Workspace: workspaceRef{Slug: "team-2"}},
74+
},
75+
}
76+
77+
s := newTestServer(map[string]interface{}{
78+
"/user/workspaces": workspacesResp,
79+
"/user/workspaces/team-1/permission": workspacePermission{Permission: "owner"},
80+
"/user/workspaces/team-2/permission": workspacePermission{Permission: "member"},
81+
})
82+
defer s.Close()
83+
84+
logger := slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))
85+
c := bitbucketConnector{apiURL: s.URL, getWorkspacePermissions: true, logger: logger}
86+
groups, err := c.userWorkspaces(context.Background(), newClient())
87+
88+
expectNil(t, err)
89+
expectEquals(t, groups, []string{
90+
"team-1",
91+
"team-2",
92+
"team-1:owner",
93+
"team-2:member",
94+
})
95+
}
96+
97+
func TestDeprecatedIncludeTeamGroups(t *testing.T) {
98+
var buf bytes.Buffer
99+
logger := slog.New(slog.NewTextHandler(&buf, nil))
100+
101+
cfg := Config{
102+
ClientID: "id",
103+
ClientSecret: "secret",
104+
RedirectURI: "http://localhost",
105+
IncludeTeamGroups: true,
106+
}
107+
108+
_, err := cfg.Open("test", logger)
109+
expectNil(t, err)
110+
111+
if !strings.Contains(buf.String(), "includeTeamGroups is deprecated") {
112+
t.Fatal("expected deprecation warning for includeTeamGroups")
113+
}
114+
}
115+
77116
func TestUsernameIncludedInFederatedIdentity(t *testing.T) {
78117
s := newTestServer(map[string]interface{}{
79118
"/user": user{Username: "some-login"},

0 commit comments

Comments
 (0)