diff --git a/tfe/credentials.go b/tfe/credentials.go new file mode 100644 index 000000000..407ea521f --- /dev/null +++ b/tfe/credentials.go @@ -0,0 +1,101 @@ +package tfe + +import ( + "log" + "os" + "strings" + + svchost "github.com/hashicorp/terraform-svchost" + "github.com/hashicorp/terraform-svchost/disco" +) + +func collectCredentialsFromEnv() map[svchost.Hostname]string { + const prefix = "TF_TOKEN_" + + ret := make(map[svchost.Hostname]string) + for _, ev := range os.Environ() { + eqIdx := strings.Index(ev, "=") + if eqIdx < 0 { + continue + } + name := ev[:eqIdx] + value := ev[eqIdx+1:] + if !strings.HasPrefix(name, prefix) { + continue + } + rawHost := name[len(prefix):] + + // We accept double underscores in place of hyphens because hyphens are not valid + // identifiers in most shells and are therefore hard to set. + // This is unambiguous with replacing single underscores below because + // hyphens are not allowed at the beginning or end of a label and therefore + // odd numbers of underscores will not appear together in a valid variable name. + rawHost = strings.ReplaceAll(rawHost, "__", "-") + + // We accept underscores in place of dots because dots are not valid + // identifiers in most shells and are therefore hard to set. + // Underscores are not valid in hostnames, so this is unambiguous for + // valid hostnames. + rawHost = strings.ReplaceAll(rawHost, "_", ".") + + // Because environment variables are often set indirectly by OS + // libraries that might interfere with how they are encoded, we'll + // be tolerant of them being given either directly as UTF-8 IDNs + // or in Punycode form, normalizing to Punycode form here because + // that is what the Terraform credentials helper protocol will + // use in its requests. + // + // Using ForDisplay first here makes this more liberal than Terraform + // itself would usually be in that it will tolerate pre-punycoded + // hostnames that Terraform normally rejects in other contexts in order + // to ensure stored hostnames are human-readable. + dispHost := svchost.ForDisplay(rawHost) + hostname, err := svchost.ForComparison(dispHost) + if err != nil { + // Ignore invalid hostnames + continue + } + + ret[hostname] = value + } + + return ret +} + +// hostTokenFromFallbackSources returns a token credential by searching for a hostname-specific +// environment variable, a TFE_TOKEN, or a CLI config credentials block. The host parameter +// is expected to be in the "comparison" form, for example, hostnames containing non-ASCII +// characters like "café.fr" should be expressed as "xn--caf-dma.fr". If the variable based +// on the hostname is not defined, nil is returned. +// +// Hyphen and period characters are allowed in environment variable names, but are not valid POSIX +// variable names. However, it's still possible to set variable names with these characters using +// utilities like env or docker. Variable names may have periods translated to underscores and +// hyphens translated to double underscores in the variable name. +// For the example "café.fr", you may use the variable names "TF_TOKEN_xn____caf__dma_fr", +// "TF_TOKEN_xn--caf-dma_fr", or "TF_TOKEN_xn--caf-dma.fr" +func hostTokenFromFallbackSources(hostname svchost.Hostname, services *disco.Disco) string { + token, ok := collectCredentialsFromEnv()[hostname] + + if ok { + log.Printf("[DEBUG] TF_TOKEN_... used for token value for host %s", hostname) + } else { + // If a token wasn't set in the host-specific variable, try and fetch it + // from the environment or from Terraform's CLI configuration or configured credential helper. + if os.Getenv("TFE_TOKEN") != "" { + log.Printf("[DEBUG] TFE_TOKEN used for token value") + return os.Getenv("TFE_TOKEN") + } else if services != nil { + log.Printf("[DEBUG] Attempting to fetch token from Terraform CLI configuration for hostname %s...", hostname) + creds, err := services.CredentialsForHost(hostname) + if err != nil { + log.Printf("[DEBUG] Failed to get credentials for %s: %s (ignoring)", hostname, err) + } + if creds != nil { + token = creds.Token() + } + } + } + + return token +} diff --git a/tfe/plugin_provider_test.go b/tfe/plugin_provider_test.go index d3d1152ea..8e6be4589 100644 --- a/tfe/plugin_provider_test.go +++ b/tfe/plugin_provider_test.go @@ -67,7 +67,7 @@ func TestPluginProvider_providerMeta(t *testing.T) { } if tc.hostname == "" && meta.hostname != "" { - t.Fatalf("Test %s: hostname was not set in config and meta hostname should be empty in this moment (in retrieveProviderMeta). It is parsed later in wihtin the `getClient` function", name) + t.Fatalf("Test %s: hostname was not set in config and meta hostname should be empty in this moment (in retrieveProviderMeta). It is parsed later in within the `getClient` function", name) } if tc.hostname != "" && meta.hostname != tc.hostname { diff --git a/tfe/provider.go b/tfe/provider.go index d7a9aed5e..dfce910d0 100644 --- a/tfe/provider.go +++ b/tfe/provider.go @@ -240,22 +240,10 @@ func getClient(tfeHost, token string, insecure bool) (*tfe.Client, error) { return nil, discoErr } - // If a token wasn't set in the provider configuration block, try and fetch it - // from the environment or from Terraform's CLI configuration or configured credential helper. + // If a token wasn't set in the provider configuration block, try and fetch it from the + // fallback methods if token == "" { - if os.Getenv("TFE_TOKEN") != "" { - log.Printf("[DEBUG] TFE_TOKEN used for token value") - token = os.Getenv("TFE_TOKEN") - } else { - log.Printf("[DEBUG] Attempting to fetch token from Terraform CLI configuration for hostname %s...", hostname) - creds, err := services.CredentialsForHost(hostname) - if err != nil { - log.Printf("[DEBUG] Failed to get credentials for %s: %s (ignoring)", hostname, err) - } - if creds != nil { - token = creds.Token() - } - } + token = hostTokenFromFallbackSources(hostname, services) } // If we still don't have a token at this point, we return an error. diff --git a/tfe/provider_test.go b/tfe/provider_test.go index 9e7066b27..bad5746ec 100644 --- a/tfe/provider_test.go +++ b/tfe/provider_test.go @@ -14,6 +14,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" "github.com/hashicorp/terraform-provider-tfe/version" + svchost "github.com/hashicorp/terraform-svchost" "github.com/hashicorp/terraform-svchost/disco" ) @@ -125,6 +126,68 @@ func TestProvider_versionConstraints(t *testing.T) { } } +func TestProvider_hostTokenFromFallbackSources(t *testing.T) { + t.Run("configure with environment", func(t *testing.T) { + + cases := map[string]struct { + EnvVarKey string + EnvVarValue string + ConfiguredHost string + ExpectedValue string + }{ + "var with dashes": { + EnvVarKey: "TF_TOKEN_private_hashicorp-internal_engineering_pl", + EnvVarValue: "foo-token", + ConfiguredHost: "private.hashicorp-internal.engineering.pl", + ExpectedValue: "foo-token", + }, + "var with encoded dashes": { + EnvVarKey: "TF_TOKEN_private_hashicorp__internal_engineering_pl", + EnvVarValue: "bar-token", + ConfiguredHost: "private.hashicorp-internal.engineering.pl", + ExpectedValue: "bar-token", + }, + "var with periods": { + EnvVarKey: "TF_TOKEN_private.hashicorp-internal.engineering.pl", + EnvVarValue: "baz-token", + ConfiguredHost: "private.hashicorp-internal.engineering.pl", + ExpectedValue: "baz-token", + }, + "global TFE_TOKEN var": { + EnvVarKey: "TFE_TOKEN", + EnvVarValue: "quz-token", + ConfiguredHost: "private.hashicorp-internal.engineering.pl", + ExpectedValue: "quz-token", + }, + } + + for description, test := range cases { + t.Run(description, func(t *testing.T) { + beforeSet, wasSetBefore := os.LookupEnv(test.EnvVarKey) + t.Cleanup(func() { + if wasSetBefore { + os.Setenv(test.EnvVarKey, beforeSet) + } else { + os.Unsetenv(test.EnvVarKey) + } + }) + + os.Setenv(test.EnvVarKey, test.EnvVarValue) + + host, err := svchost.ForComparison(test.ConfiguredHost) + if err != nil { + t.Fatalf("could not get host: %s", err) + } + + token := hostTokenFromFallbackSources(host, nil) + if token != test.ExpectedValue { + t.Errorf("Expected token %s but found %s", test.ExpectedValue, token) + } + }) + } + }) +} + func TestProvider_locateConfigFile(t *testing.T) { originalHome := os.Getenv("HOME") originalTfCliConfigFile := os.Getenv("TF_CLI_CONFIG_FILE") diff --git a/website/docs/index.html.markdown b/website/docs/index.html.markdown index d39caaf04..c1517a6f9 100644 --- a/website/docs/index.html.markdown +++ b/website/docs/index.html.markdown @@ -42,6 +42,7 @@ There are several ways to provide the required token: - **Set the `token` argument in the provider configuration.** You can set the `token` argument in the provider configuration. Use an input variable for the token. +- **Set a host-specific environment variable:** Set a `TF_TOKEN_...` variable with the host name as the suffix, for example, `TF_TOKEN_app_terraform_io`. This example token will be used for authentication if `app.terraform.io` is configured as the host. This authentication method is shared by Terraform for all network services since version 1.2. - **Set the `TFE_TOKEN` environment variable:** The provider can read the `TFE_TOKEN` environment variable and the token stored there to authenticate.