Files
opentf/internal/command/cliconfig/credentials.go
Martin Atkins 868dc2f01b hcl2shim: Split out legacy subset
Due to some past confusion about the purpose of this package, it has grown
to include a confusing mix of currently-viable code and legacy support
code from the move to HCL 2. This has in turn caused confusion about which
parts of this package _should_ be used for new code.

To help clarify that distinction we'll move the legacy support code into
a package under the "legacy" directory, which is also where most of its
callers live.

There are unfortunately still some callers to these outside of the legacy
tree, but the vast majority are either old tests written before HCL 2
adoption or helper code used only by those tests. The one dubious exception
is the use in ResourceInstanceObjectSrc.Decode, which makes a best effort
to shim flatmap as a concession to the fact that not all state-loading
codepaths are able to run the provider state upgrade function that would
normally be responsible for the flatmap-to-JSON conversion, which is
explained in a new comment inline.

Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
2025-07-10 08:13:25 -07:00

532 lines
19 KiB
Go

// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package cliconfig
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/opentofu/svchost"
"github.com/opentofu/svchost/svcauth"
"github.com/zclconf/go-cty/cty"
ctyjson "github.com/zclconf/go-cty/cty/json"
"github.com/opentofu/opentofu/internal/command/cliconfig/svcauthconfig"
"github.com/opentofu/opentofu/internal/legacy/hcl2shim"
pluginDiscovery "github.com/opentofu/opentofu/internal/plugin/discovery"
"github.com/opentofu/opentofu/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: %w", err)
}
var helper svcauth.CredentialsStore
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 := svcauthconfig.NewHelperProgramCredentialsStore(selected.Path, givenConfig.Args...)
helper = svcauth.CachingCredentialsStore(helperSource)
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.CredentialsStore, 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 OpenTofu credentials helper protocol will
// use in its requests.
//
// Using ForDisplay first here makes this more liberal than OpenTofu
// itself would usually be in that it will tolerate pre-punycoded
// hostnames that OpenTofu 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.CredentialsStore
// 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(ctx context.Context, 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 svcauthconfig.HostCredentialsFromObject(v), nil
}
// And finally, the credentials helper
if s.helper != nil {
return s.helper.ForHost(ctx, host)
}
return nil, nil
}
func (s *CredentialsSource) StoreForHost(ctx context.Context, host svchost.Hostname, credentials svcauth.NewHostCredentials) error {
return s.updateHostCredentials(ctx, host, credentials)
}
func (s *CredentialsSource) ForgetForHost(ctx context.Context, host svchost.Hostname) error {
return s.updateHostCredentials(ctx, 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(ctx context.Context, host svchost.Hostname, new svcauth.NewHostCredentials) 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(ctx, host)
}
return s.helper.StoreForHost(ctx, 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.NewHostCredentials) 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: %w", err)
}
oldSrc, err := os.ReadFile(filename)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("cannot read %s: %w", 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: %w", 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 mimic 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: %w", 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 := os.CreateTemp(dir, file)
if err != nil {
return fmt.Errorf("cannot create temporary file to update credentials: %w", 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: %w", 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: %w", 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: %w", 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 := os.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'
)