github.com/opentofu/opentofu@v1.7.1/internal/command/cliconfig/credentials.go (about)

     1  // Copyright (c) The OpenTofu Authors
     2  // SPDX-License-Identifier: MPL-2.0
     3  // Copyright (c) 2023 HashiCorp, Inc.
     4  // SPDX-License-Identifier: MPL-2.0
     5  
     6  package cliconfig
     7  
     8  import (
     9  	"bytes"
    10  	"encoding/json"
    11  	"fmt"
    12  	"log"
    13  	"os"
    14  	"path/filepath"
    15  	"strings"
    16  
    17  	"github.com/zclconf/go-cty/cty"
    18  	ctyjson "github.com/zclconf/go-cty/cty/json"
    19  
    20  	svchost "github.com/hashicorp/terraform-svchost"
    21  	svcauth "github.com/hashicorp/terraform-svchost/auth"
    22  
    23  	"github.com/opentofu/opentofu/internal/configs/hcl2shim"
    24  	pluginDiscovery "github.com/opentofu/opentofu/internal/plugin/discovery"
    25  	"github.com/opentofu/opentofu/internal/replacefile"
    26  )
    27  
    28  // credentialsConfigFile returns the path for the special configuration file
    29  // that the credentials source will use when asked to save or forget credentials
    30  // and when a "credentials helper" program is not active.
    31  func credentialsConfigFile() (string, error) {
    32  	configDir, err := ConfigDir()
    33  	if err != nil {
    34  		return "", err
    35  	}
    36  	return filepath.Join(configDir, "credentials.tfrc.json"), nil
    37  }
    38  
    39  // CredentialsSource creates and returns a service credentials source whose
    40  // behavior depends on which "credentials" and "credentials_helper" blocks,
    41  // if any, are present in the receiving config.
    42  func (c *Config) CredentialsSource(helperPlugins pluginDiscovery.PluginMetaSet) (*CredentialsSource, error) {
    43  	credentialsFilePath, err := credentialsConfigFile()
    44  	if err != nil {
    45  		// If we managed to load a Config object at all then we would already
    46  		// have located this file, so this error is very unlikely.
    47  		return nil, fmt.Errorf("can't locate credentials file: %w", err)
    48  	}
    49  
    50  	var helper svcauth.CredentialsSource
    51  	var helperType string
    52  	for givenType, givenConfig := range c.CredentialsHelpers {
    53  		available := helperPlugins.WithName(givenType)
    54  		if available.Count() == 0 {
    55  			log.Printf("[ERROR] Unable to find credentials helper %q; ignoring", givenType)
    56  			break
    57  		}
    58  
    59  		selected := available.Newest()
    60  
    61  		helperSource := svcauth.HelperProgramCredentialsSource(selected.Path, givenConfig.Args...)
    62  		helper = svcauth.CachingCredentialsSource(helperSource) // cached because external operation may be slow/expensive
    63  		helperType = givenType
    64  
    65  		// There should only be zero or one "credentials_helper" blocks. We
    66  		// assume that the config was validated earlier and so we don't check
    67  		// for extras here.
    68  		break
    69  	}
    70  
    71  	return c.credentialsSource(helperType, helper, credentialsFilePath), nil
    72  }
    73  
    74  // EmptyCredentialsSourceForTests constructs a CredentialsSource with
    75  // no credentials pre-loaded and which writes new credentials to a file
    76  // at the given path.
    77  //
    78  // As the name suggests, this function is here only for testing and should not
    79  // be used in normal application code.
    80  func EmptyCredentialsSourceForTests(credentialsFilePath string) *CredentialsSource {
    81  	cfg := &Config{}
    82  	return cfg.credentialsSource("", nil, credentialsFilePath)
    83  }
    84  
    85  // credentialsSource is an internal factory for the credentials source which
    86  // allows overriding the credentials file path, which allows setting it to
    87  // a temporary file location when testing.
    88  func (c *Config) credentialsSource(helperType string, helper svcauth.CredentialsSource, credentialsFilePath string) *CredentialsSource {
    89  	configured := map[svchost.Hostname]cty.Value{}
    90  	for userHost, creds := range c.Credentials {
    91  		host, err := svchost.ForComparison(userHost)
    92  		if err != nil {
    93  			// We expect the config was already validated by the time we get
    94  			// here, so we'll just ignore invalid hostnames.
    95  			continue
    96  		}
    97  
    98  		// For now our CLI config continues to use HCL 1.0, so we'll shim it
    99  		// over to HCL 2.0 types. In future we will hopefully migrate it to
   100  		// HCL 2.0 instead, and so it'll be a cty.Value already.
   101  		credsV := hcl2shim.HCL2ValueFromConfigValue(creds)
   102  		configured[host] = credsV
   103  	}
   104  
   105  	writableLocal := readHostsInCredentialsFile(credentialsFilePath)
   106  	unwritableLocal := map[svchost.Hostname]cty.Value{}
   107  	for host, v := range configured {
   108  		if _, exists := writableLocal[host]; !exists {
   109  			unwritableLocal[host] = v
   110  		}
   111  	}
   112  
   113  	return &CredentialsSource{
   114  		configured:          configured,
   115  		unwritable:          unwritableLocal,
   116  		credentialsFilePath: credentialsFilePath,
   117  		helper:              helper,
   118  		helperType:          helperType,
   119  	}
   120  }
   121  
   122  func collectCredentialsFromEnv() map[svchost.Hostname]string {
   123  	const prefix = "TF_TOKEN_"
   124  
   125  	ret := make(map[svchost.Hostname]string)
   126  	for _, ev := range os.Environ() {
   127  		eqIdx := strings.Index(ev, "=")
   128  		if eqIdx < 0 {
   129  			continue
   130  		}
   131  		name := ev[:eqIdx]
   132  		value := ev[eqIdx+1:]
   133  		if !strings.HasPrefix(name, prefix) {
   134  			continue
   135  		}
   136  		rawHost := name[len(prefix):]
   137  
   138  		// We accept double underscores in place of hyphens because hyphens are not valid
   139  		// identifiers in most shells and are therefore hard to set.
   140  		// This is unambiguous with replacing single underscores below because
   141  		// hyphens are not allowed at the beginning or end of a label and therefore
   142  		// odd numbers of underscores will not appear together in a valid variable name.
   143  		rawHost = strings.ReplaceAll(rawHost, "__", "-")
   144  
   145  		// We accept underscores in place of dots because dots are not valid
   146  		// identifiers in most shells and are therefore hard to set.
   147  		// Underscores are not valid in hostnames, so this is unambiguous for
   148  		// valid hostnames.
   149  		rawHost = strings.ReplaceAll(rawHost, "_", ".")
   150  
   151  		// Because environment variables are often set indirectly by OS
   152  		// libraries that might interfere with how they are encoded, we'll
   153  		// be tolerant of them being given either directly as UTF-8 IDNs
   154  		// or in Punycode form, normalizing to Punycode form here because
   155  		// that is what the OpenTofu credentials helper protocol will
   156  		// use in its requests.
   157  		//
   158  		// Using ForDisplay first here makes this more liberal than OpenTofu
   159  		// itself would usually be in that it will tolerate pre-punycoded
   160  		// hostnames that OpenTofu normally rejects in other contexts in order
   161  		// to ensure stored hostnames are human-readable.
   162  		dispHost := svchost.ForDisplay(rawHost)
   163  		hostname, err := svchost.ForComparison(dispHost)
   164  		if err != nil {
   165  			// Ignore invalid hostnames
   166  			continue
   167  		}
   168  
   169  		ret[hostname] = value
   170  	}
   171  
   172  	return ret
   173  }
   174  
   175  // hostCredentialsFromEnv returns a token credential by searching for a hostname-specific
   176  // environment variable. The host parameter is expected to be in the "comparison" form,
   177  // for example, hostnames containing non-ASCII characters like "café.fr"
   178  // should be expressed as "xn--caf-dma.fr". If the variable based on the hostname is not
   179  // defined, nil is returned.
   180  //
   181  // Hyphen and period characters are allowed in environment variable names, but are not valid POSIX
   182  // variable names. However, it's still possible to set variable names with these characters using
   183  // utilities like env or docker. Variable names may have periods translated to underscores and
   184  // hyphens translated to double underscores in the variable name.
   185  // For the example "café.fr", you may use the variable names "TF_TOKEN_xn____caf__dma_fr",
   186  // "TF_TOKEN_xn--caf-dma_fr", or "TF_TOKEN_xn--caf-dma.fr"
   187  func hostCredentialsFromEnv(host svchost.Hostname) svcauth.HostCredentials {
   188  	token, ok := collectCredentialsFromEnv()[host]
   189  	if !ok {
   190  		return nil
   191  	}
   192  	return svcauth.HostCredentialsToken(token)
   193  }
   194  
   195  // CredentialsSource is an implementation of svcauth.CredentialsSource
   196  // that can read and write the CLI configuration, and possibly also delegate
   197  // to a credentials helper when configured.
   198  type CredentialsSource struct {
   199  	// configured describes the credentials explicitly configured in the CLI
   200  	// config via "credentials" blocks. This map will also change to reflect
   201  	// any writes to the special credentials.tfrc.json file.
   202  	configured map[svchost.Hostname]cty.Value
   203  
   204  	// unwritable describes any credentials explicitly configured in the
   205  	// CLI config in any file other than credentials.tfrc.json. We cannot update
   206  	// these automatically because only credentials.tfrc.json is subject to
   207  	// editing by this credentials source.
   208  	unwritable map[svchost.Hostname]cty.Value
   209  
   210  	// credentialsFilePath is the full path to the credentials.tfrc.json file
   211  	// that we'll update if any changes to credentials are requested and if
   212  	// a credentials helper isn't available to use instead.
   213  	//
   214  	// (This is a field here rather than just calling credentialsConfigFile
   215  	// directly just so that we can use temporary file location instead during
   216  	// testing.)
   217  	credentialsFilePath string
   218  
   219  	// helper is the credentials source representing the configured credentials
   220  	// helper, if any. When this is non-nil, it will be consulted for any
   221  	// hostnames not explicitly represented in "configured". Any writes to
   222  	// the credentials store will also be sent to a configured helper instead
   223  	// of the credentials.tfrc.json file.
   224  	helper svcauth.CredentialsSource
   225  
   226  	// helperType is the name of the type of credentials helper that is
   227  	// referenced in "helper", or the empty string if "helper" is nil.
   228  	helperType string
   229  }
   230  
   231  // Assertion that credentialsSource implements CredentialsSource
   232  var _ svcauth.CredentialsSource = (*CredentialsSource)(nil)
   233  
   234  func (s *CredentialsSource) ForHost(host svchost.Hostname) (svcauth.HostCredentials, error) {
   235  	// The first order of precedence for credentials is a host-specific environment variable
   236  	if envCreds := hostCredentialsFromEnv(host); envCreds != nil {
   237  		return envCreds, nil
   238  	}
   239  
   240  	// Then, any credentials block present in the CLI config
   241  	v, ok := s.configured[host]
   242  	if ok {
   243  		return svcauth.HostCredentialsFromObject(v), nil
   244  	}
   245  
   246  	// And finally, the credentials helper
   247  	if s.helper != nil {
   248  		return s.helper.ForHost(host)
   249  	}
   250  
   251  	return nil, nil
   252  }
   253  
   254  func (s *CredentialsSource) StoreForHost(host svchost.Hostname, credentials svcauth.HostCredentialsWritable) error {
   255  	return s.updateHostCredentials(host, credentials)
   256  }
   257  
   258  func (s *CredentialsSource) ForgetForHost(host svchost.Hostname) error {
   259  	return s.updateHostCredentials(host, nil)
   260  }
   261  
   262  // HostCredentialsLocation returns a value indicating what type of storage is
   263  // currently used for the credentials for the given hostname.
   264  //
   265  // The current location of credentials determines whether updates are possible
   266  // at all and, if they are, where any updates will be written.
   267  func (s *CredentialsSource) HostCredentialsLocation(host svchost.Hostname) CredentialsLocation {
   268  	if _, unwritable := s.unwritable[host]; unwritable {
   269  		return CredentialsInOtherFile
   270  	}
   271  	if _, exists := s.configured[host]; exists {
   272  		return CredentialsInPrimaryFile
   273  	}
   274  	if s.helper != nil {
   275  		return CredentialsViaHelper
   276  	}
   277  	return CredentialsNotAvailable
   278  }
   279  
   280  // CredentialsFilePath returns the full path to the local credentials
   281  // configuration file, so that a caller can mention this path in order to
   282  // be transparent about where credentials will be stored.
   283  //
   284  // This file will be used for writes only if HostCredentialsLocation for the
   285  // relevant host returns CredentialsInPrimaryFile or CredentialsNotAvailable.
   286  //
   287  // The credentials file path is found relative to the current user's home
   288  // directory, so this function will return an error in the unlikely event that
   289  // we cannot determine a suitable home directory to resolve relative to.
   290  func (s *CredentialsSource) CredentialsFilePath() (string, error) {
   291  	return s.credentialsFilePath, nil
   292  }
   293  
   294  // CredentialsHelperType returns the name of the configured credentials helper
   295  // type, or an empty string if no credentials helper is configured.
   296  func (s *CredentialsSource) CredentialsHelperType() string {
   297  	return s.helperType
   298  }
   299  
   300  func (s *CredentialsSource) updateHostCredentials(host svchost.Hostname, new svcauth.HostCredentialsWritable) error {
   301  	switch loc := s.HostCredentialsLocation(host); loc {
   302  	case CredentialsInOtherFile:
   303  		return ErrUnwritableHostCredentials(host)
   304  	case CredentialsInPrimaryFile, CredentialsNotAvailable:
   305  		// If the host already has credentials stored locally then we'll update
   306  		// them locally too, even if there's a credentials helper configured,
   307  		// because the user might be intentionally retaining this particular
   308  		// host locally for some reason, e.g. if the credentials helper is
   309  		// talking to some shared remote service like HashiCorp Vault.
   310  		return s.updateLocalHostCredentials(host, new)
   311  	case CredentialsViaHelper:
   312  		// Delegate entirely to the helper, then.
   313  		if new == nil {
   314  			return s.helper.ForgetForHost(host)
   315  		}
   316  		return s.helper.StoreForHost(host, new)
   317  	default:
   318  		// Should never happen because the above cases are exhaustive
   319  		return fmt.Errorf("invalid credentials location %#v", loc)
   320  	}
   321  }
   322  
   323  func (s *CredentialsSource) updateLocalHostCredentials(host svchost.Hostname, new svcauth.HostCredentialsWritable) error {
   324  	// This function updates the local credentials file in particular,
   325  	// regardless of whether a credentials helper is active. It should be
   326  	// called only indirectly via updateHostCredentials.
   327  
   328  	filename, err := s.CredentialsFilePath()
   329  	if err != nil {
   330  		return fmt.Errorf("unable to determine credentials file path: %w", err)
   331  	}
   332  
   333  	oldSrc, err := os.ReadFile(filename)
   334  	if err != nil && !os.IsNotExist(err) {
   335  		return fmt.Errorf("cannot read %s: %w", filename, err)
   336  	}
   337  
   338  	var raw map[string]interface{}
   339  
   340  	if len(oldSrc) > 0 {
   341  		// When decoding we use a custom decoder so we can decode any numbers as
   342  		// json.Number and thus avoid losing any accuracy in our round-trip.
   343  		dec := json.NewDecoder(bytes.NewReader(oldSrc))
   344  		dec.UseNumber()
   345  		err = dec.Decode(&raw)
   346  		if err != nil {
   347  			return fmt.Errorf("cannot read %s: %w", filename, err)
   348  		}
   349  	} else {
   350  		raw = make(map[string]interface{})
   351  	}
   352  
   353  	rawCredsI, ok := raw["credentials"]
   354  	if !ok {
   355  		rawCredsI = make(map[string]interface{})
   356  		raw["credentials"] = rawCredsI
   357  	}
   358  	rawCredsMap, ok := rawCredsI.(map[string]interface{})
   359  	if !ok {
   360  		return fmt.Errorf("credentials file %s has invalid value for \"credentials\" property: must be a JSON object", filename)
   361  	}
   362  
   363  	// We use display-oriented hostnames in our file to mimick how a human user
   364  	// would write it, so we need to search for and remove any key that
   365  	// normalizes to our target hostname so we won't generate something invalid
   366  	// when the existing entry is slightly different.
   367  	for givenHost := range rawCredsMap {
   368  		canonHost, err := svchost.ForComparison(givenHost)
   369  		if err == nil && canonHost == host {
   370  			delete(rawCredsMap, givenHost)
   371  		}
   372  	}
   373  
   374  	// If we have a new object to store we'll write it in now. If the previous
   375  	// object had the hostname written in a different way then this will
   376  	// appear to change it into our canonical display form, with all the
   377  	// letters in lowercase and other transforms from the Internationalized
   378  	// Domain Names specification.
   379  	if new != nil {
   380  		toStore := new.ToStore()
   381  		rawCredsMap[host.ForDisplay()] = ctyjson.SimpleJSONValue{
   382  			Value: toStore,
   383  		}
   384  	}
   385  
   386  	newSrc, err := json.MarshalIndent(raw, "", "  ")
   387  	if err != nil {
   388  		return fmt.Errorf("cannot serialize updated credentials file: %w", err)
   389  	}
   390  
   391  	// Now we'll write our new content over the top of the existing file.
   392  	// Because we updated the data structure surgically here we should not
   393  	// have disturbed the meaning of any other content in the file, but it
   394  	// might have a different JSON layout than before.
   395  	// We'll create a new file with a different name first and then rename
   396  	// it over the old file in order to make the change as atomically as
   397  	// the underlying OS/filesystem will allow.
   398  	{
   399  		dir, file := filepath.Split(filename)
   400  		f, err := os.CreateTemp(dir, file)
   401  		if err != nil {
   402  			return fmt.Errorf("cannot create temporary file to update credentials: %w", err)
   403  		}
   404  		tmpName := f.Name()
   405  		moved := false
   406  		defer func(f *os.File, name string) {
   407  			// Remove the temporary file if it hasn't been moved yet. We're
   408  			// ignoring errors here because there's nothing we can do about
   409  			// them anyway.
   410  			if !moved {
   411  				os.Remove(name)
   412  			}
   413  		}(f, tmpName)
   414  
   415  		// Write the credentials to the temporary file, then immediately close
   416  		// it, whether or not the write succeeds.
   417  		_, err = f.Write(newSrc)
   418  		f.Close()
   419  		if err != nil {
   420  			return fmt.Errorf("cannot write to temporary file %s: %w", tmpName, err)
   421  		}
   422  
   423  		// Temporary file now replaces the original file, as atomically as
   424  		// possible. (At the very least, we should not end up with a file
   425  		// containing only a partial JSON object.)
   426  		err = replacefile.AtomicRename(tmpName, filename)
   427  		if err != nil {
   428  			return fmt.Errorf("failed to replace %s with temporary file %s: %w", filename, tmpName, err)
   429  		}
   430  
   431  		// Credentials file should be readable only by its owner. (This may
   432  		// not be effective on all platforms, but should at least work on
   433  		// Unix-like targets and should be harmless elsewhere.)
   434  		if err := os.Chmod(filename, 0600); err != nil {
   435  			return fmt.Errorf("cannot set mode for credentials file %s: %w", filename, err)
   436  		}
   437  
   438  		moved = true
   439  	}
   440  
   441  	if new != nil {
   442  		s.configured[host] = new.ToStore()
   443  	} else {
   444  		delete(s.configured, host)
   445  	}
   446  
   447  	return nil
   448  }
   449  
   450  // readHostsInCredentialsFile discovers which hosts have credentials configured
   451  // in the credentials file specifically, as opposed to in any other CLI
   452  // config file.
   453  //
   454  // If the credentials file isn't present or is unreadable for any reason then
   455  // this returns an empty set, reflecting that effectively no credentials are
   456  // stored there.
   457  func readHostsInCredentialsFile(filename string) map[svchost.Hostname]struct{} {
   458  	src, err := os.ReadFile(filename)
   459  	if err != nil {
   460  		return nil
   461  	}
   462  
   463  	var raw map[string]interface{}
   464  	err = json.Unmarshal(src, &raw)
   465  	if err != nil {
   466  		return nil
   467  	}
   468  
   469  	rawCredsI, ok := raw["credentials"]
   470  	if !ok {
   471  		return nil
   472  	}
   473  	rawCredsMap, ok := rawCredsI.(map[string]interface{})
   474  	if !ok {
   475  		return nil
   476  	}
   477  
   478  	ret := make(map[svchost.Hostname]struct{})
   479  	for givenHost := range rawCredsMap {
   480  		host, err := svchost.ForComparison(givenHost)
   481  		if err != nil {
   482  			// We expect the config was already validated by the time we get
   483  			// here, so we'll just ignore invalid hostnames.
   484  			continue
   485  		}
   486  		ret[host] = struct{}{}
   487  	}
   488  	return ret
   489  }
   490  
   491  // ErrUnwritableHostCredentials is an error type that is returned when a caller
   492  // tries to write credentials for a host that has existing credentials configured
   493  // in a file that we cannot automatically update.
   494  type ErrUnwritableHostCredentials svchost.Hostname
   495  
   496  func (err ErrUnwritableHostCredentials) Error() string {
   497  	return fmt.Sprintf("cannot change credentials for %s: existing manually-configured credentials in a CLI config file", svchost.Hostname(err).ForDisplay())
   498  }
   499  
   500  // Hostname returns the host that could not be written.
   501  func (err ErrUnwritableHostCredentials) Hostname() svchost.Hostname {
   502  	return svchost.Hostname(err)
   503  }
   504  
   505  // CredentialsLocation describes a type of storage used for the credentials
   506  // for a particular hostname.
   507  type CredentialsLocation rune
   508  
   509  const (
   510  	// CredentialsNotAvailable means that we know that there are no credential
   511  	// available for the host.
   512  	//
   513  	// Note that CredentialsViaHelper might also lead to no credentials being
   514  	// available, depending on how the helper answers when we request credentials
   515  	// from it.
   516  	CredentialsNotAvailable CredentialsLocation = 0
   517  
   518  	// CredentialsInPrimaryFile means that there is already a credentials object
   519  	// for the host in the credentials.tfrc.json file.
   520  	CredentialsInPrimaryFile CredentialsLocation = 'P'
   521  
   522  	// CredentialsInOtherFile means that there is already a credentials object
   523  	// for the host in a CLI config file other than credentials.tfrc.json.
   524  	CredentialsInOtherFile CredentialsLocation = 'O'
   525  
   526  	// CredentialsViaHelper indicates that no statically-configured credentials
   527  	// are available for the host but a helper program is available that may
   528  	// or may not have credentials for the host.
   529  	CredentialsViaHelper CredentialsLocation = 'H'
   530  )