Add Bitbucket Cloud as an auth provider with Perms syncing (#46309)

This commit is contained in:
Petri-Johan Last 2023-01-16 14:20:35 +02:00 committed by GitHub
parent 3851a2a1dd
commit b4458950c1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 2748 additions and 73 deletions

View File

@ -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

View File

@ -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<React.PropsWithChildren<SignInP
<Icon aria-hidden={true} svgPath={mdiGitlab} />{' '}
</>
)}
{provider.serviceType === 'bitbucketCloud' && (
<>
<Icon aria-hidden={true} svgPath={mdiBitbucket} />{' '}
</>
)}
Continue with {provider.displayName}
</Button>
</div>

View File

@ -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<React.PropsWithChildren<SignUpF
<Icon aria-hidden={true} svgPath={mdiGithub} />
) : provider.serviceType === 'gitlab' ? (
<Icon aria-hidden={true} svgPath={mdiGitlab} />
) : provider.serviceType === 'bitbucketCloud' ? (
<Icon aria-hidden={true} svPath={mdiBitbucket} />
) : null}{' '}
Continue with {provider.displayName}
</Button>

View File

@ -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<ExternalAccountKind, ExternalAccoun
title: 'SAML',
icon: AccountCircleIcon,
},
bitbucketCloud: {
title: 'Bitbucket Cloud',
icon: BitbucketIcon,
},
}

View File

@ -7,7 +7,15 @@ export type DeployType = 'kubernetes' | 'docker-container' | 'docker-compose' |
*/
export interface AuthProvider {
serviceType: 'github' | 'gitlab' | 'http-header' | 'openidconnect' | 'sourcegraph-operator' | 'saml' | 'builtin'
serviceType:
| 'github'
| 'gitlab'
| 'bitbucketCloud'
| 'http-header'
| 'openidconnect'
| 'sourcegraph-operator'
| 'saml'
| 'builtin'
displayName: string
isBuiltin: boolean
authenticationURL: string

View File

@ -24,6 +24,16 @@ interface GitLabExternalData {
web_url: string
}
interface BitbucketCloudExternalData {
display_name: string
username: string
links: {
self: {
href: string
}
}
}
export interface SamlExternalData {
Values: {
emailaddress?: Attribute
@ -152,6 +162,21 @@ const getNormalizedAccount = (
}
}
break
case 'bitbucketCloud':
{
const bbCloudExternalData = accountExternalData as BitbucketCloudExternalData
normalizedAccount = {
...normalizedAccount,
external: {
id: account.id,
// map Bitbucket Cloud fields
userName: bbCloudExternalData.display_name,
userLogin: bbCloudExternalData.username,
userUrl: bbCloudExternalData.links.self.href,
},
}
}
break
case 'saml':
{
const samlExternalData = accountExternalData as SamlExternalData

View File

@ -6,6 +6,7 @@ Sourcegraph supports the following ways for users to sign in:
- [Builtin password authentication](#builtin-password-authentication)
- [GitHub](#github)
- [GitLab](#gitlab)
- [Bitbucket Cloud](#bitbucket-cloud)
- [SAML](saml/index.md)
- [OpenID Connect](#openid-connect)
- [Google Workspace (Google accounts)](#google-workspace-google-accounts)
@ -309,6 +310,31 @@ You can use the following filters to control how users can create accounts and s
}
```
## Bitbucket Cloud
[Create a Bitbucket Cloud OAuth consumer](https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/). Set the following values, replacing `sourcegraph.example.com` with the IP or hostname of your
Sourcegraph instance:
- Callback URL: `https://sourcegraph.example.com/.auth/bitbucketcloud/callback`
- Permissions:
- `Account`: `Read`
- `Repositories`: `Read` (if [permissions syncing](../repo/permissions.md) is desired)
After the consumer is created, you will need the `Key` and the `Secret`, which can be found by expanding OAuth consumer in the list.
Then add the following lines to your [site configuration](config/site_config.md):
```json
{
// ...
"auth.providers": [
{
"type": "bitbucketcloud",
"displayName": "Bitbucket Cloud",
"clientKey": "replace-with-the-oauth-consumer-key",
"clientSecret": "replace-with-the-oauth-consumer-secret",
"allowSignup": false // If not set, it defaults to true allowing any Bitbucket Cloud user with access to your instance to sign up.
}
]
```
Replace the `clientKey` and `clientSecret` values with the values from your Bitbucket Cloud OAuth consumer.
## OpenID Connect

View File

@ -33,6 +33,11 @@ If enabled, the default rate is set at 7200 per hour (2 per second), which can b
**NOTE** Internal rate limiting is only currently applied when synchronizing changesets in [batch changes](../../batch_changes/index.md), repository permissions, and repository metadata from code hosts.
## User authentication
To configure Bitbucket Cloud as an authentication provider (which will enable sign-in via Bitbucket Cloud), see the
[authentication documentation](../auth/index.md#bitbucket-cloud).
## Configuration
Bitbucket Cloud connections support the following configuration options, which are specified in the JSON editor in the site admin "Manage code hosts" area.

View File

@ -0,0 +1,87 @@
package bitbucketcloudoauth
import (
"fmt"
"github.com/dghubble/gologin"
"github.com/sourcegraph/log"
"github.com/sourcegraph/sourcegraph/cmd/frontend/auth/providers"
"github.com/sourcegraph/sourcegraph/enterprise/internal/licensing"
"github.com/sourcegraph/sourcegraph/internal/conf"
"github.com/sourcegraph/sourcegraph/internal/conf/conftypes"
"github.com/sourcegraph/sourcegraph/internal/database"
"github.com/sourcegraph/sourcegraph/schema"
)
func Init(logger log.Logger, db database.DB) {
const pkgName = "bitbucketcloudoauth"
logger = logger.Scoped(pkgName, "Bitbucket Cloud OAuth config watch")
conf.ContributeValidator(func(cfg conftypes.SiteConfigQuerier) conf.Problems {
_, problems := parseConfig(logger, cfg, db)
return problems
})
go conf.Watch(func() {
newProviders, _ := parseConfig(logger, conf.Get(), db)
if len(newProviders) == 0 {
providers.Update(pkgName, nil)
return
}
if err := licensing.Check(licensing.FeatureSSO); err != nil {
logger.Error("Check license for SSO (Bitbucket Cloud OAuth)", log.Error(err))
providers.Update(pkgName, nil)
return
}
newProvidersList := make([]providers.Provider, 0, len(newProviders))
for _, p := range newProviders {
newProvidersList = append(newProvidersList, p.Provider)
}
providers.Update(pkgName, newProvidersList)
})
}
type Provider struct {
*schema.BitbucketCloudAuthProvider
providers.Provider
}
func parseConfig(logger log.Logger, cfg conftypes.SiteConfigQuerier, db database.DB) (ps []Provider, problems conf.Problems) {
configured := map[string]struct{}{}
for _, pr := range cfg.SiteConfig().AuthProviders {
if pr.Bitbucketcloud == nil {
continue
}
provider, providerProblems := parseProvider(logger, pr.Bitbucketcloud, db, pr)
problems = append(problems, conf.NewSiteProblems(providerProblems...)...)
if provider == nil {
continue
}
if _, ok := configured[provider.ServiceID]; ok {
problems = append(problems, conf.NewSiteProblems(fmt.Sprintf(`Cannot have more than one auth provider with url %q, only the first one will be used`, provider.ServiceID))...)
continue
}
ps = append(ps, Provider{
BitbucketCloudAuthProvider: pr.Bitbucketcloud,
Provider: provider,
})
configured[provider.ServiceID] = struct{}{}
}
return ps, problems
}
func getStateConfig() gologin.CookieConfig {
cfg := gologin.CookieConfig{
Name: "bitbucketcloud-state-cookie",
Path: "/",
MaxAge: 900, // 15 minutes
HTTPOnly: true,
Secure: conf.IsExternalURLSecure(),
}
return cfg
}

View File

@ -0,0 +1,165 @@
package bitbucketcloudoauth
import (
"testing"
"github.com/google/go-cmp/cmp"
"golang.org/x/oauth2"
"github.com/sourcegraph/log/logtest"
"github.com/sourcegraph/sourcegraph/enterprise/cmd/frontend/internal/auth/oauth"
"github.com/sourcegraph/sourcegraph/internal/conf"
"github.com/sourcegraph/sourcegraph/internal/database"
"github.com/sourcegraph/sourcegraph/internal/database/dbtest"
"github.com/sourcegraph/sourcegraph/internal/extsvc"
"github.com/sourcegraph/sourcegraph/schema"
)
func TestParseConfig(t *testing.T) {
logger := logtest.Scoped(t)
db := database.NewDB(logger, dbtest.NewDB(logger, t))
type args struct {
cfg *conf.Unified
}
tests := []struct {
name string
args args
wantProviders []Provider
wantProblems []string
}{
{
name: "No configs",
args: args{cfg: &conf.Unified{}},
wantProviders: []Provider(nil),
},
{
name: "1 Bitbucket Cloud config",
args: args{cfg: &conf.Unified{SiteConfiguration: schema.SiteConfiguration{
AuthProviders: []schema.AuthProviders{{
Bitbucketcloud: &schema.BitbucketCloudAuthProvider{
ClientKey: "myclientid",
ClientSecret: "myclientsecret",
DisplayName: "Bitbucket Cloud",
Type: extsvc.TypeBitbucketCloud,
Url: "https://bitbucket.org",
ApiScope: "account,email",
},
}},
}}},
wantProviders: []Provider{
{
BitbucketCloudAuthProvider: &schema.BitbucketCloudAuthProvider{
ClientKey: "myclientid",
ClientSecret: "myclientsecret",
DisplayName: "Bitbucket Cloud",
Type: extsvc.TypeBitbucketCloud,
Url: "https://bitbucket.org",
ApiScope: "account,email",
},
Provider: provider("https://bitbucket.org/", oauth2.Config{
ClientID: "myclientid",
ClientSecret: "myclientsecret",
Endpoint: oauth2.Endpoint{
AuthURL: "https://bitbucket.org/site/oauth2/authorize",
TokenURL: "https://bitbucket.org/site/oauth2/access_token",
},
Scopes: []string{"account", "email"},
}),
},
},
},
{
name: "2 Bitbucket Cloud configs with the same Url",
args: args{cfg: &conf.Unified{SiteConfiguration: schema.SiteConfiguration{
AuthProviders: []schema.AuthProviders{{
Bitbucketcloud: &schema.BitbucketCloudAuthProvider{
ClientKey: "myclientid",
ClientSecret: "myclientsecret",
DisplayName: "Bitbucket Cloud",
Type: extsvc.TypeBitbucketCloud,
Url: "https://bitbucket.org",
ApiScope: "account,email",
},
}, {
Bitbucketcloud: &schema.BitbucketCloudAuthProvider{
ClientKey: "myclientid2",
ClientSecret: "myclientsecret2",
DisplayName: "Bitbucket Cloud Duplicate",
Type: extsvc.TypeBitbucketCloud,
Url: "https://bitbucket.org",
ApiScope: "account,email",
},
}},
}}},
wantProviders: []Provider{
{
BitbucketCloudAuthProvider: &schema.BitbucketCloudAuthProvider{
ClientKey: "myclientid",
ClientSecret: "myclientsecret",
DisplayName: "Bitbucket Cloud",
Type: extsvc.TypeBitbucketCloud,
Url: "https://bitbucket.org",
ApiScope: "account,email",
},
Provider: provider("https://bitbucket.org/", oauth2.Config{
ClientID: "myclientid",
ClientSecret: "myclientsecret",
Endpoint: oauth2.Endpoint{
AuthURL: "https://bitbucket.org/site/oauth2/authorize",
TokenURL: "https://bitbucket.org/site/oauth2/access_token",
},
Scopes: []string{"account", "email"},
}),
},
},
wantProblems: []string{
`Cannot have more than one auth provider with url "https://bitbucket.org/", only the first one will be used`,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotProviders, gotProblems := parseConfig(logtest.Scoped(t), tt.args.cfg, db)
gotConfigs := make([]oauth2.Config, len(gotProviders))
for k, p := range gotProviders {
if p, ok := p.Provider.(*oauth.Provider); ok {
p.Login, p.Callback = nil, nil
gotConfigs[k] = p.OAuth2Config()
p.OAuth2Config = nil
p.ProviderOp.Login, p.ProviderOp.Callback = nil, nil
}
}
wantConfigs := make([]oauth2.Config, len(tt.wantProviders))
for k, p := range tt.wantProviders {
k := k
if q, ok := p.Provider.(*oauth.Provider); ok {
q.SourceConfig = schema.AuthProviders{Bitbucketcloud: p.BitbucketCloudAuthProvider}
wantConfigs[k] = q.OAuth2Config()
q.OAuth2Config = nil
}
}
if diff := cmp.Diff(tt.wantProviders, gotProviders); diff != "" {
t.Errorf("providers: %s", diff)
}
if diff := cmp.Diff(tt.wantProblems, gotProblems.Messages()); diff != "" {
t.Errorf("problems: %s", diff)
}
if diff := cmp.Diff(wantConfigs, gotConfigs); diff != "" {
t.Errorf("problems: %s", diff)
}
})
}
}
func provider(serviceID string, oauth2Config oauth2.Config) *oauth.Provider {
op := oauth.ProviderOp{
AuthPrefix: authPrefix,
OAuth2Config: func() oauth2.Config { return oauth2Config },
StateConfig: getStateConfig(),
ServiceID: serviceID,
ServiceType: extsvc.TypeBitbucketCloud,
}
return &oauth.Provider{ProviderOp: op}
}

View File

@ -0,0 +1,30 @@
package bitbucketcloudoauth
import (
"net/http"
"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/schema"
)
const authPrefix = auth.AuthURLPrefix + "/bitbucketcloud"
func init() {
oauth.AddIsOAuth(func(p schema.AuthProviders) bool {
return p.Bitbucketcloud != nil
})
}
func Middleware(db database.DB) *auth.Middleware {
return &auth.Middleware{
API: func(next http.Handler) http.Handler {
return oauth.NewMiddleware(db, extsvc.TypeBitbucketCloud, authPrefix, true, next)
},
App: func(next http.Handler) http.Handler {
return oauth.NewMiddleware(db, extsvc.TypeBitbucketCloud, authPrefix, false, next)
},
}
}

View File

@ -0,0 +1,250 @@
package bitbucketcloudoauth
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"golang.org/x/oauth2"
"github.com/sourcegraph/log/logtest"
"github.com/sourcegraph/sourcegraph/cmd/frontend/auth"
"github.com/sourcegraph/sourcegraph/cmd/frontend/auth/providers"
"github.com/sourcegraph/sourcegraph/cmd/frontend/external/session"
"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/schema"
)
// TestMiddleware exercises the Middleware with requests that simulate the OAuth 2 login flow on
// Bitbucket Cloud. This tests the logic between the client-issued HTTP requests and the responses from the
// various endpoints, but does NOT cover the logic that is contained within `golang.org/x/oauth2`
// and `github.com/dghubble/gologin` which ensures the correctness of the `/callback` handler.
func TestMiddleware(t *testing.T) {
cleanup := session.ResetMockSessionStore(t)
defer cleanup()
db := database.NewMockDB()
const mockUserID = 123
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("got through"))
})
authedHandler := http.NewServeMux()
authedHandler.Handle("/.api/", Middleware(nil).API(h))
authedHandler.Handle("/", Middleware(nil).App(h))
mockBitbucketCloud := newMockProvider(t, db, "bitbucketcloudclient", "bitbucketcloudsecret", "https://bitbucket.org/")
providers.MockProviders = []providers.Provider{mockBitbucketCloud.Provider}
defer func() { providers.MockProviders = nil }()
doRequest := func(method, urlStr, body string, cookies []*http.Cookie, authed bool) *http.Response {
req := httptest.NewRequest(method, urlStr, bytes.NewBufferString(body))
for _, cookie := range cookies {
req.AddCookie(cookie)
}
req.Header.Set("User-Agent", "Mozilla")
if authed {
req = req.WithContext(actor.WithActor(context.Background(), &actor.Actor{UID: mockUserID}))
}
respRecorder := httptest.NewRecorder()
authedHandler.ServeHTTP(respRecorder, req)
return respRecorder.Result()
}
t.Run("unauthenticated homepage visit, sign-out cookie present -> 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
}

View File

@ -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, ",")
}

View File

@ -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)
}
})
}
}

View File

@ -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
}
}

View File

@ -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,
}
}

View File

@ -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:

View File

@ -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,

View File

@ -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 {

View File

@ -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{

View File

@ -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
}

View File

@ -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)
})
})
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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

1
go.mod
View File

@ -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

2
go.sum
View File

@ -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=

View File

@ -57,6 +57,9 @@ const (
SecurityEventGitLabAuthSucceeded SecurityEventName = "GitLabAuthSucceeded"
SecurityEventGitLabAuthFailed SecurityEventName = "GitLabAuthFailed"
SecurityEventBitbucketCloudAuthSucceeded SecurityEventName = "BitbucketCloudAuthSucceeded"
SecurityEventBitbucketCloudAuthFailed SecurityEventName = "BitbucketCloudAuthFailed"
SecurityEventOIDCLoginSucceeded SecurityEventName = "SecurityEventOIDCLoginSucceeded"
SecurityEventOIDCLoginFailed SecurityEventName = "SecurityEventOIDCLoginFailed"
)

View File

@ -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))
}

View File

@ -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
}

View File

@ -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"`
}

View File

@ -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 = "<nil>"
}
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)
}

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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}"
}
}

View File

@ -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",

View File

@ -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",

View File

@ -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}"
}
}

View File

@ -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",

View File

@ -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) {

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}
}
]

View File

@ -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
}
}
]

View File

@ -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
}
}
]

View File

@ -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

View File

@ -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"

View File

@ -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
}

View File

@ -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}"}).

View File

@ -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.",