From 1e2da4f776bdf74058154ed31c4cd62b052d3f2d Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Thu, 1 Aug 2019 16:48:25 -0700 Subject: [PATCH] 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. --- command/cliconfig/config_unix.go | 5 + command/cliconfig/config_windows.go | 25 ++ command/cliconfig/credentials.go | 429 ++++++++++++++++++++++++++ command/cliconfig/credentials_test.go | 355 +++++++++++++++++++++ svchost/auth/from_map.go | 30 ++ 5 files changed, 844 insertions(+) create mode 100644 command/cliconfig/credentials.go create mode 100644 command/cliconfig/credentials_test.go diff --git a/command/cliconfig/config_unix.go b/command/cliconfig/config_unix.go index 5922c17ac4..36a5939da5 100644 --- a/command/cliconfig/config_unix.go +++ b/command/cliconfig/config_unix.go @@ -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) +} diff --git a/command/cliconfig/config_windows.go b/command/cliconfig/config_windows.go index 526b5d1dea..ff3a10993b 100644 --- a/command/cliconfig/config_windows.go +++ b/command/cliconfig/config_windows.go @@ -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 +} diff --git a/command/cliconfig/credentials.go b/command/cliconfig/credentials.go new file mode 100644 index 0000000000..f38087e00e --- /dev/null +++ b/command/cliconfig/credentials.go @@ -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' +) diff --git a/command/cliconfig/credentials_test.go b/command/cliconfig/credentials_test.go new file mode 100644 index 0000000000..3cb0212f01 --- /dev/null +++ b/command/cliconfig/credentials_test.go @@ -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 +} diff --git a/svchost/auth/from_map.go b/svchost/auth/from_map.go index f91006aece..7198c6744b 100644 --- a/svchost/auth/from_map.go +++ b/svchost/auth/from_map.go @@ -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()) +}