diff --git a/.github.sample.toml b/.github.sample.toml index 49fd416..e445084 100644 --- a/.github.sample.toml +++ b/.github.sample.toml @@ -7,8 +7,8 @@ # Defines the url for Zappr. Comment it out to load it from ZAPPR_URL environment variable URL="https://zappr.domain.com" - # Defines the Zappr token. Comment it out to load it from ZAPPR_TOKEN environment variable - Token="" + # Specifies if calls made by Zappr to Github should use its own Client token or yours. Comment it out to load it from ZAPPR_USE_APP_CREDENTIALS environment variable + UseZapprGithubCredentials="true" [github] # Defines the github organization diff --git a/README.md b/README.md index 9b3d206..b8b6bfb 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ You will need to fill in the following values to be able to create a repo: ### Zappr `zappr` -For Zappr you will need a Zappr URL and a Zappr token, the URL is the base URL of your zappr configuration and the token can be found when you login into the UI and copy the value of any request going out the same domain. The cookies that you need are http-only so you can't access them through JS. Just look for a request with the `Cookie:` header and then copy the value. +For Zappr you will need to specify the URL to Zappr, the URL is the base URL of Zappr, its typically `https://zappr.tools-k8s.hellofresh.io`. ### GitHub `github` diff --git a/cmd/repo_create.go b/cmd/repo_create.go index 037bffe..0a820b3 100644 --- a/cmd/repo_create.go +++ b/cmd/repo_create.go @@ -20,19 +20,20 @@ import ( // CreateRepoOptions are the flags for the create repository command type CreateRepoOptions struct { - Description string - Private bool - HasPullApprove bool - HasZappr bool - HasTeams bool - HasCollaborators bool - HasLabels bool - HasDefaultLabels bool - HasWebhooks bool - HasBranchProtections bool - HasIssues bool - HasWiki bool - HasPages bool + Description string + Private bool + HasPullApprove bool + HasZappr bool + UseZapprGithubCredentials bool + HasTeams bool + HasCollaborators bool + HasLabels bool + HasDefaultLabels bool + HasWebhooks bool + HasBranchProtections bool + HasIssues bool + HasWiki bool + HasPages bool } // NewCreateRepoCmd creates a new create repo command @@ -68,6 +69,7 @@ func NewCreateRepoCmd(ctx context.Context) *cobra.Command { cmd.Flags().BoolVar(&opts.HasDefaultLabels, "rm-default-labels", true, "Removes the default github labels") cmd.Flags().BoolVar(&opts.HasWebhooks, "has-webhooks", false, "Enables webhooks configurations") cmd.Flags().BoolVar(&opts.HasBranchProtections, "has-branch-protections", true, "Enables branch protections") + cmd.Flags().BoolVar(&opts.UseZapprGithubCredentials, "use-zappr-credentials", true, "Enables authenticating to Github as Zapps App") return cmd } @@ -95,6 +97,7 @@ func RunCreateRepo(ctx context.Context, repoName string, opts *CreateRepoOptions logger.Debugf("\tAdd labels to repository? %s", strconv.FormatBool(opts.HasLabels)) logger.Debugf("\tAdd webhooks to repository? %s", strconv.FormatBool(opts.HasWebhooks)) logger.Debugf("\tConfigure branch protection? %s", strconv.FormatBool(opts.HasBranchProtections)) + logger.Debugf("\tAuthenticate to Github as Zappr? %s", strconv.FormatBool(opts.UseZapprGithubCredentials)) description := opts.Description githubOpts := &repo.GithubRepoOpts{ @@ -158,17 +161,23 @@ func RunCreateRepo(ctx context.Context, repoName string, opts *CreateRepoOptions } var zapprClient zappr.Client - if cfg.Zappr.Token == "" { - logger.Debug("Authenticating to Zappr using Github token") - zapprClient = zappr.NewWithGithubToken(cfg.Zappr.URL, cfg.Github.Token, nil) - } else { - logger.Debug("Authenticating to Zappr using Zappr token") - zapprClient = zappr.NewWithZapprToken(cfg.Zappr.URL, cfg.Zappr.Token, nil) + zapprClient = zappr.New(cfg.Zappr.URL, cfg.Github.Token, nil) + + if cfg.Zappr.UseZapprGithubCredentials { + logger.Debug("Retrieving token for zappr github app from zappr") + err = zapprClient.ImpersonateGitHubApp() + if err != nil { + if errwrap.Contains(err, zappr.ErrZapprUnauthorized.Error()) { + return errwrap.Wrapf("could not retrieve token representing github zappr app from zappr. it seems you have not logged in to zappr, if you have, please logout from zappr, log back in and try again: {{err}}", err) + } + + return errwrap.Wrapf("could not retrieve token representing github zappr app from zappr: {{err}}", err) + } } err = zapprClient.Enable(*ghRepo.ID) if errwrap.Contains(err, zappr.ErrZapprAlreadyEnabled.Error()) { - logger.Debug("zappr already enabled, moving on...") + logger.Debug("Zappr already enabled, moving on...") } else if err != nil { return errwrap.Wrapf("could not enable zappr: {{err}}", err) } diff --git a/cmd/repo_delete.go b/cmd/repo_delete.go index 74b5e33..eea4bd1 100644 --- a/cmd/repo_delete.go +++ b/cmd/repo_delete.go @@ -59,13 +59,7 @@ func RunDeleteRepo(ctx context.Context, name string, opts *DeleteRepoOpts) error } var zapprClient zappr.Client - if cfg.Zappr.Token == "" { - logger.Debug("Authenticating to Zappr using Github token") - zapprClient = zappr.NewWithGithubToken(cfg.Zappr.URL, cfg.Github.Token, nil) - } else { - logger.Debug("Authenticating to Zappr using Zappr token") - zapprClient = zappr.NewWithZapprToken(cfg.Zappr.URL, cfg.Zappr.Token, nil) - } + zapprClient = zappr.New(cfg.Zappr.URL, cfg.Github.Token, nil) logger.Debug("Disabling Zappr on repo...") err = zapprClient.Disable(*ghRepo.ID) diff --git a/cmd/root.go b/cmd/root.go index d9740d9..33c1dea 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -46,11 +46,16 @@ func NewRootCmd() *cobra.Command { } cfg := config.WithContext(ctx) + if opts.token != "" { cfg.Github.Token = opts.token cfg.GithubTestOrg.Token = opts.token } + if cfg.Github.Token == "" { + log.WithContext(ctx).Fatal("Github token not specified. Please set the GITHUB_TOKEN environment variable, set it in your config file, or provide it with the \"-t\" flag") + } + if opts.org != "" { cfg.Github.Organization = opts.org cfg.GithubTestOrg.Organization = opts.org @@ -59,7 +64,7 @@ func NewRootCmd() *cobra.Command { ctx, err = github.NewContext(ctx, cfg.Github.Token) if err != nil { - log.WithContext(ctx).WithError(err).Fatal("Could not create the kube client") + log.WithContext(ctx).WithError(err).Fatal("could not create the kube client") } // Aggregates Root commands diff --git a/pkg/config/config.go b/pkg/config/config.go index 71b613f..6fc9d4a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -31,8 +31,8 @@ type ( // Zappr represents Zappr configurations Zappr struct { - URL string - Token string + URL string + UseZapprGithubCredentials bool } // Github represents the github configurations @@ -105,7 +105,8 @@ func NewContext(ctx context.Context, configFile string) (context.Context, error) viper.SetDefault("githubtestorg.token", os.Getenv("GITHUB_TOKEN")) viper.SetDefault("zappr.url", os.Getenv("ZAPPR_URL")) - viper.SetDefault("zappr.token", os.Getenv("ZAPPR_TOKEN")) + viper.SetDefault("zappr.usezapprgithubcredentials", true) + viper.BindEnv("ZAPPR_USE_APP_CREDENTIALS", "zappr.usezapprgithubcredentials") err := viper.ReadInConfig() if err != nil { diff --git a/pkg/zappr/client.go b/pkg/zappr/client.go index 45b017b..756e351 100644 --- a/pkg/zappr/client.go +++ b/pkg/zappr/client.go @@ -15,12 +15,13 @@ import ( type Client interface { Enable(repoID int) error Disable(repoID int) error + ImpersonateGitHubApp() error // Retrieves the Github token representing Zappr and uses that for future requests } type clientImpl struct { - zapprToken string - githubToken string - slingClient *sling.Sling + githubToken string + githubZapprAppToken string + slingClient *sling.Sling } type zapprErrorResponse struct { @@ -29,6 +30,10 @@ type zapprErrorResponse struct { Title string `json:"title,omitempty"` } +type zapprAppTokenResponse struct { + Token string `json:"token"` +} + var ( timeout = 30 * time.Second @@ -45,22 +50,8 @@ var ( ErrZapprServerError = errors.New("unknown error from zappr") ) -// NewWithZapprToken creates a new Zappr client that uses Zappr Token to make calls to Zappr -func NewWithZapprToken(zapprURL string, zapprToken string, httpClient *http.Client) Client { - if httpClient == nil { - httpClient = &http.Client{Timeout: timeout} - } - - slingClient := sling.New().Client(httpClient).Base(zapprURL) - - return &clientImpl{ - zapprToken: zapprToken, - slingClient: slingClient, - } -} - -// NewWithGithubToken creates a new Zappr client that uses Github Token to make calls to Zappr -func NewWithGithubToken(zapprURL string, githubToken string, httpClient *http.Client) Client { +// New creates a new Zappr client that uses Github Token to make calls to Zappr +func New(zapprURL string, githubToken string, httpClient *http.Client) Client { if httpClient == nil { httpClient = &http.Client{Timeout: timeout} } @@ -75,12 +66,12 @@ func NewWithGithubToken(zapprURL string, githubToken string, httpClient *http.Cl // Enable turns on Zappr approval check on a Github repo func (c *clientImpl) Enable(repoID int) error { - req, err := c.slingClient.Get(fmt.Sprintf("api/repos/%d?autoSync=true", repoID)).Request() + req, err := c.slingClient.Get(fmt.Sprintf("/api/repos/%d?autoSync=true", repoID)).Request() if err != nil { return errwrap.Wrapf("could not fetch repo on zappr to enable approval check: {{err}}", err) } - status, zapprErrorResponse, err := c.doRequest(req) + status, zapprErrorResponse, err := c.doRequest(req, nil) if err != nil { return errwrap.Wrapf("could not fetch repo on zappr to enable approval check: {{err}}", err) } @@ -90,7 +81,7 @@ func (c *clientImpl) Enable(repoID int) error { return errwrap.Wrapf("could not Enable Zappr approval checks on repo: {{err}}", err) } - status, zapprErrorResponse, err = c.doRequest(req) + status, zapprErrorResponse, err = c.doRequest(req, nil) if status == http.StatusServiceUnavailable && zapprErrorResponse != nil { // Zappr already active on the repo if strings.HasPrefix(zapprErrorResponse.Detail, "Check approval already exists for repository") { @@ -103,12 +94,12 @@ func (c *clientImpl) Enable(repoID int) error { // Disable turns off Zappr approval check on a Github repo func (c *clientImpl) Disable(repoID int) error { - req, err := c.slingClient.Get(fmt.Sprintf("api/repos/%d?autoSync=true", repoID)).Request() + req, err := c.slingClient.Get(fmt.Sprintf("/api/repos/%d?autoSync=true", repoID)).Request() if err != nil { return errwrap.Wrapf("could not fetch repo on zappr to enable approval check: {{err}}", err) } - status, zapprErrorResponse, err := c.doRequest(req) + status, zapprErrorResponse, err := c.doRequest(req, nil) if err != nil { return errwrap.Wrapf("could not fetch repo on zappr to enable approval check: {{err}}", err) } @@ -118,7 +109,7 @@ func (c *clientImpl) Disable(repoID int) error { return errwrap.Wrapf("could not Disable Zappr approval checks on repo: {{err}}", err) } - status, zapprErrorResponse, err = c.doRequest(req) + status, zapprErrorResponse, err = c.doRequest(req, nil) if status == http.StatusServiceUnavailable && zapprErrorResponse != nil { // Zappr active on the repo, but repo has been deleted from github if strings.HasSuffix(zapprErrorResponse.Detail, "required_status_checks 404 Not Found") { @@ -134,15 +125,31 @@ func (c *clientImpl) Disable(repoID int) error { return err } -func (c *clientImpl) doRequest(req *http.Request) (int, *zapprErrorResponse, error) { - if c.zapprToken == "" { - req.Header.Add("Authorization", fmt.Sprintf("token %s", c.githubToken)) - } else { - req.Header.Add("Cookie", c.zapprToken) +func (c *clientImpl) ImpersonateGitHubApp() error { + req, err := c.slingClient.Get("api/apptoken").Request() + if err != nil { + return errwrap.Wrapf("could not fetch github token for zappr github app: {{err}}", err) + } + + tokenResponse := &zapprAppTokenResponse{} + _, _, err = c.doRequest(req, tokenResponse) + if err != nil { + return errwrap.Wrapf("could not fetch github token for zappr github app: {{err}}", err) + } + + c.githubZapprAppToken = tokenResponse.Token + return nil +} + +func (c *clientImpl) doRequest(req *http.Request, response interface{}) (int, *zapprErrorResponse, error) { + req.Header.Set("Authorization", fmt.Sprintf("token %s", c.githubToken)) + + if c.githubZapprAppToken != "" { + req.Header.Set("Authorization", fmt.Sprintf("token %s", c.githubZapprAppToken)) } zapprErrorResponse := &zapprErrorResponse{} - resp, err := c.slingClient.Do(req, nil, zapprErrorResponse) + resp, err := c.slingClient.Do(req, response, zapprErrorResponse) if resp == nil { // Even though 0 does not seem like a valid http response code diff --git a/pkg/zappr/client_test.go b/pkg/zappr/client_test.go index 7672c88..e890591 100644 --- a/pkg/zappr/client_test.go +++ b/pkg/zappr/client_test.go @@ -1,6 +1,7 @@ package zappr import ( + "fmt" "net/http" "testing" @@ -21,38 +22,12 @@ func TestAuthWithGithubToken(t *testing.T) { defer testServer.Close() // Should call the "fetch repo" zappr endpoint to find the just created repo - zapprMock.On("Handle", "GET", "/api/repos/1?autoSync=true", mock.Anything, mock.Anything).Return(test.Response{ - Status: http.StatusOK, - }) - - // Add handlers to define expected call(s) to, and response(s) from Zappr - zapprMock.On("Handle", "PUT", "/api/repos/1/approval", getGithubAuthHeader(token), mock.Anything).Return(test.Response{ - Status: http.StatusCreated, - }) - - client.Enable(1) - - // Assert expected calls were made to Zappr - zapprMock.AssertExpectations(t) -} - -func TestAuthWithZapprToken(t *testing.T) { - token := "abcdefgh" - - // Get the Zappr Client, Mock Handler and Test Server - client, zapprMock, testServer := NewMockAndHandlerWithZapprToken(token) - - // Start the test server and stop it when done - testServer.Start() - defer testServer.Close() - - // Should call the "fetch repo" zappr endpoint to find the just created repo - zapprMock.On("Handle", "GET", "/api/repos/1?autoSync=true", mock.Anything, mock.Anything).Return(test.Response{ + zapprMock.On("Handle", "GET", "/api/repos/1?autoSync=true", getGithubAuthHeader(token, false), mock.Anything).Return(test.Response{ Status: http.StatusOK, }) // Add handlers to define expected call(s) to, and response(s) from Zappr - zapprMock.On("Handle", "PUT", "/api/repos/1/approval", getZapprAuthHeader(token), mock.Anything).Return(test.Response{ + zapprMock.On("Handle", "PUT", "/api/repos/1/approval", getGithubAuthHeader(token, true), mock.Anything).Return(test.Response{ Status: http.StatusCreated, }) @@ -295,3 +270,47 @@ func TestProblematicRequest(t *testing.T) { // Assert expected calls were made to Zappr zapprMock.AssertExpectations(t) } + +func TestImpersonateGitHubApp(t *testing.T) { + token := "12345678" + zapprAppToken := "abcdefgh" + + // Get the Zappr Client, Mock Handler and Test Server + client, zapprMock, testServer := NewMockAndHandlerWithGithubToken(token) + + // Start the test server and stop it when done + testServer.Start() + defer testServer.Close() + + // Should call the "apptoken" zappr endpoint to get the github token representing zappr app and use the users github token + zapprMock.On("Handle", "GET", "/api/apptoken", getGithubAuthHeader(token, false), mock.Anything).Return(test.Response{ + Status: http.StatusOK, + Body: []byte(fmt.Sprintf(`{ "token": "%s" }`, zapprAppToken)), + }) + + err := client.ImpersonateGitHubApp() + + // Assert no errors were received + assert.Nil(t, err) + + // Assert expected calls were made to Zappr + zapprMock.AssertExpectations(t) + + // reset expectations, i need to use the same mock object that is using the retrieved zappr app github token + zapprMock.ExpectedCalls = []*mock.Call{} + + // Should call the "fetch repo" zappr endpoint to find the just created repo and use zappr app's github token + zapprMock.On("Handle", "GET", "/api/repos/1?autoSync=true", getGithubAuthHeader(zapprAppToken, false), mock.Anything).Return(test.Response{ + Status: http.StatusOK, + }) + + // Add handlers to define expected call(s) to, and response(s) from Zappr and use zappr app's github token + zapprMock.On("Handle", "PUT", "/api/repos/1/approval", getGithubAuthHeader(zapprAppToken, true), mock.Anything).Return(test.Response{ + Status: http.StatusCreated, + }) + + client.Enable(1) + + // Assert expected calls were made to Zappr + zapprMock.AssertExpectations(t) +} diff --git a/pkg/zappr/zappr_mock.go b/pkg/zappr/zappr_mock.go index 8feb797..0c8ae32 100644 --- a/pkg/zappr/zappr_mock.go +++ b/pkg/zappr/zappr_mock.go @@ -9,29 +9,25 @@ import ( "github.com/hellofresh/github-cli/pkg/test" ) -func getDefaultHeader() *http.Header { +func getDefaultHeader(addContentLength bool) *http.Header { header := &http.Header{} header.Add("User-Agent", "Go-http-client/1.1") header.Add("Accept-Encoding", "gzip") - header.Add("Content-Length", "0") + + if addContentLength { + header.Add("Content-Length", "0") + } return header } -func getGithubAuthHeader(token string) *http.Header { - authHeader := getDefaultHeader() +func getGithubAuthHeader(token string, addContentLength bool) *http.Header { + authHeader := getDefaultHeader(addContentLength) authHeader.Add("Authorization", fmt.Sprintf("token %s", token)) return authHeader } -func getZapprAuthHeader(token string) *http.Header { - authHeader := getDefaultHeader() - authHeader.Add("Cookie", token) - - return authHeader -} - func newMockAndHandler() (*http.Client, *test.MockHandler, *test.Server) { mockHandler := &test.MockHandler{} mockServer := test.NewUnstartedServer(mockHandler) @@ -69,7 +65,7 @@ func NewMockAndHandler() (Client, *test.MockHandler, *test.Server) { // The internal http client always returns a nil response object. func NewMockAndHandlerNilResponse() (Client, *test.MockHandler, *test.Server) { httpClient, mockHandler, mockServer := newMockAndHandlerWithNilResponseTransport() - client := NewWithGithubToken("https://fake.zappr/", "1234567890", httpClient) + client := New("https://fake.zappr/", "1234567890", httpClient) return client, mockHandler, mockServer } @@ -79,17 +75,7 @@ func NewMockAndHandlerNilResponse() (Client, *test.MockHandler, *test.Server) { // requests. The caller must close the test server. func NewMockAndHandlerWithGithubToken(githubToken string) (Client, *test.MockHandler, *test.Server) { httpClient, mockHandler, mockServer := newMockAndHandler() - client := NewWithGithubToken("https://fake.zappr/", githubToken, httpClient) - - return client, mockHandler, mockServer -} - -// NewMockAndHandlerWithZapprToken returns a Zappr Client that uses Zappr Token, Mockhandler, and Server. The client proxies -// requests to the server and handlers can be registered on the mux to handle -// requests. The caller must close the test server. -func NewMockAndHandlerWithZapprToken(zapprToken string) (Client, *test.MockHandler, *test.Server) { - httpClient, mockHandler, mockServer := newMockAndHandler() - client := NewWithZapprToken("https://fake.zappr/", zapprToken, httpClient) + client := New("https://fake.zappr", githubToken, httpClient) return client, mockHandler, mockServer }