diff --git a/README.md b/README.md index c4c6ccd..07843dc 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ 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. 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 or update the repository in it. The organization can also be created manually or an existing organization that you own can be used, in which case the `repo` and `workflow` scopes are sufficient. The token can also be provided by setting the `CODEQL_ACTION_SYNC_TOOL_DESTINATION_TOKEN` environment variable. +* `--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. 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 or update the repository in it. The organization can also be created manually or an existing organization that you own can be used, in which case the `repo` and `workflow` scopes are sufficient. The token can also be provided by setting the `CODEQL_ACTION_SYNC_TOOL_DESTINATION_TOKEN` environment variable. A GitHub App installation token can be used instead together with the `--github-app-auth` flag. **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. @@ -29,6 +29,7 @@ From a machine with access to both GitHub.com and GitHub Enterprise Server use t * `--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. +* `--github-app-auth` - Authenticate using a GitHub App installation token provided as the `--destination-token`, rather than a Personal Access Token. See ["Authenticating with a GitHub App"](#authenticating-with-a-github-app). ### I don't have a machine that can access both GitHub.com and GitHub Enterprise Server. From a machine with access to GitHub.com use the `./codeql-action-sync pull` command to download a copy of the CodeQL Action and bundles to a local folder. @@ -43,7 +44,7 @@ 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. 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 or update the repository in it. The organization can also be created manually or an existing organization that you own can be used, in which case the `repo` and `workflow` scopes are sufficient. The token can also be provided by setting the `CODEQL_ACTION_SYNC_TOOL_DESTINATION_TOKEN` environment variable. +* `--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. 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 or update the repository in it. The organization can also be created manually or an existing organization that you own can be used, in which case the `repo` and `workflow` scopes are sufficient. The token can also be provided by setting the `CODEQL_ACTION_SYNC_TOOL_DESTINATION_TOKEN` environment variable. A GitHub App installation token can be used instead together with the `--github-app-auth` flag. **Optional Arguments:** * `--cache-dir` - The directory to which the Action was previously downloaded. @@ -51,6 +52,19 @@ Now use the `./codeql-action-sync push` command to upload the CodeQL Action and * `--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. +* `--github-app-auth` - Authenticate using a GitHub App installation token provided as the `--destination-token`, rather than a Personal Access Token. See ["Authenticating with a GitHub App"](#authenticating-with-a-github-app). + +## Authenticating with a GitHub App +Instead of a Personal Access Token, the `push` and `sync` commands can authenticate to GitHub Enterprise Server using a [GitHub App installation access token](https://docs.github.com/en/enterprise-server@latest/apps/creating-github-apps/authenticating-with-a-github-app/generating-an-installation-access-token-for-a-github-app). + +1. Create a GitHub App on your GitHub Enterprise Server instance with the following repository permissions: `Administration` (read and write), `Contents` (read and write), `Metadata` (read-only) and `Workflows` (read and write). +2. Install the GitHub App on the organization that owns the destination repository (`github` unless you specify a different `--destination-repository`). If the destination repository already exists, make sure the installation has access to it. +3. Generate an installation access token for the installation and provide it using `--destination-token` (or the `CODEQL_ACTION_SYNC_TOOL_DESTINATION_TOKEN` environment variable), together with the `--github-app-auth` flag. + +Installation access tokens are not associated with a user, so when using `--github-app-auth`: +* The destination organization must already exist, as it can't be created automatically. The destination repository can't be owned by a user account. +* The Actions admin user is not impersonated, so `--actions-admin-user` is ignored. +* Installation access tokens expire after one hour. If the token expires before the push completes, generate a new token and run the command again. Content that has already been uploaded will not be uploaded again. ## Contributing For more details on contributing improvements to this tool, see our [contributor guide](CONTRIBUTING.md). diff --git a/cmd/push.go b/cmd/push.go index 6b433d9..9b7f751 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -16,7 +16,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.actionsAdminUser, pushFlags.force, pushFlags.pushSSH, pushFlags.gitURL) + return push.Push(cmd.Context(), cacheDirectory, pushFlags.destinationURL, pushFlags.destinationToken, pushFlags.destinationRepository, pushFlags.actionsAdminUser, pushFlags.force, pushFlags.pushSSH, pushFlags.gitURL, pushFlags.githubAppAuth) }, } @@ -28,6 +28,7 @@ type pushFlagFields struct { force bool pushSSH bool gitURL string + githubAppAuth bool } var pushFlags = pushFlagFields{} @@ -46,6 +47,7 @@ func (f *pushFlagFields) Init(cmd *cobra.Command) { 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.") + cmd.Flags().BoolVar(&f.githubAppAuth, "github-app-auth", false, "Authenticate using a GitHub App installation token rather than a personal access token. The destination organization must already exist and the GitHub App must be installed on it.") cmd.Flags().StringVar(&f.gitURL, "git-url", "", "Use a custom Git URL for pushing the Action repository contents to.") cmd.Flags().MarkHidden("git-url") } diff --git a/cmd/sync.go b/cmd/sync.go index 7aec7b7..91997f0 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.actionsAdminUser, pushFlags.force, pushFlags.pushSSH, pushFlags.gitURL) + err = push.Push(cmd.Context(), cacheDirectory, pushFlags.destinationURL, pushFlags.destinationToken, pushFlags.destinationRepository, pushFlags.actionsAdminUser, pushFlags.force, pushFlags.pushSSH, pushFlags.gitURL, pushFlags.githubAppAuth) if err != nil { return err } diff --git a/internal/push/push.go b/internal/push/push.go index 05439b7..d7c3321 100644 --- a/internal/push/push.go +++ b/internal/push/push.go @@ -36,6 +36,8 @@ const repositoryHomepage = "https://github.com/github/codeql-action-sync-tool/" const errorAlreadyExists = "The destination repository already exists, but it was not created with the CodeQL Action sync tool. If you are sure you want to push the CodeQL Action to it, re-run this command with the `--force` flag." const errorInvalidDestinationToken = "The destination token you've provided is not valid." +const errorUseGitHubAppAuth = "If you are using a GitHub App installation token, re-run this command with the `--github-app-auth` flag." +const errorGitHubAppAccess = "When using GitHub App authentication, the destination organization must already exist, the GitHub App must be installed on it with access to the destination repository, and the GitHub App must have read and write access to administration, contents and workflows." const enterpriseAPIPath = "/api/v3" const enterpriseUploadsPath = "/api/uploads" @@ -54,25 +56,32 @@ type pushService struct { force bool pushSSH bool gitURL string + githubAppAuth bool } -func (pushService *pushService) createRepository() (*github.Repository, error) { - minimumRepositoryScope := "public_repo" - acceptableRepositoryScopes := []string{"public_repo", "repo"} - desiredVisibility := "public" - if pushService.aegis { - minimumRepositoryScope = "repo" - acceptableRepositoryScopes = []string{"repo"} - desiredVisibility = "internal" +func (pushService *pushService) isGitHubAppAccessError(response *github.Response) bool { + return pushService.githubAppAuth && response != nil && (response.StatusCode == http.StatusForbidden || response.StatusCode == http.StatusNotFound) +} + +// getDestinationOrganization returns the organization to create the destination repository in, or an empty string if it should be created under the current user. +// If necessary, it creates the organization or switches the destination token to an impersonation token for the Actions admin user. +func (pushService *pushService) getDestinationOrganization(minimumRepositoryScope string) (string, error) { + if pushService.githubAppAuth { + // GitHub App installation tokens have no user context, so we can't look up the current user, create organizations or impersonate the Actions admin user. + // The repository must therefore be in an existing organization that the GitHub App is installed on. + log.Debugf("Using GitHub App authentication. The destination repository will be created in the existing %s organization.", pushService.destinationRepositoryOwner) + return pushService.destinationRepositoryOwner, nil } - log.Debug("Ensuring repository exists...") user, response, err := pushService.githubEnterpriseClient.Users.Get(pushService.ctx, "") if err != nil { if response != nil && response.StatusCode == http.StatusUnauthorized { - return nil, usererrors.New(errorInvalidDestinationToken) + return "", usererrors.New(errorInvalidDestinationToken) + } + if response != nil && response.StatusCode == http.StatusForbidden { + return "", githubapiutil.EnrichResponseError(response, err, "Error getting current user. "+errorUseGitHubAppAuth) } - return nil, githubapiutil.EnrichResponseError(response, err, "Error getting current user.") + return "", githubapiutil.EnrichResponseError(response, err, "Error getting current user.") } // When creating a repository we can either create it in a named organization or under the current user (represented in go-github by an empty string). @@ -84,7 +93,7 @@ func (pushService *pushService) createRepository() (*github.Repository, error) { if destinationOrganization != "" { _, response, err := pushService.githubEnterpriseClient.Organizations.Get(pushService.ctx, pushService.destinationRepositoryOwner) if err != nil && (response == nil || response.StatusCode != http.StatusNotFound) { - return nil, githubapiutil.EnrichResponseError(response, err, "Error checking if destination organization exists.") + return "", githubapiutil.EnrichResponseError(response, err, "Error checking if destination organization exists.") } if response != nil && response.StatusCode == http.StatusNotFound { log.Debugf("The organization %s does not exist. Creating it...", pushService.destinationRepositoryOwner) @@ -94,26 +103,45 @@ func (pushService *pushService) createRepository() (*github.Repository, error) { }, user.GetLogin()) if err != nil { 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 "", usererrors.New("The destination token you have provided does not have the `site_admin` scope, so the destination organization cannot be created.") } - return nil, githubapiutil.EnrichResponseError(response, err, "Error creating organization.") + return "", githubapiutil.EnrichResponseError(response, err, "Error creating organization.") } } _, response, err = pushService.githubEnterpriseClient.Organizations.IsMember(pushService.ctx, pushService.destinationRepositoryOwner, user.GetLogin()) if err != nil { - return nil, githubapiutil.EnrichResponseError(response, err, "Failed to check membership of destination organization.") + return "", githubapiutil.EnrichResponseError(response, err, "Failed to check membership of destination organization.") } if (response.StatusCode == http.StatusFound || response.StatusCode == http.StatusNotFound) && githubapiutil.HasAnyScope(response, "site_admin") { log.Debugf("No access to destination organization (status code %d). Switching to impersonation token for %s...", response.StatusCode, pushService.actionsAdminUser) impersonationToken, response, err := pushService.githubEnterpriseClient.Admin.CreateUserImpersonation(pushService.ctx, pushService.actionsAdminUser, &github.ImpersonateUserOptions{Scopes: []string{minimumRepositoryScope, "workflow"}}) if err != nil { - return nil, githubapiutil.EnrichResponseError(response, err, "Failed to impersonate Actions admin user.") + return "", githubapiutil.EnrichResponseError(response, err, "Failed to impersonate Actions admin user.") } pushService.destinationToken.AccessToken = impersonationToken.GetToken() } } + return destinationOrganization, nil +} + +func (pushService *pushService) createRepository() (*github.Repository, error) { + minimumRepositoryScope := "public_repo" + acceptableRepositoryScopes := []string{"public_repo", "repo"} + desiredVisibility := "public" + if pushService.aegis { + minimumRepositoryScope = "repo" + acceptableRepositoryScopes = []string{"repo"} + desiredVisibility = "internal" + } + + log.Debug("Ensuring repository exists...") + destinationOrganization, err := pushService.getDestinationOrganization(minimumRepositoryScope) + if err != nil { + return nil, err + } + repository, response, err := pushService.githubEnterpriseClient.Repositories.Get(pushService.ctx, pushService.destinationRepositoryOwner, pushService.destinationRepositoryName) if err != nil && (response == nil || response.StatusCode != http.StatusNotFound) { return nil, githubapiutil.EnrichResponseError(response, err, "Error checking if destination repository exists.") @@ -140,6 +168,9 @@ func (pushService *pushService) createRepository() (*github.Repository, error) { log.Debug("Repository does not exist. Creating it...") repository, response, err = pushService.githubEnterpriseClient.Repositories.Create(pushService.ctx, destinationOrganization, &desiredRepositoryProperties) if err != nil { + if pushService.isGitHubAppAccessError(response) { + return nil, githubapiutil.EnrichResponseError(response, err, "Error creating destination repository. "+errorGitHubAppAccess) + } if response.StatusCode == http.StatusNotFound && !githubapiutil.HasAnyScope(response, acceptableRepositoryScopes...) { return nil, fmt.Errorf("The destination token you have provided does not have the `%s` scope.", minimumRepositoryScope) } @@ -149,6 +180,9 @@ func (pushService *pushService) createRepository() (*github.Repository, error) { log.Debug("Repository already exists. Updating its metadata...") repository, response, err = pushService.githubEnterpriseClient.Repositories.Edit(pushService.ctx, pushService.destinationRepositoryOwner, pushService.destinationRepositoryName, &desiredRepositoryProperties) if err != nil { + if pushService.isGitHubAppAccessError(response) { + return nil, githubapiutil.EnrichResponseError(response, err, "Error updating destination repository. "+errorGitHubAppAccess) + } if response.StatusCode == http.StatusNotFound { if !githubapiutil.HasAnyScope(response, acceptableRepositoryScopes...) { return nil, fmt.Errorf("The destination token you have provided does not have the `%s` scope.", minimumRepositoryScope) @@ -433,7 +467,7 @@ func (pushService *pushService) pushReleases() error { return nil } -func Push(ctx context.Context, cacheDirectory cachedirectory.CacheDirectory, destinationURL string, destinationToken string, destinationRepository string, actionsAdminUser string, force bool, pushSSH bool, gitURL string) error { +func Push(ctx context.Context, cacheDirectory cachedirectory.CacheDirectory, destinationURL string, destinationToken string, destinationRepository string, actionsAdminUser string, force bool, pushSSH bool, gitURL string, githubAppAuth bool) error { err := cacheDirectory.CheckOrCreateVersionFile(false, version.Version()) if err != nil { return err @@ -492,6 +526,7 @@ func Push(ctx context.Context, cacheDirectory cachedirectory.CacheDirectory, des force: force, pushSSH: pushSSH, gitURL: gitURL, + githubAppAuth: githubAppAuth, } repository, err := pushService.createRepository() diff --git a/internal/push/push_test.go b/internal/push/push_test.go index b31d429..8ed8f0d 100644 --- a/internal/push/push_test.go +++ b/internal/push/push_test.go @@ -127,6 +127,97 @@ func TestCreateOrganizationAndRepositoryWhenOrganizationIsOwner(t *testing.T) { require.NoError(t, err) } +func serveGitHubAppForbiddenResponse(t *testing.T, response http.ResponseWriter) { + response.WriteHeader(http.StatusForbidden) + test.ServeHTTPResponseFromString(t, `{"message": "Resource not accessible by integration"}`, response) +} + +func handleCurrentUserForGitHubAppAuth(t *testing.T, githubTestServer *mux.Router) { + githubTestServer.HandleFunc("/api/v3/user", func(response http.ResponseWriter, request *http.Request) { + t.Error("The current user should not be requested when using GitHub App authentication.") + serveGitHubAppForbiddenResponse(t, response) + }).Methods("GET") +} + +func TestCreateRepositoryWithGitHubAppAuth(t *testing.T) { + temporaryDirectory := test.CreateTemporaryDirectory(t) + githubTestServer, githubEnterpriseURL := test.GetTestHTTPServer(t) + pushService := getTestPushService(t, temporaryDirectory, githubEnterpriseURL) + pushService.githubAppAuth = true + handleCurrentUserForGitHubAppAuth(t, githubTestServer) + githubTestServer.HandleFunc("/api/v3/repos/destination-repository-owner/destination-repository-name", func(response http.ResponseWriter, request *http.Request) { + response.WriteHeader(http.StatusNotFound) + }).Methods("GET") + githubTestServer.HandleFunc("/api/v3/orgs/destination-repository-owner/repos", func(response http.ResponseWriter, request *http.Request) { + test.ServeHTTPResponseFromObject(t, github.Repository{}, response) + }).Methods("POST") + _, err := pushService.createRepository() + require.NoError(t, err) + require.Equal(t, "token", pushService.destinationToken.AccessToken) +} + +func TestUpdateRepositoryWithGitHubAppAuth(t *testing.T) { + temporaryDirectory := test.CreateTemporaryDirectory(t) + githubTestServer, githubEnterpriseURL := test.GetTestHTTPServer(t) + pushService := getTestPushService(t, temporaryDirectory, githubEnterpriseURL) + pushService.githubAppAuth = true + handleCurrentUserForGitHubAppAuth(t, githubTestServer) + githubTestServer.HandleFunc("/api/v3/repos/destination-repository-owner/destination-repository-name", func(response http.ResponseWriter, request *http.Request) { + test.ServeHTTPResponseFromObject(t, github.Repository{Homepage: github.String(repositoryHomepage)}, response) + }).Methods("GET") + githubTestServer.HandleFunc("/api/v3/repos/destination-repository-owner/destination-repository-name", func(response http.ResponseWriter, request *http.Request) { + test.ServeHTTPResponseFromObject(t, github.Repository{}, response) + }).Methods("PATCH") + _, err := pushService.createRepository() + require.NoError(t, err) + require.Equal(t, "token", pushService.destinationToken.AccessToken) +} + +func TestCreateRepositoryWithGitHubAppAuthWithoutPermission(t *testing.T) { + temporaryDirectory := test.CreateTemporaryDirectory(t) + githubTestServer, githubEnterpriseURL := test.GetTestHTTPServer(t) + pushService := getTestPushService(t, temporaryDirectory, githubEnterpriseURL) + pushService.githubAppAuth = true + handleCurrentUserForGitHubAppAuth(t, githubTestServer) + githubTestServer.HandleFunc("/api/v3/repos/destination-repository-owner/destination-repository-name", func(response http.ResponseWriter, request *http.Request) { + response.WriteHeader(http.StatusNotFound) + }).Methods("GET") + githubTestServer.HandleFunc("/api/v3/orgs/destination-repository-owner/repos", func(response http.ResponseWriter, request *http.Request) { + serveGitHubAppForbiddenResponse(t, response) + }).Methods("POST") + _, err := pushService.createRepository() + require.ErrorContains(t, err, errorGitHubAppAccess) + require.ErrorContains(t, err, "Resource not accessible by integration") +} + +func TestUpdateRepositoryWithGitHubAppAuthWithoutAccess(t *testing.T) { + temporaryDirectory := test.CreateTemporaryDirectory(t) + githubTestServer, githubEnterpriseURL := test.GetTestHTTPServer(t) + pushService := getTestPushService(t, temporaryDirectory, githubEnterpriseURL) + pushService.githubAppAuth = true + handleCurrentUserForGitHubAppAuth(t, githubTestServer) + githubTestServer.HandleFunc("/api/v3/repos/destination-repository-owner/destination-repository-name", func(response http.ResponseWriter, request *http.Request) { + test.ServeHTTPResponseFromObject(t, github.Repository{Homepage: github.String(repositoryHomepage)}, response) + }).Methods("GET") + githubTestServer.HandleFunc("/api/v3/repos/destination-repository-owner/destination-repository-name", func(response http.ResponseWriter, request *http.Request) { + response.WriteHeader(http.StatusNotFound) + }).Methods("PATCH") + _, err := pushService.createRepository() + require.ErrorContains(t, err, errorGitHubAppAccess) + require.NotContains(t, err.Error(), "scope") +} + +func TestCreateRepositoryWithGitHubAppTokenWithoutGitHubAppAuth(t *testing.T) { + temporaryDirectory := test.CreateTemporaryDirectory(t) + githubTestServer, githubEnterpriseURL := test.GetTestHTTPServer(t) + pushService := getTestPushService(t, temporaryDirectory, githubEnterpriseURL) + githubTestServer.HandleFunc("/api/v3/user", func(response http.ResponseWriter, request *http.Request) { + serveGitHubAppForbiddenResponse(t, response) + }).Methods("GET") + _, err := pushService.createRepository() + require.ErrorContains(t, err, errorUseGitHubAppAuth) +} + func TestPushGit(t *testing.T) { temporaryDirectory := test.CreateTemporaryDirectory(t) destinationPath := path.Join(temporaryDirectory, "target")