diff --git a/examples/app_token/main.tf b/examples/app_token/main.tf index 4bbead22a3..ecab44dfe4 100644 --- a/examples/app_token/main.tf +++ b/examples/app_token/main.tf @@ -13,3 +13,11 @@ data "github_app_token" "this" { installation_id = var.installation_id pem_file = file(var.pem_file_path) } + +data "github_app_token" "scoped" { + count = length(var.repositories) > 0 ? 1 : 0 + app_id = var.app_id + installation_id = var.installation_id + pem_file = file(var.pem_file_path) + repositories = var.repositories +} diff --git a/examples/app_token/variables.tf b/examples/app_token/variables.tf index 827af03b11..e45dca76be 100644 --- a/examples/app_token/variables.tf +++ b/examples/app_token/variables.tf @@ -9,3 +9,7 @@ variable "installation_id" { variable "pem_file_path" { type = string } + +variable "repositories" { + type = list(string) +} \ No newline at end of file diff --git a/github/apps.go b/github/apps.go index b35350b748..316495d9ab 100644 --- a/github/apps.go +++ b/github/apps.go @@ -1,6 +1,7 @@ package github import ( + "bytes" "crypto/x509" "encoding/json" "encoding/pem" @@ -18,12 +19,19 @@ import ( // GenerateOAuthTokenFromApp generates a GitHub OAuth access token from a set of valid GitHub App credentials. // The returned token can be used to interact with both GitHub's REST and GraphQL APIs. func GenerateOAuthTokenFromApp(apiURL *url.URL, appID, appInstallationID, pemData string) (string, error) { + return GenerateOAuthTokenFromAppWithRepositories(apiURL, appID, appInstallationID, pemData, nil) +} + +// GenerateOAuthTokenFromAppWithRepositories generates a GitHub OAuth access token from a set of valid GitHub App credentials, +// optionally scoped to specific repositories. If repositories is nil or empty, the token will have access to all +// repositories the installation has access to. +func GenerateOAuthTokenFromAppWithRepositories(apiURL *url.URL, appID, appInstallationID, pemData string, repositories []string) (string, error) { appJWT, err := generateAppJWT(appID, time.Now(), []byte(pemData)) if err != nil { return "", err } - token, err := getInstallationAccessToken(apiURL, appJWT, appInstallationID) + token, err := getInstallationAccessToken(apiURL, appJWT, appInstallationID, repositories) if err != nil { return "", err } @@ -31,14 +39,31 @@ func GenerateOAuthTokenFromApp(apiURL *url.URL, appID, appInstallationID, pemDat return token, nil } -func getInstallationAccessToken(apiURL *url.URL, jwt, installationID string) (string, error) { - req, err := http.NewRequest(http.MethodPost, apiURL.JoinPath("app/installations", installationID, "access_tokens").String(), nil) +func getInstallationAccessToken(apiURL *url.URL, jwt, installationID string, repositories []string) (string, error) { + var reqBody io.Reader + if len(repositories) > 0 { + body := struct { + Repositories []string `json:"repositories"` + }{ + Repositories: repositories, + } + bodyBytes, err := json.Marshal(body) + if err != nil { + return "", err + } + reqBody = bytes.NewReader(bodyBytes) + } + + req, err := http.NewRequest(http.MethodPost, apiURL.JoinPath("app/installations", installationID, "access_tokens").String(), reqBody) if err != nil { return "", err } req.Header.Add("Accept", "application/vnd.github.v3+json") req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt)) + if len(repositories) > 0 { + req.Header.Add("Content-Type", "application/json") + } res, err := http.DefaultClient.Do(req) if err != nil { diff --git a/github/apps_test.go b/github/apps_test.go index 769285a767..83a035301e 100644 --- a/github/apps_test.go +++ b/github/apps_test.go @@ -163,7 +163,7 @@ func TestGetInstallationAccessToken(t *testing.T) { t.Fatalf("could not parse test server url") } - accessToken, err := getInstallationAccessToken(u, fakeJWT, testGitHubAppInstallationID) + accessToken, err := getInstallationAccessToken(u, fakeJWT, testGitHubAppInstallationID, nil) if err != nil { t.Logf("Unexpected error: %s", err) t.Fail() diff --git a/github/data_source_github_app_token.go b/github/data_source_github_app_token.go index 69861e046b..9be82581c6 100644 --- a/github/data_source_github_app_token.go +++ b/github/data_source_github_app_token.go @@ -26,6 +26,12 @@ func dataSourceGithubAppToken() *schema.Resource { Required: true, Description: descriptions["app_auth.pem_file"], }, + "repositories": { + Type: schema.TypeList, + Optional: true, + Description: "List of repository names to scope the token to. If not specified, the token will have access to all repositories the installation has access to.", + Elem: &schema.Schema{Type: schema.TypeString}, + }, "token": { Type: schema.TypeString, Computed: true, @@ -50,7 +56,14 @@ func dataSourceGithubAppTokenRead(d *schema.ResourceData, meta any) error { // actual new line character before decoding. pemFile = strings.ReplaceAll(pemFile, `\n`, "\n") - token, err := GenerateOAuthTokenFromApp(meta.(*Owner).v3client.BaseURL, appID, installationID, pemFile) + var repositories []string + if v, ok := d.GetOk("repositories"); ok { + for _, repo := range v.([]any) { + repositories = append(repositories, repo.(string)) + } + } + + token, err := GenerateOAuthTokenFromAppWithRepositories(meta.(*Owner).v3client.BaseURL, appID, installationID, pemFile, repositories) if err != nil { return err } diff --git a/github/data_source_github_app_token_test.go b/github/data_source_github_app_token_test.go index 76a6710c6e..ac4877074b 100644 --- a/github/data_source_github_app_token_test.go +++ b/github/data_source_github_app_token_test.go @@ -51,6 +51,7 @@ func TestAccGithubAppTokenDataSource(t *testing.T) { "app_id": {Type: schema.TypeString}, "installation_id": {Type: schema.TypeString}, "pem_file": {Type: schema.TypeString}, + "repositories": {Type: schema.TypeList, Elem: &schema.Schema{Type: schema.TypeString}}, "token": {Type: schema.TypeString}, } @@ -72,4 +73,69 @@ func TestAccGithubAppTokenDataSource(t *testing.T) { t.Fail() } }) + + t.Run("creates a application token scoped to repositories", func(t *testing.T) { + expectedAccessToken := "ghs_scoped_token_12345" + + owner := "test-owner" + + pemData, err := os.ReadFile(testGitHubAppPrivateKeyFile) + if err != nil { + t.Logf("Unexpected error: %s", err) + t.Fail() + } + + ts := githubApiMock([]*mockResponse{ + { + ExpectedUri: fmt.Sprintf("/app/installations/%s/access_tokens", testGitHubAppInstallationID), + ExpectedHeaders: map[string]string{ + "Accept": "application/vnd.github.v3+json", + "Content-Type": "application/json", + }, + ExpectedBody: []byte(`{"repositories":["repo1","repo2"]}`), + ResponseBody: fmt.Sprintf(`{"token": "%s"}`, expectedAccessToken), + StatusCode: 201, + }, + }) + defer ts.Close() + + httpCl := http.DefaultClient + httpCl.Transport = http.DefaultTransport + + client := github.NewClient(httpCl) + u, _ := url.Parse(ts.URL + "/") + client.BaseURL = u + + meta := &Owner{ + name: owner, + v3client: client, + } + + testSchema := map[string]*schema.Schema{ + "app_id": {Type: schema.TypeString}, + "installation_id": {Type: schema.TypeString}, + "pem_file": {Type: schema.TypeString}, + "repositories": {Type: schema.TypeList, Elem: &schema.Schema{Type: schema.TypeString}}, + "token": {Type: schema.TypeString}, + } + + schema := schema.TestResourceDataRaw(t, testSchema, map[string]any{ + "app_id": testGitHubAppID, + "installation_id": testGitHubAppInstallationID, + "pem_file": string(pemData), + "repositories": []any{"repo1", "repo2"}, + "token": "", + }) + + err = dataSourceGithubAppTokenRead(schema, meta) + if err != nil { + t.Logf("Unexpected error: %s", err) + t.Fail() + } + + if schema.Get("token") != expectedAccessToken { + t.Logf("Expected %s, got %s", expectedAccessToken, schema.Get("token")) + t.Fail() + } + }) } diff --git a/website/docs/d/app_token.html.markdown b/website/docs/d/app_token.html.markdown index b224b5f609..29e3d12a19 100644 --- a/website/docs/d/app_token.html.markdown +++ b/website/docs/d/app_token.html.markdown @@ -19,6 +19,17 @@ data "github_app_token" "this" { } ``` +### Scoped to specific repositories + +```hcl +data "github_app_token" "scoped" { + app_id = "123456" + installation_id = "78910" + pem_file = file("foo/bar.pem") + repositories = ["my-repo", "another-repo"] +} +``` + ## Argument Reference The following arguments are supported: @@ -29,6 +40,8 @@ The following arguments are supported: * `pem_file` - (Required) This is the contents of the GitHub App private key PEM file. +* `repositories` - (Optional) List of repository names to scope the token to. If not specified, the token will have access to all repositories the installation has access to. + ## Attribute Reference The following additional attributes are exported: