diff --git a/CHANGELOG.md b/CHANGELOG.md index d13c5a0..410784a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ TBD ### ENHANCEMENTS +* Multiple okta-aws-cli configurations in `okta.yaml` by AWS profile name. + [#162](https://github.com/okta/okta-aws-cli/pull/162), thanks [@MatthewJohn](https://github.com/MatthewJohn)! + * Explicitly set AWS Region with CLI flag `--aws-region` [#174](https://github.com/okta/okta-aws-cli/pull/174), thanks [@euchen-circle](https://github.com/euchen-circle), [@igaskin](https://github.com/igaskin)! ### BUG FIXES diff --git a/README.md b/README.md index 0bab329..7714221 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ format. - [Web command settings](#web-command-settings) - [M2M command settings](#m2m-command-settings) - [Friendly IdP and Role menu labels](#friendly-idp-and-role-menu-labels) + - [Configuration by profile name](#configuration-by-profile-name) + - [Debug okta.yaml](#debug-oktayaml) - [Installation](#installation) - [Recommendations](#recommendations) - [Operation](#operation) @@ -515,7 +517,39 @@ awscli: Ops ``` -#### Debug okta.yaml +### Configuration by profile name + +Multiple `okta-aws-cli` configurations can be saved in the `$HOME/.okta/okta.yaml` +file and are keyed by AWS profile name in the `awscli.profiles` section. This +allows the operator to save many `okta-aws-cli` configurations in the okta.yaml. + +``` +$ okta-aws-cli web --profile staging +``` + +#### Example `$HOME/.okta/okta.yaml` + +```yaml +--- +awscli: + profiles: + staging: + oidc-client-id: "0osabc" + org-domain: "org-stg.okata.com" + aws-iam-idp: "arn:aws:iam::123:saml-provider/MyIdP" + aws-iam-role: "arn:aws:iam::123:role/S3_Read" + write-aws-credentials: true + open-browser: true + production: + oidc-client-id: "0opabc" + org-domain: "org-prd.okata.com" + aws-iam-idp: "arn:aws:iam::456:saml-provider/MyIdP" + aws-iam-role: "arn:aws:iam::456:role/S3_Read" + write-aws-credentials: true + open-browser: true +``` + +## Debug okta.yaml okta-aws-cli has a debug option to check if the okta.yaml file is readable and in valid format. diff --git a/internal/config/config.go b/internal/config/config.go index 93e9920..11549b8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ package config import ( + "bytes" "fmt" "net/http" "net/url" @@ -41,6 +42,10 @@ const ( // Version app version Version = "2.0.1" + //////////////////////////////////////////////////////////// + // FORMATS + //////////////////////////////////////////////////////////// + // AWSCredentialsFormat format const AWSCredentialsFormat = "aws-credentials" // EnvVarFormat format const @@ -50,6 +55,12 @@ const ( // NoopFormat format const NoopFormat = "noop" + //////////////////////////////////////////////////////////// + // FLAGS + // NOTE: if a new Flag value is added be sure to update the + // OktaYamlConfigProfile struct with that new value. + //////////////////////////////////////////////////////////// + // AllProfilesFlag cli flag const AllProfilesFlag = "all-profiles" // AuthzIDFlag cli flag const @@ -103,6 +114,10 @@ const ( // CacheAccessTokenFlag cli flag const CacheAccessTokenFlag = "cache-access-token" + //////////////////////////////////////////////////////////// + // ENV VARS + //////////////////////////////////////////////////////////// + // AllProfilesEnvVar env var const AllProfilesEnvVar = "OKTA_AWSCLI_ALL_PROFILES" // AuthzIDEnvVar env var const @@ -162,6 +177,10 @@ const ( // WriteAWSCredentialsEnvVar env var const WriteAWSCredentialsEnvVar = "OKTA_AWSCLI_WRITE_AWS_CREDENTIALS" + //////////////////////////////////////////////////////////// + // Other + //////////////////////////////////////////////////////////// + // CannotBeBlankErrMsg error message const CannotBeBlankErrMsg = "cannot be blank" // OrgDomainMsg error message const @@ -176,11 +195,42 @@ const ( // OktaYamlConfig represents config settings from $HOME/.okta/okta.yaml type OktaYamlConfig struct { AWSCLI struct { - IDPS map[string]string `yaml:"idps"` - ROLES map[string]string `yaml:"roles"` + IDPS map[string]string `yaml:"idps"` + ROLES map[string]string `yaml:"roles"` + PROFILES map[string]OktaYamlConfigProfile `yaml:"profiles"` } `yaml:"awscli"` } +// OktaYamlConfigProfile represents config settings that are indexed by profile name +type OktaYamlConfigProfile struct { + AllProfiles string `yaml:"all-profiles"` + AuthzID string `yaml:"authz-id"` + AWSAcctFedAppID string `yaml:"aws-acct-fed-app-id"` + AWSCredentials string `yaml:"aws-credentials"` + AWSIAMIdP string `yaml:"aws-iam-idp"` + AWSIAMRole string `yaml:"aws-iam-role"` + AWSRegion string `yaml:"aws-region"` + CustomScope string `yaml:"custom-scope"` + Debug string `yaml:"debug"` + DebugAPICalls string `yaml:"debug-api-calls"` + Exec string `yaml:"exec"` + Format string `yaml:"format"` + OIDCClientID string `yaml:"oidc-client-id"` + OpenBrowser string `yaml:"open-browser"` + OpenBrowserCommand string `yaml:"open-browser-command"` + OrgDomain string `yaml:"org-domain"` + PrivateKey string `yaml:"private-key"` + PrivateKeyFile string `yaml:"private-key-file"` + KeyID string `yaml:"key-id"` + Profile string `yaml:"profile"` + QRCode string `yaml:"qr-code"` + SessionDuration string `yaml:"session-duration"` + WriteAWSCredentials string `yaml:"write-aws-credentials"` + LegacyAWSVariables string `yaml:"legacy-aws-variables"` + ExpiryAWSVariables string `yaml:"expiry-aws-variables"` + CacheAccessToken string `yaml:"cache-access-token"` +} + // Clock interface to abstract time operations type Clock interface { Now() time.Time @@ -318,12 +368,36 @@ func NewConfig(attrs *Attributes) (*Config, error) { func getFlagNameFromProfile(awsProfile string, flag string) string { profileKey := fmt.Sprintf("%s.%s", awsProfile, flag) if awsProfile != "" && viper.IsSet(profileKey) == true { + // NOTE: If the flag was from a multiple profiles keyed by aws profile + // name i.e. `staging.oidc-client-id`, set the base value to that as + // well, `oidc-client-id`, such that input validation is satisfied. + v := viper.Get(profileKey) + viper.Set(flag, v) + return profileKey } return flag } func readConfig() (Attributes, error) { + // Side loading multiple profiles from okta.yaml file if it exists + if oktaConfig, err := OktaConfig(); err == nil { + profiles := oktaConfig.AWSCLI.PROFILES + viper.SetConfigType("yaml") + yamlData, err := yaml.Marshal(&profiles) + if err != nil { + path, _ := OktaConfigPath() + fmt.Fprintf(os.Stderr, "WARNING: error reading from %q: %+v.\n\n", path, err) + } + if err == nil { + r := bytes.NewReader(yamlData) + err = viper.MergeConfig(r) + if err != nil { + fmt.Fprintf(os.Stderr, "WARNING: error with okta.yaml %+v.\n\n", err) + } + } + } + awsProfile := viper.GetString(ProfileFlag) attrs := Attributes{ @@ -808,14 +882,25 @@ func (c *Config) SetQRCode(qrCode bool) error { return nil } -// OktaConfig returns an Okta YAML Config object representation of $HOME/.okta/okta.yaml -func (c *Config) OktaConfig() (config *OktaYamlConfig, err error) { - homeDir, err := os.UserHomeDir() +// OktaConfigPath returns OS specific path to the okta config file, for example +// $HOME/.okta/okta.yaml +func OktaConfigPath() (path string, err error) { + var homeDir string + homeDir, err = os.UserHomeDir() if err != nil { return } - configPath := filepath.Join(homeDir, DotOkta, OktaYaml) + path = filepath.Join(homeDir, DotOkta, OktaYaml) + return +} + +// OktaConfig returns an Okta YAML Config object representation of $HOME/.okta/okta.yaml +func OktaConfig() (config *OktaYamlConfig, err error) { + configPath, err := OktaConfigPath() + if err != nil { + return + } yamlConfig, err := os.ReadFile(configPath) if err != nil { return @@ -953,6 +1038,28 @@ awscli: fmt.Fprintf(os.Stderr, "okta.yaml \"awscli.roles\" section is a map of %d ARN string keys to friendly string label values\n", len(_roles)) + profiles, ok := _awscli["profiles"] + if !ok { + fmt.Fprintf(os.Stderr, "WARNING: okta.yaml missing \"awscli.profiles\" section\n") + return + } + if profiles == nil { + fmt.Fprintf(os.Stderr, "WARNING: okta.yaml \"awscli.profiles\" section has no values\n") + return + } + + _profiles, ok := profiles.(map[any]any) + if !ok { + fmt.Fprintf(os.Stderr, "WARNING: okta.yaml \"awscli.profiles\" section is not a map of separate config settings keyed by profile name\n") + return + } + if len(_profiles) == 0 { + fmt.Fprintf(os.Stderr, "WARNING: okta.yaml \"awscli.profiles\" section is an empty map of separate config settings keyed by profile name\n") + return + } + + fmt.Fprintf(os.Stderr, "okta.yaml \"awscli.profiles\" section is a map of %d separate config settings keyed by profile name\n", len(_profiles)) + fmt.Fprintf(os.Stderr, "okta.yaml is OK\n") return nil } diff --git a/internal/flag/flag.go b/internal/flag/flag.go index c6d12d1..10a771a 100644 --- a/internal/flag/flag.go +++ b/internal/flag/flag.go @@ -20,7 +20,6 @@ import ( "errors" "fmt" "os" - "os/user" "path/filepath" "strings" @@ -82,28 +81,6 @@ func MakeFlagBindings(cmd *cobra.Command, flags []Flag, persistent bool) { if vipAwsRegion != "" && os.Getenv(awsRegionEnvVar) == "" { _ = os.Setenv(awsRegionEnvVar, vipAwsRegion) } - } else { - // Check if .okta-aws-cli/conifg.yml exists - usr, err := user.Current() - if err == nil { - oktaConfig := filepath.Join(usr.HomeDir, ".okta-aws-cli", "config.yml") - if _, err := os.Stat(oktaConfig); err == nil || !errors.Is(err, os.ErrNotExist) { - viper.AddConfigPath(filepath.Join(usr.HomeDir, ".okta-aws-cli")) - viper.SetConfigName("config.yml") - viper.SetConfigType("yml") - - _ = viper.ReadInConfig() - - // After viper reads in the dotenv file check if AWS_REGION is set - // there. The value will be keyed by lower case name. If it is, set - // AWS_REGION as an ENV VAR if it hasn't already been. - awsRegionEnvVar := "AWS_REGION" - vipAwsRegion := viper.GetString(strings.ToLower(awsRegionEnvVar)) - if vipAwsRegion != "" && os.Getenv(awsRegionEnvVar) == "" { - _ = os.Setenv(awsRegionEnvVar, vipAwsRegion) - } - } - } } viper.AutomaticEnv() diff --git a/internal/webssoauth/webssoauth.go b/internal/webssoauth/webssoauth.go index 9c55a9e..74c8a27 100644 --- a/internal/webssoauth/webssoauth.go +++ b/internal/webssoauth/webssoauth.go @@ -268,7 +268,7 @@ func (w *WebSSOAuthentication) selectFedApp(apps []*okta.Application) (string, e choices := make([]string, len(apps)) var selected string var configIDPs map[string]string - oktaConfig, err := w.config.OktaConfig() + oktaConfig, err := config.OktaConfig() if err == nil { configIDPs = oktaConfig.AWSCLI.IDPS } @@ -463,7 +463,7 @@ func (w *WebSSOAuthentication) choiceFriendlyLabelRole(arn string, roles map[str // promptForRole prompt operator for the AWS Role ARN given a slice of Role ARNs func (w *WebSSOAuthentication) promptForRole(idp string, roleARNs []string) (roleARN string, err error) { - oktaConfig, err := w.config.OktaConfig() + oktaConfig, err := config.OktaConfig() var configRoles map[string]string if err == nil { configRoles = oktaConfig.AWSCLI.ROLES @@ -519,7 +519,7 @@ func (w *WebSSOAuthentication) promptForRole(idp string, roleARNs []string) (rol // to pretty print out the IdP name again. func (w *WebSSOAuthentication) promptForIDP(idpARNs []string) (idpARN string, err error) { var configIDPs map[string]string - if oktaConfig, cErr := w.config.OktaConfig(); cErr == nil { + if oktaConfig, cErr := config.OktaConfig(); cErr == nil { configIDPs = oktaConfig.AWSCLI.IDPS }