Skip to content

Commit

Permalink
Merge pull request #477 from hashicorp/brandonc/env_creds
Browse files Browse the repository at this point in the history
allow TF_TOKEN_... variables to specify host-specific credentials
  • Loading branch information
brandonc authored Apr 21, 2022
2 parents e2e451c + 3e53d5f commit 0afafc3
Show file tree
Hide file tree
Showing 5 changed files with 169 additions and 16 deletions.
101 changes: 101 additions & 0 deletions tfe/credentials.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion tfe/plugin_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 3 additions & 15 deletions tfe/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
63 changes: 63 additions & 0 deletions tfe/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions website/docs/index.html.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down

0 comments on commit 0afafc3

Please sign in to comment.