Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

allow TF_TOKEN_... variables to specify host-specific credentials #477

Merged
merged 2 commits into from
Apr 21, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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, "=")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious here on readability: Would it be better to strings.Split() on = and compare each index rather than using ranges ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split might be slightly more readable, but this version doesn't do any array allocation and is shared with terraform already. I think I'd like to keep it.

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 @@ -241,22 +241,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