| package cliconfig |
| |
| import ( |
| "bytes" |
| "encoding/json" |
| "fmt" |
| "io/ioutil" |
| "log" |
| "os" |
| "path/filepath" |
| "strings" |
| |
| "github.com/zclconf/go-cty/cty" |
| ctyjson "github.com/zclconf/go-cty/cty/json" |
| |
| svchost "github.com/hashicorp/terraform-svchost" |
| svcauth "github.com/hashicorp/terraform-svchost/auth" |
| "github.com/hashicorp/terraform/internal/configs/hcl2shim" |
| pluginDiscovery "github.com/hashicorp/terraform/internal/plugin/discovery" |
| "github.com/hashicorp/terraform/internal/replacefile" |
| ) |
| |
| // credentialsConfigFile returns the path for the special configuration file |
| // that the credentials source will use when asked to save or forget credentials |
| // and when a "credentials helper" program is not active. |
| func credentialsConfigFile() (string, error) { |
| configDir, err := ConfigDir() |
| if err != nil { |
| return "", err |
| } |
| return filepath.Join(configDir, "credentials.tfrc.json"), nil |
| } |
| |
| // CredentialsSource creates and returns a service credentials source whose |
| // behavior depends on which "credentials" and "credentials_helper" blocks, |
| // if any, are present in the receiving config. |
| func (c *Config) CredentialsSource(helperPlugins pluginDiscovery.PluginMetaSet) (*CredentialsSource, error) { |
| credentialsFilePath, err := credentialsConfigFile() |
| if err != nil { |
| // If we managed to load a Config object at all then we would already |
| // have located this file, so this error is very unlikely. |
| return nil, fmt.Errorf("can't locate credentials file: %s", err) |
| } |
| |
| var helper svcauth.CredentialsSource |
| var helperType string |
| for givenType, givenConfig := range c.CredentialsHelpers { |
| available := helperPlugins.WithName(givenType) |
| if available.Count() == 0 { |
| log.Printf("[ERROR] Unable to find credentials helper %q; ignoring", givenType) |
| break |
| } |
| |
| selected := available.Newest() |
| |
| helperSource := svcauth.HelperProgramCredentialsSource(selected.Path, givenConfig.Args...) |
| helper = svcauth.CachingCredentialsSource(helperSource) // cached because external operation may be slow/expensive |
| helperType = givenType |
| |
| // There should only be zero or one "credentials_helper" blocks. We |
| // assume that the config was validated earlier and so we don't check |
| // for extras here. |
| break |
| } |
| |
| return c.credentialsSource(helperType, helper, credentialsFilePath), nil |
| } |
| |
| // EmptyCredentialsSourceForTests constructs a CredentialsSource with |
| // no credentials pre-loaded and which writes new credentials to a file |
| // at the given path. |
| // |
| // As the name suggests, this function is here only for testing and should not |
| // be used in normal application code. |
| func EmptyCredentialsSourceForTests(credentialsFilePath string) *CredentialsSource { |
| cfg := &Config{} |
| return cfg.credentialsSource("", nil, credentialsFilePath) |
| } |
| |
| // credentialsSource is an internal factory for the credentials source which |
| // allows overriding the credentials file path, which allows setting it to |
| // a temporary file location when testing. |
| func (c *Config) credentialsSource(helperType string, helper svcauth.CredentialsSource, credentialsFilePath string) *CredentialsSource { |
| configured := map[svchost.Hostname]cty.Value{} |
| for userHost, creds := range c.Credentials { |
| host, err := svchost.ForComparison(userHost) |
| if err != nil { |
| // We expect the config was already validated by the time we get |
| // here, so we'll just ignore invalid hostnames. |
| continue |
| } |
| |
| // For now our CLI config continues to use HCL 1.0, so we'll shim it |
| // over to HCL 2.0 types. In future we will hopefully migrate it to |
| // HCL 2.0 instead, and so it'll be a cty.Value already. |
| credsV := hcl2shim.HCL2ValueFromConfigValue(creds) |
| configured[host] = credsV |
| } |
| |
| writableLocal := readHostsInCredentialsFile(credentialsFilePath) |
| unwritableLocal := map[svchost.Hostname]cty.Value{} |
| for host, v := range configured { |
| if _, exists := writableLocal[host]; !exists { |
| unwritableLocal[host] = v |
| } |
| } |
| |
| return &CredentialsSource{ |
| configured: configured, |
| unwritable: unwritableLocal, |
| credentialsFilePath: credentialsFilePath, |
| helper: helper, |
| helperType: helperType, |
| } |
| } |
| |
| 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 |
| } |
| |
| // hostCredentialsFromEnv returns a token credential by searching for a hostname-specific |
| // environment variable. 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 hostCredentialsFromEnv(host svchost.Hostname) svcauth.HostCredentials { |
| token, ok := collectCredentialsFromEnv()[host] |
| if !ok { |
| return nil |
| } |
| return svcauth.HostCredentialsToken(token) |
| } |
| |
| // CredentialsSource is an implementation of svcauth.CredentialsSource |
| // that can read and write the CLI configuration, and possibly also delegate |
| // to a credentials helper when configured. |
| type CredentialsSource struct { |
| // configured describes the credentials explicitly configured in the CLI |
| // config via "credentials" blocks. This map will also change to reflect |
| // any writes to the special credentials.tfrc.json file. |
| configured map[svchost.Hostname]cty.Value |
| |
| // unwritable describes any credentials explicitly configured in the |
| // CLI config in any file other than credentials.tfrc.json. We cannot update |
| // these automatically because only credentials.tfrc.json is subject to |
| // editing by this credentials source. |
| unwritable map[svchost.Hostname]cty.Value |
| |
| // credentialsFilePath is the full path to the credentials.tfrc.json file |
| // that we'll update if any changes to credentials are requested and if |
| // a credentials helper isn't available to use instead. |
| // |
| // (This is a field here rather than just calling credentialsConfigFile |
| // directly just so that we can use temporary file location instead during |
| // testing.) |
| credentialsFilePath string |
| |
| // helper is the credentials source representing the configured credentials |
| // helper, if any. When this is non-nil, it will be consulted for any |
| // hostnames not explicitly represented in "configured". Any writes to |
| // the credentials store will also be sent to a configured helper instead |
| // of the credentials.tfrc.json file. |
| helper svcauth.CredentialsSource |
| |
| // helperType is the name of the type of credentials helper that is |
| // referenced in "helper", or the empty string if "helper" is nil. |
| helperType string |
| } |
| |
| // Assertion that credentialsSource implements CredentialsSource |
| var _ svcauth.CredentialsSource = (*CredentialsSource)(nil) |
| |
| func (s *CredentialsSource) ForHost(host svchost.Hostname) (svcauth.HostCredentials, error) { |
| // The first order of precedence for credentials is a host-specific environment variable |
| if envCreds := hostCredentialsFromEnv(host); envCreds != nil { |
| return envCreds, nil |
| } |
| |
| // Then, any credentials block present in the CLI config |
| v, ok := s.configured[host] |
| if ok { |
| return svcauth.HostCredentialsFromObject(v), nil |
| } |
| |
| // And finally, the credentials helper |
| if s.helper != nil { |
| return s.helper.ForHost(host) |
| } |
| |
| return nil, nil |
| } |
| |
| func (s *CredentialsSource) StoreForHost(host svchost.Hostname, credentials svcauth.HostCredentialsWritable) error { |
| return s.updateHostCredentials(host, credentials) |
| } |
| |
| func (s *CredentialsSource) ForgetForHost(host svchost.Hostname) error { |
| return s.updateHostCredentials(host, nil) |
| } |
| |
| // HostCredentialsLocation returns a value indicating what type of storage is |
| // currently used for the credentials for the given hostname. |
| // |
| // The current location of credentials determines whether updates are possible |
| // at all and, if they are, where any updates will be written. |
| func (s *CredentialsSource) HostCredentialsLocation(host svchost.Hostname) CredentialsLocation { |
| if _, unwritable := s.unwritable[host]; unwritable { |
| return CredentialsInOtherFile |
| } |
| if _, exists := s.configured[host]; exists { |
| return CredentialsInPrimaryFile |
| } |
| if s.helper != nil { |
| return CredentialsViaHelper |
| } |
| return CredentialsNotAvailable |
| } |
| |
| // CredentialsFilePath returns the full path to the local credentials |
| // configuration file, so that a caller can mention this path in order to |
| // be transparent about where credentials will be stored. |
| // |
| // This file will be used for writes only if HostCredentialsLocation for the |
| // relevant host returns CredentialsInPrimaryFile or CredentialsNotAvailable. |
| // |
| // The credentials file path is found relative to the current user's home |
| // directory, so this function will return an error in the unlikely event that |
| // we cannot determine a suitable home directory to resolve relative to. |
| func (s *CredentialsSource) CredentialsFilePath() (string, error) { |
| return s.credentialsFilePath, nil |
| } |
| |
| // CredentialsHelperType returns the name of the configured credentials helper |
| // type, or an empty string if no credentials helper is configured. |
| func (s *CredentialsSource) CredentialsHelperType() string { |
| return s.helperType |
| } |
| |
| func (s *CredentialsSource) updateHostCredentials(host svchost.Hostname, new svcauth.HostCredentialsWritable) error { |
| switch loc := s.HostCredentialsLocation(host); loc { |
| case CredentialsInOtherFile: |
| return ErrUnwritableHostCredentials(host) |
| case CredentialsInPrimaryFile, CredentialsNotAvailable: |
| // If the host already has credentials stored locally then we'll update |
| // them locally too, even if there's a credentials helper configured, |
| // because the user might be intentionally retaining this particular |
| // host locally for some reason, e.g. if the credentials helper is |
| // talking to some shared remote service like HashiCorp Vault. |
| return s.updateLocalHostCredentials(host, new) |
| case CredentialsViaHelper: |
| // Delegate entirely to the helper, then. |
| if new == nil { |
| return s.helper.ForgetForHost(host) |
| } |
| return s.helper.StoreForHost(host, new) |
| default: |
| // Should never happen because the above cases are exhaustive |
| return fmt.Errorf("invalid credentials location %#v", loc) |
| } |
| } |
| |
| func (s *CredentialsSource) updateLocalHostCredentials(host svchost.Hostname, new svcauth.HostCredentialsWritable) error { |
| // This function updates the local credentials file in particular, |
| // regardless of whether a credentials helper is active. It should be |
| // called only indirectly via updateHostCredentials. |
| |
| filename, err := s.CredentialsFilePath() |
| if err != nil { |
| return fmt.Errorf("unable to determine credentials file path: %s", err) |
| } |
| |
| oldSrc, err := ioutil.ReadFile(filename) |
| if err != nil && !os.IsNotExist(err) { |
| return fmt.Errorf("cannot read %s: %s", filename, err) |
| } |
| |
| var raw map[string]interface{} |
| |
| if len(oldSrc) > 0 { |
| // When decoding we use a custom decoder so we can decode any numbers as |
| // json.Number and thus avoid losing any accuracy in our round-trip. |
| dec := json.NewDecoder(bytes.NewReader(oldSrc)) |
| dec.UseNumber() |
| err = dec.Decode(&raw) |
| if err != nil { |
| return fmt.Errorf("cannot read %s: %s", filename, err) |
| } |
| } else { |
| raw = make(map[string]interface{}) |
| } |
| |
| rawCredsI, ok := raw["credentials"] |
| if !ok { |
| rawCredsI = make(map[string]interface{}) |
| raw["credentials"] = rawCredsI |
| } |
| rawCredsMap, ok := rawCredsI.(map[string]interface{}) |
| if !ok { |
| return fmt.Errorf("credentials file %s has invalid value for \"credentials\" property: must be a JSON object", filename) |
| } |
| |
| // We use display-oriented hostnames in our file to mimick how a human user |
| // would write it, so we need to search for and remove any key that |
| // normalizes to our target hostname so we won't generate something invalid |
| // when the existing entry is slightly different. |
| for givenHost := range rawCredsMap { |
| canonHost, err := svchost.ForComparison(givenHost) |
| if err == nil && canonHost == host { |
| delete(rawCredsMap, givenHost) |
| } |
| } |
| |
| // If we have a new object to store we'll write it in now. If the previous |
| // object had the hostname written in a different way then this will |
| // appear to change it into our canonical display form, with all the |
| // letters in lowercase and other transforms from the Internationalized |
| // Domain Names specification. |
| if new != nil { |
| toStore := new.ToStore() |
| rawCredsMap[host.ForDisplay()] = ctyjson.SimpleJSONValue{ |
| Value: toStore, |
| } |
| } |
| |
| newSrc, err := json.MarshalIndent(raw, "", " ") |
| if err != nil { |
| return fmt.Errorf("cannot serialize updated credentials file: %s", err) |
| } |
| |
| // Now we'll write our new content over the top of the existing file. |
| // Because we updated the data structure surgically here we should not |
| // have disturbed the meaning of any other content in the file, but it |
| // might have a different JSON layout than before. |
| // We'll create a new file with a different name first and then rename |
| // it over the old file in order to make the change as atomically as |
| // the underlying OS/filesystem will allow. |
| { |
| dir, file := filepath.Split(filename) |
| f, err := ioutil.TempFile(dir, file) |
| if err != nil { |
| return fmt.Errorf("cannot create temporary file to update credentials: %s", err) |
| } |
| tmpName := f.Name() |
| moved := false |
| defer func(f *os.File, name string) { |
| // Remove the temporary file if it hasn't been moved yet. We're |
| // ignoring errors here because there's nothing we can do about |
| // them anyway. |
| if !moved { |
| os.Remove(name) |
| } |
| }(f, tmpName) |
| |
| // Write the credentials to the temporary file, then immediately close |
| // it, whether or not the write succeeds. |
| _, err = f.Write(newSrc) |
| f.Close() |
| if err != nil { |
| return fmt.Errorf("cannot write to temporary file %s: %s", tmpName, err) |
| } |
| |
| // Temporary file now replaces the original file, as atomically as |
| // possible. (At the very least, we should not end up with a file |
| // containing only a partial JSON object.) |
| err = replacefile.AtomicRename(tmpName, filename) |
| if err != nil { |
| return fmt.Errorf("failed to replace %s with temporary file %s: %s", filename, tmpName, err) |
| } |
| |
| // Credentials file should be readable only by its owner. (This may |
| // not be effective on all platforms, but should at least work on |
| // Unix-like targets and should be harmless elsewhere.) |
| if err := os.Chmod(filename, 0600); err != nil { |
| return fmt.Errorf("cannot set mode for credentials file %s: %s", filename, err) |
| } |
| |
| moved = true |
| } |
| |
| if new != nil { |
| s.configured[host] = new.ToStore() |
| } else { |
| delete(s.configured, host) |
| } |
| |
| return nil |
| } |
| |
| // readHostsInCredentialsFile discovers which hosts have credentials configured |
| // in the credentials file specifically, as opposed to in any other CLI |
| // config file. |
| // |
| // If the credentials file isn't present or is unreadable for any reason then |
| // this returns an empty set, reflecting that effectively no credentials are |
| // stored there. |
| func readHostsInCredentialsFile(filename string) map[svchost.Hostname]struct{} { |
| src, err := ioutil.ReadFile(filename) |
| if err != nil { |
| return nil |
| } |
| |
| var raw map[string]interface{} |
| err = json.Unmarshal(src, &raw) |
| if err != nil { |
| return nil |
| } |
| |
| rawCredsI, ok := raw["credentials"] |
| if !ok { |
| return nil |
| } |
| rawCredsMap, ok := rawCredsI.(map[string]interface{}) |
| if !ok { |
| return nil |
| } |
| |
| ret := make(map[svchost.Hostname]struct{}) |
| for givenHost := range rawCredsMap { |
| host, err := svchost.ForComparison(givenHost) |
| if err != nil { |
| // We expect the config was already validated by the time we get |
| // here, so we'll just ignore invalid hostnames. |
| continue |
| } |
| ret[host] = struct{}{} |
| } |
| return ret |
| } |
| |
| // ErrUnwritableHostCredentials is an error type that is returned when a caller |
| // tries to write credentials for a host that has existing credentials configured |
| // in a file that we cannot automatically update. |
| type ErrUnwritableHostCredentials svchost.Hostname |
| |
| func (err ErrUnwritableHostCredentials) Error() string { |
| return fmt.Sprintf("cannot change credentials for %s: existing manually-configured credentials in a CLI config file", svchost.Hostname(err).ForDisplay()) |
| } |
| |
| // Hostname returns the host that could not be written. |
| func (err ErrUnwritableHostCredentials) Hostname() svchost.Hostname { |
| return svchost.Hostname(err) |
| } |
| |
| // CredentialsLocation describes a type of storage used for the credentials |
| // for a particular hostname. |
| type CredentialsLocation rune |
| |
| const ( |
| // CredentialsNotAvailable means that we know that there are no credential |
| // available for the host. |
| // |
| // Note that CredentialsViaHelper might also lead to no credentials being |
| // available, depending on how the helper answers when we request credentials |
| // from it. |
| CredentialsNotAvailable CredentialsLocation = 0 |
| |
| // CredentialsInPrimaryFile means that there is already a credentials object |
| // for the host in the credentials.tfrc.json file. |
| CredentialsInPrimaryFile CredentialsLocation = 'P' |
| |
| // CredentialsInOtherFile means that there is already a credentials object |
| // for the host in a CLI config file other than credentials.tfrc.json. |
| CredentialsInOtherFile CredentialsLocation = 'O' |
| |
| // CredentialsViaHelper indicates that no statically-configured credentials |
| // are available for the host but a helper program is available that may |
| // or may not have credentials for the host. |
| CredentialsViaHelper CredentialsLocation = 'H' |
| ) |