diff --git a/README.md b/README.md index 4507af4..05a48f6 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,13 @@ From a machine with access to both GitHub.com and GitHub Enterprise Server use t **Required Arguments:** * `--destination-url` - The URL of the GitHub Enterprise Server instance to push the Action to. -* `--destination-token` - A [Personal Access Token](https://docs.github.com/en/enterprise/user/github/authenticating-to-github/creating-a-personal-access-token) for the destination GitHub Enterprise Server instance. The token should be granted at least the `public_repo` scope. If the destination repository is in an organization that does not yet exist, your token will need to have the `site_admin` scope in order to create the organization. The organization can also be created manually or an existing organization used. +* `--destination-token` - A [Personal Access Token](https://docs.github.com/en/enterprise/user/github/authenticating-to-github/creating-a-personal-access-token) for the destination GitHub Enterprise Server instance. The token should be granted at least the `public_repo` scope. If the destination repository is in an organization that does not yet exist or that you are not an owner of, your token will need to have the `site_admin` scope in order to create the organization. The organization can also be created manually or an existing organization used. **Optional Arguments:** * `--cache-dir` - A temporary directory in which to store data downloaded from GitHub.com before it is uploaded to GitHub Enterprise Server. If not specified a directory next to the sync tool will be used. * `--source-token` - A token to access the API of GitHub.com. This is normally not required, but can be provided if you have issues with API rate limiting. The token does not need to have any scopes. * `--destination-repository` - The name of the repository in which to create or update the CodeQL Action. If not specified `github/codeql-action` will be used. +* `--actions-admin-user` - The name of the Actions admin user, which will be used if you are updating the bundled CodeQL Action. If not specified `actions-admin` will be used. * `--force` - By default the tool will not overwrite existing repositories. Providing this flag will allow it to. * `--push-ssh` - Push Git contents over SSH rather than HTTPS. To use this option you must have SSH access to your GitHub Enterprise instance configured. @@ -42,11 +43,12 @@ Now use the `./codeql-action-sync push` command to upload the CodeQL Action and **Required Arguments:** * `--destination-url` - The URL of the GitHub Enterprise Server instance to push the Action to. -* `--destination-token` - A [Personal Access Token](https://docs.github.com/en/enterprise/user/github/authenticating-to-github/creating-a-personal-access-token) for the destination GitHub Enterprise Server instance. The token should be granted at least the `public_repo` scope. If the destination repository is in an organization that does not yet exist, your token will need to have the `site_admin` scope in order to create the organization. The organization can also be created manually or an existing organization used. +* `--destination-token` - A [Personal Access Token](https://docs.github.com/en/enterprise/user/github/authenticating-to-github/creating-a-personal-access-token) for the destination GitHub Enterprise Server instance. The token should be granted at least the `public_repo` scope. If the destination repository is in an organization that does not yet exist or that you are not an owner of, your token will need to have the `site_admin` scope in order to create the organization. The organization can also be created manually or an existing organization used. **Optional Arguments:** * `--cache-dir` - The directory to which the Action was previously downloaded. * `--destination-repository` - The name of the repository in which to create or update the CodeQL Action. If not specified `github/codeql-action` will be used. +* `--actions-admin-user` - The name of the Actions admin user, which will be used if you are updating the bundled CodeQL Action. If not specified `actions-admin` will be used. * `--force` - By default the tool will not overwrite existing repositories. Providing this flag will allow it to. * `--push-ssh` - Push Git contents over SSH rather than HTTPS. To use this option you must have SSH access to your GitHub Enterprise instance configured. diff --git a/cmd/push.go b/cmd/push.go index 771b89d..c90117f 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -13,7 +13,7 @@ var pushCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { version.LogVersion() cacheDirectory := cachedirectory.NewCacheDirectory(rootFlags.cacheDir) - return push.Push(cmd.Context(), cacheDirectory, pushFlags.destinationURL, pushFlags.destinationToken, pushFlags.destinationRepository, pushFlags.force, pushFlags.pushSSH) + return push.Push(cmd.Context(), cacheDirectory, pushFlags.destinationURL, pushFlags.destinationToken, pushFlags.destinationRepository, pushFlags.actionsAdminUser, pushFlags.force, pushFlags.pushSSH) }, } @@ -21,6 +21,7 @@ type pushFlagFields struct { destinationURL string destinationToken string destinationRepository string + actionsAdminUser string force bool pushSSH bool } @@ -33,6 +34,7 @@ func (f *pushFlagFields) Init(cmd *cobra.Command) { cmd.Flags().StringVar(&f.destinationToken, "destination-token", "", "A token to access the API on the GitHub Enterprise instance.") cmd.MarkFlagRequired("destination-token") cmd.Flags().StringVar(&f.destinationRepository, "destination-repository", "github/codeql-action", "The name of the repository to create on GitHub Enterprise.") + cmd.Flags().StringVar(&f.actionsAdminUser, "actions-admin-user", "actions-admin", "The name of the Actions admin user.") cmd.Flags().BoolVar(&f.force, "force", false, "Replace the existing repository even if it was not created by the sync tool.") cmd.Flags().BoolVar(&f.pushSSH, "push-ssh", false, "Push Git contents over SSH rather than HTTPS. To use this option you must have SSH access to your GitHub Enterprise instance configured.") } diff --git a/cmd/sync.go b/cmd/sync.go index bf7509d..6e8b059 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -18,7 +18,7 @@ var syncCmd = &cobra.Command{ if err != nil { return err } - err = push.Push(cmd.Context(), cacheDirectory, pushFlags.destinationURL, pushFlags.destinationToken, pushFlags.destinationRepository, pushFlags.force, pushFlags.pushSSH) + err = push.Push(cmd.Context(), cacheDirectory, pushFlags.destinationURL, pushFlags.destinationToken, pushFlags.destinationRepository, pushFlags.actionsAdminUser, pushFlags.force, pushFlags.pushSSH) if err != nil { return err } diff --git a/internal/githubapiutil/githubapiutil.go b/internal/githubapiutil/githubapiutil.go index 5e5614f..c9dc867 100644 --- a/internal/githubapiutil/githubapiutil.go +++ b/internal/githubapiutil/githubapiutil.go @@ -8,7 +8,7 @@ import ( const xOAuthScopesHeader = "X-OAuth-Scopes" -func MissingAllScopes(response *github.Response, requiredAnyScopes ...string) bool { +func HasAnyScope(response *github.Response, scopes ...string) bool { if response == nil { return false } @@ -18,11 +18,11 @@ func MissingAllScopes(response *github.Response, requiredAnyScopes ...string) bo actualScopes := strings.Split(response.Header.Get(xOAuthScopesHeader), ",") for _, actualScope := range actualScopes { actualScope = strings.Trim(actualScope, " ") - for _, requiredAnyScope := range requiredAnyScopes { - if actualScope == requiredAnyScope { - return false + for _, requiredScope := range scopes { + if actualScope == requiredScope { + return true } } } - return true + return false } diff --git a/internal/githubapiutil/githubapiutil_test.go b/internal/githubapiutil/githubapiutil_test.go index c909037..7b856b1 100644 --- a/internal/githubapiutil/githubapiutil_test.go +++ b/internal/githubapiutil/githubapiutil_test.go @@ -9,17 +9,17 @@ import ( "github.com/google/go-github/v32/github" ) -func TestHasAnyScopes(t *testing.T) { +func TestHasAnyScope(t *testing.T) { response := github.Response{ Response: &http.Response{Header: http.Header{}}, } response.Header.Set(xOAuthScopesHeader, "gist, notifications, admin:org, repo") - require.False(t, MissingAllScopes(&response, "public_repo", "repo")) + require.True(t, HasAnyScope(&response, "public_repo", "repo")) response.Header.Set(xOAuthScopesHeader, "gist, notifications, public_repo, admin:org") - require.False(t, MissingAllScopes(&response, "public_repo", "repo")) + require.True(t, HasAnyScope(&response, "public_repo", "repo")) response.Header.Set(xOAuthScopesHeader, "gist, notifications, admin:org") - require.True(t, MissingAllScopes(&response, "public_repo", "repo")) + require.False(t, HasAnyScope(&response, "public_repo", "repo")) } diff --git a/internal/push/push.go b/internal/push/push.go index c03aabd..f14eb68 100644 --- a/internal/push/push.go +++ b/internal/push/push.go @@ -43,7 +43,8 @@ type pushService struct { githubEnterpriseClient *github.Client destinationRepositoryName string destinationRepositoryOwner string - destinationToken string + destinationToken *oauth2.Token + actionsAdminUser string force bool pushSSH bool } @@ -76,12 +77,25 @@ func (pushService *pushService) createRepository() (*github.Repository, error) { Name: github.String(pushService.destinationRepositoryOwner), }, user.GetLogin()) if err != nil { - if response != nil && response.StatusCode == http.StatusNotFound && githubapiutil.MissingAllScopes(response, "site_admin") { + if response != nil && response.StatusCode == http.StatusNotFound && !githubapiutil.HasAnyScope(response, "site_admin") { return nil, usererrors.New("The destination token you have provided does not have the `site_admin` scope, so the destination organization cannot be created.") } return nil, errors.Wrap(err, "Error creating organization.") } } + + _, response, err = pushService.githubEnterpriseClient.Organizations.GetOrgMembership(pushService.ctx, user.GetLogin(), pushService.destinationRepositoryOwner) + if err != nil && (response == nil || response.StatusCode != http.StatusNotFound) { + return nil, errors.Wrap(err, "Failed to check membership of destination organization.") + } + if err != nil && githubapiutil.HasAnyScope(response, "site_admin") { + log.Debugf("No access to destination organization. Switching to impersonation token for %s...", pushService.actionsAdminUser) + impersonationToken, _, err := pushService.githubEnterpriseClient.Admin.CreateUserImpersonation(pushService.ctx, pushService.actionsAdminUser, &github.ImpersonateUserOptions{Scopes: []string{"public_repo", "workflow"}}) + if err != nil { + return nil, errors.Wrap(err, "Failed to impersonate Actions admin user.") + } + pushService.destinationToken.AccessToken = impersonationToken.GetToken() + } } repository, response, err := pushService.githubEnterpriseClient.Repositories.Get(pushService.ctx, pushService.destinationRepositoryOwner, pushService.destinationRepositoryName) @@ -105,7 +119,7 @@ func (pushService *pushService) createRepository() (*github.Repository, error) { if response.StatusCode == http.StatusNotFound { repository, response, err = pushService.githubEnterpriseClient.Repositories.Create(pushService.ctx, destinationOrganization, &desiredRepositoryProperties) if err != nil { - if response.StatusCode == http.StatusNotFound && githubapiutil.MissingAllScopes(response, "public_repo", "repo") { + if response.StatusCode == http.StatusNotFound && !githubapiutil.HasAnyScope(response, "public_repo", "repo") { return nil, usererrors.New("The destination token you have provided does not have the `public_repo` scope.") } return nil, errors.Wrap(err, "Error creating destination repository.") @@ -113,8 +127,12 @@ func (pushService *pushService) createRepository() (*github.Repository, error) { } else { repository, response, err = pushService.githubEnterpriseClient.Repositories.Edit(pushService.ctx, pushService.destinationRepositoryOwner, pushService.destinationRepositoryName, &desiredRepositoryProperties) if err != nil { - if response.StatusCode == http.StatusNotFound && githubapiutil.MissingAllScopes(response, "public_repo", "repo") { - return nil, usererrors.New("The destination token you have provided does not have the `public_repo` scope.") + if response.StatusCode == http.StatusNotFound { + if !githubapiutil.HasAnyScope(response, "public_repo", "repo") { + return nil, usererrors.New("The destination token you have provided does not have the `public_repo` scope.") + } else { + return nil, fmt.Errorf("You don't have permission to update the repository at %s/%s. If you wish to update the bundled CodeQL Action please provide a token with the `site_admin` scope.", pushService.destinationRepositoryOwner, pushService.destinationRepositoryName) + } } return nil, errors.Wrap(err, "Error updating destination repository.") } @@ -145,7 +163,7 @@ func (pushService *pushService) pushGit(repository *github.Repository, initialPu credentials := &githttp.BasicAuth{ Username: "x-access-token", - Password: pushService.destinationToken, + Password: pushService.destinationToken.AccessToken, } if pushService.pushSSH { // Use the SSH key from the environment. @@ -327,7 +345,7 @@ func (pushService *pushService) pushReleases() error { return nil } -func Push(ctx context.Context, cacheDirectory cachedirectory.CacheDirectory, destinationURL string, destinationToken string, destinationRepository string, force bool, pushSSH bool) error { +func Push(ctx context.Context, cacheDirectory cachedirectory.CacheDirectory, destinationURL string, destinationToken string, destinationRepository string, actionsAdminUser string, force bool, pushSSH bool) error { err := cacheDirectory.CheckOrCreateVersionFile(false, version.Version()) if err != nil { return err @@ -338,8 +356,9 @@ func Push(ctx context.Context, cacheDirectory cachedirectory.CacheDirectory, des } destinationURL = strings.TrimRight(destinationURL, "/") + token := oauth2.Token{AccessToken: destinationToken} tokenSource := oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: destinationToken}, + &token, ) tokenClient := oauth2.NewClient(ctx, tokenSource) client, err := github.NewEnterpriseClient(destinationURL+"/api/v3", destinationURL+"/api/uploads", tokenClient) @@ -357,7 +376,8 @@ func Push(ctx context.Context, cacheDirectory cachedirectory.CacheDirectory, des githubEnterpriseClient: client, destinationRepositoryOwner: destinationRepositoryOwner, destinationRepositoryName: destinationRepositoryName, - destinationToken: destinationToken, + destinationToken: &token, + actionsAdminUser: actionsAdminUser, force: force, pushSSH: pushSSH, } diff --git a/internal/push/push_test.go b/internal/push/push_test.go index 2487224..f4206af 100644 --- a/internal/push/push_test.go +++ b/internal/push/push_test.go @@ -15,6 +15,7 @@ import ( "github.com/go-git/go-git/v5" "github.com/gorilla/mux" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" "github.com/google/go-github/v32/github" ) @@ -29,13 +30,14 @@ func getTestPushService(t *testing.T, cacheDirectoryString string, githubEnterpr } else { githubEnterpriseClient = nil } + token := oauth2.Token{AccessToken: "token"} return pushService{ ctx: context.Background(), cacheDirectory: cacheDirectory, githubEnterpriseClient: githubEnterpriseClient, destinationRepositoryOwner: "destination-repository-owner", destinationRepositoryName: "destination-repository-name", - destinationToken: "token", + destinationToken: &token, } }