diff --git a/examples/app_authentication/README.md b/examples/app_authentication/README.md index 7f2852d8dc..8bb0683f6c 100644 --- a/examples/app_authentication/README.md +++ b/examples/app_authentication/README.md @@ -10,7 +10,7 @@ You may use variables passed via command line: export GITHUB_OWNER= export GITHUB_APP_ID= export GITHUB_APP_INSTALLATION_ID= -export GITHUB_APP_PEM_FILE= +export GITHUB_APP_PRIVATE_KEY= ``` ```console diff --git a/examples/app_authentication/providers.tf b/examples/app_authentication/providers.tf index df2b382473..60f67e29b6 100644 --- a/examples/app_authentication/providers.tf +++ b/examples/app_authentication/providers.tf @@ -1,10 +1,8 @@ provider "github" { - owner = var.owner - app_auth { - // Empty block to allow the provider configurations to be specified through - // environment variables. - // See: https://github.com/hashicorp/terraform-plugin-sdk/issues/142 - } + owner = var.owner + auth_mode = "app" + # Credentials are specified through environment variables: + # GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, GITHUB_APP_PRIVATE_KEY } terraform { diff --git a/github/provider.go b/github/provider.go index 2d5d6dc33a..e0b801b705 100644 --- a/github/provider.go +++ b/github/provider.go @@ -10,19 +10,27 @@ import ( "strings" "time" + "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) func Provider() *schema.Provider { p := &schema.Provider{ Schema: map[string]*schema.Schema{ + "auth_mode": { + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("GITHUB_AUTH_MODE", nil), + Description: descriptions["auth_mode"], + ValidateFunc: validation.StringInSlice([]string{"anonymous", "token", "app"}, false), + }, "token": { Type: schema.TypeString, Optional: true, DefaultFunc: schema.EnvDefaultFunc("GITHUB_TOKEN", nil), Description: descriptions["token"], - // ConflictsWith: []string{"app_auth"}, // TODO: Enable as part of v7. }, "owner": { Type: schema.TypeString, @@ -94,11 +102,12 @@ func Provider() *schema.Provider { Description: descriptions["parallel_requests"], }, "app_auth": { - Type: schema.TypeList, - Optional: true, - MaxItems: 1, - Description: descriptions["app_auth"], - // ConflictsWith: []string{"token"}, // TODO: Enable as part of v7. + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Description: descriptions["app_auth"], + Deprecated: "Use top-level app_id, app_installation_id, and app_private_key instead.", + ConflictsWith: []string{"app_id", "app_installation_id", "app_private_key"}, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "id": { @@ -123,6 +132,28 @@ func Provider() *schema.Provider { }, }, }, + "app_id": { + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_ID", nil), + Description: descriptions["app_id"], + ConflictsWith: []string{"app_auth"}, + }, + "app_installation_id": { + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_INSTALLATION_ID", nil), + Description: descriptions["app_installation_id"], + ConflictsWith: []string{"app_auth"}, + }, + "app_private_key": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_PRIVATE_KEY", nil), + Description: descriptions["app_private_key"], + ConflictsWith: []string{"app_auth"}, + }, // https://developer.github.com/guides/traversing-with-pagination/#basics-of-pagination "max_per_page": { Type: schema.TypeInt, @@ -307,8 +338,11 @@ var descriptions map[string]string func init() { descriptions = map[string]string{ - "token": "The OAuth token used to connect to GitHub. Anonymous mode is enabled if both `token` and " + - "`app_auth` are not set.", + "auth_mode": "Explicit authentication mode. Valid values are `anonymous`, `token`, and `app`. " + + "When not set, the provider auto-detects the mode based on provided credentials for backward compatibility.", + + "token": "The OAuth token used to connect to GitHub. " + + "When `auth_mode` is not set, anonymous mode is enabled if no credentials are provided.", "base_url": "The GitHub Base API URL", @@ -320,11 +354,14 @@ func init() { "organization": "The GitHub organization name to manage. " + "Use this field instead of `owner` when managing organization accounts.", - "app_auth": "The GitHub App credentials used to connect to GitHub. Conflicts with " + - "`token`. Anonymous mode is enabled if both `token` and `app_auth` are not set.", + "app_auth": "Deprecated: use top-level `app_id`, `app_installation_id`, and `app_private_key` instead. " + + "The GitHub App credentials used to connect to GitHub.", "app_auth.id": "The GitHub App ID.", "app_auth.installation_id": "The GitHub App installation instance ID.", "app_auth.pem_file": "The GitHub App PEM file contents.", + "app_id": "The GitHub App ID.", + "app_installation_id": "The GitHub App installation instance ID.", + "app_private_key": "The GitHub App private key in PEM format.", "write_delay_ms": "Amount of time in milliseconds to sleep in between writes to GitHub API. " + "Defaults to 1000ms or 1s if not set.", "read_delay_ms": "Amount of time in milliseconds to sleep in between non-write requests to GitHub API. " + @@ -347,9 +384,11 @@ func init() { func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { return func(ctx context.Context, d *schema.ResourceData) (any, diag.Diagnostics) { + var diags diag.Diagnostics + owner := d.Get("owner").(string) - token := d.Get("token").(string) insecure := d.Get("insecure").(bool) + authMode := d.Get("auth_mode").(string) // BEGIN backwards compatibility // OwnerOrOrgEnvDefaultFunc used to be the default value for both @@ -374,38 +413,41 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { org := d.Get("organization").(string) if org != "" { - log.Printf("[INFO] Selecting organization attribute as owner: %s", org) + tflog.Info(ctx, "Selecting organization attribute as owner", map[string]any{"owner": org}) owner = org } - if appAuth, ok := d.Get("app_auth").([]any); ok && len(appAuth) > 0 && appAuth[0] != nil { - appAuthAttr := appAuth[0].(map[string]any) + var token string - var appID, appInstallationID, appPemFile string + switch authMode { + case "anonymous": + tflog.Info(ctx, "Auth mode: anonymous") - if v, ok := appAuthAttr["id"].(string); ok && v != "" { - appID = v - } else { - return nil, wrapErrors([]error{fmt.Errorf("app_auth.id must be set and contain a non-empty value")}) + case "token": + token = d.Get("token").(string) + if token == "" { + return nil, diag.Errorf( + "auth_mode is set to \"token\" but no token was provided; " + + "set the `token` argument or `GITHUB_TOKEN` environment variable") } + tflog.Info(ctx, "Auth mode: token") - if v, ok := appAuthAttr["installation_id"].(string); ok && v != "" { - appInstallationID = v - } else { - return nil, wrapErrors([]error{fmt.Errorf("app_auth.installation_id must be set and contain a non-empty value")}) + case "app": + appID, appInstallationID, appPemFile := getAppCredentials(d) + var missingFields []string + if appID == "" { + missingFields = append(missingFields, "app_id (GITHUB_APP_ID)") } - - if v, ok := appAuthAttr["pem_file"].(string); ok && v != "" { - // The Go encoding/pem package only decodes PEM formatted blocks - // that contain new lines. Some platforms, like Terraform Cloud, - // do not support new lines within Environment Variables. - // Any occurrence of \n in the `pem_file` argument's value - // (explicit value, or default value taken from - // GITHUB_APP_PEM_FILE Environment Variable) is replaced with an - // actual new line character before decoding. - appPemFile = strings.ReplaceAll(v, `\n`, "\n") - } else { - return nil, wrapErrors([]error{fmt.Errorf("app_auth.pem_file must be set and contain a non-empty value")}) + if appInstallationID == "" { + missingFields = append(missingFields, "app_installation_id (GITHUB_APP_INSTALLATION_ID)") + } + if appPemFile == "" { + missingFields = append(missingFields, "app_private_key (GITHUB_APP_PRIVATE_KEY)") + } + if len(missingFields) > 0 { + return nil, diag.Errorf( + "auth_mode is set to \"app\" but the following app credentials are missing: %s", + strings.Join(missingFields, ", ")) } apiPath := "" @@ -419,36 +461,81 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { } token = appToken - } + tflog.Info(ctx, "Auth mode: app", map[string]any{"app_id": appID, "installation_id": appInstallationID}) + + default: // auto-detect (backward compatibility) + token = d.Get("token").(string) + + if token == "" { + // Top-level app fields require an explicit auth_mode. + // Skip this check when app_auth is configured for backward compatibility. + appAuth, _ := d.Get("app_auth").([]any) + hasAppAuth := len(appAuth) > 0 && appAuth[0] != nil + topLevelAppSet := d.Get("app_id").(string) != "" || + d.Get("app_installation_id").(string) != "" || + d.Get("app_private_key").(string) != "" + if topLevelAppSet && !hasAppAuth { + return nil, diag.Errorf( + "top-level app credentials (app_id, app_installation_id, app_private_key) " + + "require auth_mode = \"app\" to be set explicitly; use the `auth_mode` " + + "provider argument or the GITHUB_AUTH_MODE environment variable") + } + + appID, appInstallationID, appPemFile := getAppCredentials(d) + if appID != "" && appInstallationID != "" && appPemFile != "" { + apiPath := "" + if isGHES { + apiPath = GHESRESTAPIPath + } + + appToken, err := GenerateOAuthTokenFromApp(baseURL.JoinPath(apiPath), appID, appInstallationID, appPemFile) + if err != nil { + return nil, wrapErrors([]error{err}) + } + token = appToken + tflog.Info(ctx, "Auth mode: app", map[string]any{"app_id": appID, "installation_id": appInstallationID}) + } + } - if token == "" { - log.Printf("[INFO] No token found, using GitHub CLI to get token from hostname %s", baseURL.Host) - token = tokenFromGHCLI(baseURL) + if token == "" { + tflog.Info(ctx, "No token found, trying GitHub CLI to get token", map[string]any{"hostname": baseURL.Host}) + ghToken := tokenFromGHCLI(baseURL) + if ghToken != "" { + token = ghToken + diags = append(diags, diag.Diagnostic{ + Severity: diag.Warning, + Summary: "GitHub CLI token fallback is deprecated", + Detail: "Automatic token detection from `gh auth token` is deprecated and will be removed in a future major release. " + + "Please set the `token` provider argument or `GITHUB_TOKEN` environment variable explicitly. " + + "You can use `export GITHUB_TOKEN=$(gh auth token)` as a replacement.", + }) + } + } } writeDelay := d.Get("write_delay_ms").(int) if writeDelay <= 0 { return nil, wrapErrors([]error{fmt.Errorf("write_delay_ms must be greater than 0ms")}) } - log.Printf("[INFO] Setting write_delay_ms to %d", writeDelay) + tflog.Info(ctx, "Setting write_delay_ms", map[string]any{"write_delay_ms": writeDelay}) readDelay := d.Get("read_delay_ms").(int) if readDelay < 0 { return nil, wrapErrors([]error{fmt.Errorf("read_delay_ms must be greater than or equal to 0ms")}) } - log.Printf("[DEBUG] Setting read_delay_ms to %d", readDelay) + tflog.Debug(ctx, "Setting read_delay_ms", map[string]any{"read_delay_ms": readDelay}) retryDelay := d.Get("read_delay_ms").(int) if retryDelay < 0 { - return nil, diag.FromErr(fmt.Errorf("retry_delay_ms must be greater than or equal to 0ms")) + return nil, diag.Errorf("retry_delay_ms must be greater than or equal to 0ms") } - log.Printf("[DEBUG] Setting retry_delay_ms to %d", retryDelay) + tflog.Debug(ctx, "Setting retry_delay_ms", map[string]any{"retry_delay_ms": retryDelay}) maxRetries := d.Get("max_retries").(int) if maxRetries < 0 { - return nil, diag.FromErr(fmt.Errorf("max_retries must be greater than or equal to 0")) + return nil, diag.Errorf("max_retries must be greater than or equal to 0") } - log.Printf("[DEBUG] Setting max_retries to %d", maxRetries) + tflog.Debug(ctx, "Setting max_retries", map[string]any{"max_retries": maxRetries}) retryableErrors := make(map[int]bool) if maxRetries > 0 { reParam := d.Get("retryable_errors").([]any) @@ -460,19 +547,19 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { } } - log.Printf("[DEBUG] Setting retriableErrors to %v", retryableErrors) + tflog.Debug(ctx, "Setting retryable_errors", map[string]any{"retryable_errors": retryableErrors}) } _maxPerPage := d.Get("max_per_page").(int) if _maxPerPage <= 0 { - return nil, diag.FromErr(fmt.Errorf("max_per_page must be greater than than 0")) + return nil, diag.Errorf("max_per_page must be greater than than 0") } - log.Printf("[DEBUG] Setting max_per_page to %d", _maxPerPage) + tflog.Debug(ctx, "Setting max_per_page", map[string]any{"max_per_page": _maxPerPage}) maxPerPage = _maxPerPage parallelRequests := d.Get("parallel_requests").(bool) - log.Printf("[DEBUG] Setting parallel_requests to %t", parallelRequests) + tflog.Debug(ctx, "Setting parallel_requests", map[string]any{"parallel_requests": parallelRequests}) config := Config{ Token: token, @@ -493,10 +580,54 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { return nil, wrapErrors([]error{err}) } - return meta, nil + return meta, diags } } +func getAppCredentials(d *schema.ResourceData) (appID, appInstallationID, appPemFile string) { + // Try top-level fields first + if v, ok := d.Get("app_id").(string); ok && v != "" { + appID = v + } + if v, ok := d.Get("app_installation_id").(string); ok && v != "" { + appInstallationID = v + } + if v, ok := d.Get("app_private_key").(string); ok && v != "" { + // The Go encoding/pem package only decodes PEM formatted blocks + // that contain new lines. Some platforms, like Terraform Cloud, + // do not support new lines within Environment Variables. + // Any occurrence of \n in the `app_private_key` argument's value + // (explicit value, or default value taken from + // GITHUB_APP_PRIVATE_KEY Environment Variable) is replaced with an + // actual new line character before decoding. + appPemFile = strings.ReplaceAll(v, `\n`, "\n") + } + + // Fall back to app_auth block for any missing values + if appID == "" || appInstallationID == "" || appPemFile == "" { + if appAuth, ok := d.Get("app_auth").([]any); ok && len(appAuth) > 0 && appAuth[0] != nil { + appAuthAttr := appAuth[0].(map[string]any) + if appID == "" { + if v, ok := appAuthAttr["id"].(string); ok && v != "" { + appID = v + } + } + if appInstallationID == "" { + if v, ok := appAuthAttr["installation_id"].(string); ok && v != "" { + appInstallationID = v + } + } + if appPemFile == "" { + if v, ok := appAuthAttr["pem_file"].(string); ok && v != "" { + appPemFile = strings.ReplaceAll(v, `\n`, "\n") + } + } + } + } + + return +} + // ghCLIHostFromAPIHost maps an API hostname to the corresponding // gh-CLI --hostname value. For example api.github.com -> github.com // and api..ghe.com -> .ghe.com. diff --git a/github/provider_test.go b/github/provider_test.go index bb9ab01400..5b2bc65764 100644 --- a/github/provider_test.go +++ b/github/provider_test.go @@ -232,6 +232,236 @@ data "github_ip_ranges" "test" {} }, }) }) + t.Run("auth_mode anonymous ignores GITHUB_TOKEN in env", func(t *testing.T) { + config := ` + provider "github" { + auth_mode = "anonymous" + } + data "github_ip_ranges" "test" {} + ` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { t.Setenv("GITHUB_TOKEN", "some-token-value"); t.Setenv("GH_PATH", "none-existent-path") }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + PlanOnly: true, + ExpectNonEmptyPlan: false, + }, + }, + }) + }) + + t.Run("auth_mode token errors when no token provided", func(t *testing.T) { + config := ` + provider "github" { + auth_mode = "token" + } + data "github_ip_ranges" "test" {} + ` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { t.Setenv("GITHUB_TOKEN", ""); t.Setenv("GH_PATH", "none-existent-path") }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`auth_mode is set to "token" but no token was provided`), + }, + }, + }) + }) + + t.Run("can be configured with auth_mode token", func(t *testing.T) { + config := fmt.Sprintf(` + provider "github" { + auth_mode = "token" + token = "%s" + owner = "%s" + }`, testAccConf.token, testAccConf.owner) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, individual) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + PlanOnly: true, + ExpectNonEmptyPlan: false, + }, + }, + }) + }) + + t.Run("auth_mode app errors when credentials are incomplete", func(t *testing.T) { + config := ` + provider "github" { + auth_mode = "app" + app_id = "12345" + } + data "github_ip_ranges" "test" {} + ` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + t.Setenv("GITHUB_APP_INSTALLATION_ID", "") + t.Setenv("GITHUB_APP_PRIVATE_KEY", "") + t.Setenv("GITHUB_APP_PEM_FILE", "") + }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`auth_mode is set to "app" but the following app credentials are missing`), + }, + }, + }) + }) + + t.Run("top-level app fields without auth_mode fail validation", func(t *testing.T) { + config := ` + provider "github" { + app_id = "123456" + app_installation_id = "1234567890" + app_private_key = "not-valid-pem" + } + data "github_ip_ranges" "test" {} + ` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { t.Setenv("GITHUB_TOKEN", "") }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`require auth_mode = "app" to be set explicitly`), + }, + }, + }) + }) + + t.Run("top-level app fields with GITHUB_AUTH_MODE env var pass validation", func(t *testing.T) { + config := ` + provider "github" { + app_id = "123456" + app_installation_id = "1234567890" + app_private_key = "not-valid-pem" + } + data "github_ip_ranges" "test" {} + ` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + t.Setenv("GITHUB_AUTH_MODE", "app") + t.Setenv("GITHUB_TOKEN", "") + }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + // Should fail on invalid PEM, not on "requires auth_mode" + Config: config, + ExpectError: regexp.MustCompile(`no decodeable PEM data found`), + }, + }, + }) + }) + + t.Run("app_auth block without auth_mode still auto-detects (backward compatibility)", func(t *testing.T) { + config := ` + provider "github" { + app_auth {} + } + data "github_ip_ranges" "test" {} + ` + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + t.Setenv("GITHUB_APP_ID", "12345") + t.Setenv("GITHUB_APP_INSTALLATION_ID", "67890") + t.Setenv("GITHUB_APP_PEM_FILE", "not-valid-pem") + t.Setenv("GITHUB_TOKEN", "") + }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + // Should fail on invalid PEM, NOT on "requires auth_mode" + Config: config, + ExpectError: regexp.MustCompile(`no decodeable PEM data found`), + }, + }, + }) + }) + + t.Run("app_auth and top-level app fields cannot both be explicitly configured", func(t *testing.T) { + config := ` + provider "github" { + app_id = "123456" + app_auth { + id = "123456" + installation_id = "1234567890" + pem_file = "not-valid-pem" + } + } + data "github_ip_ranges" "test" {} + ` + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`conflicts with`), + }, + }, + }) + }) + + t.Run("top-level app fields are ignored when auth_mode is set to token", func(t *testing.T) { + config := fmt.Sprintf(` + provider "github" { + auth_mode = "token" + token = "%s" + owner = "%s" + app_id = "123456" + app_installation_id = "1234567890" + app_private_key = "not-valid-pem" + } + data "github_ip_ranges" "test" {} + `, testAccConf.token, testAccConf.owner) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, individual) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + PlanOnly: true, + ExpectNonEmptyPlan: false, + }, + }, + }) + }) + + t.Run("auth_mode rejects invalid values", func(t *testing.T) { + config := ` + provider "github" { + auth_mode = "invalid" + } + data "github_ip_ranges" "test" {} + ` + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`expected auth_mode to be one of`), + }, + }, + }) + }) + t.Run("should not allow both token and app_auth to be configured", func(t *testing.T) { t.Skip("This would be a semver breaking change, this will be reinstated for v7.") config := fmt.Sprintf(` diff --git a/website/docs/index.html.markdown b/website/docs/index.html.markdown index 28408af769..3d6d9ad780 100644 --- a/website/docs/index.html.markdown +++ b/website/docs/index.html.markdown @@ -56,46 +56,80 @@ resource "github_membership" "membership_for_user_x" { ## Authentication -The GitHub provider offers multiple ways to authenticate with GitHub API. +The GitHub provider offers multiple ways to authenticate with GitHub API. You can explicitly set the authentication mode using the `auth_mode` argument, or let the provider auto-detect based on the provided credentials. -### GitHub CLI +### Explicit Authentication Mode (Recommended) -The GitHub provider taps into [GitHub CLI](https://cli.github.com/) authentication, where it picks up the token issued by [`gh auth login`](https://cli.github.com/manual/gh_auth_login) command. It is possible to specify the path to the `gh` executable in the `GH_PATH` environment variable, which is useful for when the GitHub Terraform provider can not properly determine its the path to GitHub CLI such as in the cygwin terminal. +Setting `auth_mode` explicitly is recommended for clarity and to avoid unexpected behavior. -### OAuth / Personal Access Token - -To authenticate using OAuth tokens, ensure that the `token` argument or the `GITHUB_TOKEN` environment variable is set. +#### Anonymous ```terraform provider "github" { - token = var.token # or `GITHUB_TOKEN` + auth_mode = "anonymous" } ``` -### GitHub App Installation +When `auth_mode` is set to `"anonymous"`, the provider will not use any credentials, even if `GITHUB_TOKEN` or other authentication environment variables are set. -To authenticate using a GitHub App installation, ensure that arguments in the `app_auth` block or the `GITHUB_APP_XXX` environment variables are set. -The `owner` parameter required in this situation. Leaving out will throw a `403 "Resource not accessible by integration"` error. +#### OAuth / Personal Access Token -Some API operations may not be available when using a GitHub App installation configuration. For more information, refer to the list of [supported endpoints](https://docs.github.com/en/rest/overview/endpoints-available-for-github-apps). +```terraform +provider "github" { + auth_mode = "token" + token = var.token # or `GITHUB_TOKEN` +} +``` + +When `auth_mode` is set to `"token"`, the provider requires the `token` argument or `GITHUB_TOKEN` environment variable. An error will be returned if no token is provided. + +#### GitHub App Installation ```terraform provider "github" { - owner = var.github_organization - app_auth { - id = var.app_id # or `GITHUB_APP_ID` - installation_id = var.app_installation_id # or `GITHUB_APP_INSTALLATION_ID` - pem_file = var.app_pem_file # or `GITHUB_APP_PEM_FILE` - } + auth_mode = "app" + owner = var.github_owner + app_id = var.app_id # or `GITHUB_APP_ID` + app_installation_id = var.app_installation_id # or `GITHUB_APP_INSTALLATION_ID` + app_private_key = var.app_private_key # or `GITHUB_APP_PRIVATE_KEY` } ``` -~> **Note:** When using environment variables, an empty `app_auth` block is required to allow provider configurations from environment variables to be specified. See: https://github.com/hashicorp/terraform-plugin-sdk/issues/142 +When `auth_mode` is set to `"app"`, the provider requires all three app credential arguments (`app_id`, `app_installation_id`, `app_private_key`) or their corresponding environment variables. The `owner` argument is also required when using app authentication. + +Some API operations may not be available when using a GitHub App installation configuration. For more information, refer to the list of [supported endpoints](https://docs.github.com/en/rest/overview/endpoints-available-for-github-apps). + +### Auto-detected Authentication (Default) + +When `auth_mode` is not set, the provider auto-detects the authentication method using the following priority: + +1. If `token` (or `GITHUB_TOKEN`) is set, use token-based authentication. +2. If app credentials (`app_id`, `app_installation_id`, `app_private_key`, or the deprecated `app_auth` block) are set, use GitHub App authentication. +3. If none of the above, fall back to the GitHub CLI (`gh auth token`). +4. If no credentials are found, operate in anonymous mode. + +This is equivalent to the pre-existing behavior and is preserved for backward compatibility. + +~> **Note:** Using `auth_mode = "anonymous"` is the only way to ensure the provider runs anonymously when `GITHUB_TOKEN` or GitHub CLI credentials are present in the environment. + +### GitHub CLI (Deprecated) + +~> The GitHub CLI token fallback is deprecated and will be removed in a future major release. Please set the `token` provider argument or `GITHUB_TOKEN` environment variable explicitly. You can use `export GITHUB_TOKEN=$(gh auth token)` as a replacement. + +### Deprecated: `app_auth` block + +~> The `app_auth` block is deprecated. Use the top-level `app_id`, `app_installation_id`, and `app_private_key` arguments instead. Note that the deprecated `app_auth` block reads from `GITHUB_APP_PEM_FILE`, while the new `app_private_key` argument reads from `GITHUB_APP_PRIVATE_KEY`. + +The `app_auth` block is still supported for backward compatibility: ```terraform provider "github" { owner = var.github_organization - app_auth {} # When using `GITHUB_APP_XXX` environment variables + app_auth { + id = var.app_id # or `GITHUB_APP_ID` + installation_id = var.app_installation_id # or `GITHUB_APP_INSTALLATION_ID` + pem_file = var.app_private_key # or `GITHUB_APP_PEM_FILE` + } } ``` @@ -103,6 +137,8 @@ provider "github" { The following arguments are supported in the `provider` block: +* `auth_mode` - (Optional) Explicit authentication mode. Valid values are `anonymous`, `token`, and `app`. When not set, the provider auto-detects the mode based on provided credentials for backward compatibility. Can also be sourced from the `GITHUB_AUTH_MODE` environment variable. + * `token` - (Optional) A GitHub OAuth / Personal Access Token. When not provided or made available via the `GITHUB_TOKEN` environment variable, the provider can only access resources available anonymously. * `base_url` - (Optional) This is the target GitHub base API endpoint. Providing a value is a requirement when working with GitHub Enterprise. It is optional to provide this value and it can also be sourced from the `GITHUB_BASE_URL` environment variable. The value must end with a slash, for example: `https://terraformtesting-ghe.westus.cloudapp.azure.com/` @@ -111,7 +147,13 @@ The following arguments are supported in the `provider` block: * `organization` - (Deprecated) This behaves the same as `owner`, which should be used instead. This value can also be sourced from the `GITHUB_ORGANIZATION` environment variable. -* `app_auth` - (Optional) Configuration block to use GitHub App installation token. When not provided, the provider can only access resources available anonymously. +* `app_id` - (Optional) This is the ID of the GitHub App. It can also be sourced from the `GITHUB_APP_ID` environment variable. + +* `app_installation_id` - (Optional) This is the ID of the GitHub App installation. It can also be sourced from the `GITHUB_APP_INSTALLATION_ID` environment variable. + +* `app_private_key` - (Optional) This is the contents of the GitHub App private key in PEM format. It can also be sourced from the `GITHUB_APP_PRIVATE_KEY` environment variable and may use `\n` instead of actual new lines. If you have a PEM file on disk, you can pass it in via `app_private_key = file("path/to/file.pem")`. + +* `app_auth` - (Optional, **Deprecated**: Use top-level `app_id`, `app_installation_id`, and `app_private_key` instead.) Configuration block to use GitHub App installation token. * `id` - (Required) This is the ID of the GitHub App. It can sourced from the `GITHUB_APP_ID` environment variable. * `installation_id` - (Required) This is the ID of the GitHub App installation. It can sourced from the `GITHUB_APP_INSTALLATION_ID` environment variable. * `pem_file` - (Required) This is the contents of the GitHub App private key PEM file. It can also be sourced from the `GITHUB_APP_PEM_FILE` environment variable and may use `\n` instead of actual new lines. @@ -126,8 +168,6 @@ The following arguments are supported in the `provider` block: * `max_retries` - (Optional) Number of times to retry a request after receiving an error status code. Defaults to 3 -Note: If you have a PEM file on disk, you can pass it in via `pem_file = file("path/to/file.pem")`. - For backwards compatibility, if more than one of `owner`, `organization`, `GITHUB_OWNER` and `GITHUB_ORGANIZATION` are set, the first in this list takes priority.