diff --git a/CHANGELOG.md b/CHANGELOG.md index 37435ede88f..e460f7f8313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ All notable changes to Sourcegraph are documented in this file. - When setting and resetting passwords, if the user's primary email address is not yet verified, using the password reset link sent via email will now also verify the email address. [#46307](https://github.com/sourcegraph/sourcegraph/pull/46307) - Added new code host details and updated edit code host pages in site admin area. [#46327](https://github.com/sourcegraph/sourcegraph/pull/46327) - If the experimental setting `insightsDataRetention` is enabled, the number of Code Insights data points that can be viewed will be limited by the site configuration setting `insights.maximumSampleSize`, set to 30 by default. Older points beyond that number will be periodically archived. [#46206](https://github.com/sourcegraph/sourcegraph/pull/46206), [#46440](https://github.com/sourcegraph/sourcegraph/pull/46440) +- Bitbucket Cloud can now be added as an authentication provider on Sourcegraph. [#46309](https://github.com/sourcegraph/sourcegraph/pull/46309) +- Bitbucket Cloud code host connections now support permissions syncing. [#46312](https://github.com/sourcegraph/sourcegraph/pull/46312) ### Changed diff --git a/client/web/src/auth/SignInPage.tsx b/client/web/src/auth/SignInPage.tsx index bf2bd619212..76acc29cc06 100644 --- a/client/web/src/auth/SignInPage.tsx +++ b/client/web/src/auth/SignInPage.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react' -import { mdiGithub, mdiGitlab } from '@mdi/js' +import { mdiBitbucket, mdiGithub, mdiGitlab } from '@mdi/js' import classNames from 'classnames' import { partition } from 'lodash' import { Navigate, useLocation } from 'react-router-dom-v5-compat' @@ -95,6 +95,11 @@ export const SignInPage: React.FunctionComponent{' '} )} + {provider.serviceType === 'bitbucketCloud' && ( + <> + {' '} + + )} Continue with {provider.displayName} diff --git a/client/web/src/auth/SignUpForm.tsx b/client/web/src/auth/SignUpForm.tsx index 4d22b3b1b4a..d0dfa52e059 100644 --- a/client/web/src/auth/SignUpForm.tsx +++ b/client/web/src/auth/SignUpForm.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo, useState } from 'react' -import { mdiGithub, mdiGitlab } from '@mdi/js' +import { mdiBitbucket, mdiGithub, mdiGitlab } from '@mdi/js' import classNames from 'classnames' import cookies from 'js-cookie' import { Observable, of } from 'rxjs' @@ -256,6 +256,8 @@ export const SignUpForm: React.FunctionComponent ) : provider.serviceType === 'gitlab' ? ( + ) : provider.serviceType === 'bitbucketCloud' ? ( + ) : null}{' '} Continue with {provider.displayName} diff --git a/client/web/src/components/externalAccounts/externalAccounts.ts b/client/web/src/components/externalAccounts/externalAccounts.ts index 1ce45d73624..992d5ad25c8 100644 --- a/client/web/src/components/externalAccounts/externalAccounts.ts +++ b/client/web/src/components/externalAccounts/externalAccounts.ts @@ -1,6 +1,7 @@ import React from 'react' import AccountCircleIcon from 'mdi-react/AccountCircleIcon' +import BitbucketIcon from 'mdi-react/BitbucketIcon' import GithubIcon from 'mdi-react/GithubIcon' import GitLabIcon from 'mdi-react/GitlabIcon' @@ -40,4 +41,8 @@ export const defaultExternalAccounts: Record sg sign-in", func(t *testing.T) { + cookie := &http.Cookie{Name: auth.SignoutCookie, Value: "true"} + + resp := doRequest("GET", "http://example.com/", "", []*http.Cookie{cookie}, false) + if want := http.StatusOK; resp.StatusCode != want { + t.Errorf("got response code %v, want %v", resp.StatusCode, want) + } + }) + t.Run("unauthenticated homepage visit, no sign-out cookie -> bitbucket cloud oauth flow", func(t *testing.T) { + resp := doRequest("GET", "http://example.com/", "", nil, false) + if want := http.StatusFound; resp.StatusCode != want { + t.Errorf("got response code %v, want %v", resp.StatusCode, want) + } + if got, want := resp.Header.Get("Location"), "/.auth/bitbucketcloud/login?"; !strings.Contains(got, want) { + t.Errorf("got redirect URL %v, want contains %v", got, want) + } + redirectURL, err := url.Parse(resp.Header.Get("Location")) + if err != nil { + t.Fatal(err) + } + if got, want := redirectURL.Query().Get("redirect"), "/"; got != want { + t.Errorf("got return-to URL %v, want %v", got, want) + } + }) + t.Run("unauthenticated subpage visit -> bitbucket cloud oauth flow", func(t *testing.T) { + resp := doRequest("GET", "http://example.com/page", "", nil, false) + if want := http.StatusFound; resp.StatusCode != want { + t.Errorf("got response code %v, want %v", resp.StatusCode, want) + } + + if got, want := resp.Header.Get("Location"), "/.auth/bitbucketcloud/login?"; !strings.Contains(got, want) { + t.Errorf("got redirect URL %v, want contains %v", got, want) + } + redirectURL, err := url.Parse(resp.Header.Get("Location")) + if err != nil { + t.Fatal(err) + } + if got, want := redirectURL.Query().Get("redirect"), "/page"; got != want { + t.Errorf("got return-to URL %v, want %v", got, want) + } + }) + + t.Run("unauthenticated API request -> pass through", func(t *testing.T) { + resp := doRequest("GET", "http://example.com/.api/foo", "", nil, false) + if want := http.StatusOK; resp.StatusCode != want { + t.Errorf("got response code %v, want %v", resp.StatusCode, want) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if got, want := string(body), "got through"; got != want { + t.Errorf("got response body %v, want %v", got, want) + } + }) + t.Run("login -> bitbucket cloud auth flow with redirect param", func(t *testing.T) { + resp := doRequest("GET", "http://example.com/.auth/bitbucketcloud/login?pc="+mockBitbucketCloud.Provider.ConfigID().ID+"&redirect=%2Fpage", "", nil, false) + if want := http.StatusFound; resp.StatusCode != want { + t.Errorf("got response code %v, want %v", resp.StatusCode, want) + } + redirect := resp.Header.Get("Location") + if got, want := redirect, "https://bitbucket.org/site/oauth2/authorize?"; !strings.HasPrefix(got, want) { + t.Errorf("got redirect URL %v, want contains %v", got, want) + } + uredirect, err := url.Parse(redirect) + if err != nil { + t.Fatal(err) + } + if got, want := uredirect.Query().Get("client_id"), mockBitbucketCloud.Provider.CachedInfo().ClientID; got != want { + t.Errorf("got %v, want %v", got, want) + } + if got, want := uredirect.Query().Get("scope"), "account email"; got != want { + t.Errorf("got %v, want %v", got, want) + } + if got, want := uredirect.Query().Get("response_type"), "code"; got != want { + t.Errorf("got %v, want %v", got, want) + } + state, err := oauth.DecodeState(uredirect.Query().Get("state")) + if err != nil { + t.Fatalf("could not decode state: %v", err) + } + if got, want := state.ProviderID, mockBitbucketCloud.Provider.ConfigID().ID; got != want { + t.Fatalf("got state provider ID %v, want %v", got, want) + } + if got, want := state.Redirect, "/page"; got != want { + t.Fatalf("got state redirect %v, want %v", got, want) + } + }) + t.Run("Bitbucket Cloud OAuth callback with valid state param", func(t *testing.T) { + encodedState, err := oauth.LoginState{ + Redirect: "/return-to-url", + ProviderID: mockBitbucketCloud.Provider.ConfigID().ID, + CSRF: "csrf-code", + }.Encode() + if err != nil { + t.Fatal(err) + } + callbackCookies := []*http.Cookie{oauth.NewCookie(getStateConfig(), encodedState)} + resp := doRequest("GET", "http://example.com/.auth/bitbucketcloud/callback?code=the-oauth-code&state="+encodedState, "", callbackCookies, false) + if want := http.StatusFound; resp.StatusCode != want { + t.Errorf("got response code %v, want %v", resp.StatusCode, want) + } + if got, want := mockBitbucketCloud.lastCallbackRequestURL, "http://example.com/callback?code=the-oauth-code&state="+encodedState; got == nil || got.String() != want { + t.Errorf("got last bitbucket cloud callback request url %v, want %v", got, want) + } + mockBitbucketCloud.lastCallbackRequestURL = nil + }) + t.Run("Bitbucket Cloud OAuth callback with state with unknown provider", func(t *testing.T) { + encodedState, err := oauth.LoginState{ + Redirect: "/return-to-url", + ProviderID: "unknown", + CSRF: "csrf-code", + }.Encode() + if err != nil { + t.Fatal(err) + } + callbackCookies := []*http.Cookie{oauth.NewCookie(getStateConfig(), encodedState)} + resp := doRequest("GET", "http://example.com/.auth/bitbucketcloud/callback?code=the-oauth-code&state="+encodedState, "", callbackCookies, false) + if want := http.StatusBadRequest; resp.StatusCode != want { + t.Errorf("got response code %v, want %v", resp.StatusCode, want) + } + if mockBitbucketCloud.lastCallbackRequestURL != nil { + t.Errorf("got last bitbucket.org callback request url was non-nil: %v", mockBitbucketCloud.lastCallbackRequestURL) + } + mockBitbucketCloud.lastCallbackRequestURL = nil + }) + t.Run("authenticated app request", func(t *testing.T) { + resp := doRequest("GET", "http://example.com/", "", nil, true) + if want := http.StatusOK; resp.StatusCode != want { + t.Errorf("got response code %v, want %v", resp.StatusCode, want) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if got, want := string(body), "got through"; got != want { + t.Errorf("got response body %v, want %v", got, want) + } + }) + t.Run("authenticated API request", func(t *testing.T) { + resp := doRequest("GET", "http://example.com/.api/foo", "", nil, true) + if want := http.StatusOK; resp.StatusCode != want { + t.Errorf("got response code %v, want %v", resp.StatusCode, want) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if got, want := string(body), "got through"; got != want { + t.Errorf("got response body %v, want %v", got, want) + } + }) +} + +type MockProvider struct { + *oauth.Provider + lastCallbackRequestURL *url.URL +} + +func newMockProvider(t *testing.T, db database.DB, clientID, clientSecret, baseURL string) *MockProvider { + var ( + mp MockProvider + problems []string + ) + cfg := schema.AuthProviders{Bitbucketcloud: &schema.BitbucketCloudAuthProvider{ + Url: baseURL, + ClientSecret: clientSecret, + ClientKey: clientID, + ApiScope: "account,email", + }} + mp.Provider, problems = parseProvider(logtest.Scoped(t), cfg.Bitbucketcloud, db, cfg) + if len(problems) > 0 { + t.Fatalf("Expected 0 problems, but got %d: %+v", len(problems), problems) + } + if mp.Provider == nil { + t.Fatalf("Expected provider") + } + mp.Provider.Callback = func(oauth2.Config) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.Method, "GET"; got != want { + t.Errorf("In OAuth callback handler got %q request, wanted %q", got, want) + } + w.WriteHeader(http.StatusFound) + mp.lastCallbackRequestURL = r.URL + }) + } + return &mp +} diff --git a/enterprise/cmd/frontend/internal/auth/bitbucketcloudoauth/provider.go b/enterprise/cmd/frontend/internal/auth/bitbucketcloudoauth/provider.go new file mode 100644 index 00000000000..3ad274e0e76 --- /dev/null +++ b/enterprise/cmd/frontend/internal/auth/bitbucketcloudoauth/provider.go @@ -0,0 +1,112 @@ +package bitbucketcloudoauth + +import ( + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/dghubble/gologin" + "github.com/dghubble/gologin/bitbucket" + goauth2 "github.com/dghubble/gologin/oauth2" + "golang.org/x/oauth2" + + "github.com/sourcegraph/log" + + "github.com/sourcegraph/sourcegraph/cmd/frontend/auth" + "github.com/sourcegraph/sourcegraph/enterprise/cmd/frontend/internal/auth/oauth" + "github.com/sourcegraph/sourcegraph/internal/database" + "github.com/sourcegraph/sourcegraph/internal/extsvc" + "github.com/sourcegraph/sourcegraph/internal/lazyregexp" + "github.com/sourcegraph/sourcegraph/schema" +) + +const sessionKey = "bitbucketcloudoauth@0" +const defaultBBCloudURL = "https://bitbucket.org" + +func parseProvider(logger log.Logger, p *schema.BitbucketCloudAuthProvider, db database.DB, sourceCfg schema.AuthProviders) (provider *oauth.Provider, messages []string) { + rawURL := p.Url + if rawURL == "" { + rawURL = defaultBBCloudURL + } + parsedURL, err := url.Parse(rawURL) + parsedURL = extsvc.NormalizeBaseURL(parsedURL) + if err != nil { + messages = append(messages, fmt.Sprintf("Could not parse Bitbucket Cloud URL %q. You will not be able to login via Bitbucket Cloud.", rawURL)) + return nil, messages + } + + if !validateClientKeyOrSecret(p.ClientKey) { + messages = append(messages, "Bitbucket Cloud key contains unexpected characters, possibly hidden") + } + if !validateClientKeyOrSecret(p.ClientSecret) { + messages = append(messages, "Bitbucket Cloud secret contains unexpected characters, possibly hidden") + } + + return oauth.NewProvider(oauth.ProviderOp{ + AuthPrefix: authPrefix, + OAuth2Config: func() oauth2.Config { + return oauth2.Config{ + ClientID: p.ClientKey, + ClientSecret: p.ClientSecret, + Scopes: requestedScopes(p.ApiScope), + Endpoint: oauth2.Endpoint{ + AuthURL: parsedURL.ResolveReference(&url.URL{Path: "/site/oauth2/authorize"}).String(), + TokenURL: parsedURL.ResolveReference(&url.URL{Path: "/site/oauth2/access_token"}).String(), + }, + } + }, + SourceConfig: sourceCfg, + StateConfig: getStateConfig(), + ServiceID: parsedURL.String(), + ServiceType: extsvc.TypeBitbucketCloud, + Login: func(oauth2Cfg oauth2.Config) http.Handler { + return bitbucket.LoginHandler(&oauth2Cfg, nil) + }, + Callback: func(oauth2Cfg oauth2.Config) http.Handler { + return bitbucket.CallbackHandler( + &oauth2Cfg, + oauth.SessionIssuer(logger, db, &sessionIssuerHelper{ + baseURL: parsedURL, + db: db, + clientKey: p.ClientKey, + allowSignup: p.AllowSignup, + }, sessionKey), + http.HandlerFunc(failureHandler), + ) + }, + }), messages +} + +func failureHandler(w http.ResponseWriter, r *http.Request) { + // As a special case we want to handle `access_denied` errors by redirecting + // back. This case arises when the user decides not to proceed by clicking `cancel`. + if err := r.URL.Query().Get("error"); err != "access_denied" { + // Fall back to default failure handler + gologin.DefaultFailureHandler.ServeHTTP(w, r) + return + } + + ctx := r.Context() + encodedState, err := goauth2.StateFromContext(ctx) + if err != nil { + http.Error(w, "Authentication failed. Try signing in again (and clearing cookies for the current site). The error was: could not get OAuth state from context.", http.StatusInternalServerError) + return + } + state, err := oauth.DecodeState(encodedState) + if err != nil { + http.Error(w, "Authentication failed. Try signing in again (and clearing cookies for the current site). The error was: could not get decode OAuth state.", http.StatusInternalServerError) + return + } + http.Redirect(w, r, auth.SafeRedirectURL(state.Redirect), http.StatusFound) +} + +var clientKeySecretValidator = lazyregexp.New("^[a-zA-Z0-9.]*$") + +func validateClientKeyOrSecret(clientKeyOrSecret string) (valid bool) { + return clientKeySecretValidator.MatchString(clientKeyOrSecret) +} + +func requestedScopes(apiScopes string) []string { + return strings.Split(apiScopes, ",") +} diff --git a/enterprise/cmd/frontend/internal/auth/bitbucketcloudoauth/provider_test.go b/enterprise/cmd/frontend/internal/auth/bitbucketcloudoauth/provider_test.go new file mode 100644 index 00000000000..81ebe303d78 --- /dev/null +++ b/enterprise/cmd/frontend/internal/auth/bitbucketcloudoauth/provider_test.go @@ -0,0 +1,36 @@ +package bitbucketcloudoauth + +import ( + "sort" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/sourcegraph/sourcegraph/cmd/frontend/envvar" + "github.com/sourcegraph/sourcegraph/schema" +) + +func TestRequestedScopes(t *testing.T) { + defer envvar.MockSourcegraphDotComMode(false) + + tests := []struct { + schema *schema.BitbucketCloudAuthProvider + expScopes []string + }{ + { + schema: &schema.BitbucketCloudAuthProvider{ + ApiScope: "account,email,repository", + }, + expScopes: []string{"account", "email", "repository"}, + }, + } + for _, test := range tests { + t.Run("", func(t *testing.T) { + scopes := requestedScopes(test.schema.ApiScope) + sort.Strings(scopes) + if diff := cmp.Diff(test.expScopes, scopes); diff != "" { + t.Fatalf("scopes: %s", diff) + } + }) + } +} diff --git a/enterprise/cmd/frontend/internal/auth/bitbucketcloudoauth/session.go b/enterprise/cmd/frontend/internal/auth/bitbucketcloudoauth/session.go new file mode 100644 index 00000000000..0c10e3bb704 --- /dev/null +++ b/enterprise/cmd/frontend/internal/auth/bitbucketcloudoauth/session.go @@ -0,0 +1,174 @@ +package bitbucketcloudoauth + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + + "golang.org/x/oauth2" + + "github.com/sourcegraph/sourcegraph/cmd/frontend/auth" + "github.com/sourcegraph/sourcegraph/cmd/frontend/auth/providers" + "github.com/sourcegraph/sourcegraph/cmd/frontend/hubspot" + "github.com/sourcegraph/sourcegraph/cmd/frontend/hubspot/hubspotutil" + "github.com/sourcegraph/sourcegraph/enterprise/cmd/frontend/internal/auth/oauth" + "github.com/sourcegraph/sourcegraph/internal/actor" + "github.com/sourcegraph/sourcegraph/internal/database" + "github.com/sourcegraph/sourcegraph/internal/extsvc" + esauth "github.com/sourcegraph/sourcegraph/internal/extsvc/auth" + "github.com/sourcegraph/sourcegraph/internal/extsvc/bitbucketcloud" + "github.com/sourcegraph/sourcegraph/lib/errors" + "github.com/sourcegraph/sourcegraph/schema" +) + +type sessionIssuerHelper struct { + baseURL *url.URL + clientKey string + db database.DB + allowSignup bool + client bitbucketcloud.Client +} + +func (s *sessionIssuerHelper) AuthSucceededEventName() database.SecurityEventName { + return database.SecurityEventBitbucketCloudAuthSucceeded +} + +func (s *sessionIssuerHelper) AuthFailedEventName() database.SecurityEventName { + return database.SecurityEventBitbucketCloudAuthFailed +} + +func (s *sessionIssuerHelper) GetOrCreateUser(ctx context.Context, token *oauth2.Token, anonymousUserID, firstSourceURL, lastSourceURL string) (actr *actor.Actor, safeErrMsg string, err error) { + var client bitbucketcloud.Client + if s.client != nil { + client = s.client + } else { + conf := &schema.BitbucketCloudConnection{ + Url: s.baseURL.String(), + } + client, err = bitbucketcloud.NewClient(s.baseURL.String(), conf, nil) + if err != nil { + return nil, "Could not initialize Bitbucket Cloud client", err + } + } + + // The token used here is fresh from Bitbucket OAuth. It should be valid + // for 1 hour, so we don't bother with setting up token refreshing yet. + // If account creation/linking succeeds, the token will be stored in the + // database with the refresh token, and refreshing can happen from that point. + auther := &esauth.OAuthBearerToken{Token: token.AccessToken} + client = client.WithAuthenticator(auther) + bbUser, err := client.CurrentUser(ctx) + if err != nil { + return nil, "Could not read Bitbucket user from callback request.", errors.Wrap(err, "could not read user from bitbucket") + } + + var data extsvc.AccountData + if err := bitbucketcloud.SetExternalAccountData(&data, &bbUser.Account, token); err != nil { + return nil, "", err + } + + emails, err := client.AllCurrentUserEmails(ctx) + if err != nil { + return nil, "", err + } + + attempts, err := buildUserFetchAttempts(emails, s.allowSignup) + if err != nil { + return nil, "Could not find verified email address for Bitbucket user.", err + } + + var ( + firstSafeErrMsg string + firstErr error + ) + + for i, attempt := range attempts { + userID, safeErrMsg, err := auth.GetAndSaveUser(ctx, s.db, auth.GetAndSaveUserOp{ + UserProps: database.NewUser{ + Username: bbUser.Username, + Email: attempt.email, + EmailIsVerified: true, + DisplayName: bbUser.Nickname, + AvatarURL: bbUser.Links["avatar"].Href, + }, + ExternalAccount: extsvc.AccountSpec{ + ServiceType: extsvc.TypeBitbucketCloud, + ServiceID: s.baseURL.String(), + ClientID: s.clientKey, + AccountID: bbUser.UUID, + }, + ExternalAccountData: data, + CreateIfNotExist: attempt.createIfNotExist, + }) + if err == nil { + go hubspotutil.SyncUser(attempt.email, hubspotutil.SignupEventID, &hubspot.ContactProperties{ + AnonymousUserID: anonymousUserID, + FirstSourceURL: firstSourceURL, + LastSourceURL: lastSourceURL, + }) + return actor.FromUser(userID), "", nil + } + if i == 0 { + firstSafeErrMsg, firstErr = safeErrMsg, err + } + } + + // On failure, return the first error + verifiedEmails := make([]string, 0, len(attempts)) + for i, attempt := range attempts { + verifiedEmails[i] = attempt.email + } + return nil, fmt.Sprintf("No Sourcegraph user exists matching any of the verified emails: %s.\n\nFirst error was: %s", strings.Join(verifiedEmails, ", "), firstSafeErrMsg), firstErr +} + +type attempt struct { + email string + createIfNotExist bool +} + +func buildUserFetchAttempts(emails []*bitbucketcloud.UserEmail, allowSignup bool) ([]attempt, error) { + attempts := []attempt{} + for _, email := range emails { + if email.IsConfirmed { + attempts = append(attempts, attempt{ + email: email.Email, + createIfNotExist: false, + }) + } + } + if len(attempts) == 0 { + return nil, errors.New("no verified email") + } + // If allowSignup is true, we will create an account using the first verified + // email address from Bitbucket which we expect to be their primary address. Note + // that the order of attempts is important. If we manage to connect with an + // existing account we return early and don't attempt to create a new account. + if allowSignup { + attempts = append(attempts, attempt{ + email: attempts[0].email, + createIfNotExist: true, + }) + } + + return attempts, nil +} + +func (s *sessionIssuerHelper) DeleteStateCookie(w http.ResponseWriter) { + stateConfig := getStateConfig() + stateConfig.MaxAge = -1 + http.SetCookie(w, oauth.NewCookie(stateConfig, "")) +} + +func (s *sessionIssuerHelper) SessionData(token *oauth2.Token) oauth.SessionData { + return oauth.SessionData{ + ID: providers.ConfigID{ + ID: s.baseURL.String(), + Type: extsvc.TypeBitbucketCloud, + }, + AccessToken: token.AccessToken, + TokenType: token.Type(), + // TODO(pjlast): investigate exactly where and how we use this SessionData + } +} diff --git a/enterprise/cmd/frontend/internal/auth/bitbucketcloudoauth/session_test.go b/enterprise/cmd/frontend/internal/auth/bitbucketcloudoauth/session_test.go new file mode 100644 index 00000000000..60c7a01056d --- /dev/null +++ b/enterprise/cmd/frontend/internal/auth/bitbucketcloudoauth/session_test.go @@ -0,0 +1,302 @@ +package bitbucketcloudoauth + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" + + bitbucketlogin "github.com/dghubble/gologin/bitbucket" + "github.com/google/go-cmp/cmp" + "golang.org/x/oauth2" + + "github.com/sourcegraph/sourcegraph/cmd/frontend/auth" + "github.com/sourcegraph/sourcegraph/internal/actor" + "github.com/sourcegraph/sourcegraph/internal/database" + "github.com/sourcegraph/sourcegraph/internal/extsvc" + "github.com/sourcegraph/sourcegraph/internal/extsvc/bitbucketcloud" + "github.com/sourcegraph/sourcegraph/lib/errors" + "github.com/sourcegraph/sourcegraph/schema" +) + +type emailResponse struct { + Values []bitbucketcloud.UserEmail `json:"values"` +} + +var returnUsername string +var returnAccountID string +var returnEmails emailResponse + +func createTestServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/user") { + json.NewEncoder(w).Encode(struct { + Username string `json:"username"` + UUID string `json:"uuid"` + }{ + Username: returnUsername, + UUID: returnAccountID, + }) + return + } + if strings.HasSuffix(r.URL.Path, "/user/emails") { + json.NewEncoder(w).Encode(returnEmails) + return + } + + })) +} + +func TestSessionIssuerHelper_GetOrCreateUser(t *testing.T) { + server := createTestServer() + defer server.Close() + bbURL, _ := url.Parse(server.URL) + clientID := "client-id" + + // Top-level mock data + // + // authSaveableUsers that will be accepted by auth.GetAndSaveUser + authSaveableUsers := map[string]int32{ + "alice": 1, + } + + type input struct { + description string + bbUser *bitbucketlogin.User + bbUserEmails []bitbucketcloud.UserEmail + bbUserEmailsErr error + allowSignup bool + } + cases := []struct { + inputs []input + expActor *actor.Actor + expErr bool + expAuthUserOp *auth.GetAndSaveUserOp + }{ + { + inputs: []input{{ + description: "bbUser, verified email -> session created", + bbUser: &bitbucketlogin.User{Username: "alice"}, + bbUserEmails: []bitbucketcloud.UserEmail{ + { + Email: "alice@example.com", + IsConfirmed: true, + IsPrimary: true, + }, + }, + }}, + expActor: &actor.Actor{UID: 1}, + expAuthUserOp: &auth.GetAndSaveUserOp{ + UserProps: u("alice", "alice@example.com", true), + ExternalAccount: acct(extsvc.TypeBitbucketCloud, server.URL+"/", clientID, "1234"), + }, + }, + { + inputs: []input{{ + description: "bbUser, primary email not verified but another is -> no session created", + bbUser: &bitbucketlogin.User{Username: "alice"}, + bbUserEmails: []bitbucketcloud.UserEmail{ + { + Email: "alice@example1.com", + IsPrimary: true, + IsConfirmed: false, + }, + { + Email: "alice@example2.com", + IsPrimary: false, + IsConfirmed: true, + }, + }, + }}, + expActor: &actor.Actor{UID: 1}, + expAuthUserOp: &auth.GetAndSaveUserOp{ + UserProps: u("alice", "alice@example2.com", true), + ExternalAccount: acct(extsvc.TypeBitbucketCloud, server.URL+"/", clientID, "1234"), + }, + }, + { + inputs: []input{{ + description: "bbUser, no emails -> no session created", + bbUser: &bitbucketlogin.User{Username: "alice"}, + bbUserEmails: []bitbucketcloud.UserEmail{}, + }, { + description: "bbUser, email fetching err -> no session created", + bbUser: &bitbucketlogin.User{Username: "alice"}, + bbUserEmailsErr: errors.New("x"), + }, { + description: "bbUser, plenty of emails but none verified -> no session created", + bbUser: &bitbucketlogin.User{Username: "alice"}, + bbUserEmails: []bitbucketcloud.UserEmail{ + { + Email: "alice@example1.com", + IsPrimary: true, + IsConfirmed: false, + }, + { + Email: "alice@example2.com", + IsPrimary: true, + IsConfirmed: false, + }, + { + Email: "alice@example3.com", + IsPrimary: true, + IsConfirmed: false, + }, + }, + }, { + description: "no bbUser -> no session created", + }, { + description: "bbUser, verified email, unsaveable -> no session created", + bbUser: &bitbucketlogin.User{Username: "bob"}, + }}, + expErr: true, + }, + } + for _, c := range cases { + for _, ci := range c.inputs { + c, ci := c, ci + t.Run(ci.description, func(t *testing.T) { + if ci.bbUser != nil { + returnUsername = ci.bbUser.Username + returnAccountID = "1234" + } + returnEmails.Values = ci.bbUserEmails + + var gotAuthUserOp *auth.GetAndSaveUserOp + auth.MockGetAndSaveUser = func(ctx context.Context, op auth.GetAndSaveUserOp) (userID int32, safeErrMsg string, err error) { + if gotAuthUserOp != nil { + t.Fatal("GetAndSaveUser called more than once") + } + op.ExternalAccountData = extsvc.AccountData{} // ignore AccountData value + gotAuthUserOp = &op + + if uid, ok := authSaveableUsers[op.UserProps.Username]; ok { + return uid, "", nil + } + return 0, "safeErr", errors.New("auth.GetAndSaveUser error") + } + defer func() { + auth.MockGetAndSaveUser = nil + }() + + ctx := bitbucketlogin.WithUser(context.Background(), ci.bbUser) + conf := &schema.BitbucketCloudConnection{ + Url: server.URL, + ApiURL: server.URL, + } + bbClient, err := bitbucketcloud.NewClient(server.URL, conf, nil) + if err != nil { + t.Fatal(err) + } + s := &sessionIssuerHelper{ + baseURL: extsvc.NormalizeBaseURL(bbURL), + clientKey: clientID, + allowSignup: ci.allowSignup, + client: bbClient, + } + + tok := &oauth2.Token{AccessToken: "dummy-value-that-isnt-relevant-to-unit-correctness"} + actr, _, err := s.GetOrCreateUser(ctx, tok, "", "", "") + if c.expErr && err == nil { + t.Errorf("expected err %v, but was nil", c.expErr) + } else if !c.expErr && err != nil { + t.Errorf("expected no error, but was %v", err) + } + + if got, exp := actr, c.expActor; !reflect.DeepEqual(got, exp) { + t.Errorf("expected actor %v, got %v", exp, got) + } + + if got, exp := gotAuthUserOp, c.expAuthUserOp; !reflect.DeepEqual(got, exp) { + t.Error(cmp.Diff(got, exp)) + } + }) + } + } +} + +func TestSessionIssuerHelper_SignupMatchesSecondaryAccount(t *testing.T) { + server := createTestServer() + defer server.Close() + + returnEmails = emailResponse{Values: []bitbucketcloud.UserEmail{ + { + Email: "primary@example.com", + IsPrimary: true, + IsConfirmed: true, + }, + { + Email: "secondary@example.com", + IsPrimary: false, + IsConfirmed: true, + }, + }} + + // We just want to make sure that we end up getting to the secondary email + auth.MockGetAndSaveUser = func(ctx context.Context, op auth.GetAndSaveUserOp) (userID int32, safeErrMsg string, err error) { + if op.CreateIfNotExist { + // We should not get here as we should hit the second email address + // before trying again with creation enabled. + t.Fatal("Should not get here") + } + // Mock the second email address matching + if op.UserProps.Email == "secondary@example.com" { + return 1, "", nil + } + return 0, "no match", errors.New("no match") + } + defer func() { + auth.MockGetAndSaveUser = nil + }() + + bbURL, _ := url.Parse(server.URL) + clientID := "client-id" + returnUsername = "alice" + returnAccountID = "1234" + + bbUser := &bitbucketlogin.User{ + Username: returnUsername, + } + + ctx := bitbucketlogin.WithUser(context.Background(), bbUser) + conf := &schema.BitbucketCloudConnection{ + Url: server.URL, + ApiURL: server.URL, + } + bbClient, err := bitbucketcloud.NewClient(server.URL, conf, nil) + if err != nil { + t.Fatal(err) + } + s := &sessionIssuerHelper{ + baseURL: extsvc.NormalizeBaseURL(bbURL), + clientKey: clientID, + allowSignup: true, + client: bbClient, + } + tok := &oauth2.Token{AccessToken: "dummy-value-that-isnt-relevant-to-unit-correctness"} + _, _, err = s.GetOrCreateUser(ctx, tok, "", "", "") + if err != nil { + t.Fatal(err) + } +} + +func u(username, email string, emailIsVerified bool) database.NewUser { + return database.NewUser{ + Username: username, + Email: email, + EmailIsVerified: emailIsVerified, + } +} + +func acct(serviceType, serviceID, clientID, accountID string) extsvc.AccountSpec { + return extsvc.AccountSpec{ + ServiceType: serviceType, + ServiceID: serviceID, + ClientID: clientID, + AccountID: accountID, + } +} diff --git a/enterprise/cmd/frontend/internal/auth/init.go b/enterprise/cmd/frontend/internal/auth/init.go index 54043c8306e..6abfb125c33 100644 --- a/enterprise/cmd/frontend/internal/auth/init.go +++ b/enterprise/cmd/frontend/internal/auth/init.go @@ -12,6 +12,7 @@ import ( "github.com/sourcegraph/sourcegraph/cmd/frontend/auth" "github.com/sourcegraph/sourcegraph/cmd/frontend/external/app" "github.com/sourcegraph/sourcegraph/cmd/frontend/graphqlbackend" + "github.com/sourcegraph/sourcegraph/enterprise/cmd/frontend/internal/auth/bitbucketcloudoauth" "github.com/sourcegraph/sourcegraph/enterprise/cmd/frontend/internal/auth/githuboauth" "github.com/sourcegraph/sourcegraph/enterprise/cmd/frontend/internal/auth/gitlaboauth" "github.com/sourcegraph/sourcegraph/enterprise/cmd/frontend/internal/auth/httpheader" @@ -33,6 +34,7 @@ func Init(logger log.Logger, db database.DB) { httpheader.Init() githuboauth.Init(logger, db) gitlaboauth.Init(logger, db) + bitbucketcloudoauth.Init(logger, db) // Register enterprise auth middleware auth.RegisterMiddlewares( @@ -42,6 +44,7 @@ func Init(logger log.Logger, db database.DB) { httpheader.Middleware(db), githuboauth.Middleware(db), gitlaboauth.Middleware(db), + bitbucketcloudoauth.Middleware(db), ) // Register app-level sign-out handler app.RegisterSSOSignOutHandler(ssoSignOutHandler) @@ -71,6 +74,8 @@ func Init(logger log.Logger, db database.DB) { name = "GitHub OAuth" case p.Gitlab != nil: name = "GitLab OAuth" + case p.Bitbucketcloud != nil: + name = "Bitbucket Cloud OAuth" case p.HttpHeader != nil: name = "HTTP header" case p.Openidconnect != nil: diff --git a/enterprise/cmd/frontend/internal/auth/oauth/provider.go b/enterprise/cmd/frontend/internal/auth/oauth/provider.go index dc810674670..7417ec38db8 100644 --- a/enterprise/cmd/frontend/internal/auth/oauth/provider.go +++ b/enterprise/cmd/frontend/internal/auth/oauth/provider.go @@ -57,6 +57,8 @@ func (p *Provider) CachedInfo() *providers.Info { displayName = p.SourceConfig.Github.DisplayName case p.SourceConfig.Gitlab != nil && p.SourceConfig.Gitlab.DisplayName != "": displayName = p.SourceConfig.Gitlab.DisplayName + case p.SourceConfig.Bitbucketcloud != nil && p.SourceConfig.Bitbucketcloud.DisplayName != "": + displayName = p.SourceConfig.Bitbucketcloud.DisplayName } return &providers.Info{ ServiceID: p.ServiceID, diff --git a/enterprise/internal/authz/authz.go b/enterprise/internal/authz/authz.go index 4e621475f7b..8b0d6450aca 100644 --- a/enterprise/internal/authz/authz.go +++ b/enterprise/internal/authz/authz.go @@ -9,6 +9,7 @@ import ( "github.com/sourcegraph/log" + "github.com/sourcegraph/sourcegraph/enterprise/internal/authz/bitbucketcloud" "github.com/sourcegraph/sourcegraph/enterprise/internal/authz/bitbucketserver" "github.com/sourcegraph/sourcegraph/enterprise/internal/authz/github" "github.com/sourcegraph/sourcegraph/enterprise/internal/authz/gitlab" @@ -60,6 +61,7 @@ func ProvidersFromConfig( extsvc.KindGitHub, extsvc.KindGitLab, extsvc.KindBitbucketServer, + extsvc.KindBitbucketCloud, extsvc.KindPerforce, }, LimitOffset: &database.LimitOffset{ @@ -72,6 +74,7 @@ func ProvidersFromConfig( gitLabConns []*types.GitLabConnection bitbucketServerConns []*types.BitbucketServerConnection perforceConns []*types.PerforceConnection + bitbucketCloudConns []*types.BitbucketCloudConnection ) for { svcs, err := store.List(ctx, opt) @@ -116,6 +119,11 @@ func ProvidersFromConfig( URN: svc.URN(), BitbucketServerConnection: c, }) + case *schema.BitbucketCloudConnection: + bitbucketCloudConns = append(bitbucketCloudConns, &types.BitbucketCloudConnection{ + URN: svc.URN(), + BitbucketCloudConnection: c, + }) case *schema.PerforceConnection: perforceConns = append(perforceConns, &types.PerforceConnection{ URN: svc.URN(), @@ -170,6 +178,14 @@ func ProvidersFromConfig( invalidConnections = append(invalidConnections, pfInvalidConnections...) } + if len(bitbucketCloudConns) > 0 { + bbcloudProviders, bbcloudProblems, bbcloudWarnings, bbcloudInvalidConnections := bitbucketcloud.NewAuthzProviders(db, bitbucketCloudConns, cfg.SiteConfig().AuthProviders) + providers = append(providers, bbcloudProviders...) + seriousProblems = append(seriousProblems, bbcloudProblems...) + warnings = append(warnings, bbcloudWarnings...) + invalidConnections = append(invalidConnections, bbcloudInvalidConnections...) + } + // 🚨 SECURITY: Warn the admin when both code host authz provider and the permissions user mapping are configured. if cfg.SiteConfig().PermissionsUserMapping != nil && cfg.SiteConfig().PermissionsUserMapping.Enabled { diff --git a/enterprise/internal/authz/authz_test.go b/enterprise/internal/authz/authz_test.go index 4f94333decb..907d2c356a1 100644 --- a/enterprise/internal/authz/authz_test.go +++ b/enterprise/internal/authz/authz_test.go @@ -489,7 +489,7 @@ func TestAuthzProvidersFromConfig(t *testing.T) { Config: extsvc.NewUnencryptedConfig(mustMarshalJSONString(bbs)), }) } - case extsvc.KindGitHub, extsvc.KindPerforce: + case extsvc.KindGitHub, extsvc.KindPerforce, extsvc.KindBitbucketCloud: default: return nil, errors.Errorf("unexpected kind: %s", kind) } @@ -521,6 +521,7 @@ func TestAuthzProvidersEnabledACLsDisabled(t *testing.T) { bitbucketServerConnections []*schema.BitbucketServerConnection githubConnections []*schema.GitHubConnection perforceConnections []*schema.PerforceConnection + bitbucketCloudConnections []*schema.BitbucketCloudConnection expInvalidConnections []string expSeriousProblems []string @@ -577,7 +578,7 @@ func TestAuthzProvidersEnabledACLsDisabled(t *testing.T) { expInvalidConnections: []string{"gitlab"}, }, { - description: "Bitbucket connection with authz enabled but missing license for ACLs", + description: "Bitbucket Server connection with authz enabled but missing license for ACLs", cfg: conf.Unified{}, bitbucketServerConnections: []*schema.BitbucketServerConnection{ { @@ -600,6 +601,20 @@ func TestAuthzProvidersEnabledACLsDisabled(t *testing.T) { expSeriousProblems: []string{"failed"}, expInvalidConnections: []string{"bitbucketServer"}, }, + { + description: "Bitbucket Cloud connection with authz enabled but missing license for ACLs", + cfg: conf.Unified{}, + bitbucketCloudConnections: []*schema.BitbucketCloudConnection{ + { + Authorization: &schema.BitbucketCloudAuthorization{}, + Url: "https://bitbucket.org", + Username: "admin", + AppPassword: "secret-password", + }, + }, + expSeriousProblems: []string{"failed"}, + expInvalidConnections: []string{"bitbucketCloud"}, + }, { description: "Perforce connection with authz enabled but missing license for ACLs", cfg: conf.Unified{}, @@ -654,6 +669,13 @@ func TestAuthzProvidersEnabledACLsDisabled(t *testing.T) { Config: extsvc.NewUnencryptedConfig(mustMarshalJSONString(gh)), }) } + case extsvc.KindBitbucketCloud: + for _, bbcloud := range test.bitbucketCloudConnections { + svcs = append(svcs, &types.ExternalService{ + Kind: kind, + Config: extsvc.NewUnencryptedConfig(mustMarshalJSONString(bbcloud)), + }) + } case extsvc.KindPerforce: for _, pf := range test.perforceConnections { svcs = append(svcs, &types.ExternalService{ diff --git a/enterprise/internal/authz/bitbucketcloud/authz.go b/enterprise/internal/authz/bitbucketcloud/authz.go new file mode 100644 index 00000000000..932b7fe2921 --- /dev/null +++ b/enterprise/internal/authz/bitbucketcloud/authz.go @@ -0,0 +1,97 @@ +package bitbucketcloud + +import ( + "fmt" + "net/url" + + "github.com/sourcegraph/sourcegraph/enterprise/internal/licensing" + + "github.com/sourcegraph/sourcegraph/internal/authz" + "github.com/sourcegraph/sourcegraph/internal/database" + "github.com/sourcegraph/sourcegraph/internal/extsvc" + "github.com/sourcegraph/sourcegraph/internal/extsvc/bitbucketcloud" + "github.com/sourcegraph/sourcegraph/internal/types" + "github.com/sourcegraph/sourcegraph/schema" +) + +// NewAuthzProviders returns the set of Bitbucket Cloud authz providers derived from the connections. +// +// It also returns any simple validation problems with the config, separating these into "serious problems" +// and "warnings". "Serious problems" are those that should make Sourcegraph set authz.allowAccessByDefault +// to false. "Warnings" are all other validation problems. +// +// This constructor does not and should not directly check connectivity to external services - if +// desired, callers should use `(*Provider).ValidateConnection` directly to get warnings related +// to connection issues. +func NewAuthzProviders(db database.DB, conns []*types.BitbucketCloudConnection, authProviders []schema.AuthProviders) (ps []authz.Provider, problems []string, warnings []string, invalidConnections []string) { + bbcloudAuthProviders := make(map[string]*schema.BitbucketCloudAuthProvider) + for _, p := range authProviders { + if p.Bitbucketcloud != nil { + var id string + bbURL, err := url.Parse(p.Bitbucketcloud.GetURL()) + if err != nil { + // error reporting for this should happen elsewhere, for now just use what is given + id = p.Bitbucketcloud.GetURL() + } else { + // use codehost normalized URL as ID + ch := extsvc.NewCodeHost(bbURL, p.Bitbucketcloud.Type) + id = ch.ServiceID + } + bbcloudAuthProviders[id] = p.Bitbucketcloud + } + } + + for _, c := range conns { + p, err := newAuthzProvider(db, c) + if err != nil { + invalidConnections = append(invalidConnections, extsvc.TypeBitbucketCloud) + problems = append(problems, err.Error()) + } + if p == nil { + continue + } + + if _, exists := bbcloudAuthProviders[p.ServiceID()]; !exists { + warnings = append(warnings, + fmt.Sprintf("Bitbucket Cloud config for %[1]s has `authorization` enabled, "+ + "but no authentication provider matching %[1]q was found. "+ + "Check the [**site configuration**](/site-admin/configuration) to "+ + "verify an entry in [`auth.providers`](https://docs.sourcegraph.com/admin/auth) exists for %[1]s.", + p.ServiceID())) + } + + ps = append(ps, p) + } + + return ps, problems, warnings, invalidConnections +} + +func newAuthzProvider( + db database.DB, + c *types.BitbucketCloudConnection, +) (authz.Provider, error) { + // If authorization is not set for this connection, we do not need an + // authz provider. + if c.Authorization == nil { + return nil, nil + } + if err := licensing.Check(licensing.FeatureACLs); err != nil { + return nil, err + } + + bbClient, err := bitbucketcloud.NewClient(c.URN, c.BitbucketCloudConnection, nil) + if err != nil { + return nil, err + } + + return NewProvider(db, c, ProviderOptions{ + BitbucketCloudClient: bbClient, + }), nil +} + +// ValidateAuthz validates the authorization fields of the given Perforce +// external service config. +func ValidateAuthz(_ *schema.BitbucketCloudConnection) error { + // newAuthzProvider always succeeds, so directly return nil here. + return nil +} diff --git a/enterprise/internal/authz/bitbucketcloud/authz_test.go b/enterprise/internal/authz/bitbucketcloud/authz_test.go new file mode 100644 index 00000000000..3fa6a1567f1 --- /dev/null +++ b/enterprise/internal/authz/bitbucketcloud/authz_test.go @@ -0,0 +1,109 @@ +package bitbucketcloud + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sourcegraph/sourcegraph/enterprise/internal/licensing" + "github.com/sourcegraph/sourcegraph/internal/database" + "github.com/sourcegraph/sourcegraph/internal/types" + "github.com/sourcegraph/sourcegraph/schema" +) + +func TestNewAuthzProviders(t *testing.T) { + db := database.NewMockDB() + db.ExternalServicesFunc.SetDefaultReturn(database.NewMockExternalServiceStore()) + t.Run("no authorization", func(t *testing.T) { + providers, problems, warnings, invalidConnections := NewAuthzProviders( + db, + []*types.BitbucketCloudConnection{{ + BitbucketCloudConnection: &schema.BitbucketCloudConnection{ + Url: schema.DefaultBitbucketCloudURL, + }, + }}, + []schema.AuthProviders{}, + ) + + assert := assert.New(t) + + assert.Len(providers, 0, "unexpected a providers: %+v", providers) + assert.Len(problems, 0, "unexpected problems: %+v", problems) + assert.Len(warnings, 0, "unexpected warnings: %+v", warnings) + assert.Len(invalidConnections, 0, "unexpected invalidConnections: %+v", invalidConnections) + }) + + t.Run("no matching auth provider", func(t *testing.T) { + licensing.MockCheckFeatureError("") + providers, problems, warnings, invalidConnections := NewAuthzProviders( + db, + []*types.BitbucketCloudConnection{ + { + BitbucketCloudConnection: &schema.BitbucketCloudConnection{ + Url: "https://bitbucket.org/my-org", // incorrect + Authorization: &schema.BitbucketCloudAuthorization{}, + }, + }, + }, + []schema.AuthProviders{{Bitbucketcloud: &schema.BitbucketCloudAuthProvider{}}}, + ) + + require.Len(t, providers, 1, "expect exactly one provider") + assert.NotNil(t, providers[0]) + + assert.Empty(t, problems) + assert.Empty(t, invalidConnections) + + require.Len(t, warnings, 1, "expect exactly one warning") + assert.Contains(t, warnings[0], "no authentication provider") + }) + + t.Run("matching auth provider found", func(t *testing.T) { + t.Run("default case", func(t *testing.T) { + licensing.MockCheckFeatureError("") + providers, problems, warnings, invalidConnections := NewAuthzProviders( + db, + []*types.BitbucketCloudConnection{ + { + BitbucketCloudConnection: &schema.BitbucketCloudConnection{ + Url: schema.DefaultBitbucketCloudURL, + Authorization: &schema.BitbucketCloudAuthorization{}, + }, + }, + }, + []schema.AuthProviders{{Bitbucketcloud: &schema.BitbucketCloudAuthProvider{}}}, + ) + + require.Len(t, providers, 1, "expect exactly one provider") + assert.NotNil(t, providers[0]) + + assert.Empty(t, problems) + assert.Empty(t, warnings) + assert.Empty(t, invalidConnections) + }) + + t.Run("license does not have ACLs feature", func(t *testing.T) { + licensing.MockCheckFeatureError("failed") + providers, problems, warnings, invalidConnections := NewAuthzProviders( + db, + []*types.BitbucketCloudConnection{ + { + BitbucketCloudConnection: &schema.BitbucketCloudConnection{ + Url: schema.DefaultBitbucketCloudURL, + Authorization: &schema.BitbucketCloudAuthorization{}, + }, + }, + }, + []schema.AuthProviders{{Bitbucketcloud: &schema.BitbucketCloudAuthProvider{}}}, + ) + + expectedError := []string{"failed"} + expInvalidConnectionErr := []string{"bitbucketCloud"} + assert.Equal(t, expectedError, problems) + assert.Equal(t, expInvalidConnectionErr, invalidConnections) + assert.Empty(t, providers) + assert.Empty(t, warnings) + }) + }) +} diff --git a/enterprise/internal/authz/bitbucketcloud/provider.go b/enterprise/internal/authz/bitbucketcloud/provider.go new file mode 100644 index 00000000000..9e8562dd08c --- /dev/null +++ b/enterprise/internal/authz/bitbucketcloud/provider.go @@ -0,0 +1,171 @@ +// Package bitbucketcloud contains an authorization provider for Bitbucket Cloud. +package bitbucketcloud + +import ( + "context" + "net/url" + "strings" + + "github.com/sourcegraph/sourcegraph/internal/authz" + "github.com/sourcegraph/sourcegraph/internal/database" + "github.com/sourcegraph/sourcegraph/internal/extsvc" + "github.com/sourcegraph/sourcegraph/internal/extsvc/auth" + "github.com/sourcegraph/sourcegraph/internal/extsvc/bitbucketcloud" + "github.com/sourcegraph/sourcegraph/internal/httpcli" + "github.com/sourcegraph/sourcegraph/internal/types" + "github.com/sourcegraph/sourcegraph/lib/errors" +) + +// Provider is an implementation of AuthzProvider that provides repository and +// user permissions as determined from Bitbucket Cloud. +type Provider struct { + urn string + codeHost *extsvc.CodeHost + client bitbucketcloud.Client + pageSize int // Page size to use in paginated requests. + db database.DB +} + +type ProviderOptions struct { + BitbucketCloudClient bitbucketcloud.Client +} + +var _ authz.Provider = (*Provider)(nil) + +// NewProvider returns a new Bitbucket Cloud authorization provider that uses +// the given bitbucket.Client to talk to the Bitbucket Cloud API that is +// the source of truth for permissions. Sourcegraph users will need a valid +// Bitbucket Cloud external account for permissions to sync correctly. +func NewProvider(db database.DB, conn *types.BitbucketCloudConnection, opts ProviderOptions) *Provider { + baseURL, err := url.Parse(conn.Url) + if err != nil { + return nil + } + + if opts.BitbucketCloudClient == nil { + opts.BitbucketCloudClient, err = bitbucketcloud.NewClient(conn.URN, conn.BitbucketCloudConnection, httpcli.ExternalClient) + if err != nil { + return nil + } + } + + return &Provider{ + urn: conn.URN, + codeHost: extsvc.NewCodeHost(baseURL, extsvc.TypeBitbucketCloud), + client: opts.BitbucketCloudClient, + pageSize: 1000, + db: db, + } +} + +// ValidateConnection validates that the Provider has access to the Bitbucket Cloud API +// with the credentials it was configured with. +func (p *Provider) ValidateConnection(ctx context.Context) []string { + _, err := p.client.CurrentUser(ctx) + if err != nil { + return []string{err.Error()} + } + return []string{} +} + +func (p *Provider) URN() string { + return p.urn +} + +// ServiceID returns the absolute URL that identifies the Bitbucket Server instance +// this provider is configured with. +func (p *Provider) ServiceID() string { return p.codeHost.ServiceID } + +// ServiceType returns the type of this Provider, namely, "bitbucketCloud". +func (p *Provider) ServiceType() string { return p.codeHost.ServiceType } + +// FetchAccount satisfies the authz.Provider interface. +func (p *Provider) FetchAccount(ctx context.Context, user *types.User, _ []*extsvc.Account, _ []string) (acct *extsvc.Account, err error) { + return nil, nil +} + +// FetchUserPerms returns a list of repository IDs (on code host) that the given account +// has read access on the code host. The repository ID has the same value as it would be +// used as api.ExternalRepoSpec.ID. The returned list only includes private repository IDs. +// +// This method may return partial but valid results in case of error, and it is up to +// callers to decide whether to discard. +// +// API docs: https://docs.atlassian.com/bitbucket-server/rest/5.16.0/bitbucket-rest.html#idm8296923984 +func (p *Provider) FetchUserPerms(ctx context.Context, account *extsvc.Account, opts authz.FetchPermsOptions) (*authz.ExternalUserPermissions, error) { + switch { + case account == nil: + return nil, errors.New("no account provided") + case !extsvc.IsHostOfAccount(p.codeHost, account): + return nil, errors.Errorf("not a code host of the account: want %q but have %q", + p.codeHost.ServiceID, account.AccountSpec.ServiceID) + case account.Data == nil: + return nil, errors.New("no account data provided") + } + + _, tok, err := bitbucketcloud.GetExternalAccountData(ctx, &account.AccountData) + if err != nil { + return nil, err + } + oauthToken := &auth.OAuthBearerToken{ + Token: tok.AccessToken, + RefreshToken: tok.RefreshToken, + Expiry: tok.Expiry, + NeedsRefreshBuffer: 5, + } + oauthToken.RefreshFunc = database.GetAccountRefreshAndStoreOAuthTokenFunc(p.db, account.ID, bitbucketcloud.GetOAuthContext(p.codeHost.BaseURL.String())) + + client := p.client.WithAuthenticator(oauthToken) + + repos, _, err := client.Repos(ctx, &bitbucketcloud.PageToken{Pagelen: 100}, "", &bitbucketcloud.ReposOptions{RequestOptions: bitbucketcloud.RequestOptions{FetchAll: true}, Role: "member"}) + if err != nil { + return nil, err + } + + extIDs := make([]extsvc.RepoID, 0, len(repos)) + for _, repo := range repos { + extIDs = append(extIDs, extsvc.RepoID(repo.UUID)) + } + + return &authz.ExternalUserPermissions{ + Exacts: extIDs, + }, err +} + +// FetchRepoPerms returns a list of user IDs (on code host) who have read access to +// the given repo on the code host. The user ID has the same value as it would +// be used as extsvc.Account.AccountID. The returned list includes both direct access +// and inherited from the group membership. +// +// This method may return partial but valid results in case of error, and it is up to +// callers to decide whether to discard. +// +// API docs: https://docs.atlassian.com/bitbucket-server/rest/5.16.0/bitbucket-rest.html#idm8283203728 +func (p *Provider) FetchRepoPerms(ctx context.Context, repo *extsvc.Repository, opts authz.FetchPermsOptions) ([]extsvc.AccountID, error) { + repoNameParts := strings.Split(repo.URI, "/") + repoOwner := repoNameParts[1] + repoName := repoNameParts[2] + + users, _, err := p.client.ListExplicitUserPermsForRepo(ctx, &bitbucketcloud.PageToken{Pagelen: 100}, repoOwner, repoName, &bitbucketcloud.RequestOptions{FetchAll: true}) + if err != nil { + return nil, err + } + + // Bitbucket Cloud API does not return the owner of the repository as part + // of the explicit permissions list, so we need to fetch and add them. + bbCloudRepo, err := p.client.Repo(ctx, repoOwner, repoName) + if err != nil { + return nil, err + } + + if bbCloudRepo.Owner != nil { + users = append(users, bbCloudRepo.Owner) + } + + userIDs := make([]extsvc.AccountID, len(users)) + for i, user := range users { + userIDs[i] = extsvc.AccountID(user.UUID) + } + + return userIDs, nil +} diff --git a/enterprise/internal/authz/bitbucketcloud/provider_test.go b/enterprise/internal/authz/bitbucketcloud/provider_test.go new file mode 100644 index 00000000000..0b180dcadab --- /dev/null +++ b/enterprise/internal/authz/bitbucketcloud/provider_test.go @@ -0,0 +1,216 @@ +package bitbucketcloud + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/sourcegraph/sourcegraph/internal/authz" + "github.com/sourcegraph/sourcegraph/internal/database" + "github.com/sourcegraph/sourcegraph/internal/extsvc" + "github.com/sourcegraph/sourcegraph/internal/extsvc/bitbucketcloud" + "github.com/sourcegraph/sourcegraph/internal/types" + "github.com/sourcegraph/sourcegraph/schema" + "golang.org/x/oauth2" +) + +type mockDoer struct { + do func(*http.Request) (*http.Response, error) +} + +func (c *mockDoer) Do(r *http.Request) (*http.Response, error) { + return c.do(r) +} + +func mustURL(t *testing.T, u string) *url.URL { + parsed, err := url.Parse(u) + if err != nil { + t.Fatal(err) + } + return parsed +} + +func createTestServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/repositories") { + json.NewEncoder(w).Encode(struct { + Values []bitbucketcloud.Repo `json:"values"` + }{ + Values: []bitbucketcloud.Repo{ + {UUID: "1"}, + {UUID: "2"}, + {UUID: "3"}, + }, + }) + return + } + + if strings.HasSuffix(r.URL.Path, "/permissions-config/users") { + json.NewEncoder(w).Encode(struct { + Values []bitbucketcloud.ExplicitUserPermsResponse `json:"values"` + }{ + Values: []bitbucketcloud.ExplicitUserPermsResponse{ + {User: &bitbucketcloud.Account{UUID: "1"}}, + {User: &bitbucketcloud.Account{UUID: "2"}}, + {User: &bitbucketcloud.Account{UUID: "3"}}, + }, + }) + return + } + + if strings.HasSuffix(r.URL.Path, "/repositories/user/repo") { + json.NewEncoder(w).Encode(bitbucketcloud.Repo{ + Owner: &bitbucketcloud.Account{UUID: "4"}, + }) + return + } + })) +} + +func TestProvider_FetchUserPerms(t *testing.T) { + db := database.NewMockDB() + t.Run("nil account", func(t *testing.T) { + p := NewProvider(db, + &types.BitbucketCloudConnection{ + BitbucketCloudConnection: &schema.BitbucketCloudConnection{ + ApiURL: "https://bitbucket.org", + Url: "https://bitbucket.org", + }, + }, ProviderOptions{}) + _, err := p.FetchUserPerms(context.Background(), nil, authz.FetchPermsOptions{}) + want := "no account provided" + got := fmt.Sprintf("%v", err) + if got != want { + t.Fatalf("err: want %q but got %q", want, got) + } + }) + + t.Run("not the code host of the account", func(t *testing.T) { + p := NewProvider(db, + &types.BitbucketCloudConnection{ + BitbucketCloudConnection: &schema.BitbucketCloudConnection{ + ApiURL: "https://bitbucket.org", + Url: "https://bitbucket.org", + }, + }, ProviderOptions{}) + _, err := p.FetchUserPerms(context.Background(), + &extsvc.Account{ + AccountSpec: extsvc.AccountSpec{ + ServiceType: extsvc.TypeGitHub, + ServiceID: "https://github.com/", + }, + }, + authz.FetchPermsOptions{}, + ) + want := `not a code host of the account: want "https://bitbucket.org/" but have "https://github.com/"` + got := fmt.Sprintf("%v", err) + if got != want { + t.Fatalf("err: want %q but got %q", want, got) + } + }) + + t.Run("no account data provided", func(t *testing.T) { + p := NewProvider(db, + &types.BitbucketCloudConnection{ + BitbucketCloudConnection: &schema.BitbucketCloudConnection{ + ApiURL: "https://bitbucket.org", + Url: "https://bitbucket.org", + }, + }, ProviderOptions{}) + _, err := p.FetchUserPerms(context.Background(), + &extsvc.Account{ + AccountSpec: extsvc.AccountSpec{ + ServiceType: extsvc.TypeBitbucketCloud, + ServiceID: "https://bitbucket.org/", + }, + }, + authz.FetchPermsOptions{}, + ) + want := `no account data provided` + got := fmt.Sprintf("%v", err) + if got != want { + t.Fatalf("err: want %q but got %q", want, got) + } + }) + + server := createTestServer() + defer server.Close() + + t.Run("fetch user permissions", func(t *testing.T) { + conn := &schema.BitbucketCloudConnection{ + ApiURL: server.URL, + Url: server.URL, + } + client, err := bitbucketcloud.NewClient(server.URL, conn, http.DefaultClient) + if err != nil { + t.Fatal(err) + } + + p := NewProvider(db, + &types.BitbucketCloudConnection{ + BitbucketCloudConnection: conn, + }, ProviderOptions{BitbucketCloudClient: client}) + + var acctData extsvc.AccountData + err = bitbucketcloud.SetExternalAccountData(&acctData, &bitbucketcloud.Account{}, &oauth2.Token{AccessToken: "my-access-token"}) + if err != nil { + t.Fatal(err) + } + + account := &extsvc.Account{ + AccountSpec: extsvc.AccountSpec{ + ServiceType: extsvc.TypeBitbucketCloud, + ServiceID: extsvc.NormalizeBaseURL(mustURL(t, server.URL)).String(), + }, + AccountData: acctData, + } + userPerms, err := p.FetchUserPerms(context.Background(), account, authz.FetchPermsOptions{}) + if err != nil { + t.Fatal(err) + } + + expRepoIDs := []extsvc.RepoID{"1", "2", "3"} + if diff := cmp.Diff(expRepoIDs, userPerms.Exacts); diff != "" { + t.Fatal(diff) + } + }) +} + +func TestProvider_FetchRepoPerms(t *testing.T) { + server := createTestServer() + defer server.Close() + db := database.NewMockDB() + + conn := &schema.BitbucketCloudConnection{ + ApiURL: server.URL, + Url: server.URL, + } + client, err := bitbucketcloud.NewClient(server.URL, conn, http.DefaultClient) + if err != nil { + t.Fatal(err) + } + + p := NewProvider(db, + &types.BitbucketCloudConnection{ + BitbucketCloudConnection: conn, + }, ProviderOptions{BitbucketCloudClient: client}) + + perms, err := p.FetchRepoPerms(context.Background(), &extsvc.Repository{ + URI: "bitbucket.org/user/repo", + }, authz.FetchPermsOptions{}) + + if err != nil { + t.Fatal(err) + } + + expUserIDs := []extsvc.AccountID{"1", "2", "3", "4"} + if diff := cmp.Diff(expUserIDs, perms); diff != "" { + t.Fatal(diff) + } +} diff --git a/enterprise/internal/batches/sources/mocks_test.go b/enterprise/internal/batches/sources/mocks_test.go index 95c7cc75f2b..65eeae80735 100644 --- a/enterprise/internal/batches/sources/mocks_test.go +++ b/enterprise/internal/batches/sources/mocks_test.go @@ -3589,6 +3589,9 @@ func (c SourcerStoreUserCredentialsFuncCall) Results() []interface{} { // github.com/sourcegraph/sourcegraph/internal/extsvc/bitbucketcloud) used // for unit testing. type MockBitbucketCloudClient struct { + // AllCurrentUserEmailsFunc is an instance of a mock function object + // controlling the behavior of the method AllCurrentUserEmails. + AllCurrentUserEmailsFunc *BitbucketCloudClientAllCurrentUserEmailsFunc // AuthenticatorFunc is an instance of a mock function object // controlling the behavior of the method Authenticator. AuthenticatorFunc *BitbucketCloudClientAuthenticatorFunc @@ -3601,6 +3604,9 @@ type MockBitbucketCloudClient struct { // CurrentUserFunc is an instance of a mock function object controlling // the behavior of the method CurrentUser. CurrentUserFunc *BitbucketCloudClientCurrentUserFunc + // CurrentUserEmailsFunc is an instance of a mock function object + // controlling the behavior of the method CurrentUserEmails. + CurrentUserEmailsFunc *BitbucketCloudClientCurrentUserEmailsFunc // DeclinePullRequestFunc is an instance of a mock function object // controlling the behavior of the method DeclinePullRequest. DeclinePullRequestFunc *BitbucketCloudClientDeclinePullRequestFunc @@ -3613,6 +3619,10 @@ type MockBitbucketCloudClient struct { // GetPullRequestStatusesFunc is an instance of a mock function object // controlling the behavior of the method GetPullRequestStatuses. GetPullRequestStatusesFunc *BitbucketCloudClientGetPullRequestStatusesFunc + // ListExplicitUserPermsForRepoFunc is an instance of a mock function + // object controlling the behavior of the method + // ListExplicitUserPermsForRepo. + ListExplicitUserPermsForRepoFunc *BitbucketCloudClientListExplicitUserPermsForRepoFunc // MergePullRequestFunc is an instance of a mock function object // controlling the behavior of the method MergePullRequest. MergePullRequestFunc *BitbucketCloudClientMergePullRequestFunc @@ -3637,6 +3647,11 @@ type MockBitbucketCloudClient struct { // All methods return zero values for all results, unless overwritten. func NewMockBitbucketCloudClient() *MockBitbucketCloudClient { return &MockBitbucketCloudClient{ + AllCurrentUserEmailsFunc: &BitbucketCloudClientAllCurrentUserEmailsFunc{ + defaultHook: func(context.Context) (r0 []*bitbucketcloud.UserEmail, r1 error) { + return + }, + }, AuthenticatorFunc: &BitbucketCloudClientAuthenticatorFunc{ defaultHook: func() (r0 auth.Authenticator) { return @@ -3657,6 +3672,11 @@ func NewMockBitbucketCloudClient() *MockBitbucketCloudClient { return }, }, + CurrentUserEmailsFunc: &BitbucketCloudClientCurrentUserEmailsFunc{ + defaultHook: func(context.Context, *bitbucketcloud.PageToken) (r0 []*bitbucketcloud.UserEmail, r1 *bitbucketcloud.PageToken, r2 error) { + return + }, + }, DeclinePullRequestFunc: &BitbucketCloudClientDeclinePullRequestFunc{ defaultHook: func(context.Context, *bitbucketcloud.Repo, int64) (r0 *bitbucketcloud.PullRequest, r1 error) { return @@ -3677,6 +3697,11 @@ func NewMockBitbucketCloudClient() *MockBitbucketCloudClient { return }, }, + ListExplicitUserPermsForRepoFunc: &BitbucketCloudClientListExplicitUserPermsForRepoFunc{ + defaultHook: func(context.Context, *bitbucketcloud.PageToken, string, string, *bitbucketcloud.RequestOptions) (r0 []*bitbucketcloud.Account, r1 *bitbucketcloud.PageToken, r2 error) { + return + }, + }, MergePullRequestFunc: &BitbucketCloudClientMergePullRequestFunc{ defaultHook: func(context.Context, *bitbucketcloud.Repo, int64, bitbucketcloud.MergePullRequestOpts) (r0 *bitbucketcloud.PullRequest, r1 error) { return @@ -3693,7 +3718,7 @@ func NewMockBitbucketCloudClient() *MockBitbucketCloudClient { }, }, ReposFunc: &BitbucketCloudClientReposFunc{ - defaultHook: func(context.Context, *bitbucketcloud.PageToken, string) (r0 []*bitbucketcloud.Repo, r1 *bitbucketcloud.PageToken, r2 error) { + defaultHook: func(context.Context, *bitbucketcloud.PageToken, string, *bitbucketcloud.ReposOptions) (r0 []*bitbucketcloud.Repo, r1 *bitbucketcloud.PageToken, r2 error) { return }, }, @@ -3714,6 +3739,11 @@ func NewMockBitbucketCloudClient() *MockBitbucketCloudClient { // interface. All methods panic on invocation, unless overwritten. func NewStrictMockBitbucketCloudClient() *MockBitbucketCloudClient { return &MockBitbucketCloudClient{ + AllCurrentUserEmailsFunc: &BitbucketCloudClientAllCurrentUserEmailsFunc{ + defaultHook: func(context.Context) ([]*bitbucketcloud.UserEmail, error) { + panic("unexpected invocation of MockBitbucketCloudClient.AllCurrentUserEmails") + }, + }, AuthenticatorFunc: &BitbucketCloudClientAuthenticatorFunc{ defaultHook: func() auth.Authenticator { panic("unexpected invocation of MockBitbucketCloudClient.Authenticator") @@ -3734,6 +3764,11 @@ func NewStrictMockBitbucketCloudClient() *MockBitbucketCloudClient { panic("unexpected invocation of MockBitbucketCloudClient.CurrentUser") }, }, + CurrentUserEmailsFunc: &BitbucketCloudClientCurrentUserEmailsFunc{ + defaultHook: func(context.Context, *bitbucketcloud.PageToken) ([]*bitbucketcloud.UserEmail, *bitbucketcloud.PageToken, error) { + panic("unexpected invocation of MockBitbucketCloudClient.CurrentUserEmails") + }, + }, DeclinePullRequestFunc: &BitbucketCloudClientDeclinePullRequestFunc{ defaultHook: func(context.Context, *bitbucketcloud.Repo, int64) (*bitbucketcloud.PullRequest, error) { panic("unexpected invocation of MockBitbucketCloudClient.DeclinePullRequest") @@ -3754,6 +3789,11 @@ func NewStrictMockBitbucketCloudClient() *MockBitbucketCloudClient { panic("unexpected invocation of MockBitbucketCloudClient.GetPullRequestStatuses") }, }, + ListExplicitUserPermsForRepoFunc: &BitbucketCloudClientListExplicitUserPermsForRepoFunc{ + defaultHook: func(context.Context, *bitbucketcloud.PageToken, string, string, *bitbucketcloud.RequestOptions) ([]*bitbucketcloud.Account, *bitbucketcloud.PageToken, error) { + panic("unexpected invocation of MockBitbucketCloudClient.ListExplicitUserPermsForRepo") + }, + }, MergePullRequestFunc: &BitbucketCloudClientMergePullRequestFunc{ defaultHook: func(context.Context, *bitbucketcloud.Repo, int64, bitbucketcloud.MergePullRequestOpts) (*bitbucketcloud.PullRequest, error) { panic("unexpected invocation of MockBitbucketCloudClient.MergePullRequest") @@ -3770,7 +3810,7 @@ func NewStrictMockBitbucketCloudClient() *MockBitbucketCloudClient { }, }, ReposFunc: &BitbucketCloudClientReposFunc{ - defaultHook: func(context.Context, *bitbucketcloud.PageToken, string) ([]*bitbucketcloud.Repo, *bitbucketcloud.PageToken, error) { + defaultHook: func(context.Context, *bitbucketcloud.PageToken, string, *bitbucketcloud.ReposOptions) ([]*bitbucketcloud.Repo, *bitbucketcloud.PageToken, error) { panic("unexpected invocation of MockBitbucketCloudClient.Repos") }, }, @@ -3792,6 +3832,9 @@ func NewStrictMockBitbucketCloudClient() *MockBitbucketCloudClient { // implementation, unless overwritten. func NewMockBitbucketCloudClientFrom(i bitbucketcloud.Client) *MockBitbucketCloudClient { return &MockBitbucketCloudClient{ + AllCurrentUserEmailsFunc: &BitbucketCloudClientAllCurrentUserEmailsFunc{ + defaultHook: i.AllCurrentUserEmails, + }, AuthenticatorFunc: &BitbucketCloudClientAuthenticatorFunc{ defaultHook: i.Authenticator, }, @@ -3804,6 +3847,9 @@ func NewMockBitbucketCloudClientFrom(i bitbucketcloud.Client) *MockBitbucketClou CurrentUserFunc: &BitbucketCloudClientCurrentUserFunc{ defaultHook: i.CurrentUser, }, + CurrentUserEmailsFunc: &BitbucketCloudClientCurrentUserEmailsFunc{ + defaultHook: i.CurrentUserEmails, + }, DeclinePullRequestFunc: &BitbucketCloudClientDeclinePullRequestFunc{ defaultHook: i.DeclinePullRequest, }, @@ -3816,6 +3862,9 @@ func NewMockBitbucketCloudClientFrom(i bitbucketcloud.Client) *MockBitbucketClou GetPullRequestStatusesFunc: &BitbucketCloudClientGetPullRequestStatusesFunc{ defaultHook: i.GetPullRequestStatuses, }, + ListExplicitUserPermsForRepoFunc: &BitbucketCloudClientListExplicitUserPermsForRepoFunc{ + defaultHook: i.ListExplicitUserPermsForRepo, + }, MergePullRequestFunc: &BitbucketCloudClientMergePullRequestFunc{ defaultHook: i.MergePullRequest, }, @@ -3837,6 +3886,115 @@ func NewMockBitbucketCloudClientFrom(i bitbucketcloud.Client) *MockBitbucketClou } } +// BitbucketCloudClientAllCurrentUserEmailsFunc describes the behavior when +// the AllCurrentUserEmails method of the parent MockBitbucketCloudClient +// instance is invoked. +type BitbucketCloudClientAllCurrentUserEmailsFunc struct { + defaultHook func(context.Context) ([]*bitbucketcloud.UserEmail, error) + hooks []func(context.Context) ([]*bitbucketcloud.UserEmail, error) + history []BitbucketCloudClientAllCurrentUserEmailsFuncCall + mutex sync.Mutex +} + +// AllCurrentUserEmails delegates to the next hook function in the queue and +// stores the parameter and result values of this invocation. +func (m *MockBitbucketCloudClient) AllCurrentUserEmails(v0 context.Context) ([]*bitbucketcloud.UserEmail, error) { + r0, r1 := m.AllCurrentUserEmailsFunc.nextHook()(v0) + m.AllCurrentUserEmailsFunc.appendCall(BitbucketCloudClientAllCurrentUserEmailsFuncCall{v0, r0, r1}) + return r0, r1 +} + +// SetDefaultHook sets function that is called when the AllCurrentUserEmails +// method of the parent MockBitbucketCloudClient instance is invoked and the +// hook queue is empty. +func (f *BitbucketCloudClientAllCurrentUserEmailsFunc) SetDefaultHook(hook func(context.Context) ([]*bitbucketcloud.UserEmail, error)) { + f.defaultHook = hook +} + +// PushHook adds a function to the end of hook queue. Each invocation of the +// AllCurrentUserEmails method of the parent MockBitbucketCloudClient +// instance invokes the hook at the front of the queue and discards it. +// After the queue is empty, the default hook function is invoked for any +// future action. +func (f *BitbucketCloudClientAllCurrentUserEmailsFunc) PushHook(hook func(context.Context) ([]*bitbucketcloud.UserEmail, error)) { + f.mutex.Lock() + f.hooks = append(f.hooks, hook) + f.mutex.Unlock() +} + +// SetDefaultReturn calls SetDefaultHook with a function that returns the +// given values. +func (f *BitbucketCloudClientAllCurrentUserEmailsFunc) SetDefaultReturn(r0 []*bitbucketcloud.UserEmail, r1 error) { + f.SetDefaultHook(func(context.Context) ([]*bitbucketcloud.UserEmail, error) { + return r0, r1 + }) +} + +// PushReturn calls PushHook with a function that returns the given values. +func (f *BitbucketCloudClientAllCurrentUserEmailsFunc) PushReturn(r0 []*bitbucketcloud.UserEmail, r1 error) { + f.PushHook(func(context.Context) ([]*bitbucketcloud.UserEmail, error) { + return r0, r1 + }) +} + +func (f *BitbucketCloudClientAllCurrentUserEmailsFunc) nextHook() func(context.Context) ([]*bitbucketcloud.UserEmail, error) { + f.mutex.Lock() + defer f.mutex.Unlock() + + if len(f.hooks) == 0 { + return f.defaultHook + } + + hook := f.hooks[0] + f.hooks = f.hooks[1:] + return hook +} + +func (f *BitbucketCloudClientAllCurrentUserEmailsFunc) appendCall(r0 BitbucketCloudClientAllCurrentUserEmailsFuncCall) { + f.mutex.Lock() + f.history = append(f.history, r0) + f.mutex.Unlock() +} + +// History returns a sequence of +// BitbucketCloudClientAllCurrentUserEmailsFuncCall objects describing the +// invocations of this function. +func (f *BitbucketCloudClientAllCurrentUserEmailsFunc) History() []BitbucketCloudClientAllCurrentUserEmailsFuncCall { + f.mutex.Lock() + history := make([]BitbucketCloudClientAllCurrentUserEmailsFuncCall, len(f.history)) + copy(history, f.history) + f.mutex.Unlock() + + return history +} + +// BitbucketCloudClientAllCurrentUserEmailsFuncCall is an object that +// describes an invocation of method AllCurrentUserEmails on an instance of +// MockBitbucketCloudClient. +type BitbucketCloudClientAllCurrentUserEmailsFuncCall struct { + // Arg0 is the value of the 1st argument passed to this method + // invocation. + Arg0 context.Context + // Result0 is the value of the 1st result returned from this method + // invocation. + Result0 []*bitbucketcloud.UserEmail + // Result1 is the value of the 2nd result returned from this method + // invocation. + Result1 error +} + +// Args returns an interface slice containing the arguments of this +// invocation. +func (c BitbucketCloudClientAllCurrentUserEmailsFuncCall) Args() []interface{} { + return []interface{}{c.Arg0} +} + +// Results returns an interface slice containing the results of this +// invocation. +func (c BitbucketCloudClientAllCurrentUserEmailsFuncCall) Results() []interface{} { + return []interface{}{c.Result0, c.Result1} +} + // BitbucketCloudClientAuthenticatorFunc describes the behavior when the // Authenticator method of the parent MockBitbucketCloudClient instance is // invoked. @@ -4280,6 +4438,121 @@ func (c BitbucketCloudClientCurrentUserFuncCall) Results() []interface{} { return []interface{}{c.Result0, c.Result1} } +// BitbucketCloudClientCurrentUserEmailsFunc describes the behavior when the +// CurrentUserEmails method of the parent MockBitbucketCloudClient instance +// is invoked. +type BitbucketCloudClientCurrentUserEmailsFunc struct { + defaultHook func(context.Context, *bitbucketcloud.PageToken) ([]*bitbucketcloud.UserEmail, *bitbucketcloud.PageToken, error) + hooks []func(context.Context, *bitbucketcloud.PageToken) ([]*bitbucketcloud.UserEmail, *bitbucketcloud.PageToken, error) + history []BitbucketCloudClientCurrentUserEmailsFuncCall + mutex sync.Mutex +} + +// CurrentUserEmails delegates to the next hook function in the queue and +// stores the parameter and result values of this invocation. +func (m *MockBitbucketCloudClient) CurrentUserEmails(v0 context.Context, v1 *bitbucketcloud.PageToken) ([]*bitbucketcloud.UserEmail, *bitbucketcloud.PageToken, error) { + r0, r1, r2 := m.CurrentUserEmailsFunc.nextHook()(v0, v1) + m.CurrentUserEmailsFunc.appendCall(BitbucketCloudClientCurrentUserEmailsFuncCall{v0, v1, r0, r1, r2}) + return r0, r1, r2 +} + +// SetDefaultHook sets function that is called when the CurrentUserEmails +// method of the parent MockBitbucketCloudClient instance is invoked and the +// hook queue is empty. +func (f *BitbucketCloudClientCurrentUserEmailsFunc) SetDefaultHook(hook func(context.Context, *bitbucketcloud.PageToken) ([]*bitbucketcloud.UserEmail, *bitbucketcloud.PageToken, error)) { + f.defaultHook = hook +} + +// PushHook adds a function to the end of hook queue. Each invocation of the +// CurrentUserEmails method of the parent MockBitbucketCloudClient instance +// invokes the hook at the front of the queue and discards it. After the +// queue is empty, the default hook function is invoked for any future +// action. +func (f *BitbucketCloudClientCurrentUserEmailsFunc) PushHook(hook func(context.Context, *bitbucketcloud.PageToken) ([]*bitbucketcloud.UserEmail, *bitbucketcloud.PageToken, error)) { + f.mutex.Lock() + f.hooks = append(f.hooks, hook) + f.mutex.Unlock() +} + +// SetDefaultReturn calls SetDefaultHook with a function that returns the +// given values. +func (f *BitbucketCloudClientCurrentUserEmailsFunc) SetDefaultReturn(r0 []*bitbucketcloud.UserEmail, r1 *bitbucketcloud.PageToken, r2 error) { + f.SetDefaultHook(func(context.Context, *bitbucketcloud.PageToken) ([]*bitbucketcloud.UserEmail, *bitbucketcloud.PageToken, error) { + return r0, r1, r2 + }) +} + +// PushReturn calls PushHook with a function that returns the given values. +func (f *BitbucketCloudClientCurrentUserEmailsFunc) PushReturn(r0 []*bitbucketcloud.UserEmail, r1 *bitbucketcloud.PageToken, r2 error) { + f.PushHook(func(context.Context, *bitbucketcloud.PageToken) ([]*bitbucketcloud.UserEmail, *bitbucketcloud.PageToken, error) { + return r0, r1, r2 + }) +} + +func (f *BitbucketCloudClientCurrentUserEmailsFunc) nextHook() func(context.Context, *bitbucketcloud.PageToken) ([]*bitbucketcloud.UserEmail, *bitbucketcloud.PageToken, error) { + f.mutex.Lock() + defer f.mutex.Unlock() + + if len(f.hooks) == 0 { + return f.defaultHook + } + + hook := f.hooks[0] + f.hooks = f.hooks[1:] + return hook +} + +func (f *BitbucketCloudClientCurrentUserEmailsFunc) appendCall(r0 BitbucketCloudClientCurrentUserEmailsFuncCall) { + f.mutex.Lock() + f.history = append(f.history, r0) + f.mutex.Unlock() +} + +// History returns a sequence of +// BitbucketCloudClientCurrentUserEmailsFuncCall objects describing the +// invocations of this function. +func (f *BitbucketCloudClientCurrentUserEmailsFunc) History() []BitbucketCloudClientCurrentUserEmailsFuncCall { + f.mutex.Lock() + history := make([]BitbucketCloudClientCurrentUserEmailsFuncCall, len(f.history)) + copy(history, f.history) + f.mutex.Unlock() + + return history +} + +// BitbucketCloudClientCurrentUserEmailsFuncCall is an object that describes +// an invocation of method CurrentUserEmails on an instance of +// MockBitbucketCloudClient. +type BitbucketCloudClientCurrentUserEmailsFuncCall struct { + // Arg0 is the value of the 1st argument passed to this method + // invocation. + Arg0 context.Context + // Arg1 is the value of the 2nd argument passed to this method + // invocation. + Arg1 *bitbucketcloud.PageToken + // Result0 is the value of the 1st result returned from this method + // invocation. + Result0 []*bitbucketcloud.UserEmail + // Result1 is the value of the 2nd result returned from this method + // invocation. + Result1 *bitbucketcloud.PageToken + // Result2 is the value of the 3rd result returned from this method + // invocation. + Result2 error +} + +// Args returns an interface slice containing the arguments of this +// invocation. +func (c BitbucketCloudClientCurrentUserEmailsFuncCall) Args() []interface{} { + return []interface{}{c.Arg0, c.Arg1} +} + +// Results returns an interface slice containing the results of this +// invocation. +func (c BitbucketCloudClientCurrentUserEmailsFuncCall) Results() []interface{} { + return []interface{}{c.Result0, c.Result1, c.Result2} +} + // BitbucketCloudClientDeclinePullRequestFunc describes the behavior when // the DeclinePullRequest method of the parent MockBitbucketCloudClient // instance is invoked. @@ -4735,6 +5008,130 @@ func (c BitbucketCloudClientGetPullRequestStatusesFuncCall) Results() []interfac return []interface{}{c.Result0, c.Result1} } +// BitbucketCloudClientListExplicitUserPermsForRepoFunc describes the +// behavior when the ListExplicitUserPermsForRepo method of the parent +// MockBitbucketCloudClient instance is invoked. +type BitbucketCloudClientListExplicitUserPermsForRepoFunc struct { + defaultHook func(context.Context, *bitbucketcloud.PageToken, string, string, *bitbucketcloud.RequestOptions) ([]*bitbucketcloud.Account, *bitbucketcloud.PageToken, error) + hooks []func(context.Context, *bitbucketcloud.PageToken, string, string, *bitbucketcloud.RequestOptions) ([]*bitbucketcloud.Account, *bitbucketcloud.PageToken, error) + history []BitbucketCloudClientListExplicitUserPermsForRepoFuncCall + mutex sync.Mutex +} + +// ListExplicitUserPermsForRepo delegates to the next hook function in the +// queue and stores the parameter and result values of this invocation. +func (m *MockBitbucketCloudClient) ListExplicitUserPermsForRepo(v0 context.Context, v1 *bitbucketcloud.PageToken, v2 string, v3 string, v4 *bitbucketcloud.RequestOptions) ([]*bitbucketcloud.Account, *bitbucketcloud.PageToken, error) { + r0, r1, r2 := m.ListExplicitUserPermsForRepoFunc.nextHook()(v0, v1, v2, v3, v4) + m.ListExplicitUserPermsForRepoFunc.appendCall(BitbucketCloudClientListExplicitUserPermsForRepoFuncCall{v0, v1, v2, v3, v4, r0, r1, r2}) + return r0, r1, r2 +} + +// SetDefaultHook sets function that is called when the +// ListExplicitUserPermsForRepo method of the parent +// MockBitbucketCloudClient instance is invoked and the hook queue is empty. +func (f *BitbucketCloudClientListExplicitUserPermsForRepoFunc) SetDefaultHook(hook func(context.Context, *bitbucketcloud.PageToken, string, string, *bitbucketcloud.RequestOptions) ([]*bitbucketcloud.Account, *bitbucketcloud.PageToken, error)) { + f.defaultHook = hook +} + +// PushHook adds a function to the end of hook queue. Each invocation of the +// ListExplicitUserPermsForRepo method of the parent +// MockBitbucketCloudClient instance invokes the hook at the front of the +// queue and discards it. After the queue is empty, the default hook +// function is invoked for any future action. +func (f *BitbucketCloudClientListExplicitUserPermsForRepoFunc) PushHook(hook func(context.Context, *bitbucketcloud.PageToken, string, string, *bitbucketcloud.RequestOptions) ([]*bitbucketcloud.Account, *bitbucketcloud.PageToken, error)) { + f.mutex.Lock() + f.hooks = append(f.hooks, hook) + f.mutex.Unlock() +} + +// SetDefaultReturn calls SetDefaultHook with a function that returns the +// given values. +func (f *BitbucketCloudClientListExplicitUserPermsForRepoFunc) SetDefaultReturn(r0 []*bitbucketcloud.Account, r1 *bitbucketcloud.PageToken, r2 error) { + f.SetDefaultHook(func(context.Context, *bitbucketcloud.PageToken, string, string, *bitbucketcloud.RequestOptions) ([]*bitbucketcloud.Account, *bitbucketcloud.PageToken, error) { + return r0, r1, r2 + }) +} + +// PushReturn calls PushHook with a function that returns the given values. +func (f *BitbucketCloudClientListExplicitUserPermsForRepoFunc) PushReturn(r0 []*bitbucketcloud.Account, r1 *bitbucketcloud.PageToken, r2 error) { + f.PushHook(func(context.Context, *bitbucketcloud.PageToken, string, string, *bitbucketcloud.RequestOptions) ([]*bitbucketcloud.Account, *bitbucketcloud.PageToken, error) { + return r0, r1, r2 + }) +} + +func (f *BitbucketCloudClientListExplicitUserPermsForRepoFunc) nextHook() func(context.Context, *bitbucketcloud.PageToken, string, string, *bitbucketcloud.RequestOptions) ([]*bitbucketcloud.Account, *bitbucketcloud.PageToken, error) { + f.mutex.Lock() + defer f.mutex.Unlock() + + if len(f.hooks) == 0 { + return f.defaultHook + } + + hook := f.hooks[0] + f.hooks = f.hooks[1:] + return hook +} + +func (f *BitbucketCloudClientListExplicitUserPermsForRepoFunc) appendCall(r0 BitbucketCloudClientListExplicitUserPermsForRepoFuncCall) { + f.mutex.Lock() + f.history = append(f.history, r0) + f.mutex.Unlock() +} + +// History returns a sequence of +// BitbucketCloudClientListExplicitUserPermsForRepoFuncCall objects +// describing the invocations of this function. +func (f *BitbucketCloudClientListExplicitUserPermsForRepoFunc) History() []BitbucketCloudClientListExplicitUserPermsForRepoFuncCall { + f.mutex.Lock() + history := make([]BitbucketCloudClientListExplicitUserPermsForRepoFuncCall, len(f.history)) + copy(history, f.history) + f.mutex.Unlock() + + return history +} + +// BitbucketCloudClientListExplicitUserPermsForRepoFuncCall is an object +// that describes an invocation of method ListExplicitUserPermsForRepo on an +// instance of MockBitbucketCloudClient. +type BitbucketCloudClientListExplicitUserPermsForRepoFuncCall struct { + // Arg0 is the value of the 1st argument passed to this method + // invocation. + Arg0 context.Context + // Arg1 is the value of the 2nd argument passed to this method + // invocation. + Arg1 *bitbucketcloud.PageToken + // Arg2 is the value of the 3rd argument passed to this method + // invocation. + Arg2 string + // Arg3 is the value of the 4th argument passed to this method + // invocation. + Arg3 string + // Arg4 is the value of the 5th argument passed to this method + // invocation. + Arg4 *bitbucketcloud.RequestOptions + // Result0 is the value of the 1st result returned from this method + // invocation. + Result0 []*bitbucketcloud.Account + // Result1 is the value of the 2nd result returned from this method + // invocation. + Result1 *bitbucketcloud.PageToken + // Result2 is the value of the 3rd result returned from this method + // invocation. + Result2 error +} + +// Args returns an interface slice containing the arguments of this +// invocation. +func (c BitbucketCloudClientListExplicitUserPermsForRepoFuncCall) Args() []interface{} { + return []interface{}{c.Arg0, c.Arg1, c.Arg2, c.Arg3, c.Arg4} +} + +// Results returns an interface slice containing the results of this +// invocation. +func (c BitbucketCloudClientListExplicitUserPermsForRepoFuncCall) Results() []interface{} { + return []interface{}{c.Result0, c.Result1, c.Result2} +} + // BitbucketCloudClientMergePullRequestFunc describes the behavior when the // MergePullRequest method of the parent MockBitbucketCloudClient instance // is invoked. @@ -5069,24 +5466,24 @@ func (c BitbucketCloudClientRepoFuncCall) Results() []interface{} { // BitbucketCloudClientReposFunc describes the behavior when the Repos // method of the parent MockBitbucketCloudClient instance is invoked. type BitbucketCloudClientReposFunc struct { - defaultHook func(context.Context, *bitbucketcloud.PageToken, string) ([]*bitbucketcloud.Repo, *bitbucketcloud.PageToken, error) - hooks []func(context.Context, *bitbucketcloud.PageToken, string) ([]*bitbucketcloud.Repo, *bitbucketcloud.PageToken, error) + defaultHook func(context.Context, *bitbucketcloud.PageToken, string, *bitbucketcloud.ReposOptions) ([]*bitbucketcloud.Repo, *bitbucketcloud.PageToken, error) + hooks []func(context.Context, *bitbucketcloud.PageToken, string, *bitbucketcloud.ReposOptions) ([]*bitbucketcloud.Repo, *bitbucketcloud.PageToken, error) history []BitbucketCloudClientReposFuncCall mutex sync.Mutex } // Repos delegates to the next hook function in the queue and stores the // parameter and result values of this invocation. -func (m *MockBitbucketCloudClient) Repos(v0 context.Context, v1 *bitbucketcloud.PageToken, v2 string) ([]*bitbucketcloud.Repo, *bitbucketcloud.PageToken, error) { - r0, r1, r2 := m.ReposFunc.nextHook()(v0, v1, v2) - m.ReposFunc.appendCall(BitbucketCloudClientReposFuncCall{v0, v1, v2, r0, r1, r2}) +func (m *MockBitbucketCloudClient) Repos(v0 context.Context, v1 *bitbucketcloud.PageToken, v2 string, v3 *bitbucketcloud.ReposOptions) ([]*bitbucketcloud.Repo, *bitbucketcloud.PageToken, error) { + r0, r1, r2 := m.ReposFunc.nextHook()(v0, v1, v2, v3) + m.ReposFunc.appendCall(BitbucketCloudClientReposFuncCall{v0, v1, v2, v3, r0, r1, r2}) return r0, r1, r2 } // SetDefaultHook sets function that is called when the Repos method of the // parent MockBitbucketCloudClient instance is invoked and the hook queue is // empty. -func (f *BitbucketCloudClientReposFunc) SetDefaultHook(hook func(context.Context, *bitbucketcloud.PageToken, string) ([]*bitbucketcloud.Repo, *bitbucketcloud.PageToken, error)) { +func (f *BitbucketCloudClientReposFunc) SetDefaultHook(hook func(context.Context, *bitbucketcloud.PageToken, string, *bitbucketcloud.ReposOptions) ([]*bitbucketcloud.Repo, *bitbucketcloud.PageToken, error)) { f.defaultHook = hook } @@ -5094,7 +5491,7 @@ func (f *BitbucketCloudClientReposFunc) SetDefaultHook(hook func(context.Context // Repos method of the parent MockBitbucketCloudClient instance invokes the // hook at the front of the queue and discards it. After the queue is empty, // the default hook function is invoked for any future action. -func (f *BitbucketCloudClientReposFunc) PushHook(hook func(context.Context, *bitbucketcloud.PageToken, string) ([]*bitbucketcloud.Repo, *bitbucketcloud.PageToken, error)) { +func (f *BitbucketCloudClientReposFunc) PushHook(hook func(context.Context, *bitbucketcloud.PageToken, string, *bitbucketcloud.ReposOptions) ([]*bitbucketcloud.Repo, *bitbucketcloud.PageToken, error)) { f.mutex.Lock() f.hooks = append(f.hooks, hook) f.mutex.Unlock() @@ -5103,19 +5500,19 @@ func (f *BitbucketCloudClientReposFunc) PushHook(hook func(context.Context, *bit // SetDefaultReturn calls SetDefaultHook with a function that returns the // given values. func (f *BitbucketCloudClientReposFunc) SetDefaultReturn(r0 []*bitbucketcloud.Repo, r1 *bitbucketcloud.PageToken, r2 error) { - f.SetDefaultHook(func(context.Context, *bitbucketcloud.PageToken, string) ([]*bitbucketcloud.Repo, *bitbucketcloud.PageToken, error) { + f.SetDefaultHook(func(context.Context, *bitbucketcloud.PageToken, string, *bitbucketcloud.ReposOptions) ([]*bitbucketcloud.Repo, *bitbucketcloud.PageToken, error) { return r0, r1, r2 }) } // PushReturn calls PushHook with a function that returns the given values. func (f *BitbucketCloudClientReposFunc) PushReturn(r0 []*bitbucketcloud.Repo, r1 *bitbucketcloud.PageToken, r2 error) { - f.PushHook(func(context.Context, *bitbucketcloud.PageToken, string) ([]*bitbucketcloud.Repo, *bitbucketcloud.PageToken, error) { + f.PushHook(func(context.Context, *bitbucketcloud.PageToken, string, *bitbucketcloud.ReposOptions) ([]*bitbucketcloud.Repo, *bitbucketcloud.PageToken, error) { return r0, r1, r2 }) } -func (f *BitbucketCloudClientReposFunc) nextHook() func(context.Context, *bitbucketcloud.PageToken, string) ([]*bitbucketcloud.Repo, *bitbucketcloud.PageToken, error) { +func (f *BitbucketCloudClientReposFunc) nextHook() func(context.Context, *bitbucketcloud.PageToken, string, *bitbucketcloud.ReposOptions) ([]*bitbucketcloud.Repo, *bitbucketcloud.PageToken, error) { f.mutex.Lock() defer f.mutex.Unlock() @@ -5157,6 +5554,9 @@ type BitbucketCloudClientReposFuncCall struct { // Arg2 is the value of the 3rd argument passed to this method // invocation. Arg2 string + // Arg3 is the value of the 4th argument passed to this method + // invocation. + Arg3 *bitbucketcloud.ReposOptions // Result0 is the value of the 1st result returned from this method // invocation. Result0 []*bitbucketcloud.Repo @@ -5171,7 +5571,7 @@ type BitbucketCloudClientReposFuncCall struct { // Args returns an interface slice containing the arguments of this // invocation. func (c BitbucketCloudClientReposFuncCall) Args() []interface{} { - return []interface{}{c.Arg0, c.Arg1, c.Arg2} + return []interface{}{c.Arg0, c.Arg1, c.Arg2, c.Arg3} } // Results returns an interface slice containing the results of this diff --git a/go.mod b/go.mod index 164a3104f7c..6f859dde6c3 100644 --- a/go.mod +++ b/go.mod @@ -196,6 +196,7 @@ require ( github.com/cloudflare/circl v1.3.0 // indirect github.com/cockroachdb/apd/v2 v2.0.1 // indirect github.com/dennwc/varint v1.0.0 // indirect + github.com/dghubble/sling v1.4.1 // indirect github.com/emicklei/go-restful/v3 v3.8.0 // indirect github.com/go-sql-driver/mysql v1.6.0 // indirect github.com/google/gnostic v0.5.7-v3refs // indirect diff --git a/go.sum b/go.sum index 1c8d5119ff1..81a6bb08756 100644 --- a/go.sum +++ b/go.sum @@ -620,6 +620,8 @@ github.com/derision-test/glock v1.0.0/go.mod h1:jKtLdBMrF+XQatqvg46wiWdDfDSSDjdh github.com/derision-test/go-mockgen v1.3.7 h1:b/DXAXL2FkaRPpnbYK3ODdZzklmJAwox0tkc6yyXx74= github.com/derision-test/go-mockgen v1.3.7/go.mod h1:/TXUePlhtHmDDCaDAi/a4g6xOHqMDz3Wf0r2NPGskB4= github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY= +github.com/dghubble/sling v1.4.1 h1:AxjTubpVyozMvbBCtXcsWEyGGgUZutC5YGrfxPNVOcQ= +github.com/dghubble/sling v1.4.1/go.mod h1:QoMB1KL3GAo+7HsD8Itd6S+6tW91who8BGZzuLvpOyc= github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4= github.com/dgraph-io/ristretto v0.1.0 h1:Jv3CGQHp9OjuMBSne1485aDpUkTKEcUqF+jm/LuerPI= github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug= diff --git a/internal/database/security_event_logs.go b/internal/database/security_event_logs.go index c10bf844ed1..c6b89c4cbe2 100644 --- a/internal/database/security_event_logs.go +++ b/internal/database/security_event_logs.go @@ -57,6 +57,9 @@ const ( SecurityEventGitLabAuthSucceeded SecurityEventName = "GitLabAuthSucceeded" SecurityEventGitLabAuthFailed SecurityEventName = "GitLabAuthFailed" + SecurityEventBitbucketCloudAuthSucceeded SecurityEventName = "BitbucketCloudAuthSucceeded" + SecurityEventBitbucketCloudAuthFailed SecurityEventName = "BitbucketCloudAuthFailed" + SecurityEventOIDCLoginSucceeded SecurityEventName = "SecurityEventOIDCLoginSucceeded" SecurityEventOIDCLoginFailed SecurityEventName = "SecurityEventOIDCLoginFailed" ) diff --git a/internal/extsvc/bitbucketcloud/client.go b/internal/extsvc/bitbucketcloud/client.go index 271ba76f496..8b12bc9eb0d 100644 --- a/internal/extsvc/bitbucketcloud/client.go +++ b/internal/extsvc/bitbucketcloud/client.go @@ -17,6 +17,7 @@ import ( "github.com/sourcegraph/sourcegraph/internal/extsvc/auth" "github.com/sourcegraph/sourcegraph/internal/httpcli" "github.com/sourcegraph/sourcegraph/internal/metrics" + "github.com/sourcegraph/sourcegraph/internal/oauthutil" "github.com/sourcegraph/sourcegraph/internal/ratelimit" "github.com/sourcegraph/sourcegraph/internal/trace/ot" "github.com/sourcegraph/sourcegraph/lib/errors" @@ -41,10 +42,18 @@ type Client interface { MergePullRequest(ctx context.Context, repo *Repo, id int64, opts MergePullRequestOpts) (*PullRequest, error) Repo(ctx context.Context, namespace, slug string) (*Repo, error) - Repos(ctx context.Context, pageToken *PageToken, accountName string) ([]*Repo, *PageToken, error) + Repos(ctx context.Context, pageToken *PageToken, accountName string, opts *ReposOptions) ([]*Repo, *PageToken, error) ForkRepository(ctx context.Context, upstream *Repo, input ForkInput) (*Repo, error) + ListExplicitUserPermsForRepo(ctx context.Context, pageToken *PageToken, owner, slug string, opts *RequestOptions) ([]*Account, *PageToken, error) + CurrentUser(ctx context.Context) (*User, error) + CurrentUserEmails(ctx context.Context, pageToken *PageToken) ([]*UserEmail, *PageToken, error) + AllCurrentUserEmails(ctx context.Context) ([]*UserEmail, error) +} + +type RequestOptions struct { + FetchAll bool } // client access a Bitbucket Cloud via the REST API 2.0. @@ -144,6 +153,21 @@ func (c *client) Ping(ctx context.Context) error { return nil } +func fetchAll[T any](ctx context.Context, c *client, results []T, next *PageToken, err error) ([]T, error) { + var page []T + var nextURL *url.URL + for err == nil && next.HasMore() { + nextURL, err = url.Parse(next.Next) + if err != nil { + return nil, err + } + next, err = c.page(ctx, nextURL.Path, nil, next, &page) + results = append(results, page...) + } + + return results, err +} + func (c *client) page(ctx context.Context, path string, qry url.Values, token *PageToken, results any) (*PageToken, error) { if qry == nil { qry = make(url.Values) @@ -200,15 +224,11 @@ func (c *client) do(ctx context.Context, req *http.Request, result any) error { nethttp.ClientTrace(false)) defer ht.Finish() - if err := c.Auth.Authenticate(req); err != nil { - return err - } - if err := c.rateLimit.Wait(ctx); err != nil { return err } - resp, err := c.httpClient.Do(req) + resp, err := oauthutil.DoRequest(ctx, nil, c.httpClient, req, c.Auth) if err != nil { return err } @@ -254,6 +274,12 @@ func (t *PageToken) Values() url.Values { if t == nil { return v } + if t.Next != "" { + nextURL, err := url.Parse(t.Next) + if err == nil { + v = nextURL.Query() + } + } if t.Pagelen != 0 { v.Set("pagelen", strconv.Itoa(t.Pagelen)) } diff --git a/internal/extsvc/bitbucketcloud/common.go b/internal/extsvc/bitbucketcloud/common.go new file mode 100644 index 00000000000..927f68a9808 --- /dev/null +++ b/internal/extsvc/bitbucketcloud/common.go @@ -0,0 +1,50 @@ +package bitbucketcloud + +import ( + "net/url" + "strings" + + "github.com/sourcegraph/sourcegraph/internal/conf" + "github.com/sourcegraph/sourcegraph/internal/oauthutil" + "golang.org/x/oauth2" +) + +var MockGetOAuthContext func() *oauthutil.OAuthContext + +func GetOAuthContext(baseURL string) *oauthutil.OAuthContext { + if MockGetOAuthContext != nil { + return MockGetOAuthContext() + } + + for _, authProvider := range conf.SiteConfig().AuthProviders { + if authProvider.Bitbucketcloud != nil { + p := authProvider.Bitbucketcloud + rawURL := p.Url + if rawURL == "" { + rawURL = "https://bitbucket.org" + } + rawURL = strings.TrimSuffix(rawURL, "/") + if !strings.HasPrefix(baseURL, rawURL) { + continue + } + authURL, err := url.JoinPath(rawURL, "/site/oauth2/authorize") + if err != nil { + continue + } + tokenURL, err := url.JoinPath(rawURL, "/site/oauth2/access_token") + if err != nil { + continue + } + + return &oauthutil.OAuthContext{ + ClientID: p.ClientKey, + ClientSecret: p.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + } + } + } + return nil +} diff --git a/internal/extsvc/bitbucketcloud/repositories.go b/internal/extsvc/bitbucketcloud/repositories.go index bea4517c14c..0cd90bc5fec 100644 --- a/internal/extsvc/bitbucketcloud/repositories.go +++ b/internal/extsvc/bitbucketcloud/repositories.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "github.com/sourcegraph/sourcegraph/lib/errors" ) @@ -25,24 +26,77 @@ func (c *client) Repo(ctx context.Context, namespace, slug string) (*Repo, error return &repo, nil } +type ReposOptions struct { + RequestOptions + Role string `url:"role,omitempty"` +} + // Repos returns a list of repositories that are fetched and populated based on given account // name and pagination criteria. If the account requested is a team, results will be filtered // down to the ones that the app password's user has access to. // If the argument pageToken.Next is not empty, it will be used directly as the URL to make // the request. The PageToken it returns may also contain the URL to the next page for // succeeding requests if any. -func (c *client) Repos(ctx context.Context, pageToken *PageToken, accountName string) ([]*Repo, *PageToken, error) { - var repos []*Repo - var next *PageToken - var err error +// If the argument accountName is empty, it will return all repositories for +// the authenticated user. +func (c *client) Repos(ctx context.Context, pageToken *PageToken, accountName string, opts *ReposOptions) (repos []*Repo, next *PageToken, err error) { if pageToken.HasMore() { next, err = c.reqPage(ctx, pageToken.Next, &repos) - } else { - next, err = c.page(ctx, fmt.Sprintf("/2.0/repositories/%s", accountName), nil, pageToken, &repos) + return } + + var reposURL string + if accountName == "" { + reposURL = "/2.0/repositories" + } else { + reposURL = fmt.Sprintf("/2.0/repositories/%s", url.PathEscape(accountName)) + } + + var urlValues url.Values + if opts != nil && opts.Role != "" { + urlValues = make(url.Values) + urlValues.Set("role", opts.Role) + } + + next, err = c.page(ctx, reposURL, urlValues, pageToken, &repos) + + if opts != nil && opts.FetchAll { + repos, err = fetchAll(ctx, c, repos, next, err) + } + return repos, next, err } +type ExplicitUserPermsResponse struct { + User *Account `json:"user"` + Permission string `json:"permission"` +} + +func (c *client) ListExplicitUserPermsForRepo(ctx context.Context, pageToken *PageToken, namespace, slug string, opts *RequestOptions) (users []*Account, next *PageToken, err error) { + var resp []ExplicitUserPermsResponse + if pageToken.HasMore() { + next, err = c.reqPage(ctx, pageToken.Next, &resp) + } else { + userPermsURL := fmt.Sprintf("/2.0/repositories/%s/%s/permissions-config/users", url.PathEscape(namespace), url.PathEscape(slug)) + next, err = c.page(ctx, userPermsURL, nil, pageToken, &resp) + } + + if opts != nil && opts.FetchAll { + resp, err = fetchAll(ctx, c, resp, next, err) + } + + if err != nil { + return + } + + users = make([]*Account, len(resp)) + for i, r := range resp { + users[i] = r.User + } + + return +} + type ForkInputProject struct { Key string `json:"key"` } diff --git a/internal/extsvc/bitbucketcloud/repositories_test.go b/internal/extsvc/bitbucketcloud/repositories_test.go index 76fc57e8b8e..7d30c5749b0 100644 --- a/internal/extsvc/bitbucketcloud/repositories_test.go +++ b/internal/extsvc/bitbucketcloud/repositories_test.go @@ -61,6 +61,16 @@ func TestClient_Repos(t *testing.T) { HTML: Link{Href: "https://bitbucket.org/sourcegraph-testing/src-cli"}, }, ForkPolicy: ForkPolicyNoPublic, + Owner: &Account{ + Links: Links{ + "avatar": Link{Href: "https://secure.gravatar.com/avatar/f964dc31564db8243e952bdaeabbe884?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FST-2.png"}, + "html": Link{Href: "https://bitbucket.org/%7B4b85b785-1433-4092-8512-20302f4a03be%7D/"}, + "self": Link{Href: "https://api.bitbucket.org/2.0/users/%7B4b85b785-1433-4092-8512-20302f4a03be%7D"}, + }, + Nickname: "Sourcegraph Testing", + DisplayName: "Sourcegraph Testing", + UUID: "{4b85b785-1433-4092-8512-20302f4a03be}", + }, }, "sourcegraph": { Slug: "sourcegraph", @@ -77,6 +87,16 @@ func TestClient_Repos(t *testing.T) { HTML: Link{Href: "https://bitbucket.org/sourcegraph-testing/sourcegraph"}, }, ForkPolicy: ForkPolicyNoPublic, + Owner: &Account{ + Links: Links{ + "avatar": Link{Href: "https://secure.gravatar.com/avatar/f964dc31564db8243e952bdaeabbe884?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FST-2.png"}, + "html": Link{Href: "https://bitbucket.org/%7B4b85b785-1433-4092-8512-20302f4a03be%7D/"}, + "self": Link{Href: "https://api.bitbucket.org/2.0/users/%7B4b85b785-1433-4092-8512-20302f4a03be%7D"}, + }, + Nickname: "Sourcegraph Testing", + DisplayName: "Sourcegraph Testing", + UUID: "{4b85b785-1433-4092-8512-20302f4a03be}", + }, }, } @@ -131,7 +151,7 @@ func TestClient_Repos(t *testing.T) { tc.err = "" } - repos, next, err := cli.Repos(tc.ctx, tc.page, tc.account) + repos, next, err := cli.Repos(tc.ctx, tc.page, tc.account, nil) if have, want := fmt.Sprint(err), tc.err; have != want { t.Errorf("error:\nhave: %q\nwant: %q", have, want) } diff --git a/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-CreatePullRequest-Fork-recreated b/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-CreatePullRequest-Fork-recreated index e275d0043a7..60d6215edb5 100644 --- a/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-CreatePullRequest-Fork-recreated +++ b/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-CreatePullRequest-Fork-recreated @@ -101,7 +101,8 @@ "href": "https://bitbucket.org/aharvey-sg/src-cli-testing" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null }, "branch": { "name": "branch-fork-00", @@ -128,7 +129,8 @@ "href": "https://bitbucket.org/sourcegraph-testing/src-cli" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null }, "branch": { "name": "master", diff --git a/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-CreatePullRequest-Fork-valid-omitted-destination-branch b/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-CreatePullRequest-Fork-valid-omitted-destination-branch index 0de48b34b40..4e33424323a 100644 --- a/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-CreatePullRequest-Fork-valid-omitted-destination-branch +++ b/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-CreatePullRequest-Fork-valid-omitted-destination-branch @@ -101,7 +101,8 @@ "href": "https://bitbucket.org/aharvey-sg/src-cli-testing" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null }, "branch": { "name": "branch-fork-00", @@ -128,7 +129,8 @@ "href": "https://bitbucket.org/sourcegraph-testing/src-cli" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null }, "branch": { "name": "master", diff --git a/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-CreatePullRequest-SameOrigin-recreated b/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-CreatePullRequest-SameOrigin-recreated index 9ef3e458cb5..0fbca3ef8ba 100644 --- a/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-CreatePullRequest-SameOrigin-recreated +++ b/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-CreatePullRequest-SameOrigin-recreated @@ -101,7 +101,8 @@ "href": "https://bitbucket.org/sourcegraph-testing/src-cli" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null }, "branch": { "name": "branch-00", @@ -128,7 +129,8 @@ "href": "https://bitbucket.org/sourcegraph-testing/src-cli" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null }, "branch": { "name": "master", diff --git a/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-CreatePullRequest-SameOrigin-valid-destination-branch b/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-CreatePullRequest-SameOrigin-valid-destination-branch index 241e91432b5..98a7728efa0 100644 --- a/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-CreatePullRequest-SameOrigin-valid-destination-branch +++ b/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-CreatePullRequest-SameOrigin-valid-destination-branch @@ -101,7 +101,8 @@ "href": "https://bitbucket.org/sourcegraph-testing/src-cli" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null }, "branch": { "name": "branch-00", @@ -128,7 +129,8 @@ "href": "https://bitbucket.org/sourcegraph-testing/src-cli" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null }, "branch": { "name": "master", diff --git a/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-DeclinePullRequest-found b/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-DeclinePullRequest-found index fbe9233e5eb..dba6aa2a609 100644 --- a/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-DeclinePullRequest-found +++ b/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-DeclinePullRequest-found @@ -101,7 +101,8 @@ "href": "https://bitbucket.org/sourcegraph-testing/src-cli" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null }, "branch": { "name": "branch-00", @@ -128,7 +129,8 @@ "href": "https://bitbucket.org/sourcegraph-testing/src-cli" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null }, "branch": { "name": "master", diff --git a/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-ForkRepository-success b/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-ForkRepository-success index a7b92e04545..81a6faa80b8 100644 --- a/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-ForkRepository-success +++ b/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-ForkRepository-success @@ -20,7 +20,8 @@ "href": "https://bitbucket.org/sourcegraph-testing/src-cli" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null }, "is_private": true, "links": { @@ -38,5 +39,25 @@ "href": "https://bitbucket.org/sourcegraph-testing/src-cli-fork-00" } }, - "fork_policy": "no_public_forks" + "fork_policy": "no_public_forks", + "owner": { + "links": { + "avatar": { + "href": "https://secure.gravatar.com/avatar/f964dc31564db8243e952bdaeabbe884?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FST-2.png" + }, + "html": { + "href": "https://bitbucket.org/%7B4b85b785-1433-4092-8512-20302f4a03be%7D/" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B4b85b785-1433-4092-8512-20302f4a03be%7D" + } + }, + "username": "", + "nickname": "Sourcegraph Testing", + "account_status": "", + "display_name": "Sourcegraph Testing", + "website": "", + "created_on": "0001-01-01T00:00:00Z", + "uuid": "{4b85b785-1433-4092-8512-20302f4a03be}" + } } \ No newline at end of file diff --git a/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-GetPullRequest-found b/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-GetPullRequest-found index ef3442afe10..c2ed0ca2670 100644 --- a/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-GetPullRequest-found +++ b/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-GetPullRequest-found @@ -101,7 +101,8 @@ "href": "https://bitbucket.org/sourcegraph-testing/src-cli" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null }, "branch": { "name": "always-open", @@ -128,7 +129,8 @@ "href": "https://bitbucket.org/sourcegraph-testing/src-cli" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null }, "branch": { "name": "master", diff --git a/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-MergePullRequest-found b/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-MergePullRequest-found index 928b1cc1bdd..038ff659722 100644 --- a/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-MergePullRequest-found +++ b/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-MergePullRequest-found @@ -101,7 +101,8 @@ "href": "https://bitbucket.org/sourcegraph-testing/src-cli" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null }, "branch": { "name": "merge-test-01", @@ -128,7 +129,8 @@ "href": "https://bitbucket.org/sourcegraph-testing/src-cli" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null }, "branch": { "name": "master", diff --git a/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-Repo-valid-repo b/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-Repo-valid-repo index d59ddac0da5..c357e2a5416 100644 --- a/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-Repo-valid-repo +++ b/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-Repo-valid-repo @@ -22,5 +22,25 @@ "href": "https://bitbucket.org/sourcegraph-testing/sourcegraph" } }, - "fork_policy": "no_public_forks" + "fork_policy": "no_public_forks", + "owner": { + "links": { + "avatar": { + "href": "https://secure.gravatar.com/avatar/f964dc31564db8243e952bdaeabbe884?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FST-2.png" + }, + "html": { + "href": "https://bitbucket.org/%7B4b85b785-1433-4092-8512-20302f4a03be%7D/" + }, + "self": { + "href": "https://api.bitbucket.org/2.0/users/%7B4b85b785-1433-4092-8512-20302f4a03be%7D" + } + }, + "username": "", + "nickname": "Sourcegraph Testing", + "account_status": "", + "display_name": "Sourcegraph Testing", + "website": "", + "created_on": "0001-01-01T00:00:00Z", + "uuid": "{4b85b785-1433-4092-8512-20302f4a03be}" + } } \ No newline at end of file diff --git a/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-UpdatePullRequest-found b/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-UpdatePullRequest-found index 54c259605bc..6f0596f4861 100644 --- a/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-UpdatePullRequest-found +++ b/internal/extsvc/bitbucketcloud/testdata/golden/TestClient-UpdatePullRequest-found @@ -101,7 +101,8 @@ "href": "https://bitbucket.org/sourcegraph-testing/src-cli" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null }, "branch": { "name": "always-open", @@ -128,7 +129,8 @@ "href": "https://bitbucket.org/sourcegraph-testing/src-cli" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null }, "branch": { "name": "master", diff --git a/internal/extsvc/bitbucketcloud/types.go b/internal/extsvc/bitbucketcloud/types.go index f4e3acedc62..3ac3bf59e7d 100644 --- a/internal/extsvc/bitbucketcloud/types.go +++ b/internal/extsvc/bitbucketcloud/types.go @@ -194,6 +194,7 @@ type Repo struct { IsPrivate bool `json:"is_private"` Links RepoLinks `json:"links"` ForkPolicy ForkPolicy `json:"fork_policy"` + Owner *Account `json:"owner"` } func (r *Repo) Namespace() (string, error) { diff --git a/internal/extsvc/bitbucketcloud/user.go b/internal/extsvc/bitbucketcloud/user.go new file mode 100644 index 00000000000..fb005011b28 --- /dev/null +++ b/internal/extsvc/bitbucketcloud/user.go @@ -0,0 +1,47 @@ +package bitbucketcloud + +import ( + "context" + "encoding/json" + + "golang.org/x/oauth2" + + "github.com/sourcegraph/sourcegraph/internal/encryption" + "github.com/sourcegraph/sourcegraph/internal/extsvc" +) + +// GetExternalAccountData returns the deserialized user and token from the external account data +// JSON blob in a typesafe way. +func GetExternalAccountData(ctx context.Context, data *extsvc.AccountData) (usr *Account, tok *oauth2.Token, err error) { + if data.Data != nil { + usr, err = encryption.DecryptJSON[Account](ctx, data.Data) + if err != nil { + return nil, nil, err + } + } + + if data.AuthData != nil { + tok, err = encryption.DecryptJSON[oauth2.Token](ctx, data.AuthData) + if err != nil { + return nil, nil, err + } + } + + return usr, tok, nil +} + +// SetExternalAccountData sets the user and token into the external account data blob. +func SetExternalAccountData(data *extsvc.AccountData, user *Account, token *oauth2.Token) error { + serializedUser, err := json.Marshal(user) + if err != nil { + return err + } + serializedToken, err := json.Marshal(token) + if err != nil { + return err + } + + data.Data = extsvc.NewUnencryptedData(serializedUser) + data.AuthData = extsvc.NewUnencryptedData(serializedToken) + return nil +} diff --git a/internal/extsvc/bitbucketcloud/users.go b/internal/extsvc/bitbucketcloud/users.go index 6e0f339dad4..a46f9411889 100644 --- a/internal/extsvc/bitbucketcloud/users.go +++ b/internal/extsvc/bitbucketcloud/users.go @@ -27,3 +27,37 @@ type User struct { IsStaff bool `json:"is_staff"` AccountID string `json:"account_id"` } + +type UserEmail struct { + Email string `json:"email"` + IsConfirmed bool `json:"is_confirmed"` + IsPrimary bool `json:"is_primary"` +} + +func (c *client) CurrentUserEmails(ctx context.Context, pageToken *PageToken) (emails []*UserEmail, next *PageToken, err error) { + if pageToken.HasMore() { + next, err = c.reqPage(ctx, pageToken.Next, &emails) + return + } + + next, err = c.page(ctx, "/2.0/user/emails", nil, pageToken, &emails) + return +} + +func (c *client) AllCurrentUserEmails(ctx context.Context) (emails []*UserEmail, err error) { + emails, next, err := c.CurrentUserEmails(ctx, nil) + if err != nil { + return nil, err + } + + for next.HasMore() { + var nextEmails []*UserEmail + nextEmails, next, err = c.CurrentUserEmails(ctx, next) + if err != nil { + return nil, err + } + emails = append(emails, nextEmails...) + } + + return emails, nil +} diff --git a/internal/repos/bitbucketcloud.go b/internal/repos/bitbucketcloud.go index 8ee02170192..1a9d9eb4b64 100644 --- a/internal/repos/bitbucketcloud.go +++ b/internal/repos/bitbucketcloud.go @@ -179,7 +179,7 @@ func (s *BitbucketCloudSource) listAllRepos(ctx context.Context, results chan So var err error var repos []*bitbucketcloud.Repo for page.HasMore() || page.Page == 0 { - if repos, page, err = s.client.Repos(ctx, page, s.config.Username); err != nil { + if repos, page, err = s.client.Repos(ctx, page, s.config.Username, nil); err != nil { ch <- batch{err: errors.Wrapf(err, "bitbucketcloud.repos: item=%q, page=%+v", s.config.Username, page)} break } @@ -198,7 +198,7 @@ func (s *BitbucketCloudSource) listAllRepos(ctx context.Context, results chan So var err error var repos []*bitbucketcloud.Repo for page.HasMore() || page.Page == 0 { - if repos, page, err = s.client.Repos(ctx, page, t); err != nil { + if repos, page, err = s.client.Repos(ctx, page, t, nil); err != nil { ch <- batch{err: errors.Wrapf(err, "bitbucketcloud.teams: item=%q, page=%+v", t, page)} break } diff --git a/internal/repos/testdata/golden/BitbucketCloudSource_makeRepo_path-pattern b/internal/repos/testdata/golden/BitbucketCloudSource_makeRepo_path-pattern index ce4b7f65dfa..90042a8a78a 100644 --- a/internal/repos/testdata/golden/BitbucketCloudSource_makeRepo_path-pattern +++ b/internal/repos/testdata/golden/BitbucketCloudSource_makeRepo_path-pattern @@ -45,7 +45,8 @@ "href": "https://bitbucket.org/sg/go-langserver" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null } }, { @@ -94,7 +95,8 @@ "href": "https://bitbucket.org/sg/python-langserver" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null } }, { @@ -141,7 +143,8 @@ "href": "https://bitbucket.org/sg/python-langserver" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null }, "is_private": false, "links": { @@ -159,7 +162,8 @@ "href": "https://bitbucket.org/sg/python-langserver-fork" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null } } ] \ No newline at end of file diff --git a/internal/repos/testdata/golden/BitbucketCloudSource_makeRepo_simple b/internal/repos/testdata/golden/BitbucketCloudSource_makeRepo_simple index a7692f4030f..e97079dc4bb 100644 --- a/internal/repos/testdata/golden/BitbucketCloudSource_makeRepo_simple +++ b/internal/repos/testdata/golden/BitbucketCloudSource_makeRepo_simple @@ -45,7 +45,8 @@ "href": "https://bitbucket.org/sg/go-langserver" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null } }, { @@ -94,7 +95,8 @@ "href": "https://bitbucket.org/sg/python-langserver" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null } }, { @@ -141,7 +143,8 @@ "href": "https://bitbucket.org/sg/python-langserver" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null }, "is_private": false, "links": { @@ -159,7 +162,8 @@ "href": "https://bitbucket.org/sg/python-langserver-fork" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null } } ] \ No newline at end of file diff --git a/internal/repos/testdata/golden/BitbucketCloudSource_makeRepo_ssh b/internal/repos/testdata/golden/BitbucketCloudSource_makeRepo_ssh index f13fd26785a..a3393fd42a1 100644 --- a/internal/repos/testdata/golden/BitbucketCloudSource_makeRepo_ssh +++ b/internal/repos/testdata/golden/BitbucketCloudSource_makeRepo_ssh @@ -45,7 +45,8 @@ "href": "https://bitbucket.org/sg/go-langserver" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null } }, { @@ -94,7 +95,8 @@ "href": "https://bitbucket.org/sg/python-langserver" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null } }, { @@ -141,7 +143,8 @@ "href": "https://bitbucket.org/sg/python-langserver" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null }, "is_private": false, "links": { @@ -159,7 +162,8 @@ "href": "https://bitbucket.org/sg/python-langserver-fork" } }, - "fork_policy": "" + "fork_policy": "", + "owner": null } } ] \ No newline at end of file diff --git a/internal/types/external_services.go b/internal/types/external_services.go index 7e4d7c32eff..528761475ba 100644 --- a/internal/types/external_services.go +++ b/internal/types/external_services.go @@ -22,6 +22,12 @@ type GitLabConnection struct { *schema.GitLabConnection } +type BitbucketCloudConnection struct { + // The unique resource identifier of the external service. + URN string + *schema.BitbucketCloudConnection +} + type PerforceConnection struct { // The unique resource identifier of the external service. URN string diff --git a/schema/bitbucket_cloud.schema.json b/schema/bitbucket_cloud.schema.json index 4dc6781da5c..4cfdba505de 100644 --- a/schema/bitbucket_cloud.schema.json +++ b/schema/bitbucket_cloud.schema.json @@ -53,6 +53,17 @@ "requestsPerHour": 7200 } }, + "authorization": { + "title": "BitbucketCloudAuthorization", + "description": "If non-null, enforces Bitbucket Cloud repository permissions. This requires that there is an item in the [site configuration json](https://docs.sourcegraph.com/admin/config/site_config#auth-providers) `auth.providers` field, of type \"bitbucketcloud\" with the same `url` field as specified in this `BitbucketCloudConnection`.", + "type": "object", + "properties": { + "identityProvider": { + "description": "The identity provider to use for user information. If not set, the `url` field is used.", + "type": "string" + } + } + }, "username": { "description": "The username to use when authenticating to the Bitbucket Cloud. Also set the corresponding \"appPassword\" field.", "type": "string" diff --git a/schema/bitbucketcloud_util.go b/schema/bitbucketcloud_util.go new file mode 100644 index 00000000000..d0820597bab --- /dev/null +++ b/schema/bitbucketcloud_util.go @@ -0,0 +1,11 @@ +package schema + +var DefaultBitbucketCloudURL = "https://bitbucket.org" + +// GetURL retrieves the configured GitHub URL or a default if one is not set. +func (p *BitbucketCloudAuthProvider) GetURL() string { + if p != nil && p.Url != "" { + return p.Url + } + return DefaultBitbucketCloudURL +} diff --git a/schema/schema.go b/schema/schema.go index a56c9a07c06..421cb21929b 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -112,12 +112,13 @@ type AuthProviderCommon struct { DisplayName string `json:"displayName,omitempty"` } type AuthProviders struct { - Builtin *BuiltinAuthProvider - Saml *SAMLAuthProvider - Openidconnect *OpenIDConnectAuthProvider - HttpHeader *HTTPHeaderAuthProvider - Github *GitHubAuthProvider - Gitlab *GitLabAuthProvider + Builtin *BuiltinAuthProvider + Saml *SAMLAuthProvider + Openidconnect *OpenIDConnectAuthProvider + HttpHeader *HTTPHeaderAuthProvider + Github *GitHubAuthProvider + Gitlab *GitLabAuthProvider + Bitbucketcloud *BitbucketCloudAuthProvider } func (v AuthProviders) MarshalJSON() ([]byte, error) { @@ -139,6 +140,9 @@ func (v AuthProviders) MarshalJSON() ([]byte, error) { if v.Gitlab != nil { return json.Marshal(v.Gitlab) } + if v.Bitbucketcloud != nil { + return json.Marshal(v.Bitbucketcloud) + } return nil, errors.New("tagged union type must have exactly 1 non-nil field value") } func (v *AuthProviders) UnmarshalJSON(data []byte) error { @@ -149,6 +153,8 @@ func (v *AuthProviders) UnmarshalJSON(data []byte) error { return err } switch d.DiscriminantProperty { + case "bitbucketcloud": + return json.Unmarshal(data, &v.Bitbucketcloud) case "builtin": return json.Unmarshal(data, &v.Builtin) case "github": @@ -162,7 +168,7 @@ func (v *AuthProviders) UnmarshalJSON(data []byte) error { case "saml": return json.Unmarshal(data, &v.Saml) } - return fmt.Errorf("tagged union type must have a %q property whose value is one of %s", "type", []string{"builtin", "saml", "openidconnect", "http-header", "github", "gitlab"}) + return fmt.Errorf("tagged union type must have a %q property whose value is one of %s", "type", []string{"builtin", "saml", "openidconnect", "http-header", "github", "gitlab", "bitbucketcloud"}) } type BackendInsight struct { @@ -213,12 +219,36 @@ type BatchSpec struct { Workspaces []*WorkspaceConfiguration `json:"workspaces,omitempty"` } +// BitbucketCloudAuthProvider description: Configures the Bitbucket Cloud OAuth authentication provider for SSO. In addition to specifying this configuration object, you must also create a OAuth App on your Bitbucket Cloud workspace: https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/. The application should have account, email, and repository scopes and the callback URL set to the concatenation of your Sourcegraph instance URL and "/.auth/bitbucketcloud/callback". +type BitbucketCloudAuthProvider struct { + // AllowSignup description: Allows new visitors to sign up for accounts via Bitbucket Cloud authentication. If false, users signing in via Bitbucket Cloud must have an existing Sourcegraph account, which will be linked to their Bitbucket Cloud identity after sign-in. + AllowSignup bool `json:"allowSignup,omitempty"` + // ApiScope description: The OAuth API scope that should be used + ApiScope string `json:"apiScope,omitempty"` + // ClientKey description: The Key of the Bitbucket OAuth app. + ClientKey string `json:"clientKey"` + // ClientSecret description: The Client Secret of the Bitbucket OAuth app. + ClientSecret string `json:"clientSecret"` + DisplayName string `json:"displayName,omitempty"` + Type string `json:"type"` + // Url description: URL of the Bitbucket Cloud instance. + Url string `json:"url,omitempty"` +} + +// BitbucketCloudAuthorization description: If non-null, enforces Bitbucket Cloud repository permissions. This requires that there is an item in the [site configuration json](https://docs.sourcegraph.com/admin/config/site_config#auth-providers) `auth.providers` field, of type "bitbucketcloud" with the same `url` field as specified in this `BitbucketCloudConnection`. +type BitbucketCloudAuthorization struct { + // IdentityProvider description: The identity provider to use for user information. If not set, the `url` field is used. + IdentityProvider string `json:"identityProvider,omitempty"` +} + // BitbucketCloudConnection description: Configuration for a connection to Bitbucket Cloud. type BitbucketCloudConnection struct { // ApiURL description: The API URL of Bitbucket Cloud, such as https://api.bitbucket.org. Generally, admin should not modify the value of this option because Bitbucket Cloud is a public hosting platform. ApiURL string `json:"apiURL,omitempty"` // AppPassword description: The app password to use when authenticating to the Bitbucket Cloud. Also set the corresponding "username" field. AppPassword string `json:"appPassword"` + // Authorization description: If non-null, enforces Bitbucket Cloud repository permissions. This requires that there is an item in the [site configuration json](https://docs.sourcegraph.com/admin/config/site_config#auth-providers) `auth.providers` field, of type "bitbucketcloud" with the same `url` field as specified in this `BitbucketCloudConnection`. + Authorization *BitbucketCloudAuthorization `json:"authorization,omitempty"` // Exclude description: A list of repositories to never mirror from Bitbucket Cloud. Takes precedence over "teams" configuration. // // Supports excluding by name ({"name": "myorg/myrepo"}) or by UUID ({"uuid": "{fceb73c7-cef6-4abe-956d-e471281126bd}"}). diff --git a/schema/site.schema.json b/schema/site.schema.json index 6d742ee59a4..b8cc30375f2 100644 --- a/schema/site.schema.json +++ b/schema/site.schema.json @@ -1563,7 +1563,7 @@ "properties": { "type": { "type": "string", - "enum": ["builtin", "saml", "openidconnect", "http-header", "github", "gitlab"] + "enum": ["builtin", "saml", "openidconnect", "http-header", "github", "gitlab", "bitbucketcloud"] } }, "oneOf": [ @@ -1572,7 +1572,8 @@ { "$ref": "#/definitions/OpenIDConnectAuthProvider" }, { "$ref": "#/definitions/HTTPHeaderAuthProvider" }, { "$ref": "#/definitions/GitHubAuthProvider" }, - { "$ref": "#/definitions/GitLabAuthProvider" } + { "$ref": "#/definitions/GitLabAuthProvider" }, + { "$ref": "#/definitions/BitbucketCloudAuthProvider" } ], "!go": { "taggedUnionType": true @@ -2107,6 +2108,43 @@ } } }, + "BitbucketCloudAuthProvider": { + "description": "Configures the Bitbucket Cloud OAuth authentication provider for SSO. In addition to specifying this configuration object, you must also create a OAuth App on your Bitbucket Cloud workspace: https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/. The application should have account, email, and repository scopes and the callback URL set to the concatenation of your Sourcegraph instance URL and \"/.auth/bitbucketcloud/callback\".", + "type": "object", + "additionalProperties": false, + "required": ["type", "clientKey", "clientSecret"], + "properties": { + "type": { + "type": "string", + "const": "bitbucketcloud" + }, + "url": { + "type": "string", + "description": "URL of the Bitbucket Cloud instance.", + "default": "https://bitbucket.org/" + }, + "clientKey": { + "type": "string", + "description": "The Key of the Bitbucket OAuth app." + }, + "clientSecret": { + "type": "string", + "description": "The Client Secret of the Bitbucket OAuth app." + }, + "displayName": { "$ref": "#/definitions/AuthProviderCommon/properties/displayName" }, + "apiScope": { + "type": "string", + "description": "The OAuth API scope that should be used", + "default": "account,email,repository", + "enum": ["account", "email", "repository"] + }, + "allowSignup": { + "description": "Allows new visitors to sign up for accounts via Bitbucket Cloud authentication. If false, users signing in via Bitbucket Cloud must have an existing Sourcegraph account, which will be linked to their Bitbucket Cloud identity after sign-in.", + "default": true, + "type": "boolean" + } + } + }, "AuthProviderCommon": { "$comment": "This schema is not used directly. The *AuthProvider schemas refer to its properties directly.", "description": "Common properties for authentication providers.",