Skip to content

Commit

Permalink
enhance credential process and logging (#426)
Browse files Browse the repository at this point in the history
* rewrite log to entirely abstract logrus

* fix tests and increase coverage

* add command to configure credential profiles

* add command to temporarily disable credential process functionality
  • Loading branch information
bitte-ein-bit authored Jul 11, 2024
1 parent e92216a commit 77eb63e
Show file tree
Hide file tree
Showing 31 changed files with 871 additions and 259 deletions.
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,12 @@ You can use this by adding the following to your `~/.aws/credentials` file:
credential_process = clisso get my-app --output credential_process
```

Alternatively you can run the following command to configure all Apps for use with `credential_process`:

```bash
clisso cp configure
```

The AWS SDK does not cache any credentials obtained using `credential_process`. This means that every time you use the profile, Clisso will be called to obtain new credentials. If you want to cache the credentials, you can use the `--cache` flag. For example:

```ini
Expand All @@ -323,6 +329,21 @@ global:
enable: true
```
#### Temporarily Disabling Credential Process Functionality
Different processes on your system might continue using AWS Profiles configured for use with Clisso. To temporarily disable the `credential_process` functionality, you can use the `clisso cp` submenu. For example:

```bash
clisso cp disable # to disable
clisso cp enable # to enable
clisso cp status # to check the status
```

If you disable the `credential_process` functionality, all refreshes will be disabled. While cached credentials will still be used, new credentials will not be fetched. This can be useful if you lock your computer with an active, e.g., VSCode session with CodeCommit. If you wouldn't disable the `credential_process` functionality, the VSCode would constantly trigger new credential requests to refresh the remote CodeCommit repository.

If you want to check the status programmatically, you can use the exit code of the `clisso cp status` command. If the exit code is `0`, the `credential_process` functionality is enabled. If the exit code is `1`, the `credential_process` functionality is disabled.


### Storing the password in the key chain

> WARNING: Storing the password without having MFA enabled is a security risk. It allows anyone
Expand Down
79 changes: 61 additions & 18 deletions aws/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (

"github.com/allcloud-io/clisso/log"
"github.com/go-ini/ini"
"github.com/sirupsen/logrus"
)

// Credentials represents a set of temporary credentials received from AWS STS
Expand All @@ -33,6 +32,11 @@ type Profile struct {

const expireKey = "aws_expiration"

const credentialProcessFormat = "clisso -o credential_process get %s"
const errCannotBeUsed = "Profile %s contains key %s, which indicates, it should not be used by clisso"
const infoProfileConfigured = "Profile %s is now configured for credential_process"
const infoProfileAlreadyConfigured = "Profile %s is already configured for credential_process"

func validateSection(cfg *ini.File, section string) error {
// if it doesn't exist, we're good
if cfg.Section(section) == nil {
Expand All @@ -42,21 +46,60 @@ func validateSection(cfg *ini.File, section string) error {
// it should not have any of source_profile, role_arn, mfa_serial, external_id, or credential_source
for _, key := range []string{"source_profile", "role_arn", "mfa_serial", "external_id", "credential_source", "credential_process"} {
if s.HasKey(key) {
log.Log.WithFields(logrus.Fields{
log.WithFields(log.Fields{
"section": section,
"key": key,
}).Errorf("Profile contains key %s, which indicates, it should not be used by clisso", key)
return fmt.Errorf("profile %s contains key %s, which indicates, it should not be used by clisso", section, key)
return fmt.Errorf(errCannotBeUsed, section, key)
}
}
return nil
}

// SetCredentialProcess writes the credential_process config to an AWS CLI credentials file in the format required by the SDK
func SetCredentialProcess(filename string, section string) error {
log.WithFields(log.Fields{
"filename": filename,
"section": section,
}).Debug("Writing credentials to file")
cfg, err := ini.LooseLoad(filename)
if err != nil {
return err
}
err = validateSection(cfg, section)
if err != nil {
if err.Error() == fmt.Sprintf(errCannotBeUsed, section, "credential_process") {
log.Infof(infoProfileAlreadyConfigured, section)
return nil
}
log.WithError(err).Errorf("Profile %s cannot be configured for credential_process", section)
return err
}
if cfg.HasSection(section) {
log.Tracef("Section %s exists and has passed validation, adding credential_process key to it", section)
}

_, err = cfg.Section(section).NewKey("credential_process", fmt.Sprintf(credentialProcessFormat, section))
if err != nil {
return err
}
// unset aws_secret_access_key, aws_access_key_id, aws_session_token, aws_expiration
for _, key := range []string{"aws_access_key_id", "aws_secret_access_key", "aws_session_token", expireKey} {
if cfg.Section(section).HasKey(key) {
log.Debugf("Removing key %s from profile %s", key, section)
cfg.Section(section).DeleteKey(key)
}
}
log.Infof("Profile %s is now configured for credential_process", section)

return cfg.SaveTo(filename)
}

// OutputFile writes credentials to an AWS CLI credentials file
// (https://docs.aws.amazon.com/cli/latest/userguide/cli-config-files.html). In addition, this
// function removes expired temporary credentials from the credentials file.
func OutputFile(c *Credentials, filename string, section string) error {
log.Log.WithFields(logrus.Fields{
log.WithFields(log.Fields{
"filename": filename,
"section": section,
}).Debug("Writing credentials to file")
Expand All @@ -69,7 +112,7 @@ func OutputFile(c *Credentials, filename string, section string) error {
return err
}
if cfg.HasSection(section) {
log.Log.Tracef("Section %s exists and has passed validation, adding aws_access_key_id, aws_secret_access_key, aws_session_token, %s keys to it", section, expireKey)
log.Tracef("Section %s exists and has passed validation, adding aws_access_key_id, aws_secret_access_key, aws_session_token, %s keys to it", section, expireKey)
}

_, err = cfg.Section(section).NewKey("aws_access_key_id", c.AccessKeyID)
Expand All @@ -92,27 +135,27 @@ func OutputFile(c *Credentials, filename string, section string) error {
// Remove expired credentials.
for _, s := range cfg.Sections() {
if !s.HasKey(expireKey) {
log.Log.Tracef("Skipping profile %s because it does not have an %s key", s.Name(), expireKey)
log.Tracef("Skipping profile %s because it does not have an %s key", s.Name(), expireKey)
continue
}
v, err := s.Key(expireKey).TimeFormat(time.RFC3339)
if err != nil {
log.Log.Warnf("Cannot parse date (%v) in profile %s: %s",
log.Warnf("Cannot parse date (%v) in profile %s: %s",
s.Key(expireKey), s.Name(), err)
continue
}
if time.Now().UTC().Unix() > v.Unix() {
log.Log.Tracef("Removing expired credentials for profile %s", s.Name())
log.Tracef("Removing expired credentials for profile %s", s.Name())
for _, key := range []string{"aws_access_key_id", "aws_secret_access_key", "aws_session_token", expireKey} {
cfg.Section(s.Name()).DeleteKey(key)
}
if len(cfg.Section(s.Name()).Keys()) == 0 {
log.Log.Tracef("Removing empty profile %s", s.Name())
log.Tracef("Removing empty profile %s", s.Name())
cfg.DeleteSection(s.Name())
}
continue
}
log.Log.Tracef("Profile %s expires at %s", s.Name(), v.Format(time.RFC3339))
log.Tracef("Profile %s expires at %s", s.Name(), v.Format(time.RFC3339))
}

return cfg.SaveTo(filename)
Expand Down Expand Up @@ -144,8 +187,8 @@ func OutputEnvironment(c *Credentials, windows bool, w io.Writer) {
// OutputCredentialProcess writes (prints) credentials to stdout in the format required by the AWS CLI.
// The output can be used to set the credential_process option in the AWS CLI configuration file.
func OutputCredentialProcess(c *Credentials, w io.Writer) {
log.Log.Trace("Writing credentials to stdout in credential_process format")
log.Log.Infof("Credentials expire at %s, in %d Minutes", c.Expiration.Format(time.RFC3339), int(c.Expiration.Sub(time.Now().UTC()).Minutes()))
log.Trace("Writing credentials to stdout in credential_process format")
log.Infof("Credentials expire at %s, in %d Minutes", c.Expiration.Format(time.RFC3339), int(c.Expiration.Sub(time.Now().UTC()).Minutes()))
fmt.Fprintf(
w,
`{ "Version": 1, "AccessKeyId": %q, "SecretAccessKey": %q, "SessionToken": %q, "Expiration": %q }`,
Expand All @@ -160,18 +203,18 @@ func OutputCredentialProcess(c *Credentials, w io.Writer) {
// GetValidProfiles returns profiles which have a aws_expiration key but are not yet expired.
func GetValidProfiles(filename string) ([]Profile, error) {
var profiles []Profile
log.Log.WithField("filename", filename).Trace("Loading AWS credentials file")
log.WithField("filename", filename).Trace("Loading AWS credentials file")
cfg, err := ini.LooseLoad(filename)
if err != nil {
err = fmt.Errorf("%s contains errors: %w", filename, err)
log.Log.WithError(err).Trace("Failed to load AWS credentials file")
log.WithError(err).Trace("Failed to load AWS credentials file")
return nil, err
}
for _, s := range cfg.Sections() {
if s.HasKey(expireKey) {
v, err := s.Key(expireKey).TimeFormat(time.RFC3339)
if err != nil {
log.Log.Warnf("Cannot parse date (%v) in section %s: %s",
log.Warnf("Cannot parse date (%v) in section %s: %s",
s.Key(expireKey), s.Name(), err)
continue
}
Expand All @@ -190,18 +233,18 @@ func GetValidProfiles(filename string) ([]Profile, error) {
// returns a map of profile name to credentials
func GetValidCredentials(filename string) (map[string]Credentials, error) {
credentials := make(map[string]Credentials)
log.Log.WithField("filename", filename).Trace("Loading credentials file")
log.WithField("filename", filename).Trace("Loading credentials file")
cfg, err := ini.LooseLoad(filename)
if err != nil {
err = fmt.Errorf("%s contains errors: %w", filename, err)
log.Log.WithError(err).Trace("Failed to load credentials file")
log.WithError(err).Trace("Failed to load credentials file")
return nil, err
}
for _, s := range cfg.Sections() {
if s.HasKey(expireKey) {
v, err := s.Key(expireKey).TimeFormat(time.RFC3339)
if err != nil {
log.Log.Warnf("Cannot parse date (%v) in section %s: %s",
log.Warnf("Cannot parse date (%v) in section %s: %s",
s.Key(expireKey), s.Name(), err)
continue
}
Expand Down
90 changes: 78 additions & 12 deletions aws/aws_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import (

"github.com/allcloud-io/clisso/log"
"github.com/go-ini/ini"
"github.com/stretchr/testify/assert"
)

var _ = log.NewLogger("panic", "", false)
var _, hook = log.SetupLogger("panic", "", false, true)

func TestWriteToFile(t *testing.T) {
id := "expiredkey"
Expand Down Expand Up @@ -159,6 +160,33 @@ func TestWriteToFile(t *testing.T) {
}
}

func initConfig(filename string) error {
inifile := ini.Empty()
// add some other config options to ensure we don't overwrite them
inifile.Section("default").Key("region").SetValue("us-west-2")
inifile.Section("default").Key("output").SetValue("json")

inifile.Section("cred-process").Key("credential_process").SetValue("echo")

// profile setup for using a source profile
inifile.Section("child-profile").Key("source-profile").SetValue("cred-process")
inifile.Section("child-profile").Key("role_arn").SetValue("arn:aws:iam::123456789012:role/role-name")

// mock an expired clisso temporary profile
inifile.Section("expiredprofile").Key("aws_access_key_id").SetValue("expiredkey")
inifile.Section("expiredprofile").Key("aws_secret_access_key").SetValue("expired")
inifile.Section("expiredprofile").Key("aws_session_token").SetValue("expiredtoken")
inifile.Section("expiredprofile").Key("aws_expiration").SetValue(time.Now().Add(-time.Duration(1) * time.Hour).UTC().Format(time.RFC3339))

// mock a valid clisso temporary profile
inifile.Section("validprofile").Key("aws_access_key_id").SetValue("testkey")
inifile.Section("validprofile").Key("aws_secret_access_key").SetValue("testsecret")
inifile.Section("validprofile").Key("aws_session_token").SetValue("testtoken")
inifile.Section("validprofile").Key("aws_expiration").SetValue(time.Now().Add(time.Duration(1) * time.Hour).UTC().Format(time.RFC3339))

return inifile.SaveTo(filename)

}
func TestProtectSections(t *testing.T) {
id := "expiredkey"
sec := "expiredsecret"
Expand All @@ -173,17 +201,8 @@ func TestProtectSections(t *testing.T) {
}

fn := "TestProtectSections.txt"
inifile := ini.Empty()
// add some other config options to ensure we don't overwrite them
inifile.Section("default").Key("region").SetValue("us-west-2")
inifile.Section("default").Key("output").SetValue("json")

inifile.Section("cred-process").Key("credential_process").SetValue("echo")
err := initConfig(fn)

inifile.Section("child-profile").Key("source-profile").SetValue("cred-process")
inifile.Section("child-profile").Key("role_arn").SetValue("arn:aws:iam::123456789012:role/role-name")

err := inifile.SaveTo(fn)
if err != nil {
t.Fatal("Could not write INI file: ", err)
}
Expand Down Expand Up @@ -255,7 +274,7 @@ func TestProtectSections(t *testing.T) {
}

func TestGetValidProfiles(t *testing.T) {
fn := "test_creds.txt"
fn := "TestGetValidProfiles.txt"

id := "testkey"
sec := "testsecret"
Expand Down Expand Up @@ -399,3 +418,50 @@ func TestOutputWindowsEnvironment(t *testing.T) {
t.Fatalf("Wrong info written to shell: got %v want %v", got, want)
}
}

func TestSetCredentialProcess(t *testing.T) {
assert := assert.New(t)
fn := "TestSetCredentialProcess.txt"
err := initConfig(fn)
assert.Nil(err, "Expected no error, but got: %v", err)

// nothing to todo, should be skipped
p := "cred-process"
err = SetCredentialProcess(fn, p)
assert.Nil(err, "Expected no error, but got: %v", err)
assert.GreaterOrEqual(len(hook.Entries), 1, "Expected 1 or more log message, but got: %v", hook.Entries)
expected := fmt.Sprintf(infoProfileAlreadyConfigured, p)
assert.Equal(expected, hook.LastEntry().Message, "Expected '%s', but got: %v", expected, hook.LastEntry().Message)
hook.Reset()

// set credential process on child-profile should fail
err = SetCredentialProcess(fn, "child-profile")
assert.NotNil(err, "Expected an error, but got nil")
assert.GreaterOrEqual(len(hook.Entries), 1, "Expected 1 or more log message, but got: %v", hook.Entries)
expected = fmt.Sprintf(errCannotBeUsed, "child-profile", "role_arn")
assert.Equal(expected, err.Error(), "Expected '%s', but got: %v", expected, err.Error())
hook.Reset()

// set credential process on expired and valid profile should work and remove the profile keys
for _, p := range []string{"expiredprofile", "validprofile"} {
err = SetCredentialProcess(fn, p)
assert.Nil(err, "Expected no error, but got: %v", err)
assert.GreaterOrEqual(len(hook.Entries), 1, "Expected 1 or more log message, but got: %v", hook.Entries)
expected = fmt.Sprintf(infoProfileConfigured, p)
assert.Equalf(expected, hook.LastEntry().Message, "Expected '%s', but got: %v", expected, hook.LastEntry().Message)
hook.Reset()

cfg, err := ini.Load(fn)
assert.Nil(err, "Expected no error, but got: %v", err)
// the section should only have on key left
s := cfg.Section(p)
assert.Equal(1, len(s.Keys()), "Expected 1 key, but got: %v", len(s.Keys()))
// the key should be credential_process
k, err := s.GetKey("credential_process")
assert.Nil(err, "Expected no error, but got: %v", err)
expected = fmt.Sprintf(credentialProcessFormat, p)
assert.Equal(expected, k.String(), "Expected '%s', but got: %v", expected, k.String())
}

os.Remove(fn)
}
Loading

0 comments on commit 77eb63e

Please sign in to comment.