[CLOUD-203] Handle GitHub App setup callback (#29467)

Co-authored-by: Milan Freml <kopancek@users.noreply.github.com>
Co-authored-by: Rafał Gajdulewicz <rafax@users.noreply.github.com>
This commit is contained in:
Joe Chen 2022-01-13 12:07:23 +08:00 committed by GitHub
parent 0a6f2c886c
commit 7c13225fca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 693 additions and 42 deletions

View File

@ -7,31 +7,31 @@ import (
"github.com/sourcegraph/sourcegraph/cmd/frontend/backend"
"github.com/sourcegraph/sourcegraph/cmd/frontend/envvar"
"github.com/sourcegraph/sourcegraph/cmd/frontend/graphqlbackend"
"github.com/sourcegraph/sourcegraph/cmd/frontend/webhooks"
"github.com/sourcegraph/sourcegraph/internal/conf"
"github.com/sourcegraph/sourcegraph/internal/database"
"github.com/sourcegraph/sourcegraph/cmd/frontend/graphqlbackend"
)
// Services is a bag of HTTP handlers and factory functions that are registered by the
// enterprise frontend setup hook.
type Services struct {
GitHubWebhook webhooks.Registerer
GitLabWebhook http.Handler
BitbucketServerWebhook http.Handler
NewCodeIntelUploadHandler NewCodeIntelUploadHandler
NewExecutorProxyHandler NewExecutorProxyHandler
AuthzResolver graphqlbackend.AuthzResolver
BatchChangesResolver graphqlbackend.BatchChangesResolver
CodeIntelResolver graphqlbackend.CodeIntelResolver
InsightsResolver graphqlbackend.InsightsResolver
CodeMonitorsResolver graphqlbackend.CodeMonitorsResolver
LicenseResolver graphqlbackend.LicenseResolver
DotcomResolver graphqlbackend.DotcomRootResolver
SearchContextsResolver graphqlbackend.SearchContextsResolver
OrgRepositoryResolver graphqlbackend.OrgRepositoryResolver
NotebooksResolver graphqlbackend.NotebooksResolver
GitHubWebhook webhooks.Registerer
GitLabWebhook http.Handler
BitbucketServerWebhook http.Handler
NewCodeIntelUploadHandler NewCodeIntelUploadHandler
NewExecutorProxyHandler NewExecutorProxyHandler
NewGitHubAppCloudSetupHandler NewGitHubAppCloudSetupHandler
AuthzResolver graphqlbackend.AuthzResolver
BatchChangesResolver graphqlbackend.BatchChangesResolver
CodeIntelResolver graphqlbackend.CodeIntelResolver
InsightsResolver graphqlbackend.InsightsResolver
CodeMonitorsResolver graphqlbackend.CodeMonitorsResolver
LicenseResolver graphqlbackend.LicenseResolver
DotcomResolver graphqlbackend.DotcomRootResolver
SearchContextsResolver graphqlbackend.SearchContextsResolver
OrgRepositoryResolver graphqlbackend.OrgRepositoryResolver
NotebooksResolver graphqlbackend.NotebooksResolver
}
// NewCodeIntelUploadHandler creates a new handler for the LSIF upload endpoint. The
@ -43,14 +43,19 @@ type NewCodeIntelUploadHandler func(internal bool) http.Handler
// via a shared username and password.
type NewExecutorProxyHandler func() http.Handler
// NewGitHubAppCloudSetupHandler creates a new handler for the Sourcegraph Cloud
// GitHub App setup URL endpoint.
type NewGitHubAppCloudSetupHandler func() http.Handler
// DefaultServices creates a new Services value that has default implementations for all services.
func DefaultServices() Services {
return Services{
GitHubWebhook: registerFunc(func(webhook *webhooks.GitHubWebhook) {}),
GitLabWebhook: makeNotFoundHandler("gitlab webhook"),
BitbucketServerWebhook: makeNotFoundHandler("bitbucket server webhook"),
NewCodeIntelUploadHandler: func(_ bool) http.Handler { return makeNotFoundHandler("code intel upload") },
NewExecutorProxyHandler: func() http.Handler { return makeNotFoundHandler("executor proxy") },
GitHubWebhook: registerFunc(func(webhook *webhooks.GitHubWebhook) {}),
GitLabWebhook: makeNotFoundHandler("gitlab webhook"),
BitbucketServerWebhook: makeNotFoundHandler("bitbucket server webhook"),
NewCodeIntelUploadHandler: func(_ bool) http.Handler { return makeNotFoundHandler("code intel upload") },
NewExecutorProxyHandler: func() http.Handler { return makeNotFoundHandler("executor proxy") },
NewGitHubAppCloudSetupHandler: func() http.Handler { return makeNotFoundHandler("Sourcegraph Cloud GitHub App setup") },
}
}

View File

@ -5,14 +5,13 @@ import (
"github.com/NYTimes/gziphandler"
"github.com/sourcegraph/sourcegraph/cmd/frontend/internal/auth/userpasswd"
registry "github.com/sourcegraph/sourcegraph/cmd/frontend/registry/api"
"github.com/sourcegraph/sourcegraph/cmd/frontend/globals"
"github.com/sourcegraph/sourcegraph/cmd/frontend/internal/app/errorutil"
"github.com/sourcegraph/sourcegraph/cmd/frontend/internal/app/router"
"github.com/sourcegraph/sourcegraph/cmd/frontend/internal/app/ui"
"github.com/sourcegraph/sourcegraph/cmd/frontend/internal/auth/userpasswd"
"github.com/sourcegraph/sourcegraph/cmd/frontend/internal/session"
registry "github.com/sourcegraph/sourcegraph/cmd/frontend/registry/api"
"github.com/sourcegraph/sourcegraph/internal/database"
"github.com/sourcegraph/sourcegraph/internal/trace"
)
@ -21,7 +20,7 @@ import (
//
// 🚨 SECURITY: The caller MUST wrap the returned handler in middleware that checks authentication
// and sets the actor in the request context.
func NewHandler(db database.DB) http.Handler {
func NewHandler(db database.DB, githubAppCloudSetupHandler http.Handler) http.Handler {
session.SetSessionStore(session.NewRedisStore(func() bool {
return globals.ExternalURL().Scheme == "https"
}))
@ -68,6 +67,9 @@ func NewHandler(db database.DB) http.Handler {
// Ping retrieval
r.Get(router.LatestPing).Handler(trace.Route(http.HandlerFunc(latestPingHandler(db))))
// Sourcegraph Cloud GitHub App setup
r.Get(router.SetupGitHubAppCloud).Handler(trace.Route(githubAppCloudSetupHandler))
r.Get(router.Editor).Handler(trace.Route(errorutil.Handler(serveEditor(db))))
r.Get(router.DebugHeaders).Handler(trace.Route(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@ -37,6 +37,8 @@ const (
LatestPing = "pings.latest"
SetupGitHubAppCloud = "setup.github.app.cloud"
OldToolsRedirect = "old-tools-redirect"
OldTreeRedirect = "old-tree-redirect"
@ -94,6 +96,8 @@ func newRouter() *mux.Router {
base.Path("/site-admin/pings/latest").Methods("GET").Name(LatestPing)
base.Path("/setup/github/app/cloud").Methods("GET").Name(SetupGitHubAppCloud)
repoPath := `/` + routevar.Repo
repo := base.PathPrefix(repoPath + "/" + routevar.RepoPathDelim + "/").Subrouter()
repo.Path("/badge.svg").Methods("GET").Name(RepoBadge)

View File

@ -34,7 +34,16 @@ import (
// newExternalHTTPHandler creates and returns the HTTP handler that serves the app and API pages to
// external clients.
func newExternalHTTPHandler(db database.DB, schema *graphql.Schema, gitHubWebhook webhooks.Registerer, gitLabWebhook, bitbucketServerWebhook http.Handler, newCodeIntelUploadHandler enterprise.NewCodeIntelUploadHandler, newExecutorProxyHandler enterprise.NewExecutorProxyHandler, rateLimitWatcher graphqlbackend.LimitWatcher) (http.Handler, error) {
func newExternalHTTPHandler(
db database.DB,
schema *graphql.Schema,
gitHubWebhook webhooks.Registerer,
gitLabWebhook, bitbucketServerWebhook http.Handler,
newCodeIntelUploadHandler enterprise.NewCodeIntelUploadHandler,
newExecutorProxyHandler enterprise.NewExecutorProxyHandler,
newGitHubAppCloudSetupHandler enterprise.NewGitHubAppCloudSetupHandler,
rateLimitWatcher graphqlbackend.LimitWatcher,
) (http.Handler, error) {
// Each auth middleware determines on a per-request basis whether it should be enabled (if not, it
// immediately delegates the request to the next middleware in the chain).
authMiddlewares := auth.AuthMiddleware()
@ -60,8 +69,10 @@ func newExternalHTTPHandler(db database.DB, schema *graphql.Schema, gitHubWebhoo
// 🚨 SECURITY: This handler implements its own token auth inside enterprise
executorProxyHandler := newExecutorProxyHandler()
githubAppCloudSetupHandler := newGitHubAppCloudSetupHandler()
// App handler (HTML pages), the call order of middleware is LIFO.
appHandler := app.NewHandler(db)
appHandler := app.NewHandler(db, githubAppCloudSetupHandler)
if hooks.PostAuthMiddleware != nil {
// 🚨 SECURITY: These all run after the auth handler so the client is authenticated.
appHandler = hooks.PostAuthMiddleware(appHandler)

View File

@ -315,6 +315,7 @@ func makeExternalAPI(db database.DB, schema *graphql.Schema, enterprise enterpri
enterprise.BitbucketServerWebhook,
enterprise.NewCodeIntelUploadHandler,
enterprise.NewExecutorProxyHandler,
enterprise.NewGitHubAppCloudSetupHandler,
rateLimiter,
)
if err != nil {

View File

@ -0,0 +1,190 @@
package app
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"github.com/cockroachdb/errors"
gogithub "github.com/google/go-github/v41/github"
"github.com/graph-gophers/graphql-go"
"github.com/inconshreveable/log15"
"github.com/sourcegraph/sourcegraph/cmd/frontend/backend"
"github.com/sourcegraph/sourcegraph/cmd/frontend/enterprise"
"github.com/sourcegraph/sourcegraph/cmd/frontend/envvar"
"github.com/sourcegraph/sourcegraph/cmd/frontend/graphqlbackend"
"github.com/sourcegraph/sourcegraph/internal/conf/conftypes"
"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/github"
"github.com/sourcegraph/sourcegraph/internal/jsonc"
"github.com/sourcegraph/sourcegraph/internal/types"
)
// Init initializes the app endpoints.
func Init(
db database.DB,
conf conftypes.UnifiedWatchable,
enterpriseServices *enterprise.Services,
) error {
if !envvar.SourcegraphDotComMode() {
enterpriseServices.NewGitHubAppCloudSetupHandler = func() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("Sourcegraph Cloud GitHub App setup is only available on sourcegraph.com"))
})
}
return nil
}
appConfig := conf.SiteConfig().Dotcom.GithubAppCloud
if appConfig.AppID == "" {
enterpriseServices.NewGitHubAppCloudSetupHandler = func() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("Sourcegraph Cloud GitHub App setup is not enabled"))
})
}
return nil
}
privateKey, err := base64.StdEncoding.DecodeString(appConfig.PrivateKey)
if err != nil {
return errors.Wrap(err, "decode private key")
}
auther, err := auth.NewOAuthBearerTokenWithGitHubApp(appConfig.AppID, privateKey)
if err != nil {
return errors.Wrap(err, "new authenticator with GitHub App")
}
apiURL, err := url.Parse("https://github.com")
if err != nil {
return errors.Wrap(err, "parse github.com")
}
client := github.NewV3Client(apiURL, auther, nil)
enterpriseServices.NewGitHubAppCloudSetupHandler = func() http.Handler {
return newGitHubAppCloudSetupHandler(db, apiURL, client)
}
return nil
}
type githubClient interface {
GetAppInstallation(ctx context.Context, installationID int64) (*gogithub.Installation, error)
}
func newGitHubAppCloudSetupHandler(db database.DB, apiURL *url.URL, client githubClient) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !envvar.SourcegraphDotComMode() {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("Sourcegraph Cloud GitHub App setup is only available on sourcegraph.com"))
return
}
state := r.URL.Query().Get("state")
orgID, err := graphqlbackend.UnmarshalOrgID(graphql.ID(state))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`The "state" is not a valid graphql.ID of an organization`))
return
}
installationID, err := strconv.ParseInt(r.URL.Query().Get("installation_id"), 10, 64)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`The "installation_id" is not a valid integer`))
return
}
err = backend.CheckOrgAccess(r.Context(), db, orgID)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("the authenticated user does not belong to the organization requested"))
return
}
responseServerError := func(msg string, err error) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(msg))
log15.Error(msg, "error", err)
}
org, err := db.Orgs().GetByID(r.Context(), orgID)
if err != nil {
responseServerError("Failed to get organization", err)
return
}
externalServices := db.ExternalServices()
svcs, err := externalServices.List(r.Context(),
database.ExternalServicesListOptions{
NamespaceOrgID: org.ID,
Kinds: []string{extsvc.KindGitHub},
},
)
if err != nil {
responseServerError("Failed to list organization code host connections", err)
return
}
ins, err := client.GetAppInstallation(r.Context(), installationID)
if err != nil {
responseServerError(`Failed to get the installation information using the "installation_id"`, err)
return
}
displayName := "GitHub"
if ins.Account.Login != nil {
displayName = fmt.Sprintf("GitHub (%s)", *ins.Account.Login)
}
now := time.Now()
var svc *types.ExternalService
if len(svcs) == 0 {
svc = &types.ExternalService{
Kind: extsvc.KindGitHub,
DisplayName: displayName,
Config: fmt.Sprintf(`
{
"url": "%s",
"githubAppInstallationID": "%d",
"repos": []
}
`, apiURL.String(), installationID),
NamespaceOrgID: org.ID,
CreatedAt: now,
UpdatedAt: now,
}
} else if len(svcs) == 1 {
// We have an existing github service, update it
svc = svcs[0]
svc.DisplayName = displayName
newConfig, err := jsonc.Edit(svc.Config, strconv.FormatInt(installationID, 10), "githubAppInstallationID")
if err != nil {
responseServerError("Failed to edit config", err)
return
}
svc.Config = newConfig
svc.UpdatedAt = now
} else {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("Multiple code host connections of same kind found"))
return
}
err = db.ExternalServices().Upsert(r.Context(), svc)
if err != nil {
responseServerError("Failed to upsert code host connection", err)
return
}
http.Redirect(w, r, fmt.Sprintf("/organizations/%s/settings/code-hosts", org.Name), http.StatusFound)
})
}

View File

@ -0,0 +1,143 @@
package app
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"testing"
mockrequire "github.com/derision-test/go-mockgen/testutil/require"
gogithub "github.com/google/go-github/v41/github"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sourcegraph/sourcegraph/cmd/frontend/envvar"
"github.com/sourcegraph/sourcegraph/internal/database"
"github.com/sourcegraph/sourcegraph/internal/extsvc"
"github.com/sourcegraph/sourcegraph/internal/types"
)
func TestNewGitHubAppCloudSetupHandler(t *testing.T) {
orig := envvar.SourcegraphDotComMode()
envvar.MockSourcegraphDotComMode(true)
defer envvar.MockSourcegraphDotComMode(orig)
users := database.NewMockUserStore()
users.GetByCurrentAuthUserFunc.SetDefaultReturn(&types.User{}, nil)
orgMembers := database.NewMockOrgMemberStore()
orgs := database.NewMockOrgStore()
orgs.GetByIDFunc.SetDefaultHook(func(ctx context.Context, id int32) (*types.Org, error) {
return &types.Org{
ID: id,
Name: "abc-org",
}, nil
})
externalServices := database.NewMockExternalServiceStore()
db := database.NewMockDB()
db.UsersFunc.SetDefaultReturn(users)
db.OrgMembersFunc.SetDefaultReturn(orgMembers)
db.OrgsFunc.SetDefaultReturn(orgs)
db.ExternalServicesFunc.SetDefaultReturn(externalServices)
apiURL, err := url.Parse("https://github.com")
require.NoError(t, err)
client := NewMockGithubClient()
client.GetAppInstallationFunc.SetDefaultReturn(
&gogithub.Installation{
Account: &gogithub.User{
Login: gogithub.String("abc-org"),
},
},
nil,
)
req, err := http.NewRequest(http.MethodGet, "/.setup/github-app-cloud?installation_id=21994992&setup_action=install&state=T3JnOjE%3D", nil)
require.Nil(t, err)
h := newGitHubAppCloudSetupHandler(db, apiURL, client)
t.Run("not an organization member", func(t *testing.T) {
orgMembers.GetByOrgIDAndUserIDFunc.SetDefaultReturn(nil, nil)
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
assert.Equal(t, http.StatusBadRequest, resp.Code)
assert.Equal(t, "the authenticated user does not belong to the organization requested", resp.Body.String())
})
t.Run("create new", func(t *testing.T) {
orgMembers.GetByOrgIDAndUserIDFunc.SetDefaultReturn(&types.OrgMembership{}, nil)
externalServices.UpsertFunc.SetDefaultHook(func(ctx context.Context, svcs ...*types.ExternalService) error {
require.Len(t, svcs, 1)
svc := svcs[0]
assert.Equal(t, extsvc.KindGitHub, svc.Kind)
assert.Equal(t, "GitHub (abc-org)", svc.DisplayName)
wantConfig := `
{
"url": "https://github.com",
"githubAppInstallationID": "21994992",
"repos": []
}
`
assert.Equal(t, wantConfig, svc.Config)
return nil
})
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
assert.Equal(t, http.StatusFound, resp.Code)
assert.Equal(t, "/organizations/abc-org/settings/code-hosts", resp.Header().Get("Location"))
mockrequire.Called(t, externalServices.UpsertFunc)
})
t.Run("update existing", func(t *testing.T) {
externalServices.ListFunc.SetDefaultReturn(
[]*types.ExternalService{
{
Kind: extsvc.KindGitHub,
DisplayName: "GitHub (old)",
Config: `
{
"url": "https://github.com",
"repos": []
}
`,
},
},
nil,
)
externalServices.UpsertFunc.SetDefaultHook(func(ctx context.Context, svcs ...*types.ExternalService) error {
require.Len(t, svcs, 1)
svc := svcs[0]
assert.Equal(t, extsvc.KindGitHub, svc.Kind)
assert.Equal(t, "GitHub (abc-org)", svc.DisplayName)
wantConfig := `
{
"url": "https://github.com",
"repos": [],
"githubAppInstallationID": "21994992"
}
`
assert.Equal(t, wantConfig, svc.Config)
return nil
})
resp := httptest.NewRecorder()
h.ServeHTTP(resp, req)
assert.Equal(t, http.StatusFound, resp.Code)
assert.Equal(t, "/organizations/abc-org/settings/code-hosts", resp.Header().Get("Location"))
mockrequire.Called(t, externalServices.ListFunc)
mockrequire.Called(t, externalServices.UpsertFunc)
})
}

View File

@ -0,0 +1,3 @@
package app
//go:generate ../../../../../dev/mockgen.sh github.com/sourcegraph/sourcegraph/enterprise/cmd/frontend/internal/app -i githubClient -o mock_github_client_test.go

View File

@ -0,0 +1,174 @@
// Code generated by go-mockgen 1.1.2; DO NOT EDIT.
package app
import (
"context"
"sync"
github "github.com/google/go-github/v41/github"
)
// MockGithubClient is a mock implementation of the githubClient interface
// (from the package
// github.com/sourcegraph/sourcegraph/enterprise/cmd/frontend/internal/app)
// used for unit testing.
type MockGithubClient struct {
// GetAppInstallationFunc is an instance of a mock function object
// controlling the behavior of the method GetAppInstallation.
GetAppInstallationFunc *GithubClientGetAppInstallationFunc
}
// NewMockGithubClient creates a new mock of the githubClient interface. All
// methods return zero values for all results, unless overwritten.
func NewMockGithubClient() *MockGithubClient {
return &MockGithubClient{
GetAppInstallationFunc: &GithubClientGetAppInstallationFunc{
defaultHook: func(context.Context, int64) (*github.Installation, error) {
return nil, nil
},
},
}
}
// NewStrictMockGithubClient creates a new mock of the githubClient
// interface. All methods panic on invocation, unless overwritten.
func NewStrictMockGithubClient() *MockGithubClient {
return &MockGithubClient{
GetAppInstallationFunc: &GithubClientGetAppInstallationFunc{
defaultHook: func(context.Context, int64) (*github.Installation, error) {
panic("unexpected invocation of MockGithubClient.GetAppInstallation")
},
},
}
}
// surrogateMockGithubClient is a copy of the githubClient interface (from
// the package
// github.com/sourcegraph/sourcegraph/enterprise/cmd/frontend/internal/app).
// It is redefined here as it is unexported in the source package.
type surrogateMockGithubClient interface {
GetAppInstallation(context.Context, int64) (*github.Installation, error)
}
// NewMockGithubClientFrom creates a new mock of the MockGithubClient
// interface. All methods delegate to the given implementation, unless
// overwritten.
func NewMockGithubClientFrom(i surrogateMockGithubClient) *MockGithubClient {
return &MockGithubClient{
GetAppInstallationFunc: &GithubClientGetAppInstallationFunc{
defaultHook: i.GetAppInstallation,
},
}
}
// GithubClientGetAppInstallationFunc describes the behavior when the
// GetAppInstallation method of the parent MockGithubClient instance is
// invoked.
type GithubClientGetAppInstallationFunc struct {
defaultHook func(context.Context, int64) (*github.Installation, error)
hooks []func(context.Context, int64) (*github.Installation, error)
history []GithubClientGetAppInstallationFuncCall
mutex sync.Mutex
}
// GetAppInstallation delegates to the next hook function in the queue and
// stores the parameter and result values of this invocation.
func (m *MockGithubClient) GetAppInstallation(v0 context.Context, v1 int64) (*github.Installation, error) {
r0, r1 := m.GetAppInstallationFunc.nextHook()(v0, v1)
m.GetAppInstallationFunc.appendCall(GithubClientGetAppInstallationFuncCall{v0, v1, r0, r1})
return r0, r1
}
// SetDefaultHook sets function that is called when the GetAppInstallation
// method of the parent MockGithubClient instance is invoked and the hook
// queue is empty.
func (f *GithubClientGetAppInstallationFunc) SetDefaultHook(hook func(context.Context, int64) (*github.Installation, error)) {
f.defaultHook = hook
}
// PushHook adds a function to the end of hook queue. Each invocation of the
// GetAppInstallation method of the parent MockGithubClient 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 *GithubClientGetAppInstallationFunc) PushHook(hook func(context.Context, int64) (*github.Installation, error)) {
f.mutex.Lock()
f.hooks = append(f.hooks, hook)
f.mutex.Unlock()
}
// SetDefaultReturn calls SetDefaultDefaultHook with a function that returns
// the given values.
func (f *GithubClientGetAppInstallationFunc) SetDefaultReturn(r0 *github.Installation, r1 error) {
f.SetDefaultHook(func(context.Context, int64) (*github.Installation, error) {
return r0, r1
})
}
// PushReturn calls PushDefaultHook with a function that returns the given
// values.
func (f *GithubClientGetAppInstallationFunc) PushReturn(r0 *github.Installation, r1 error) {
f.PushHook(func(context.Context, int64) (*github.Installation, error) {
return r0, r1
})
}
func (f *GithubClientGetAppInstallationFunc) nextHook() func(context.Context, int64) (*github.Installation, 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 *GithubClientGetAppInstallationFunc) appendCall(r0 GithubClientGetAppInstallationFuncCall) {
f.mutex.Lock()
f.history = append(f.history, r0)
f.mutex.Unlock()
}
// History returns a sequence of GithubClientGetAppInstallationFuncCall
// objects describing the invocations of this function.
func (f *GithubClientGetAppInstallationFunc) History() []GithubClientGetAppInstallationFuncCall {
f.mutex.Lock()
history := make([]GithubClientGetAppInstallationFuncCall, len(f.history))
copy(history, f.history)
f.mutex.Unlock()
return history
}
// GithubClientGetAppInstallationFuncCall is an object that describes an
// invocation of method GetAppInstallation on an instance of
// MockGithubClient.
type GithubClientGetAppInstallationFuncCall 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 int64
// Result0 is the value of the 1st result returned from this method
// invocation.
Result0 *github.Installation
// 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 GithubClientGetAppInstallationFuncCall) Args() []interface{} {
return []interface{}{c.Arg0, c.Arg1}
}
// Results returns an interface slice containing the results of this
// invocation.
func (c GithubClientGetAppInstallationFuncCall) Results() []interface{} {
return []interface{}{c.Result0, c.Result1}
}

View File

@ -7,19 +7,17 @@ package main
import (
"context"
"fmt"
"log"
"os"
"strconv"
"github.com/sourcegraph/sourcegraph/internal/oobmigration"
"github.com/inconshreveable/log15"
"github.com/opentracing/opentracing-go"
"github.com/prometheus/client_golang/prometheus"
"github.com/sourcegraph/sourcegraph/cmd/frontend/enterprise"
"github.com/sourcegraph/sourcegraph/cmd/frontend/shared"
"github.com/sourcegraph/sourcegraph/enterprise/cmd/frontend/internal/app"
"github.com/sourcegraph/sourcegraph/enterprise/cmd/frontend/internal/auth"
"github.com/sourcegraph/sourcegraph/enterprise/cmd/frontend/internal/authz"
"github.com/sourcegraph/sourcegraph/enterprise/cmd/frontend/internal/batches"
@ -36,6 +34,7 @@ import (
"github.com/sourcegraph/sourcegraph/internal/conf/conftypes"
"github.com/sourcegraph/sourcegraph/internal/database"
"github.com/sourcegraph/sourcegraph/internal/observation"
"github.com/sourcegraph/sourcegraph/internal/oobmigration"
"github.com/sourcegraph/sourcegraph/internal/trace"
)
@ -84,18 +83,22 @@ func enterpriseSetupHook(db database.DB, conf conftypes.UnifiedWatchable) enterp
}
if err := codeintel.Init(ctx, db, conf, &enterpriseServices, observationContext, services); err != nil {
log.Fatal(fmt.Sprintf("failed to initialize codeintel: %s", err))
log.Fatalf("failed to initialize codeintel: %s", err)
}
// Initialize executor-specific services with the code-intel services.
if err := executor.Init(ctx, db, conf, &enterpriseServices, observationContext, services.InternalUploadHandler); err != nil {
log.Fatal(fmt.Sprintf("failed to initialize executor: %s", err))
log.Fatalf("failed to initialize executor: %s", err)
}
if err := app.Init(db, conf, &enterpriseServices); err != nil {
log.Fatalf("failed to initialize app: %s", err)
}
// Initialize all the enterprise-specific services that do not need the codeintel-specific services.
for name, fn := range initFunctions {
if err := fn(ctx, db, conf, &enterpriseServices, observationContext); err != nil {
log.Fatal(fmt.Sprintf("failed to initialize %s: %s", name, err))
log.Fatalf("failed to initialize %s: %s", name, err)
}
}

View File

@ -681,11 +681,11 @@ func TestExternalServices_ValidateConfig(t *testing.T) {
},
{
kind: extsvc.KindGitHub,
desc: "without url, token, repositoryQuery, repos nor orgs",
desc: "without url, token, githubAppInstallationID, repositoryQuery, repos nor orgs",
config: `{}`,
assert: includes(
"url is required",
"token is required",
"at least one of token or githubAppInstallationID must be set",
"at least one of repositoryQuery, repos or orgs must be set",
),
},
@ -700,6 +700,17 @@ func TestExternalServices_ValidateConfig(t *testing.T) {
}`,
assert: equals(`<nil>`),
},
{
kind: extsvc.KindGitHub,
desc: "with url, githubAppInstallationID, repos",
config: `
{
"url": "https://github.corp.com",
"githubAppInstallationID": "21994992",
"repos": [],
}`,
assert: equals(`<nil>`),
},
{
kind: extsvc.KindGitHub,
desc: "with url, token, repos",

1
go.mod
View File

@ -244,6 +244,7 @@ require (
github.com/go-openapi/validate v0.20.3 // indirect
github.com/go-stack/stack v1.8.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.0.0
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd // indirect
github.com/googleapis/gax-go/v2 v2.1.1 // indirect

1
go.sum
View File

@ -645,6 +645,7 @@ github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXP
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/gogo/status v1.1.0/go.mod h1:BFv9nrluPLmrS0EmGVvLaPNmRosr9KapBYd5/hpY1WM=
github.com/golang-jwt/jwt/v4 v4.0.0 h1:RAqyYixv1p7uEnocuy8P1nru5wprCh/MH2BIlW5z5/o=
github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/gddo v0.0.0-20210115222349-20d68f94ee1f h1:16RtHeWGkJMc80Etb8RPCcKevXGldr57+LOyZt8zOlg=

View File

@ -509,6 +509,9 @@ func (e *externalServiceStore) validateGitHubConnection(ctx context.Context, id
err = multierror.Append(err, validate(c))
}
if c.Token == "" && c.GithubAppInstallationID == "" {
err = multierror.Append(err, errors.New("at least one of token or githubAppInstallationID must be set"))
}
if c.Repos == nil && c.RepositoryQuery == nil && c.Orgs == nil {
err = multierror.Append(err, errors.New("at least one of repositoryQuery, repos or orgs must be set"))
}

View File

@ -157,14 +157,14 @@ func TestExternalServicesStore_ValidateConfig(t *testing.T) {
{
name: "1 error",
kind: extsvc.KindGitHub,
config: `{"url": "https://github.com", "repositoryQuery": ["none"], "token": ""}`,
wantErr: "1 error occurred:\n\t* token: String length must be greater than or equal to 1\n\n",
config: `{"repositoryQuery": ["none"], "token": "fake"}`,
wantErr: "1 error occurred:\n\t* url is required\n\n",
},
{
name: "2 errors",
kind: extsvc.KindGitHub,
config: `{"url": "https://github.com", "repositoryQuery": ["none"], "token": "", "x": 123}`,
wantErr: "2 errors occurred:\n\t* Additional property x is not allowed\n\t* token: String length must be greater than or equal to 1\n\n",
config: `{"url": "https://github.com", "repositoryQuery": ["none"], "token": ""}`,
wantErr: "2 errors occurred:\n\t* token: String length must be greater than or equal to 1\n\t* at least one of token or githubAppInstallationID must be set\n\n",
},
{
name: "no conflicting rate limit",

View File

@ -1,9 +1,14 @@
package auth
import (
"crypto/rsa"
"crypto/sha256"
"encoding/hex"
"net/http"
"time"
"github.com/cockroachdb/errors"
"github.com/golang-jwt/jwt/v4"
)
// OAuthBearerToken implements OAuth Bearer Token authentication for extsvc
@ -49,3 +54,57 @@ func (token *OAuthBearerTokenWithSSH) Hash() string {
shaSum := sha256.Sum256([]byte(token.Token + token.PrivateKey + token.Passphrase + token.PublicKey))
return hex.EncodeToString(shaSum[:])
}
// oauthBearerTokenWithGitHubApp implements OAuth Bearer Token authentication for
// GitHub Apps.
type oauthBearerTokenWithGitHubApp struct {
appID string
key *rsa.PrivateKey
rawKey []byte
}
// NewOAuthBearerTokenWithGitHubApp constructs a new OAuth Bearer Token
// authenticator for GitHub Apps using given appID and private key.
func NewOAuthBearerTokenWithGitHubApp(appID string, privateKey []byte) (Authenticator, error) {
key, err := jwt.ParseRSAPrivateKeyFromPEM(privateKey)
if err != nil {
return nil, errors.Wrap(err, "parse private key")
}
return &oauthBearerTokenWithGitHubApp{
appID: appID,
key: key,
rawKey: privateKey,
}, nil
}
// Authenticate is a modified version of
// https://github.com/bradleyfalzon/ghinstallation/blob/24e56b3fb7669f209134a01eff731d7e2ef72a5c/appsTransport.go#L66.
func (token *oauthBearerTokenWithGitHubApp) Authenticate(r *http.Request) error {
// The payload computation is following GitHub App's Ruby example shown in
// https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#authenticating-as-a-github-app.
//
// NOTE: GitHub rejects expiry and issue timestamps that are not an integer,
// while the jwt-go library serializes to fractional timestamps. Truncate them
// before passing to jwt-go.
iss := time.Now().Add(-time.Minute).Truncate(time.Second)
exp := iss.Add(10 * time.Minute)
claims := &jwt.StandardClaims{
IssuedAt: iss.Unix(),
ExpiresAt: exp.Unix(),
Issuer: token.appID,
}
bearer := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
signedString, err := bearer.SignedString(token.key)
if err != nil {
return errors.Wrap(err, "sign JWT")
}
r.Header.Set("Authorization", "Bearer "+signedString)
return nil
}
func (token *oauthBearerTokenWithGitHubApp) Hash() string {
shaSum := sha256.Sum256(token.rawKey)
return hex.EncodeToString(shaSum[:])
}

View File

@ -12,6 +12,7 @@ import (
"time"
"github.com/cockroachdb/errors"
"github.com/google/go-github/v41/github"
"golang.org/x/time/rate"
"github.com/sourcegraph/sourcegraph/internal/conf"
@ -725,3 +726,12 @@ func (c *V3Client) Fork(ctx context.Context, owner, repo string, org *string) (*
return convertRestRepo(restRepo), nil
}
// GetAppInstallation gets information of a GitHub App installation.
func (c *V3Client) GetAppInstallation(ctx context.Context, installationID int64) (*github.Installation, error) {
var ins github.Installation
if err := c.requestGet(ctx, fmt.Sprintf("app/installations/%d", installationID), &ins); err != nil {
return nil, err
}
return &ins, nil
}

View File

@ -6,7 +6,7 @@
"allowComments": true,
"type": "object",
"additionalProperties": false,
"required": ["url", "token"],
"required": ["url"],
"properties": {
"url": {
"description": "URL of a GitHub instance, such as https://github.com or https://github-enterprise.example.com.",
@ -171,6 +171,10 @@
}
}
},
"githubAppInstallationID": {
"description": "The installation ID of the GitHub App.",
"type": "string"
},
"cloudGlobal": {
"title": "CloudGlobal",
"description": "When set to true, this external service will be chosen as our 'Global' GitHub service. Only valid on Sourcegraph.com. Only one service can have this flag set.",

View File

@ -436,6 +436,8 @@ type DebugLog struct {
// Dotcom description: Configuration options for Sourcegraph.com only.
type Dotcom struct {
// GithubAppCloud description: The config options for Sourcegraph Cloud GitHub App.
GithubAppCloud *GithubAppCloud `json:"githubApp.cloud,omitempty"`
// SlackLicenseExpirationWebhook description: Slack webhook for upcoming license expiration notifications.
SlackLicenseExpirationWebhook string `json:"slackLicenseExpirationWebhook,omitempty"`
}
@ -715,6 +717,8 @@ type GitHubConnection struct {
//
// If "ssh", Sourcegraph will access GitHub repositories using Git URLs of the form git@github.com:myteam/myproject.git. See the documentation for how to provide SSH private keys and known_hosts: https://docs.sourcegraph.com/admin/repo/auth#repositories-that-need-http-s-or-ssh-authentication.
GitURLType string `json:"gitURLType,omitempty"`
// GithubAppInstallationID description: The installation ID of the GitHub App.
GithubAppInstallationID string `json:"githubAppInstallationID,omitempty"`
// InitialRepositoryEnablement description: Deprecated and ignored field which will be removed entirely in the next release. GitHub repositories can no longer be enabled or disabled explicitly. Configure repositories to be mirrored via "repos", "exclude" and "repositoryQuery" instead.
InitialRepositoryEnablement bool `json:"initialRepositoryEnablement,omitempty"`
// Orgs description: An array of organization names identifying GitHub organizations whose repositories should be mirrored on Sourcegraph.
@ -747,7 +751,7 @@ type GitHubConnection struct {
// If you need to narrow the set of mirrored repositories further (and don't want to enumerate it with a list or query set as above), create a new bot/machine user on GitHub or GitHub Enterprise that is only affiliated with the desired repositories.
RepositoryQuery []string `json:"repositoryQuery,omitempty"`
// Token description: A GitHub personal access token. Create one for GitHub.com at https://github.com/settings/tokens/new?description=Sourcegraph (for GitHub Enterprise, replace github.com with your instance's hostname). See https://docs.sourcegraph.com/admin/external_service/github#github-api-token-and-access for which scopes are required for which use cases.
Token string `json:"token"`
Token string `json:"token,omitempty"`
// Url description: URL of a GitHub instance, such as https://github.com or https://github-enterprise.example.com.
Url string `json:"url"`
// Webhooks description: An array of configurations defining existing GitHub webhooks that send updates back to Sourcegraph.
@ -858,6 +862,14 @@ type GitLabWebhook struct {
Secret string `json:"secret"`
}
// GithubAppCloud description: The config options for Sourcegraph Cloud GitHub App.
type GithubAppCloud struct {
// AppID description: The app ID of the GitHub App for Sourcegraph Cloud.
AppID string `json:"appID,omitempty"`
// PrivateKey description: The base64-encoded private key of the GitHub App for Sourcegraph Cloud.
PrivateKey string `json:"privateKey,omitempty"`
}
// GitoliteConnection description: Configuration for a connection to Gitolite.
type GitoliteConnection struct {
// Exclude description: A list of repositories to never mirror from this Gitolite instance. Supports excluding by exact name ({"name": "foo"}).

View File

@ -1086,6 +1086,20 @@
"description": "Slack webhook for upcoming license expiration notifications.",
"type": "string",
"group": "Sourcegraph.com"
},
"githubApp.cloud": {
"description": "The config options for Sourcegraph Cloud GitHub App.",
"type": "object",
"properties": {
"appID": {
"description": "The app ID of the GitHub App for Sourcegraph Cloud.",
"type": "string"
},
"privateKey": {
"description": "The base64-encoded private key of the GitHub App for Sourcegraph Cloud.",
"type": "string"
}
}
}
},
"group": "Sourcegraph.com"