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 )