From 9521b55d92737f57c542669f08fa7e6514b5bfbe Mon Sep 17 00:00:00 2001 From: Aaron Turner Date: Sat, 22 Jan 2022 21:07:46 -0800 Subject: [PATCH] Manage static API creds * Update golangci-lint to latest version * Add awsconfig module * Add basic add/delete/list commands for managing static AWS API creds. * Update secure store to support static creds * Better detect invalid AWS AccountIDs Refs: #240 --- .github/workflows/golangci-lint.yaml | 2 +- awsconfig/config.go | 137 +++++++++++++++ awsconfig/logger.go | 35 ++++ awsconfig/profile.go | 114 ++++++++++++ awsconfig/section.go | 41 +++++ cmd/config_cmd.go | 49 +----- cmd/main.go | 3 + cmd/static_cmd.go | 249 +++++++++++++++++++++++++++ go.mod | 10 +- go.sum | 19 +- sso/roles.go | 2 +- sso/roles_test.go | 4 + sso/settings.go | 80 +++++++++ sso/settings_test.go | 44 +++++ sso/testdata/cache.json | 1 + static/creds.go | 26 +++ storage/json_store.go | 44 ++++- storage/json_store_test.go | 31 +++- storage/keyring.go | 50 +++++- storage/keyring_test.go | 25 +++ storage/secure_store.go | 7 + storage/storage.go | 31 ++++ storage/storage_test.go | 29 ++++ storage/testdata/store.json | 10 +- utils/utils.go | 18 +- utils/utils_test.go | 3 + 26 files changed, 996 insertions(+), 68 deletions(-) create mode 100644 awsconfig/config.go create mode 100644 awsconfig/logger.go create mode 100644 awsconfig/profile.go create mode 100644 awsconfig/section.go create mode 100644 cmd/static_cmd.go create mode 100644 static/creds.go diff --git a/.github/workflows/golangci-lint.yaml b/.github/workflows/golangci-lint.yaml index eff55615..b5cee8b6 100644 --- a/.github/workflows/golangci-lint.yaml +++ b/.github/workflows/golangci-lint.yaml @@ -21,7 +21,7 @@ jobs: uses: golangci/golangci-lint-action@v2 with: # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version - version: v1.43.0 + version: v1.45.2 # Optional: working directory, useful for monorepos # working-directory: somedir diff --git a/awsconfig/config.go b/awsconfig/config.go new file mode 100644 index 00000000..df7fb307 --- /dev/null +++ b/awsconfig/config.go @@ -0,0 +1,137 @@ +package awsconfig + +/* + * AWS SSO CLI + * Copyright (c) 2021-2022 Aaron Turner + * + * This program is free software: you can redistribute it + * and/or modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or with the authors permission any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import ( + "fmt" + // "os" + "strings" + + "github.com/synfinatic/aws-sso-cli/storage" + "github.com/synfinatic/aws-sso-cli/utils" + "gopkg.in/ini.v1" +) + +const ( + CONFIG_FILE = "~/.aws/config" + CREDENTIALS_FILE = "~/.aws/credentials" // #nosec +) + +type AwsConfig struct { + ConfigFile string + Config *ini.File + CredentialsFile string + Credentials *ini.File + Profiles map[string]map[string]interface{} // profile.go +} + +// NewAwsConfig creates a new *AwsConfig struct +func NewAwsConfig(config, credentials string) (*AwsConfig, error) { + var err error + a := AwsConfig{} + p := utils.GetHomePath(config) + + if a.Config, err = ini.Load(p); err != nil { + return nil, fmt.Errorf("unable to open %s: %s", p, err.Error()) + } else { + a.ConfigFile = config + } + + p = utils.GetHomePath(credentials) + if a.Credentials, err = ini.Load(p); err == nil { + a.CredentialsFile = p + } + + return &a, nil +} + +// StaticProfiles returns a list of all the profiles with static API creds +// stored in ~/.aws/config and ~/.aws/credentials +func (a *AwsConfig) StaticProfiles() ([]Profile, error) { + profiles := []Profile{} + creds := a.Credentials + + for _, profile := range a.Config.Sections() { + x := strings.Split(profile.Name(), " ") + if x[0] != "profile" || len(x) != 2 { + log.Errorf("invalid profile: %s", profile.Name()) + continue + } + + if HasStaticCreds(profile) { + log.Debugf("Found api keys for %s with in config file", x[1]) + profiles = append(profiles, + Profile{ + Name: x[1], + AccessKeyId: profile.Key("aws_access_key_id").String(), + SecretAccessKey: profile.Key("aws_secret_access_key").String(), + FromConfig: true, + }) + } else if creds != nil { + if cp, err := creds.GetSection(x[1]); err == nil { + if cp != nil && HasStaticCreds(cp) { + log.Debugf("Found api keys for %s in credentials file", x[1]) + profiles = append(profiles, + Profile{ + Name: x[1], + AccessKeyId: cp.Key("aws_access_key_id").String(), + SecretAccessKey: cp.Key("aws_secret_access_key").String(), + FromConfig: false, + }) + } + } + } else { + log.Errorf("skipping because no credentials file") + } + } + return profiles, nil +} + +// UpdateSecureStore writes any new role ARN credentials to the provided SecureStorage +func (a *AwsConfig) UpdateSecureStore(store storage.SecureStorage) error { + profiles, err := a.StaticProfiles() + if err != nil { + return err + } + + for _, p := range profiles { + arn, err := p.GetArn() + if err != nil { + return err + } + accountid, username, _ := utils.ParseUserARN(arn) + + creds := storage.StaticCredentials{ + UserName: username, + AccountId: accountid, + AccessKeyId: p.AccessKeyId, + SecretAccessKey: p.SecretAccessKey, + } + if err = store.SaveStaticCredentials(arn, creds); err != nil { + return err + } + } + return nil +} + +// Write updates the AWS ~/.aws/config file to use aws-sso via a credential_process +// and removes the associated ~/.aws/credentials entries +func (a *AwsConfig) Write() error { + return nil +} diff --git a/awsconfig/logger.go b/awsconfig/logger.go new file mode 100644 index 00000000..cda56d03 --- /dev/null +++ b/awsconfig/logger.go @@ -0,0 +1,35 @@ +package awsconfig + +/* + * AWS SSO CLI + * Copyright (c) 2021-2022 Aaron Turner + * + * This program is free software: you can redistribute it + * and/or modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or with the authors permission any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import ( + "github.com/sirupsen/logrus" +) + +var log *logrus.Logger + +func SetLogger(l *logrus.Logger) { + log = l +} + +/* +func GetLogger() *logrus.Logger { + return log +} +*/ diff --git a/awsconfig/profile.go b/awsconfig/profile.go new file mode 100644 index 00000000..469bbd1f --- /dev/null +++ b/awsconfig/profile.go @@ -0,0 +1,114 @@ +package awsconfig + +/* + * AWS SSO CLI + * Copyright (c) 2021-2022 Aaron Turner + * + * This program is free software: you can redistribute it + * and/or modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or with the authors permission any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/aws/aws-sdk-go-v2/service/sts" +) + +type Profile struct { + // Required for import of static creds + FromConfig bool + Name string + AccessKeyId string `ini:"aws_access_key_id"` + SecretAccessKey string `ini:"aws_secret_access_key"` + MfaSerial string `ini:"mfa_serial"` + // optional + /* + Region string `ini:"region"` + Output string `ini:"output"` + CABundle string `ini:"ca_bundle"` + CliAutoPrompt string `ini:"cli_auto_prompt"` + CliBinaryFormat string `ini:"cli_binary_format"` + CliPager string `ini:"cli_pager"` + CliTimestampFormat string `ini:"cli_timestamp_format"` + CredentialProcess string `ini:"credential_process"` + CredentialSource string `ini:"credential_source"` + DurationSeconds uint32 `ini:"duration_seconds"` + ExternalId string `ini:"external_id"` + MaxAttempts uint32 `ini:"max_attempts"` + ParameterValidation bool `ini:"parameter_validation"` + RetryMode string `ini:"retry_mode"` + RoleArn string `ini:"role_arn"` + RoleSessionName string `ini:"role_session_name"` + SourceProfile string `ini:"source_profile"` + SsoAccountId int64 `ini:"sso_account_id"` + SsoRegion string `ini:"sso_region"` + SsoRoleName string `ini:"sso_role_name"` + SsoStartUrl string `ini:"sso_start_url"` + StsRegionEndpoints string `ini:"sts_region_endpoints"` + WebIdentityTokenFile string `ini:"web_identity_token_file"` + TcpKeepalive bool `ini:"tcp_keepalive"` + */ +} + +func (p *Profile) config() (aws.Config, error) { + creds := credentials.NewStaticCredentialsProvider( + p.AccessKeyId, + p.SecretAccessKey, + "", // sessionToken + ) + return config.LoadDefaultConfig( + context.TODO(), + config.WithRegion("us-east-1"), + config.WithCredentialsProvider(creds), + ) +} + +// GetArn uses the credentials to call sts:GetCallerIdentity to retrieve the ARN +func (p *Profile) GetArn() (string, error) { + cfg, err := p.config() + if err != nil { + return "", err + } + + s := sts.NewFromConfig(cfg) + out, err := s.GetCallerIdentity(context.TODO(), &sts.GetCallerIdentityInput{}) + if err != nil { + return "", err + } + + return aws.ToString(out.Arn), nil +} + +// GetAccountAlias calls iam:ListAccountAliases to retrieve the AWS Account +// Alias. Returns an empty string if no alias has been set. +func (p *Profile) GetAccountAlias() string { + cfg, err := p.config() + if err != nil { + return "" + } + + i := iam.NewFromConfig(cfg) + out, err := i.ListAccountAliases(context.TODO(), &iam.ListAccountAliasesInput{}) + if err != nil { + return "" + } + + if len(out.AccountAliases) > 0 { + return out.AccountAliases[0] + } + return "" +} diff --git a/awsconfig/section.go b/awsconfig/section.go new file mode 100644 index 00000000..75f280ba --- /dev/null +++ b/awsconfig/section.go @@ -0,0 +1,41 @@ +package awsconfig + +/* + * AWS SSO CLI + * Copyright (c) 2021-2022 Aaron Turner + * + * This program is free software: you can redistribute it + * and/or modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or with the authors permission any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import ( + "gopkg.in/ini.v1" +) + +func HasStaticCreds(s *ini.Section) bool { + aws_access_key_id := false + aws_secret_access_key := false + for _, key := range s.KeyStrings() { + switch key { + case "aws_access_key_id": + aws_access_key_id = true + case "aws_secret_access_key": + aws_secret_access_key = true + case "mfa_serial": + // we don't support prompting for MFA so don't import them + log.Infof("Skipping MFA enabled profile: %s", s.Name()) + return false + } + } + return aws_access_key_id && aws_secret_access_key +} diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go index 8ad99c50..f31a12f4 100644 --- a/cmd/config_cmd.go +++ b/cmd/config_cmd.go @@ -29,6 +29,7 @@ import ( "github.com/hexops/gotextdiff" "github.com/hexops/gotextdiff/myers" "github.com/hexops/gotextdiff/span" + "github.com/synfinatic/aws-sso-cli/sso" "github.com/synfinatic/aws-sso-cli/utils" ) @@ -46,17 +47,6 @@ credential_process = {{ $profile.BinaryPath }} -u {{ $profile.Open }} -S "{{ $pr ` ) -type ProfileMap map[string]map[string]ProfileConfig - -type ProfileConfig struct { - Arn string - BinaryPath string - ConfigVariables map[string]interface{} - Open string - Profile string - Sso string -} - type ConfigCmd struct { Diff bool `kong:"help='Print a diff of changes to the config file instead of modifying it'"` Open string `kong:"help='Override how to open URLs: [clip|exec|open]',required,enum='clip,exec,open'"` @@ -64,42 +54,13 @@ type ConfigCmd struct { } func (cc *ConfigCmd) Run(ctx *RunContext) error { - set := ctx.Settings - binaryPath, err := os.Executable() + profiles, err := ctx.Settings.GetAllProfiles(ctx.Cli.Config.Open) if err != nil { return err } - profiles := ProfileMap{} - profileUniqueCheck := map[string][]string{} // ProfileName() => Arn - - // Find all the roles across all of the SSO instances - for ssoName, s := range set.Cache.SSO { - for _, role := range s.Roles.GetAllRoles() { - profile, err := role.ProfileName(ctx.Settings) - if err != nil { - log.Errorf("Unable to generate profile name for %s: %s", role.Arn, err.Error()) - } - - if match, duplicate := profileUniqueCheck[profile]; duplicate { - return fmt.Errorf("Duplicate profile name '%s' for:\n%s: %s\n%s: %s", - profile, match[0], match[1], ssoName, role.Arn) - } - profileUniqueCheck[profile] = []string{ssoName, role.Arn} - - if _, ok := profiles[ssoName]; !ok { - profiles[ssoName] = map[string]ProfileConfig{} - } - - profiles[ssoName][role.Arn] = ProfileConfig{ - Arn: role.Arn, - BinaryPath: binaryPath, - ConfigVariables: ctx.Settings.ConfigVariables, - Open: ctx.Cli.Config.Open, - Profile: profile, - Sso: ssoName, - } - } + if err := profiles.UniqueCheck(ctx.Settings); err != nil { + return err } templ, err := template.New("profile").Parse(CONFIG_TEMPLATE) @@ -116,7 +77,7 @@ func (cc *ConfigCmd) Run(ctx *RunContext) error { } // updateConfig calculates the diff -func updateConfig(ctx *RunContext, templ *template.Template, profiles ProfileMap) error { +func updateConfig(ctx *RunContext, templ *template.Template, profiles *sso.ProfileMap) error { // open our config file configFile := awsConfigFile() input, err := os.Open(configFile) diff --git a/cmd/main.go b/cmd/main.go index 0143e205..fd208931 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -27,6 +27,7 @@ import ( "github.com/posener/complete" // "github.com/davecgh/go-spew/spew" "github.com/sirupsen/logrus" + "github.com/synfinatic/aws-sso-cli/awsconfig" "github.com/synfinatic/aws-sso-cli/sso" "github.com/synfinatic/aws-sso-cli/storage" "github.com/synfinatic/aws-sso-cli/utils" @@ -104,6 +105,7 @@ type CLI struct { Flush FlushCmd `kong:"cmd,help='Flush AWS SSO/STS credentials from cache'"` List ListCmd `kong:"cmd,help='List all accounts / role (default command)'"` Process ProcessCmd `kong:"cmd,help='Generate JSON for credential_process in ~/.aws/config'"` + Static StaticCmd `kong:"cmd,help='Manage static AWS API credentials'"` Tags TagsCmd `kong:"cmd,help='List tags'"` Time TimeCmd `kong:"cmd,help='Print out much time before current STS Token expires'"` Version VersionCmd `kong:"cmd,help='Print version and exit'"` @@ -120,6 +122,7 @@ func main() { sso.SetLogger(log) storage.SetLogger(log) utils.SetLogger(log) + awsconfig.SetLogger(log) if err := logLevelValidate(cli.LogLevel); err != nil { log.Fatalf("%s", err.Error()) diff --git a/cmd/static_cmd.go b/cmd/static_cmd.go new file mode 100644 index 00000000..cbb6e566 --- /dev/null +++ b/cmd/static_cmd.go @@ -0,0 +1,249 @@ +package main + +/* + * AWS SSO CLI + * Copyright (c) 2021-2022 Aaron Turner + * + * This program is free software: you can redistribute it + * and/or modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or with the authors permission any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import ( + // "context" + // "errors" + "fmt" + "strings" + + // "github.com/aws/aws-sdk-go-v2/aws" + // "github.com/aws/aws-sdk-go-v2/config" + // "github.com/aws/aws-sdk-go-v2/credentials" + // "github.com/aws/aws-sdk-go-v2/service/iam" + // "github.com/davecgh/go-spew/spew" + // "github.com/manifoldco/promptui" + "github.com/manifoldco/promptui" + "github.com/synfinatic/aws-sso-cli/awsconfig" + "github.com/synfinatic/aws-sso-cli/sso" + "github.com/synfinatic/aws-sso-cli/storage" + "github.com/synfinatic/aws-sso-cli/utils" + "github.com/synfinatic/gotable" +) + +type StaticCmd struct { + Add StaticAddCmd `kong:"cmd,help='Manually add a static AWS API credential to the SecureStore'"` + Delete StaticDelCmd `kong:"cmd,help='Delete a static AWS API credential from the SecureStore'"` + // Import StaticImportCmd `kong:"cmd,help='Import static AWS API credentials from ~/.aws/config into the SecureStore'"` + List StaticListCmd `kong:"cmd,help='List static AWS API credentials in the SecureStore'"` +} + +var currentProfiles *sso.ProfileMap + +// StaticAddCmd interactively adds static credentials into the SecureStore +type StaticAddCmd struct { + Profile string `kong:"short='p',help='Name of the AWS Profile to create'"` + AccessKeyId string `kong:"short='a',help='AWS AccessKeyId for the profile'"` +} + +// validateAwsProfile validates our AWS_PROFILE value +func validateAwsProfile(input string) error { + if len(input) > 0 && !strings.Contains(input, " ") { + if currentProfiles.IsDuplicate(input) { + return fmt.Errorf("%s is a duplicate of an existing AWS_PROFILE", input) + } + + return nil + } + return fmt.Errorf("Must be a string without whitspace") +} + +// validateAwsKeyId verifies the AwsAccessKeyId +func validateAwsKeyId(input string) error { + if len(input) == 20 { + return nil + } + return fmt.Errorf("AwsAccessKeyId is the wrong length") +} + +// validateAwsSecretKey validates the AwsSecretAccessKey +func validateAwsSecretKey(input string) error { + if len(input) == 40 { + return nil + } + return fmt.Errorf("AwsSecretAccessKey is the wrong length") +} + +func (cc *StaticAddCmd) Run(ctx *RunContext) error { + var err error + + log.Warnf("aws-sso does not currently support MFA for static credentials.") + + // load current profiles + currentProfiles, err = ctx.Settings.GetAllProfiles("") + if err != nil { + return err + } + + profile := ctx.Cli.Static.Add.Profile + if profile == "" { + prompt := promptui.Prompt{ + Label: "AWS_PROFILE", + Validate: validateAwsProfile, + Pointer: promptui.PipeCursor, + } + if profile, err = prompt.Run(); err != nil { + return err + } + } + + accessKey := ctx.Cli.Static.Add.AccessKeyId + if accessKey == "" { + prompt := promptui.Prompt{ + Label: "AwsAccessKeyId", + Validate: validateAwsKeyId, + Pointer: promptui.PipeCursor, + } + accessKey, err = prompt.Run() + if err != nil { + return err + } + } + + prompt := promptui.Prompt{ + Label: "AwsSecretAccessKey", + Validate: validateAwsSecretKey, + Mask: '*', + Pointer: promptui.PipeCursor, + } + secretKey, err := prompt.Run() + if err != nil { + return err + } + + // check if key is valid + p := awsconfig.Profile{ + FromConfig: false, + Name: profile, + AccessKeyId: accessKey, + SecretAccessKey: secretKey, + MfaSerial: "", + } + + arn, err := p.GetArn() + if err != nil { + return fmt.Errorf("Unable to validate credentials: %s", err.Error()) + } + + accountId, userName, _ := utils.ParseUserARN(arn) + + err = ctx.Store.SaveStaticCredentials(arn, storage.StaticCredentials{ + Profile: profile, + UserName: userName, + AccountId: accountId, + AccessKeyId: accessKey, + SecretAccessKey: secretKey, + Tags: map[string]string{}, + }) + if err != nil { + return err + } + + log.Infof("Added static API credentials: %s = %s", profile, arn) + + return nil +} + +// StaticDelCmd deletes static credentials from the SecureStore +type StaticDelCmd struct { + Profile string `kong:"short='p',required,help='Name of the AWS Profile to delete'"` +} + +func (cc *StaticDelCmd) Run(ctx *RunContext) error { + arns := ctx.Store.ListStaticCredentials() + creds := storage.StaticCredentials{} + found := false + arn := "" + for _, arn = range arns { + if err := ctx.Store.GetStaticCredentials(arn, &creds); err != nil { + return err + } + if creds.Profile == ctx.Cli.Static.Delete.Profile { + found = true + break + } + } + if !found { + return fmt.Errorf("Unable to find %s in SecureStore", ctx.Cli.Static.Delete.Profile) + } + + if err := ctx.Store.DeleteStaticCredentials(arn); err != nil { + return err + } + + log.Infof("Deleted static API credentials: %s = %s", + ctx.Cli.Static.Delete.Profile, creds.UserArn()) + + return nil +} + +// List Credentials +type StaticListCmd struct{} + +// StaticListCmd lists all of the static credentials stored in the SecureStore +func (cc *StaticListCmd) Run(ctx *RunContext) error { + arns := ctx.Store.ListStaticCredentials() + + // no report + if len(arns) == 0 { + fmt.Printf("No static credentials are held in the SecureStore.") + return nil + } + + screds := make([]gotable.TableStruct, len(arns)) + + for i, arn := range arns { + staticCreds := storage.StaticCredentials{} + if err := ctx.Store.GetStaticCredentials(arn, &staticCreds); err != nil { + log.WithError(err).Warnf("Unable to retrieve static creds for %s", arn) + continue + } + screds[i] = staticCreds + } + + fields := []string{"Profile", "AccountId", "UserName"} + if err := gotable.GenerateTable(screds, fields); err != nil { + log.WithError(err).Fatalf("Unable to generate report") + } + fmt.Printf("\n") + + return nil +} + +/* +// Import Credentials +type StaticImportCmd struct{} + +// StaticImportCmd imports static credentials into the SecureStore +func (cc *StaticImportCmd) Run(ctx *RunContext) error { + a, err := awsconfig.NewAwsConfig(awsconfig.CONFIG_FILE, awsconfig.CREDENTIALS_FILE) + if err != nil { + return err + } + + p, err := a.StaticProfiles() + if err != nil { + return err + } + fmt.Printf("Profiles:\n%s", spew.Sdump(p)) + + return nil +} +*/ diff --git a/go.mod b/go.mod index 5a9589aa..6856413f 100644 --- a/go.mod +++ b/go.mod @@ -23,12 +23,14 @@ require ( ) require ( - github.com/aws/aws-sdk-go-v2 v1.13.0 + github.com/aws/aws-sdk-go-v2 v1.16.2 github.com/aws/aws-sdk-go-v2/config v1.13.0 github.com/aws/aws-sdk-go-v2/credentials v1.8.0 + github.com/aws/aws-sdk-go-v2/service/iam v1.18.3 github.com/aws/aws-sdk-go-v2/service/sso v1.9.0 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.10.0 github.com/aws/aws-sdk-go-v2/service/sts v1.14.0 + gopkg.in/ini.v1 v1.66.4 ) require ( @@ -70,10 +72,10 @@ require ( require ( github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.10.0 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.4 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.2.0 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.3 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.3.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.7.0 // indirect - github.com/aws/smithy-go v1.10.0 // indirect + github.com/aws/smithy-go v1.11.2 // indirect golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect ) diff --git a/go.sum b/go.sum index 784ef135..6506fb98 100644 --- a/go.sum +++ b/go.sum @@ -17,20 +17,25 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aws/aws-sdk-go-v2 v1.13.0 h1:1XIXAfxsEmbhbj5ry3D3vX+6ZcUYvIqSm4CWWEuGZCA= github.com/aws/aws-sdk-go-v2 v1.13.0/go.mod h1:L6+ZpqHaLbAaxsqV0L4cvxZY7QupWJB4fhkf8LXvC7w= +github.com/aws/aws-sdk-go-v2 v1.16.2 h1:fqlCk6Iy3bnCumtrLz9r3mJ/2gUT0pJ0wLFVIdWh+JA= +github.com/aws/aws-sdk-go-v2 v1.16.2/go.mod h1:ytwTPBG6fXTZLxxeeCCWj2/EMYp/xDUgX+OET6TLNNU= github.com/aws/aws-sdk-go-v2/config v1.13.0 h1:1ij3YPk13RrIn1h+pH+dArh3lNPD5JSAP+ifOkNhnB0= github.com/aws/aws-sdk-go-v2/config v1.13.0/go.mod h1:Pjv2OafecIn+4miw9VFDCr06YhKyf/oKOkIcpQOgWKk= github.com/aws/aws-sdk-go-v2/credentials v1.8.0 h1:8Ow0WcyDesGNL0No11jcgb1JAtE+WtubqXjgxau+S0o= github.com/aws/aws-sdk-go-v2/credentials v1.8.0/go.mod h1:gnMo58Vwx3Mu7hj1wpcG8DI0s57c9o42UQ6wgTQT5to= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.10.0 h1:NITDuUZO34mqtOwFWZiXo7yAHj7kf+XPE+EiKuCBNUI= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.10.0/go.mod h1:I6/fHT/fH460v09eg2gVrd8B/IqskhNdpcLH0WNO3QI= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.4 h1:CRiQJ4E2RhfDdqbie1ZYDo8QtIo75Mk7oTdJSfwJTMQ= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.4/go.mod h1:XHgQ7Hz2WY2GAn//UXHofLfPXWh+s62MbMOijrg12Lw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.2.0 h1:3ADoioDMOtF4uiK59vCpplpCwugEU+v4ZFD29jDL3RQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.9 h1:onz/VaaxZ7Z4V+WIN9Txly9XLTmoOh1oJ8XcAC3pako= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.9/go.mod h1:AnVH5pvai0pAF4lXRq0bmhbes1u9R8wTE+g+183bZNM= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.2.0/go.mod h1:BsCSJHx5DnDXIrOcqB8KN1/B+hXLG/bi4Y6Vjcx/x9E= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.3 h1:9stUQR/u2KXU6HkFJYlqnZEjBnbgrVbG6I5HN09xZh0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.3/go.mod h1:ssOhaLpRlh88H3UmEcsBoVKq309quMvm3Ds8e9d4eJM= github.com/aws/aws-sdk-go-v2/internal/ini v1.3.4 h1:0NrDHIwS1LIR750ltj6ciiu4NZLpr9rgq8vHi/4QD4s= github.com/aws/aws-sdk-go-v2/internal/ini v1.3.4/go.mod h1:R3sWUqPcfXSiF/LSFJhjyJmpg9uV6yP2yv3YZZjldVI= +github.com/aws/aws-sdk-go-v2/service/iam v1.18.3 h1:wllKL2fLtvfaNAVbXKMRmM/mD1oDNw0hXmDn8mE/6Us= +github.com/aws/aws-sdk-go-v2/service/iam v1.18.3/go.mod h1:51xGfEjd1HXnTzw2mAp++qkRo+NyGYblZkuGTsb49yw= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.7.0 h1:4QAOB3KrvI1ApJK14sliGr3Ie2pjyvNypn/lfzDHfUw= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.7.0/go.mod h1:K/qPe6AP2TGYv4l6n7c88zh9jWBDf6nHhvg1fx/EWfU= github.com/aws/aws-sdk-go-v2/service/sso v1.9.0 h1:1qLJeQGBmNQW3mBNzK2CFmrQNmoXWrscPqsrAaU1aTA= @@ -39,8 +44,9 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.10.0 h1:RxUpNEWDczDplbjNsrrDqh7D github.com/aws/aws-sdk-go-v2/service/ssooidc v1.10.0/go.mod h1:IF/CmGmVhuN32BZCByapqjxTjM4GWuRgofb07XL4qbM= github.com/aws/aws-sdk-go-v2/service/sts v1.14.0 h1:ksiDXhvNYg0D2/UFkLejsaz3LqpW5yjNQ8Nx9Sn2c0E= github.com/aws/aws-sdk-go-v2/service/sts v1.14.0/go.mod h1:u0xMJKDvvfocRjiozsoZglVNXRG19043xzp3r2ivLIk= -github.com/aws/smithy-go v1.10.0 h1:gsoZQMNHnX+PaghNw4ynPsyGP7aUCqx5sY2dlPQsZ0w= github.com/aws/smithy-go v1.10.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= +github.com/aws/smithy-go v1.11.2 h1:eG/N+CcUMAvsdffgMvjMKwfyDzIkjM6pfxMJ8Mzc6mE= +github.com/aws/smithy-go v1.11.2/go.mod h1:3xHYmszWVx2c0kIwQeEVf9uSm4fYZt67FBJnwub1bgM= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/c-bata/go-prompt v0.2.5 h1:3zg6PecEywxNn0xiqcXHD96fkbxghD+gdB2tbsYfl+Y= github.com/c-bata/go-prompt v0.2.5/go.mod h1:vFnjEGDIIA/Lib7giyE4E9c50Lvl8j0S+7FVlAwDAVw= @@ -84,8 +90,9 @@ github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= @@ -275,6 +282,8 @@ gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUy gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.66.4 h1:SsAcf+mM7mRZo2nJNGt8mZCjG8ZRaNGMURJw7BsIST4= +gopkg.in/ini.v1 v1.66.4/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/sso/roles.go b/sso/roles.go index 3543dcfb..59cff4d6 100644 --- a/sso/roles.go +++ b/sso/roles.go @@ -348,7 +348,7 @@ func (r *AWSRoleFlat) ProfileName(s *Settings) (string, error) { log.Tracef("RoleInfo: %s", spew.Sdump(r)) log.Tracef("Template: %s", spew.Sdump(templ)) if err := templ.Execute(buf, r); err != nil { - log.WithError(err).Errorf("Unable to generate AWS_SSO_PROFILE") + return "", fmt.Errorf("Unable to generate ProfileName: %s", err.Error()) } return buf.String(), nil diff --git a/sso/roles_test.go b/sso/roles_test.go index e61b1785..7af65313 100644 --- a/sso/roles_test.go +++ b/sso/roles_test.go @@ -153,11 +153,15 @@ func (suite *CacheRolesTestSuite) TestGetRole() { t := suite.T() roles := suite.cache.SSO[suite.cache.ssoName].Roles + _, err := roles.GetRole(58234615182, "AWSAdministratorAccess") + assert.Error(t, err) + r, err := roles.GetRole(258234615182, "AWSAdministratorAccess") assert.NoError(t, err) assert.Equal(t, int64(258234615182), r.AccountId) assert.Equal(t, "AWSAdministratorAccess", r.RoleName) assert.Equal(t, "", r.Profile) + assert.Equal(t, "us-east-1", r.DefaultRegion) p, err := r.ProfileName(suite.settings) assert.NoError(t, err) assert.Equal(t, "OurCompany Control Tower Playground/AWSAdministratorAccess", p) diff --git a/sso/settings.go b/sso/settings.go index c9a53644..f76111e0 100644 --- a/sso/settings.go +++ b/sso/settings.go @@ -331,6 +331,86 @@ func (s *Settings) GetEnvVarTags() map[string]string { return ret } +type ProfileMap map[string]map[string]ProfileConfig + +type ProfileConfig struct { + Arn string + BinaryPath string + ConfigVariables map[string]interface{} + Open string + Profile string + Sso string +} + +// GetAllProfiles returns a map of the ProfileConfig for each SSOConfig. +// takes the binary path to `open` URL with if set +func (s *Settings) GetAllProfiles(open string) (*ProfileMap, error) { + profiles := ProfileMap{} + + binaryPath, err := os.Executable() + if err != nil { + return &profiles, err + } + + // Find all the roles across all of the SSO instances + for ssoName, sso := range s.Cache.SSO { + for _, role := range sso.Roles.GetAllRoles() { + profile, err := role.ProfileName(s) + if err != nil { + return &profiles, err + } + + if _, ok := profiles[ssoName]; !ok { + profiles[ssoName] = map[string]ProfileConfig{} + } + + profiles[ssoName][role.Arn] = ProfileConfig{ + Arn: role.Arn, + BinaryPath: binaryPath, + ConfigVariables: s.ConfigVariables, + Open: open, + Profile: profile, + Sso: ssoName, + } + } + } + + return &profiles, nil +} + +// UniqueCheck verifies that all of the profiles are unique +func (p *ProfileMap) UniqueCheck(s *Settings) error { + profileUniqueCheck := map[string][]string{} // ProfileName() => Arn + + for ssoName, sso := range s.Cache.SSO { + for _, role := range sso.Roles.GetAllRoles() { + profile, err := role.ProfileName(s) + if err != nil { + return err + } + + if match, duplicate := profileUniqueCheck[profile]; duplicate { + return fmt.Errorf("Duplicate profile name '%s' for:\n%s: %s\n%s: %s", + profile, match[0], match[1], ssoName, role.Arn) + } + profileUniqueCheck[profile] = []string{ssoName, role.Arn} + } + } + + return nil +} + +func (p *ProfileMap) IsDuplicate(newProfile string) bool { + for _, roles := range *p { + for _, config := range roles { + if config.Profile == newProfile { + return true + } + } + } + return false +} + // Refresh should be called any time you load the SSOConfig into memory or add a role // to update the Role -> Account references func (c *SSOConfig) Refresh(s *Settings) { diff --git a/sso/settings_test.go b/sso/settings_test.go index 6749236e..a07a052a 100644 --- a/sso/settings_test.go +++ b/sso/settings_test.go @@ -224,3 +224,47 @@ func (suite *SettingsTestSuite) TestGetEnvVarTags() { y := suite.settings.GetEnvVarTags() assert.EqualValues(t, x, y) } + +func (suite *SettingsTestSuite) TestGetAllProfiles() { + t := suite.T() + + profiles, err := suite.settings.GetAllProfiles("open firefox") + assert.NoError(t, err) + + assert.Len(t, *profiles, 1) + assert.Contains(t, *profiles, "Default") + + p := *profiles + d := p["Default"] + assert.Len(t, d, 19) + + x, ok := d["arn:aws:iam::833365043586:role/AWSAdministratorAccess"] + assert.True(t, ok) + + assert.Equal(t, x.Arn, "arn:aws:iam::833365043586:role/AWSAdministratorAccess") + assert.NotEmpty(t, x.BinaryPath) + assert.Equal(t, map[string]interface{}(nil), x.ConfigVariables) + assert.Equal(t, "open firefox", x.Open) + assert.Equal(t, "Log archive/AWSAdministratorAccess", x.Profile) + assert.Equal(t, "Default", x.Sso) + + assert.NoError(t, profiles.UniqueCheck(suite.settings)) + + assert.False(t, profiles.IsDuplicate("testing")) + assert.True(t, profiles.IsDuplicate("Log archive/AWSAdministratorAccess")) + + oldFormat := suite.settings.ProfileFormat + // generates duplicates + suite.settings.ProfileFormat = "{{ .AccountId }}" + assert.Error(t, profiles.UniqueCheck(suite.settings)) + + // unable to generate a profile + suite.settings.ProfileFormat = "{{ .UniqueCheckFailure }}" + assert.Error(t, profiles.UniqueCheck(suite.settings)) + + suite.settings.ProfileFormat = "{{ .GetAllProfilesFailure }}" + _, err = suite.settings.GetAllProfiles("open firefox") + assert.Error(t, err) + + suite.settings.ProfileFormat = oldFormat +} diff --git a/sso/testdata/cache.json b/sso/testdata/cache.json index d4062e00..06d9db50 100644 --- a/sso/testdata/cache.json +++ b/sso/testdata/cache.json @@ -16,6 +16,7 @@ "Roles": { "AWSAdministratorAccess": { "Arn": "arn:aws:iam::258234615182:role/AWSAdministratorAccess", + "DefaultRegion": "us-east-1", "Tags": { "AccountAlias": "OurCompany Control Tower Playground", "AccountID": "258234615182", diff --git a/static/creds.go b/static/creds.go new file mode 100644 index 00000000..1a2b7ac6 --- /dev/null +++ b/static/creds.go @@ -0,0 +1,26 @@ +package static + +/* + * AWS SSO CLI + * Copyright (c) 2021-2022 Aaron Turner + * + * This program is free software: you can redistribute it + * and/or modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or with the authors permission any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +type AWSUser struct { + Arn string `json:"Arn"` + DefaultRegion string `json:"DefaultRegion,omitempty"` + Profile string `json:"Profile,omitempty"` + Tags map[string]string `json:"Tags,omitempty"` +} diff --git a/storage/json_store.go b/storage/json_store.go index 25206f75..d77b3f05 100644 --- a/storage/json_store.go +++ b/storage/json_store.go @@ -33,7 +33,8 @@ type JsonStore struct { RegisterClient map[string]RegisterClientData `json:"RegisterClient,omitempty"` StartDeviceAuth map[string]StartDeviceAuthData `json:"StartDeviceAuth,omitempty"` CreateTokenResponse map[string]CreateTokenResponse `json:"CreateTokenResponse,omitempty"` - RoleCredentials map[string]RoleCredentials `json:"RoleCredentials,omitempty"` + RoleCredentials map[string]RoleCredentials `json:"RoleCredentials,omitempty"` // ARN = key + StaticCredentials map[string]StaticCredentials `json:"StaticCredentials,omitempty"` // ARN = key } // OpenJsonStore opens our insecure JSON storage backend @@ -44,6 +45,7 @@ func OpenJsonStore(fileName string) (*JsonStore, error) { StartDeviceAuth: map[string]StartDeviceAuthData{}, CreateTokenResponse: map[string]CreateTokenResponse{}, RoleCredentials: map[string]RoleCredentials{}, + StaticCredentials: map[string]StaticCredentials{}, } cacheBytes, err := ioutil.ReadFile(fileName) @@ -127,7 +129,7 @@ func (jc *JsonStore) GetRoleCredentials(arn string, token *RoleCredentials) erro var ok bool *token, ok = jc.RoleCredentials[arn] if !ok { - return fmt.Errorf("No RoleCredentials for %s", arn) + return fmt.Errorf("No RoleCredentials for ARN: %s", arn) } return nil } @@ -137,3 +139,41 @@ func (jc *JsonStore) DeleteRoleCredentials(arn string) error { delete(jc.RoleCredentials, arn) return jc.save() } + +// SaveStaticCredentials stores the token in the json file +func (jc *JsonStore) SaveStaticCredentials(arn string, creds StaticCredentials) error { + jc.StaticCredentials[arn] = creds + return jc.save() +} + +// GetStaticCredentials retrieves the StaticCredentials from the json file +func (jc *JsonStore) GetStaticCredentials(arn string, creds *StaticCredentials) error { + var ok bool + *creds, ok = jc.StaticCredentials[arn] + if !ok { + return fmt.Errorf("No StaticCredentials for ARN: %s", arn) + } + return nil +} + +// DeleteStaticCredentials deletes the StaticCredentials from the json file +func (jc *JsonStore) DeleteStaticCredentials(arn string) error { + if _, ok := jc.StaticCredentials[arn]; !ok { + // return error if key doesn't exist + return fmt.Errorf("No StaticCredentials for ARN: %s", arn) + } + + delete(jc.StaticCredentials, arn) + return jc.save() +} + +// ListStaticCredentials returns all the ARN's of static credentials +func (jc *JsonStore) ListStaticCredentials() []string { + ret := make([]string, len(jc.StaticCredentials)) + i := 0 + for k := range jc.StaticCredentials { + ret[i] = k + i++ + } + return ret +} diff --git a/storage/json_store_test.go b/storage/json_store_test.go index d44891d8..142b1e9f 100644 --- a/storage/json_store_test.go +++ b/storage/json_store_test.go @@ -108,7 +108,7 @@ func (s *JsonStoreTestSuite) TestRoleCredentials() { RoleName: "AWSAdministratorAccess", AccountId: 12344553243, AccessKeyId: "not a real access key id", - SecretAccessKey: "not a real acess key", + SecretAccessKey: "not a real access key", SessionToken: "not a real session token", Expiration: 1637444478000, } @@ -156,3 +156,32 @@ func (s *JsonStoreTestSuite) TestCreateTokenResponse() { err = s.json.GetCreateTokenResponse(key, &tr) assert.NotNil(t, err) } + +func (s *JsonStoreTestSuite) TestStaticCredentials() { + t := s.T() + + arn := "arn:aws:iam::123456789012:user/foobar" + l := s.json.ListStaticCredentials() + assert.Equal(t, []string{arn}, l) + + cr := StaticCredentials{} + assert.NoError(t, s.json.GetStaticCredentials("arn:aws:iam::123456789012:user/foobar", &cr)) + + cr2 := StaticCredentials{ + UserName: "foobar", + AccountId: 123456789012, + AccessKeyId: "not a real access key id", + SecretAccessKey: "not a real access key", + } + assert.Equal(t, cr2, cr) + + assert.NoError(t, s.json.DeleteStaticCredentials(arn)) + assert.Empty(t, s.json.ListStaticCredentials()) + + assert.Error(t, s.json.GetStaticCredentials(arn, &cr)) + assert.Error(t, s.json.DeleteStaticCredentials(arn)) + + assert.NoError(t, s.json.SaveStaticCredentials(arn, cr2)) + assert.NoError(t, s.json.GetStaticCredentials("arn:aws:iam::123456789012:user/foobar", &cr)) + assert.Equal(t, cr2, cr) +} diff --git a/storage/keyring.go b/storage/keyring.go index e80fb71a..f78e7857 100644 --- a/storage/keyring.go +++ b/storage/keyring.go @@ -25,10 +25,10 @@ import ( "os" "path" "runtime" - "strings" "github.com/99designs/keyring" // "github.com/davecgh/go-spew/spew" + "github.com/synfinatic/aws-sso-cli/utils" "golang.org/x/crypto/ssh/terminal" ) @@ -53,6 +53,7 @@ type StorageData struct { RegisterClientData map[string]RegisterClientData CreateTokenResponse map[string]CreateTokenResponse RoleCredentials map[string]RoleCredentials + StaticCredentials map[string]StaticCredentials } func NewStorageData() StorageData { @@ -60,6 +61,7 @@ func NewStorageData() StorageData { RegisterClientData: map[string]RegisterClientData{}, CreateTokenResponse: map[string]CreateTokenResponse{}, RoleCredentials: map[string]RoleCredentials{}, + StaticCredentials: map[string]StaticCredentials{}, } } @@ -104,7 +106,7 @@ func NewKeyringConfig(name, configDir string) (*keyring.Config, error) { } if name != "" { c.AllowedBackends = []keyring.BackendType{keyring.BackendType(name)} - rolesFile := getHomePath(path.Join(securePath, RECORD_KEY)) + rolesFile := utils.GetHomePath(path.Join(securePath, RECORD_KEY)) if name == "file" { if _, err := os.Stat(rolesFile); os.IsNotExist(err) { @@ -324,7 +326,7 @@ func (kr *KeyringStore) DeleteRegisterClientData(region string) error { key := kr.RegisterClientKey(region) if _, ok := kr.cache.RegisterClientData[key]; !ok { // return error if key doesn't exist - return fmt.Errorf("Missing RegisterClientData for key: %s", key) + return fmt.Errorf("No RegisterClientData for key: %s", key) } delete(kr.cache.RegisterClientData, key) @@ -358,7 +360,7 @@ func (kr *KeyringStore) DeleteCreateTokenResponse(key string) error { k := kr.CreateTokenResponseKey(key) if _, ok := kr.cache.CreateTokenResponse[k]; !ok { // return error if key doesn't exist - return fmt.Errorf("Missing CreateTokenResponse for key: %s", k) + return fmt.Errorf("No CreateTokenResponse for key: %s", k) } delete(kr.cache.CreateTokenResponse, k) @@ -375,7 +377,7 @@ func (kr *KeyringStore) SaveRoleCredentials(arn string, token RoleCredentials) e func (kr *KeyringStore) GetRoleCredentials(arn string, token *RoleCredentials) error { var ok bool if *token, ok = kr.cache.RoleCredentials[arn]; !ok { - return fmt.Errorf("No RoleCredentials for %s", arn) + return fmt.Errorf("No RoleCredentials for ARN: %s", arn) } return nil } @@ -384,13 +386,45 @@ func (kr *KeyringStore) GetRoleCredentials(arn string, token *RoleCredentials) e func (kr *KeyringStore) DeleteRoleCredentials(arn string) error { if _, ok := kr.cache.RoleCredentials[arn]; !ok { // return error if key doesn't exist - return fmt.Errorf("Missing RoleCredentials for arn: %s", arn) + return fmt.Errorf("No RoleCredentials for ARN: %s", arn) } delete(kr.cache.RoleCredentials, arn) return kr.saveStorageData() } -func getHomePath(path string) string { - return strings.Replace(path, "~", os.Getenv("HOME"), 1) +// SaveStaticCredentials stores the token in the arnring +func (kr *KeyringStore) SaveStaticCredentials(arn string, creds StaticCredentials) error { + kr.cache.StaticCredentials[arn] = creds + return kr.saveStorageData() +} + +// GetStaticCredentials retrieves the StaticCredentials from the Keyring +func (kr *KeyringStore) GetStaticCredentials(arn string, creds *StaticCredentials) error { + var ok bool + if *creds, ok = kr.cache.StaticCredentials[arn]; !ok { + return fmt.Errorf("No StaticCredentials for ARN: %s", arn) + } + return nil +} + +// DeleteStaticCredentials deletes the StaticCredentials from the Keyring +func (kr *KeyringStore) DeleteStaticCredentials(arn string) error { + if _, ok := kr.cache.StaticCredentials[arn]; !ok { + // return error if key doesn't exist + return fmt.Errorf("No StaticCredentials for ARN: %s", arn) + } + + delete(kr.cache.StaticCredentials, arn) + return kr.saveStorageData() +} + +func (kr *KeyringStore) ListStaticCredentials() []string { + ret := make([]string, len(kr.cache.StaticCredentials)) + i := 0 + for k := range kr.cache.StaticCredentials { + ret[i] = k + i++ + } + return ret } diff --git a/storage/keyring_test.go b/storage/keyring_test.go index f17e7d65..2c1f0877 100644 --- a/storage/keyring_test.go +++ b/storage/keyring_test.go @@ -254,6 +254,31 @@ func (suite *KeyringSuite) TestCreateKeys() { assert.Equal(t, "client-data:mykey", suite.store.RegisterClientKey("mykey")) } +func (suite *KeyringSuite) TestStaticCredentials() { + t := suite.T() + + arn := "arn:aws:iam::123456789012:role/foobar" + cr := StaticCredentials{ + UserName: "foobar", + AccountId: 123456789012, + AccessKeyId: "not a real access key id", + SecretAccessKey: "not a real access key", + } + assert.Empty(t, suite.store.ListStaticCredentials()) + + assert.NoError(t, suite.store.SaveStaticCredentials(arn, cr)) + assert.Equal(t, []string{arn}, suite.store.ListStaticCredentials()) + + cr2 := StaticCredentials{} + assert.NoError(t, suite.store.GetStaticCredentials(arn, &cr2)) + assert.Equal(t, cr, cr2) + + assert.NoError(t, suite.store.DeleteStaticCredentials(arn)) + assert.Empty(t, suite.store.ListStaticCredentials()) + assert.Error(t, suite.store.GetStaticCredentials(arn, &cr2)) + assert.Error(t, suite.store.DeleteStaticCredentials(arn)) +} + func TestNewStorageData(t *testing.T) { s := NewStorageData() assert.Empty(t, s.RegisterClientData) diff --git a/storage/secure_store.go b/storage/secure_store.go index c8c0cf74..f3296e07 100644 --- a/storage/secure_store.go +++ b/storage/secure_store.go @@ -28,7 +28,14 @@ type SecureStorage interface { GetCreateTokenResponse(string, *CreateTokenResponse) error DeleteCreateTokenResponse(string) error + // Temporary STS creds SaveRoleCredentials(string, RoleCredentials) error GetRoleCredentials(string, *RoleCredentials) error DeleteRoleCredentials(string) error + + // Static API creds + SaveStaticCredentials(string, StaticCredentials) error + GetStaticCredentials(string, *StaticCredentials) error + DeleteStaticCredentials(string) error + ListStaticCredentials() []string } diff --git a/storage/storage.go b/storage/storage.go index 11e02a02..1e2964c3 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -19,9 +19,11 @@ package storage */ import ( + "reflect" "time" "github.com/synfinatic/aws-sso-cli/utils" + "github.com/synfinatic/gotable" ) // this struct should be cached for long term if possible @@ -107,3 +109,32 @@ func (r *RoleCredentials) AccountIdStr() string { } return s } + +type StaticCredentials struct { // Cache and storage + Profile string `json:"Profile" header:"Profile"` + UserName string `json:"userName" header:"UserName"` + AccountId int64 `json:"accountId" header:"AccountId"` + AccessKeyId string `json:"accessKeyId"` + SecretAccessKey string `json:"secretAccessKey"` + Tags map[string]string `json:"Tags" header:"Tags"` +} + +// GetHeader is required for GenerateTable() +func (sc StaticCredentials) GetHeader(fieldName string) (string, error) { + v := reflect.ValueOf(sc) + return gotable.GetHeaderTag(v, fieldName) +} + +// RoleArn returns the ARN for the role +func (sc *StaticCredentials) UserArn() string { + return utils.MakeUserARN(sc.AccountId, sc.UserName) +} + +// AccountIdStr returns our AccountId as a string +func (sc *StaticCredentials) AccountIdStr() string { + s, err := utils.AccountIdToString(sc.AccountId) + if err != nil { + log.WithError(err).Panicf("Invalid AccountId from AWS static credentials: %d", sc.AccountId) + } + return s +} diff --git a/storage/storage_test.go b/storage/storage_test.go index f9760a5f..e10e11be 100644 --- a/storage/storage_test.go +++ b/storage/storage_test.go @@ -114,3 +114,32 @@ func TestExpireISO8601(t *testing.T) { x.Expiration = time.Now().Unix() assert.Equal(t, time.UnixMilli(x.Expiration).Format(time.RFC3339), x.ExpireISO8601()) } + +func TestGetArn(t *testing.T) { + x := StaticCredentials{ + UserName: "foobar", + AccountId: 123456789012, + } + assert.Equal(t, "arn:aws:iam::123456789012:user/foobar", x.UserArn()) +} + +func TestGetAccountIdStr(t *testing.T) { + x := StaticCredentials{ + UserName: "foobar", + AccountId: 23456789012, + } + assert.Equal(t, "023456789012", x.AccountIdStr()) + + x = StaticCredentials{ + UserName: "foobar", + AccountId: -1, + } + assert.Panics(t, func() { x.AccountIdStr() }) +} + +func TestGetHeader(t *testing.T) { + x := StaticCredentials{} + h, err := x.GetHeader("Profile") + assert.NoError(t, err) + assert.Equal(t, "Profile", h) +} diff --git a/storage/testdata/store.json b/storage/testdata/store.json index 72238d28..53976e51 100644 --- a/storage/testdata/store.json +++ b/storage/testdata/store.json @@ -22,9 +22,17 @@ "roleName": "AWSAdministratorAccess", "accountId": 12344553243, "accessKeyId": "not a real access key id", - "secretAccessKey": "not a real acess key", + "secretAccessKey": "not a real access key", "sessionToken": "not a real session token", "expiration": 1637444478000 } + }, + "StaticCredentials": { + "arn:aws:iam::123456789012:user/foobar": { + "userName": "foobar", + "accountId": 123456789012, + "accessKeyId": "not a real access key id", + "secretAccessKey": "not a real access key" + } } } diff --git a/utils/utils.go b/utils/utils.go index 9a055b31..38c84099 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -33,6 +33,8 @@ import ( "github.com/skratchdot/open-golang/open" // default opener ) +const MAX_AWS_ACCOUNTID = 999999999999 + // GetHomePath returns the absolute path of the provided path with the first ~ // replaced with the location of the users home directory and the path rewritten // for the host operating system @@ -214,6 +216,11 @@ func ParseRoleARN(arn string) (int64, string, error) { return aId, role, nil } +// ParseUserARN parses an ARN representing a user in long or short format +func ParseUserARN(arn string) (int64, string, error) { + return ParseRoleARN(arn) +} + // MakeRoleARN create an IAM Role ARN using an int64 for the account func MakeRoleARN(account int64, name string) string { a, err := AccountIdToString(account) @@ -223,6 +230,15 @@ func MakeRoleARN(account int64, name string) string { return fmt.Sprintf("arn:aws:iam::%s:role/%s", a, name) } +// MakeUserARN create an IAM User ARN using an int64 for the account +func MakeUserARN(account int64, name string) string { + a, err := AccountIdToString(account) + if err != nil { + log.WithError(err).Panicf("Unable to MakeUserARN") + } + return fmt.Sprintf("arn:aws:iam::%s:user/%s", a, name) +} + // MakeRoleARNs creates an IAM Role ARN using a string for the account and role func MakeRoleARNs(account, name string) string { x, err := AccountIdToInt64(account) @@ -288,7 +304,7 @@ func TimeRemain(expires int64, space bool) (string, error) { // AccountIdToString returns a string version of AWS AccountID func AccountIdToString(a int64) (string, error) { - if a < 0 { + if a < 0 || a > MAX_AWS_ACCOUNTID { return "", fmt.Errorf("Invalid AWS AccountId: %d", a) } return fmt.Sprintf("%012d", a), nil diff --git a/utils/utils_test.go b/utils/utils_test.go index a3f29888..3b771fea 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -149,6 +149,9 @@ func (suite *UtilsTestSuite) TestAccountToString() { _, err = AccountIdToString(-19999) assert.Error(t, err) + + _, err = AccountIdToString(1000000000000) + assert.Error(t, err) } func (suite *UtilsTestSuite) TestAccountToInt64() {