mirror of
https://github.com/opentofu/opentofu.git
synced 2025-02-25 18:45:20 -06:00
command/cliconfig: New CredentialsSource implementation
This new implementation is not yet used, but should eventually replace the technique of composing together various types from the svchost/auth package, since our requirements are now complex enough that they're more straightforward to express in direct code within a single type than as a composition of the building blocks in the svchost/auth package.
This commit is contained in:
parent
ec8dadcfa9
commit
1e2da4f776
@ -50,3 +50,8 @@ func homeDir() (string, error) {
|
||||
|
||||
return user.HomeDir, nil
|
||||
}
|
||||
|
||||
func replaceFileAtomic(source, destination string) error {
|
||||
// On Unix systems, a rename is sufficiently atomic.
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
|
@ -3,9 +3,12 @@
|
||||
package cliconfig
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -44,3 +47,25 @@ func homeDir() (string, error) {
|
||||
|
||||
return syscall.UTF16ToString(b), nil
|
||||
}
|
||||
|
||||
func replaceFileAtomic(source, destination string) error {
|
||||
// On Windows, renaming one file over another is not atomic and certain
|
||||
// error conditions can result in having only the source file and nothing
|
||||
// at the destination file. Instead, we need to call into the MoveFileEx
|
||||
// Windows API function.
|
||||
srcPtr, err := syscall.UTF16PtrFromString(source)
|
||||
if err != nil {
|
||||
return &os.LinkError{"replace", source, destination, err}
|
||||
}
|
||||
destPtr, err := syscall.UTF16PtrFromString(destination)
|
||||
if err != nil {
|
||||
return &os.LinkError{"replace", source, destination, err}
|
||||
}
|
||||
|
||||
flags := uint32(windows.MOVEFILE_REPLACE_EXISTING | windows.MOVEFILE_WRITE_THROUGH)
|
||||
err = windows.MoveFileEx(srcPtr, destPtr, flags)
|
||||
if err != nil {
|
||||
return &os.LinkError{"replace", source, destination, err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
429
command/cliconfig/credentials.go
Normal file
429
command/cliconfig/credentials.go
Normal file
@ -0,0 +1,429 @@
|
||||
package cliconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
ctyjson "github.com/zclconf/go-cty/cty/json"
|
||||
|
||||
"github.com/hashicorp/terraform/configs/hcl2shim"
|
||||
pluginDiscovery "github.com/hashicorp/terraform/plugin/discovery"
|
||||
"github.com/hashicorp/terraform/svchost"
|
||||
svcauth "github.com/hashicorp/terraform/svchost/auth"
|
||||
)
|
||||
|
||||
// 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", helperType)
|
||||
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
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
v, ok := s.configured[host]
|
||||
if ok {
|
||||
return svcauth.HostCredentialsFromObject(v), nil
|
||||
}
|
||||
|
||||
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) {
|
||||
// Always close our file, and remove it if it's still at its
|
||||
// temporary name. We're ignoring errors here because there's
|
||||
// nothing we can do about them anyway.
|
||||
f.Close()
|
||||
if !moved {
|
||||
os.Remove(name)
|
||||
}
|
||||
}(f, tmpName)
|
||||
|
||||
// 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 := f.Chmod(0600); err != nil {
|
||||
return fmt.Errorf("cannot set mode for temporary file %s: %s", tmpName, err)
|
||||
}
|
||||
|
||||
_, err = f.Write(newSrc)
|
||||
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 = replaceFileAtomic(tmpName, filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to replace %s with temporary file %s: %s", filename, tmpName, 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'
|
||||
)
|
355
command/cliconfig/credentials_test.go
Normal file
355
command/cliconfig/credentials_test.go
Normal file
@ -0,0 +1,355 @@
|
||||
package cliconfig
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
|
||||
"github.com/hashicorp/terraform/svchost"
|
||||
svcauth "github.com/hashicorp/terraform/svchost/auth"
|
||||
)
|
||||
|
||||
func TestCredentialsForHost(t *testing.T) {
|
||||
credSrc := &CredentialsSource{
|
||||
configured: map[svchost.Hostname]cty.Value{
|
||||
"configured.example.com": cty.ObjectVal(map[string]cty.Value{
|
||||
"token": cty.StringVal("configured"),
|
||||
}),
|
||||
"unused.example.com": cty.ObjectVal(map[string]cty.Value{
|
||||
"token": cty.StringVal("incorrectly-configured"),
|
||||
}),
|
||||
},
|
||||
|
||||
// We'll use a static source to stand in for what would normally be
|
||||
// a credentials helper program, since we're only testing the logic
|
||||
// for choosing when to delegate to the helper here. The logic for
|
||||
// interacting with a helper program is tested in the svcauth package.
|
||||
helper: svcauth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{
|
||||
"from-helper.example.com": {
|
||||
"token": "from-helper",
|
||||
},
|
||||
|
||||
// This should be shadowed by the "configured" entry with the same
|
||||
// hostname above.
|
||||
"configured.example.com": {
|
||||
"token": "incorrectly-from-helper",
|
||||
},
|
||||
}),
|
||||
helperType: "fake",
|
||||
}
|
||||
|
||||
testReqAuthHeader := func(t *testing.T, creds svcauth.HostCredentials) string {
|
||||
t.Helper()
|
||||
|
||||
if creds == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", "http://example.com/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("cannot construct HTTP request: %s", err)
|
||||
}
|
||||
creds.PrepareRequest(req)
|
||||
return req.Header.Get("Authorization")
|
||||
}
|
||||
|
||||
t.Run("configured", func(t *testing.T) {
|
||||
creds, err := credSrc.ForHost(svchost.Hostname("configured.example.com"))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got, want := testReqAuthHeader(t, creds), "Bearer configured"; got != want {
|
||||
t.Errorf("wrong result\ngot: %s\nwant: %s", got, want)
|
||||
}
|
||||
})
|
||||
t.Run("from helper", func(t *testing.T) {
|
||||
creds, err := credSrc.ForHost(svchost.Hostname("from-helper.example.com"))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got, want := testReqAuthHeader(t, creds), "Bearer from-helper"; got != want {
|
||||
t.Errorf("wrong result\ngot: %s\nwant: %s", got, want)
|
||||
}
|
||||
})
|
||||
t.Run("not available", func(t *testing.T) {
|
||||
creds, err := credSrc.ForHost(svchost.Hostname("unavailable.example.com"))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got, want := testReqAuthHeader(t, creds), ""; got != want {
|
||||
t.Errorf("wrong result\ngot: %s\nwant: %s", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCredentialsStoreForget(t *testing.T) {
|
||||
d, err := ioutil.TempDir("", "terraform-cliconfig-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(d)
|
||||
|
||||
mockCredsFilename := filepath.Join(d, "credentials.tfrc.json")
|
||||
|
||||
cfg := &Config{
|
||||
// This simulates there being a credentials block manually configured
|
||||
// in some file _other than_ credentials.tfrc.json.
|
||||
Credentials: map[string]map[string]interface{}{
|
||||
"manually-configured.example.com": {
|
||||
"token": "manually-configured",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// We'll initially use a credentials source with no credentials helper at
|
||||
// all, and thus with credentials stored in the credentials file.
|
||||
credSrc := cfg.credentialsSource(
|
||||
"", nil,
|
||||
mockCredsFilename,
|
||||
)
|
||||
|
||||
testReqAuthHeader := func(t *testing.T, creds svcauth.HostCredentials) string {
|
||||
t.Helper()
|
||||
|
||||
if creds == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", "http://example.com/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("cannot construct HTTP request: %s", err)
|
||||
}
|
||||
creds.PrepareRequest(req)
|
||||
return req.Header.Get("Authorization")
|
||||
}
|
||||
|
||||
// Because these store/forget calls have side-effects, we'll bail out with
|
||||
// t.Fatal (or equivalent) as soon as anything unexpected happens.
|
||||
// Otherwise downstream tests might fail in confusing ways.
|
||||
{
|
||||
err := credSrc.StoreForHost(
|
||||
svchost.Hostname("manually-configured.example.com"),
|
||||
svcauth.HostCredentialsToken("not-manually-configured"),
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("successfully stored for manually-configured; want error")
|
||||
}
|
||||
if _, ok := err.(ErrUnwritableHostCredentials); !ok {
|
||||
t.Fatalf("wrong error type %T; want ErrUnwritableHostCredentials", err)
|
||||
}
|
||||
}
|
||||
{
|
||||
err := credSrc.ForgetForHost(
|
||||
svchost.Hostname("manually-configured.example.com"),
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("successfully forgot for manually-configured; want error")
|
||||
}
|
||||
if _, ok := err.(ErrUnwritableHostCredentials); !ok {
|
||||
t.Fatalf("wrong error type %T; want ErrUnwritableHostCredentials", err)
|
||||
}
|
||||
}
|
||||
{
|
||||
// We don't have a credentials file at all yet, so this first call
|
||||
// must create it.
|
||||
err := credSrc.StoreForHost(
|
||||
svchost.Hostname("stored-locally.example.com"),
|
||||
svcauth.HostCredentialsToken("stored-locally"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error storing locally: %s", err)
|
||||
}
|
||||
|
||||
creds, err := credSrc.ForHost(svchost.Hostname("stored-locally.example.com"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read back stored-locally credentials: %s", err)
|
||||
}
|
||||
|
||||
if got, want := testReqAuthHeader(t, creds), "Bearer stored-locally"; got != want {
|
||||
t.Fatalf("wrong header value for stored-locally\ngot: %s\nwant: %s", got, want)
|
||||
}
|
||||
|
||||
got := readHostsInCredentialsFile(mockCredsFilename)
|
||||
want := map[svchost.Hostname]struct{}{
|
||||
svchost.Hostname("stored-locally.example.com"): struct{}{},
|
||||
}
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Fatalf("wrong credentials file content\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
// Now we'll switch to having a credential helper active.
|
||||
// If we were loading the real CLI config from disk here then this
|
||||
// entry would already be in cfg.Credentials, but we need to fake that
|
||||
// in the test because we're constructing this *Config value directly.
|
||||
cfg.Credentials["stored-locally.example.com"] = map[string]interface{}{
|
||||
"token": "stored-locally",
|
||||
}
|
||||
mockHelper := &mockCredentialsHelper{current: make(map[svchost.Hostname]cty.Value)}
|
||||
credSrc = cfg.credentialsSource(
|
||||
"mock", mockHelper,
|
||||
mockCredsFilename,
|
||||
)
|
||||
{
|
||||
err := credSrc.StoreForHost(
|
||||
svchost.Hostname("manually-configured.example.com"),
|
||||
svcauth.HostCredentialsToken("not-manually-configured"),
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("successfully stored for manually-configured with helper active; want error")
|
||||
}
|
||||
}
|
||||
{
|
||||
err := credSrc.StoreForHost(
|
||||
svchost.Hostname("stored-in-helper.example.com"),
|
||||
svcauth.HostCredentialsToken("stored-in-helper"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error storing in helper: %s", err)
|
||||
}
|
||||
|
||||
creds, err := credSrc.ForHost(svchost.Hostname("stored-in-helper.example.com"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read back stored-in-helper credentials: %s", err)
|
||||
}
|
||||
|
||||
if got, want := testReqAuthHeader(t, creds), "Bearer stored-in-helper"; got != want {
|
||||
t.Fatalf("wrong header value for stored-in-helper\ngot: %s\nwant: %s", got, want)
|
||||
}
|
||||
|
||||
// Nothing should have changed in the saved credentials file
|
||||
got := readHostsInCredentialsFile(mockCredsFilename)
|
||||
want := map[svchost.Hostname]struct{}{
|
||||
svchost.Hostname("stored-locally.example.com"): struct{}{},
|
||||
}
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Fatalf("wrong credentials file content\n%s", diff)
|
||||
}
|
||||
}
|
||||
{
|
||||
// Because stored-locally is already in the credentials file, a new
|
||||
// store should be sent there rather than to the credentials helper.
|
||||
err := credSrc.StoreForHost(
|
||||
svchost.Hostname("stored-locally.example.com"),
|
||||
svcauth.HostCredentialsToken("stored-locally-again"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error storing locally again: %s", err)
|
||||
}
|
||||
|
||||
creds, err := credSrc.ForHost(svchost.Hostname("stored-locally.example.com"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read back stored-locally credentials: %s", err)
|
||||
}
|
||||
|
||||
if got, want := testReqAuthHeader(t, creds), "Bearer stored-locally-again"; got != want {
|
||||
t.Fatalf("wrong header value for stored-locally\ngot: %s\nwant: %s", got, want)
|
||||
}
|
||||
}
|
||||
{
|
||||
// Forgetting a host already in the credentials file should remove it
|
||||
// from the credentials file, not from the helper.
|
||||
err := credSrc.ForgetForHost(
|
||||
svchost.Hostname("stored-locally.example.com"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error forgetting locally: %s", err)
|
||||
}
|
||||
|
||||
creds, err := credSrc.ForHost(svchost.Hostname("stored-locally.example.com"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read back stored-locally credentials: %s", err)
|
||||
}
|
||||
|
||||
if got, want := testReqAuthHeader(t, creds), ""; got != want {
|
||||
t.Fatalf("wrong header value for stored-locally\ngot: %s\nwant: %s", got, want)
|
||||
}
|
||||
|
||||
// Should not be present in the credentials file anymore
|
||||
got := readHostsInCredentialsFile(mockCredsFilename)
|
||||
want := map[svchost.Hostname]struct{}{}
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Fatalf("wrong credentials file content\n%s", diff)
|
||||
}
|
||||
}
|
||||
{
|
||||
err := credSrc.ForgetForHost(
|
||||
svchost.Hostname("stored-in-helper.example.com"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error forgetting in helper: %s", err)
|
||||
}
|
||||
|
||||
creds, err := credSrc.ForHost(svchost.Hostname("stored-in-helper.example.com"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read back stored-in-helper credentials: %s", err)
|
||||
}
|
||||
|
||||
if got, want := testReqAuthHeader(t, creds), ""; got != want {
|
||||
t.Fatalf("wrong header value for stored-in-helper\ngot: %s\nwant: %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// Finally, the log in our mock helper should show that it was only
|
||||
// asked to deal with stored-in-helper, not stored-locally.
|
||||
got := mockHelper.log
|
||||
want := []mockCredentialsHelperChange{
|
||||
{
|
||||
Host: svchost.Hostname("stored-in-helper.example.com"),
|
||||
Action: "store",
|
||||
},
|
||||
{
|
||||
Host: svchost.Hostname("stored-in-helper.example.com"),
|
||||
Action: "forget",
|
||||
},
|
||||
}
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("unexpected credentials helper operation log\n%s", diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type mockCredentialsHelperChange struct {
|
||||
Host svchost.Hostname
|
||||
Action string
|
||||
}
|
||||
|
||||
type mockCredentialsHelper struct {
|
||||
current map[svchost.Hostname]cty.Value
|
||||
log []mockCredentialsHelperChange
|
||||
}
|
||||
|
||||
// Assertion that mockCredentialsHelper implements svcauth.CredentialsSource
|
||||
var _ svcauth.CredentialsSource = (*mockCredentialsHelper)(nil)
|
||||
|
||||
func (s *mockCredentialsHelper) ForHost(hostname svchost.Hostname) (svcauth.HostCredentials, error) {
|
||||
v, ok := s.current[hostname]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return svcauth.HostCredentialsFromObject(v), nil
|
||||
}
|
||||
|
||||
func (s *mockCredentialsHelper) StoreForHost(hostname svchost.Hostname, new svcauth.HostCredentialsWritable) error {
|
||||
s.log = append(s.log, mockCredentialsHelperChange{
|
||||
Host: hostname,
|
||||
Action: "store",
|
||||
})
|
||||
s.current[hostname] = new.ToStore()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mockCredentialsHelper) ForgetForHost(hostname svchost.Hostname) error {
|
||||
s.log = append(s.log, mockCredentialsHelperChange{
|
||||
Host: hostname,
|
||||
Action: "forget",
|
||||
})
|
||||
delete(s.current, hostname)
|
||||
return nil
|
||||
}
|
@ -1,5 +1,9 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
)
|
||||
|
||||
// HostCredentialsFromMap converts a map of key-value pairs from a credentials
|
||||
// definition provided by the user (e.g. in a config file, or via a credentials
|
||||
// helper) into a HostCredentials object if possible, or returns nil if
|
||||
@ -16,3 +20,29 @@ func HostCredentialsFromMap(m map[string]interface{}) HostCredentials {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HostCredentialsFromObject converts a cty.Value of an object type into a
|
||||
// HostCredentials object if possible, or returns nil if no credentials could
|
||||
// be extracted from the map.
|
||||
//
|
||||
// This function ignores object attributes it is unfamiliar with, to allow for
|
||||
// future expansion of the credentials object structure for new credential types.
|
||||
//
|
||||
// If the given value is not of an object type, this function will panic.
|
||||
func HostCredentialsFromObject(obj cty.Value) HostCredentials {
|
||||
if !obj.Type().HasAttribute("token") {
|
||||
return nil
|
||||
}
|
||||
|
||||
tokenV := obj.GetAttr("token")
|
||||
if tokenV.IsNull() || !tokenV.IsKnown() {
|
||||
return nil
|
||||
}
|
||||
if !cty.String.Equals(tokenV.Type()) {
|
||||
// Weird, but maybe some future Terraform version accepts an object
|
||||
// here for some reason, so we'll be resilient.
|
||||
return nil
|
||||
}
|
||||
|
||||
return HostCredentialsToken(tokenV.AsString())
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user