-
Notifications
You must be signed in to change notification settings - Fork 9.4k
/
credentials.go
525 lines (458 loc) · 19.1 KB
/
credentials.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
package cliconfig
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
"strings"
"github.com/zclconf/go-cty/cty"
ctyjson "github.com/zclconf/go-cty/cty/json"
svchost "github.com/hashicorp/terraform-svchost"
svcauth "github.com/hashicorp/terraform-svchost/auth"
"github.com/hashicorp/terraform/internal/configs/hcl2shim"
pluginDiscovery "github.com/hashicorp/terraform/internal/plugin/discovery"
"github.com/hashicorp/terraform/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: %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", givenType)
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
}
// 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.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,
}
}
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 Terraform credentials helper protocol will
// use in its requests.
//
// Using ForDisplay first here makes this more liberal than Terraform
// itself would usually be in that it will tolerate pre-punycoded
// hostnames that Terraform 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.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) {
// 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 svcauth.HostCredentialsFromObject(v), nil
}
// And finally, the credentials helper
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) {
// 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: %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 = replacefile.AtomicRename(tmpName, filename)
if err != nil {
return fmt.Errorf("failed to replace %s with temporary file %s: %s", 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: %s", 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 := 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'
)