From 8688c395c793c6a70ee11493654493fc57db14b7 Mon Sep 17 00:00:00 2001 From: Aaron Turner Date: Sun, 2 Jan 2022 19:45:43 -0800 Subject: [PATCH] Add `config` support and `Profile` to Roles * Roles now support specifying a custom `Profile` value which is used as `AWS_SSO_PROFILE` and for the profile name in ~/.aws/config * Add support for the `config` command which generates the necessary profile entries in ~/.aws/config * Add StringReplace function for ProfileFormat Refs: #157, #212 --- Makefile | 2 +- README.md | 48 ++++++ cmd/config_cmd.go | 91 ++++++++++ cmd/exec_cmd.go | 61 +------ cmd/list_cmd.go | 7 +- cmd/main.go | 1 + cmd/process_cmd.go | 12 +- docs/FAQ.md | 36 ++-- docs/config.md | 6 + sso/cache.go | 335 +++++-------------------------------- sso/cache_roles.go | 357 ++++++++++++++++++++++++++++++++++++++++ sso/cache_roles_test.go | 165 +++++++++++++++++++ sso/cache_test.go | 18 ++ storage/storage.go | 10 +- storage/storage_test.go | 20 ++- 15 files changed, 791 insertions(+), 378 deletions(-) create mode 100644 cmd/config_cmd.go create mode 100644 sso/cache_roles.go create mode 100644 sso/cache_roles_test.go diff --git a/Makefile b/Makefile index 98bb9774..3641ab48 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -PROJECT_VERSION := 1.6.1 +PROJECT_VERSION := 1.7.0 DOCKER_REPO := synfinatic PROJECT_NAME := aws-sso diff --git a/README.md b/README.md index 9bb9f9f2..29d5f091 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,17 @@ * [Quick Setup](#quick-setup) * [Security](#security) * [Commands](#commands) + * [cache](#cache) + * [console](#console) + * [config](#config) + * [eval](#eval) + * [exec](#exec) + * [flush](#flush) + * [list](#list) + * [process](#process) + * [tags](#tags) + * [time](#time) + * [install-autocomplete](#install-autocomplete) * [Configuration](docs/config.md) * [Environment Varables](#environment-varables) * [Release History](#release-history) @@ -130,6 +141,7 @@ been granted access! * [cache](#cache) -- Force refresh of AWS SSO role information * [console](#console) -- Open AWS Console in a browser with the selected role + * [config](#config) -- Update your `~/.aws/config` file with the AWS profiles in AWS SSO * [eval](#eval) -- Print shell environment variables for use in your shell * [exec](#exec) -- Exec a command with the selected role * [flush](#flush) -- Force delete of cached AWS SSO credentials @@ -179,6 +191,33 @@ Priority is given to: * `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN` environment variables * Prompt user interactively +### config + +Modifies the `~/.aws/config` file to contain a profile for every role accessible +via AWS SSO CLI. + +Flags: + + * `--print` -- Print profile entries instead of modifying config file + * `--output` -- Set the default output format. + Must be one of `json`, `yaml`, `yaml-stream`, `text`, `table`. Default is `json`. + +This generates a series of [named profile entries]( +https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html) in the +`~/.aws/config` file which allows you to easily use any AWS SSO role just by setting +the `$AWS_PROFILE` environment variable. By default, each profile is named according +to the [ProfileFormat](docs/config.md#profileformat) config option or overridden by +the user defined [Profile](docs/config.md#profile) option on a role by role basis. + +**Note:** You should run this command any time your list of AWS roles changes. + +**Note:** It is important that you do _NOT_ remove the `# BEGIN_AWS_SSO_CLI` and +`# END_AWS_SSO_CLI` lines from your config file! These markers are used to track +which profiles are managed by AWS SSO CLI. + +**Note:** This command does not honor the `--sso` option as it operates on all +of the configured AWS SSO instances in the `~/.aws-sso/config.yaml` file. + ### eval Generate a series of `export VARIABLE=VALUE` lines suitable for sourcing into your @@ -319,12 +358,21 @@ By default the following key/values are available as tags to your roles: * `History` -- Tag tracking if this role was recently used. See `HistoryLimit` in config. +### time + +Print a string containing the number of hours and minutes that the current +AWS Role's STS credentials are valid for in the format of `HHhMMm` + ### install-autocomplete Configures your appropriate shell configuration file to add auto-complete functionality for commands, flags and options. Must restart your shell for this to take effect. +Modifies the following file based on your shell: + * `~/.bash_profile` -- bash + * `~/.zshrc` -- zsh + ## Environment Varables ### Honored Variables diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go new file mode 100644 index 00000000..3be2ee40 --- /dev/null +++ b/cmd/config_cmd.go @@ -0,0 +1,91 @@ +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 ( + "fmt" + "os" + "text/template" + + log "github.com/sirupsen/logrus" +) + +const ( + // CONFIG_PREFIX = "# BEGIN_AWS_SSO_CLI" + // CONFIG_SUFFIX = "# END_AWS_SSO_CLI" + CONFIG_TEMPLATE = ` +# BEGIN_AWS_SSO_CLI +{{ range . }} +[profile {{ .Profile }}] +credential_process = {{ .BinaryPath }} -u open -S "{{ .Sso }}" process --arn {{ .Arn }} +output={{ .Output }} +{{end}} +# END_AWS_SSO_CLI +` +) + +type ProfileConfig struct { + Sso string + Arn string + Profile string + Output string + BinaryPath string +} + +type ConfigCmd struct { + Print bool `kong:"help='Print profile entries instead of modifying config file'"` + Output string `kong:"help='Output format [json|yaml|yaml-stream|text|table]',default='json',enum='json,yaml,yaml-stream,text,table'"` +} + +func (cc *ConfigCmd) Run(ctx *RunContext) error { + set := ctx.Settings + binaryPath, _ := os.Executable() + + // Find all the roles across all of the SSO instances + profiles := []ProfileConfig{} + 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()) + } + profiles = append(profiles, ProfileConfig{ + Sso: ssoName, + Arn: role.Arn, + Profile: profile, + Output: ctx.Cli.Config.Output, + BinaryPath: binaryPath, + }) + } + } + + templ, err := template.New("profile").Parse(CONFIG_TEMPLATE) + if err != nil { + return err + } + if ctx.Cli.Config.Print { + if err := templ.Execute(os.Stdout, profiles); err != nil { + return err + } + } else { + return fmt.Errorf("Writing to ~/.aws/config is not yet supported") + } + + return nil +} diff --git a/cmd/exec_cmd.go b/cmd/exec_cmd.go index 9ef48a4f..c1ddd4fb 100644 --- a/cmd/exec_cmd.go +++ b/cmd/exec_cmd.go @@ -19,16 +19,12 @@ package main */ import ( - "bytes" "fmt" "os" "os/exec" "runtime" - "strings" - "text/template" "github.com/c-bata/go-prompt" - "github.com/davecgh/go-spew/spew" log "github.com/sirupsen/logrus" "github.com/synfinatic/aws-sso-cli/sso" "github.com/synfinatic/aws-sso-cli/utils" @@ -105,32 +101,6 @@ func (cc *ExecCmd) Run(ctx *RunContext) error { return nil } -const ( - AwsSsoProfileTemplate = "{{AccountIdStr .AccountId}}:{{.RoleName}}" -) - -func emptyString(str string) bool { - return str == "" -} - -func firstItem(items ...string) string { - for _, v := range items { - if v != "" { - return v - } - } - return "" -} - -func accountIdToStr(id int64) string { - i, _ := utils.AccountIdToString(id) - return i -} - -func stringsJoin(x string, items ...string) string { - return strings.Join(items, x) -} - // Executes Cmd+Args in the context of the AWS Role creds func execCmd(ctx *RunContext, awssso *sso.AWSSSO, accountid int64, role string) error { region := ctx.Settings.GetDefaultRegion(ctx.Cli.Exec.AccountId, ctx.Cli.Exec.Role, ctx.Cli.Exec.NoRegion) @@ -158,6 +128,7 @@ func execCmd(ctx *RunContext, awssso *sso.AWSSSO, accountid int64, role string) } func execShellEnvs(ctx *RunContext, awssso *sso.AWSSSO, accountid int64, role, region string) map[string]string { + var err error credsPtr := GetRoleCredentials(ctx, awssso, accountid, role) creds := *credsPtr @@ -180,39 +151,17 @@ func execShellEnvs(ctx *RunContext, awssso *sso.AWSSSO, accountid int64, role, r shellVars["AWS_SSO_DEFAULT_REGION"] = "" } - var profileFormat string = AwsSsoProfileTemplate - - funcMap := template.FuncMap{ - "AccountIdStr": accountIdToStr, - "EmptyString": emptyString, - "FirstItem": firstItem, - "StringsJoin": stringsJoin, - } - - if ctx.Settings.ProfileFormat != "" { - profileFormat = ctx.Settings.ProfileFormat - } - // Set the AWS_SSO_PROFILE env var using our template - var templ *template.Template cache := ctx.Settings.Cache.GetSSO() - if roleInfo, err := cache.Roles.GetRole(accountid, role); err != nil { + var roleInfo *sso.AWSRoleFlat + if roleInfo, err = cache.Roles.GetRole(accountid, role); err != nil { // this error should never happen log.Errorf("Unable to find role in cache. Unable to set AWS_SSO_PROFILE") } else { - templ, err = template.New("main").Funcs(funcMap).Parse(profileFormat) + shellVars["AWS_SSO_PROFILE"], err = roleInfo.ProfileName(ctx.Settings) if err != nil { - log.Errorf("Invalid ProfileFormat '%s': %s -- using default", ctx.Settings.ProfileFormat, err) - templ, _ = template.New("main").Funcs(funcMap).Parse(AwsSsoProfileTemplate) - } - - buf := new(bytes.Buffer) - log.Tracef("RoleInfo: %s", spew.Sdump(roleInfo)) - log.Tracef("Template: %s", spew.Sdump(templ)) - if err := templ.Execute(buf, roleInfo); err != nil { - log.WithError(err).Errorf("Unable to generate AWS_SSO_PROFILE") + log.Errorf("Unable to generate AWS_SSO_PROFILE: %s", err.Error()) } - shellVars["AWS_SSO_PROFILE"] = buf.String() } return shellVars diff --git a/cmd/list_cmd.go b/cmd/list_cmd.go index e7a22099..7263e8ce 100644 --- a/cmd/list_cmd.go +++ b/cmd/list_cmd.go @@ -42,7 +42,7 @@ var allListFields = map[string]string{ "RoleName": "AWS Role Name", "SSO": "AWS SSO Instance Name", "Via": "Role Chain Via", - // "Profile": "AWS_PROFILE", + "Profile": "AWS_SSO_PROFILE / AWS_PROFILE", } type ListCmd struct { @@ -118,6 +118,11 @@ func printRoles(ctx *RunContext, fields []string) { roleFlat.ExpiresStr = exp } } + // update Profile + p, err := roleFlat.ProfileName(ctx.Settings) + if err == nil { + roleFlat.Profile = p + } roleFlat.Id = idx idx += 1 tr = append(tr, *roleFlat) diff --git a/cmd/main.go b/cmd/main.go index 21c38da7..b2a84276 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -95,6 +95,7 @@ type CLI struct { // Commands Cache CacheCmd `kong:"cmd,help='Force reload of cached AWS SSO role info and config.yaml'"` + Config ConfigCmd `kong:"cmd,help='Update ~/.aws/config with AWS SSO profiles'"` Console ConsoleCmd `kong:"cmd,help='Open AWS Console using specificed AWS Role/profile'"` Default DefaultCmd `kong:"cmd,hidden,default='1'"` // list command without args Eval EvalCmd `kong:"cmd,help='Print AWS Environment vars for use with eval $(aws-sso eval ...)'"` diff --git a/cmd/process_cmd.go b/cmd/process_cmd.go index 03408c5e..0288a815 100644 --- a/cmd/process_cmd.go +++ b/cmd/process_cmd.go @@ -21,9 +21,8 @@ package main import ( "encoding/json" "fmt" - "time" - //log "github.com/sirupsen/logrus" + // log "github.com/sirupsen/logrus" "github.com/synfinatic/aws-sso-cli/sso" "github.com/synfinatic/aws-sso-cli/storage" "github.com/synfinatic/aws-sso-cli/utils" @@ -71,12 +70,13 @@ type CredentialProcessOutput struct { } func NewCredentialsProcessOutput(creds *storage.RoleCredentials) *CredentialProcessOutput { + x := *creds c := CredentialProcessOutput{ Version: 1, - AccessKeyId: (*creds).AccessKeyId, - SecretAccessKey: (*creds).SecretAccessKey, - SessionToken: (*creds).SessionToken, - Expiration: time.Unix((*creds).ExpireEpoch(), 0).Format(time.RFC3339), + AccessKeyId: x.AccessKeyId, + SecretAccessKey: x.SecretAccessKey, + SessionToken: x.SessionToken, + Expiration: x.ExpireISO8601(), } return &c } diff --git a/docs/FAQ.md b/docs/FAQ.md index cadf5653..7adfd53f 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -54,7 +54,7 @@ If the above are true, then AWS SSO will define both: * `$AWS_DEFAULT_REGION` * `$AWS_SSO_DEFAULT_REGION` -to the default region as defined by `config.yaml`. If the user changes +to the default region as def)ined by `config.yaml`. If the user changes roles and the two variables are set to the same region, then AWS SSO will update the region. If the user ever overrides the `$AWS_DEFAULT_REGION` value or deletes the `$AWS_SSO_DEFAULT_REGION` then AWS SSO will no longer @@ -65,18 +65,34 @@ manage the variable. ### How to configure ProfileFormat -`aws-sso` makes it easy to modify your shell `$PROMPT` to include information -about what AWS Account/Role you have currently assumed by defining the `$AWS_SSO_PROFILE` -environment variable. By default, `ProfileFormat` is set to -`{{ AccountIdStr .AccountId }}:{{ .RoleName }}` which will generate a value like -`02345678901:MyRoleName`. +`aws-sso` uses the `ProfileFormat` configuration option for two different purposes: + + 1. Makes it easy to modify your shell `$PROMPT` to include information + about what AWS Account/Role you have currently assumed by defining the + `$AWS_SSO_PROFILE` environment variable. + 2. Makes it easy to select a role via the `$AWS_PROFILE` environment variable + when you use the [config](../README.md#config) command. + +By default, `ProfileFormat` is set to `{{ AccountIdStr .AccountId }}:{{ .RoleName }}` +which will generate a value like `02345678901:MyRoleName`. Some examples: - * `{{ FirstItem .AccountName .AccountAlias }}` -- If there is an Account Name set in the config.yaml use that, - otherwise use the Account Alias defined by the AWS administrator. - * `{{ AccountIdStr .AccountId }}` -- Pad the AccountId with leading zeros if it is < 12 digits long + * `{{ FirstItem .AccountName .AccountAlias }}` -- If there is an Account Name + set in the config.yaml print that, otherwise print the Account Alias defined + by the AWS administrator. + * `{{ AccountIdStr .AccountId }}` -- Pad the AccountId with leading zeros if it + is < 12 digits long * `{{ .AccountId }}` -- Print the AccountId as a regular number - * `{{ StringsJoin ":" .AccountAlias .RoleName}} -- Another way of writing `{{ .AccountAlias }}:{{ .RoleName }}` + * `{{ StringsJoin ":" .AccountAlias .RoleName }} -- Another way of writing + `{{ .AccountAlias }}:{{ .RoleName }}` + * `{{ StringReplace " " "_" .AccountAlias }}` -- Replace any spaces (` `) in the + AccountAlias with an underscore (`_`). + * `{{ FirstItem .AccountName .AccountAlias | StringReplace " " "_" }}:{{ .RoleName }}` -- + Use the Account Name if set, otherwise use the Account Alias and replace any spaces + with an underscore and then append a colon, followed by the role name. For a full list of available variables, [see here](config.md#profileformat). + +To see a list of values across your roles for a given variable, you can use +the [list](../README.md#list) command. diff --git a/docs/config.md b/docs/config.md index 854656bb..b390527c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -20,6 +20,7 @@ SSOConfig: : Roles: : + Profile: DefaultRegion: Tags: # tags specific for this role (will override account level tags) : @@ -108,6 +109,11 @@ the account level will be applied to all roles in that account. The `Roles` block is optional, except for roles you which to assume via role chaining. +##### Profile + +Define a custom `$AWS_PROFILE` / `$AWS_SSO_PROFILE` value for this role which overrides +the [ProfileFormat](#profileformat) config option. + ##### Tags List of key / value pairs, used by `aws-sso` in prompt mode. Any tag placed at diff --git a/sso/cache.go b/sso/cache.go index e49903cc..c2e2259d 100644 --- a/sso/cache.go +++ b/sso/cache.go @@ -22,7 +22,6 @@ import ( "encoding/json" "fmt" "io/ioutil" - "reflect" "strconv" "strings" "time" @@ -30,7 +29,6 @@ import ( // "github.com/davecgh/go-spew/spew" log "github.com/sirupsen/logrus" "github.com/synfinatic/aws-sso-cli/utils" - "github.com/synfinatic/gotable" ) const CACHE_VERSION = 3 @@ -264,6 +262,44 @@ func (c *Cache) MarkRolesExpired() error { return c.Save(false) } +// returns all tags, but with with spaces replaced with underscores +func (c *Cache) GetAllTagsSelect() *TagsList { + cache := c.GetSSO() + tags := cache.Roles.GetAllTags() + fixedTags := NewTagsList() + for k, values := range *tags { + key := strings.ReplaceAll(k, " ", "_") + for _, v := range values { + if key == "History" { + v = reformatHistory(v) + } + fixedTags.Add(key, strings.ReplaceAll(v, " ", "_")) + } + } + return fixedTags +} + +// GetRoleTagsSelect returns all the tags for each role with all the spaces +// replaced with underscores +func (c *Cache) GetRoleTagsSelect() *RoleTags { + ret := RoleTags{} + cache := c.GetSSO() + fList := cache.Roles.GetAllRoles() + for _, role := range fList { + ret[role.Arn] = map[string]string{} + for k, v := range role.Tags { + key := strings.ReplaceAll(k, " ", "_") + if key == "History" { + v = reformatHistory(v) + } + value := strings.ReplaceAll(v, " ", "_") + ret[role.Arn][key] = value + } + } + return &ret +} + +// GetRole returns the AWSRoleFlat for the given role ARN func (c *Cache) GetRole(arn string) (*AWSRoleFlat, error) { accountId, roleName, err := utils.ParseRoleARN(arn) if err != nil { @@ -273,62 +309,8 @@ func (c *Cache) GetRole(arn string) (*AWSRoleFlat, error) { return cache.Roles.GetRole(accountId, roleName) } -// main struct holding all our Roles discovered via AWS SSO and -// via the config.yaml -type Roles struct { - Accounts map[int64]*AWSAccount `json:"Accounts"` - SSORegion string `json:"SSORegion"` - StartUrl string `json:"StartUrl"` - DefaultRegion string `json:"DefaultRegion"` - ssoName string -} - -// AWSAccount and AWSRole is how we store the data -type AWSAccount struct { - Alias string `json:"Alias,omitempty"` // from AWS - Name string `json:"Name,omitempty"` // from config - EmailAddress string `json:"EmailAddress,omitempty"` - Tags map[string]string `json:"Tags,omitempty"` - Roles map[string]*AWSRole `json:"Roles,omitempty"` - DefaultRegion string `json:"DefaultRegion,omitempty"` -} - -type AWSRole struct { - Arn string `json:"Arn"` - DefaultRegion string `json:"DefaultRegion,omitempty"` - Expires int64 `json:"Expires,omitempty"` // Seconds since Unix Epoch - Profile string `json:"Profile,omitempty"` - Tags map[string]string `json:"Tags,omitempty"` - Via string `json:"Via,omitempty"` -} - -// This is what we always return for a role definition -type AWSRoleFlat struct { - Id int `header:"Id"` - AccountId int64 `json:"AccountId" header:"AccountId"` - AccountName string `json:"AccountName" header:"AccountName"` - AccountAlias string `json:"AccountAlias" header:"AccountAlias"` - EmailAddress string `json:"EmailAddress" header:"EmailAddress"` - Expires int64 `json:"Expires" header:"ExpiresEpoch"` - ExpiresStr string `json:"-" header:"Expires"` - Arn string `json:"Arn" header:"ARN"` - RoleName string `json:"RoleName" header:"Role"` - Profile string `json:"Profile" header:"Profile"` - DefaultRegion string `json:"DefaultRegion" header:"DefaultRegion"` - SSO string `json:"SSO" header:"SSO"` - SSORegion string `json:"SSORegion" header:"SSORegion"` - StartUrl string `json:"StartUrl" header:"StartUrl"` - Tags map[string]string `json:"Tags"` // not supported by GenerateTable - Via string `json:"Via,omitempty" header:"Via"` - // SelectTags map[string]string // tags without spaces -} - -func (f AWSRoleFlat) GetHeader(fieldName string) (string, error) { - v := reflect.ValueOf(f) - return gotable.GetHeaderTag(v, fieldName) -} - // Merges the AWS SSO and our Config file to create our Roles struct +// which is defined in cache_roles.go func (c *Cache) NewRoles(as *AWSSSO, config *SSOConfig) (*Roles, error) { r := Roles{ SSORegion: config.SSORegion, @@ -441,242 +423,3 @@ func (c *Cache) NewRoles(as *AWSSSO, config *SSOConfig) (*Roles, error) { return &r, nil } - -// returns all tags, but with with spaces replaced with underscores -func (c *Cache) GetAllTagsSelect() *TagsList { - cache := c.GetSSO() - tags := cache.Roles.GetAllTags() - fixedTags := NewTagsList() - for k, values := range *tags { - key := strings.ReplaceAll(k, " ", "_") - for _, v := range values { - if key == "History" { - v = reformatHistory(v) - } - fixedTags.Add(key, strings.ReplaceAll(v, " ", "_")) - } - } - return fixedTags -} - -// GetRoleTagsSelect returns all the tags for each role with all the spaces -// replaced with underscores -func (c *Cache) GetRoleTagsSelect() *RoleTags { - ret := RoleTags{} - cache := c.GetSSO() - fList := cache.Roles.GetAllRoles() - for _, role := range fList { - ret[role.Arn] = map[string]string{} - for k, v := range role.Tags { - key := strings.ReplaceAll(k, " ", "_") - if key == "History" { - v = reformatHistory(v) - } - value := strings.ReplaceAll(v, " ", "_") - ret[role.Arn][key] = value - } - } - return &ret -} - -// AccountIds returns all the configured AWS SSO AccountIds -func (r *Roles) AccountIds() []int64 { - ret := []int64{} - for id := range r.Accounts { - ret = append(ret, id) - } - return ret -} - -// AllRoles returns all the Roles as a flat list -func (r *Roles) GetAllRoles() []*AWSRoleFlat { - ret := []*AWSRoleFlat{} - for _, id := range r.AccountIds() { - for roleName := range r.Accounts[id].Roles { - flat, _ := r.GetRole(id, roleName) - ret = append(ret, flat) - } - } - return ret -} - -// GetAccountRoles returns all the roles for a given account -func (r *Roles) GetAccountRoles(accountId int64) map[string]*AWSRoleFlat { - ret := map[string]*AWSRoleFlat{} - account := r.Accounts[accountId] - if account == nil { - return ret - } - for roleName := range account.Roles { - flat, _ := r.GetRole(accountId, roleName) - ret[roleName] = flat - } - return ret -} - -// GetAllTags returns all the unique key/tag pairs for every role -func (r *Roles) GetAllTags() *TagsList { - ret := TagsList{} - fList := r.GetAllRoles() - for _, role := range fList { - for k, v := range role.Tags { - ret.Add(k, v) - } - } - return &ret -} - -// GetRoleTags returns all the tags for each role -func (r *Roles) GetRoleTags() *RoleTags { - ret := RoleTags{} - fList := r.GetAllRoles() - for _, role := range fList { - ret[role.Arn] = map[string]string{} - for k, v := range role.Tags { - ret[role.Arn][k] = v - } - } - return &ret -} - -// Role returns the specified role as an AWSRoleFlat -func (r *Roles) GetRole(accountId int64, roleName string) (*AWSRoleFlat, error) { - account, ok := r.Accounts[accountId] - if !ok { - return &AWSRoleFlat{}, fmt.Errorf("Invalid AWS AccountID: %d", accountId) - } - for thisRoleName, role := range account.Roles { - if thisRoleName == roleName { - flat := AWSRoleFlat{ - AccountId: accountId, - AccountName: account.Name, - AccountAlias: account.Alias, - EmailAddress: account.EmailAddress, - Expires: role.Expires, - Arn: role.Arn, - RoleName: roleName, - Profile: role.Profile, - DefaultRegion: r.DefaultRegion, - SSO: r.ssoName, - SSORegion: r.SSORegion, - StartUrl: r.StartUrl, - Tags: map[string]string{}, - Via: role.Via, - } - - // copy over account tags - for k, v := range account.Tags { - flat.Tags[k] = v - } - // override account values with more specific role values - if account.DefaultRegion != "" { - flat.DefaultRegion = account.DefaultRegion - } - if role.DefaultRegion != "" { - flat.DefaultRegion = role.DefaultRegion - } - // Automatic tags - flat.Tags["AccountID"], _ = utils.AccountIdToString(accountId) - flat.Tags["Email"] = account.EmailAddress - - if account.Alias != "" { - flat.Tags["AccountAlias"] = account.Alias - } - - if flat.AccountName != "" { - flat.Tags["AccountName"] = flat.AccountName - } - - if role.Profile != "" { - flat.Tags["Profile"] = role.Profile - } - - if role.Via != "" { - flat.Tags["Via"] = role.Via - } - - // finally override role specific tags - for k, v := range role.Tags { - flat.Tags[k] = v - } - return &flat, nil - } - } - return &AWSRoleFlat{}, fmt.Errorf("Unable to find role %d:%s", accountId, roleName) -} - -// GetRoleChain figures out the AssumeRole chain required to assume the given role -func (r *Roles) GetRoleChain(accountId int64, roleName string) []*AWSRoleFlat { - ret := []*AWSRoleFlat{} - - f, err := r.GetRole(accountId, roleName) - if err != nil { - log.WithError(err).Fatalf("Unable to get role: %s", utils.MakeRoleARN(accountId, roleName)) - } - ret = append(ret, f) - for f.Via != "" { - aId, rName, err := utils.ParseRoleARN(f.Via) - if err != nil { - log.WithError(err).Fatalf("Unable to parse '%s'", f.Via) - } - f, err = r.GetRole(aId, rName) - if err != nil { - log.WithError(err).Fatalf("Unable to get role: %s", utils.MakeRoleARN(aId, rName)) - } - ret = append([]*AWSRoleFlat{f}, ret...) // prepend - } - - return ret -} - -// MatchingRoles returns all the roles matching the given tags -func (r *Roles) MatchingRoles(tags map[string]string) []*AWSRoleFlat { - ret := []*AWSRoleFlat{} - for _, role := range r.GetAllRoles() { - matches := true - for k, v := range tags { - if roleVal, ok := role.Tags[k]; ok { - if roleVal != v { - matches = false - } - } else { - matches = false - } - if !matches { - break - } - } - if matches { - ret = append(ret, role) - } - } - return ret -} - -// MatchingRolesWithTagKey returns the roles that have the tag key -func (r *Roles) MatchingRolesWithTagKey(key string) []*AWSRoleFlat { - ret := []*AWSRoleFlat{} - for _, role := range r.GetAllRoles() { - for k := range role.Tags { - if k == key { - ret = append(ret, role) - break - } - } - } - return ret -} - -// IsExpired returns if this role has expired or has no creds available -func (r *AWSRoleFlat) IsExpired() bool { - if r.Expires == 0 { - return true - } - d := time.Until(time.Unix(r.Expires, 0)) - return d <= 0 -} - -// ExpiresIn returns how long until this role expires as a string -func (r *AWSRoleFlat) ExpiresIn() (string, error) { - return utils.TimeRemain(r.Expires, false) -} diff --git a/sso/cache_roles.go b/sso/cache_roles.go new file mode 100644 index 00000000..f3c80cda --- /dev/null +++ b/sso/cache_roles.go @@ -0,0 +1,357 @@ +package sso + +/* + * 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 ( + "bytes" + "fmt" + "reflect" + "strings" + "text/template" + "time" + + "github.com/davecgh/go-spew/spew" + log "github.com/sirupsen/logrus" + "github.com/synfinatic/aws-sso-cli/utils" + "github.com/synfinatic/gotable" +) + +// main struct holding all our Roles discovered via AWS SSO and +// via the config.yaml +type Roles struct { + Accounts map[int64]*AWSAccount `json:"Accounts"` + SSORegion string `json:"SSORegion"` + StartUrl string `json:"StartUrl"` + DefaultRegion string `json:"DefaultRegion"` + ssoName string +} + +// AWSAccount and AWSRole is how we store the data +type AWSAccount struct { + Alias string `json:"Alias,omitempty"` // from AWS + Name string `json:"Name,omitempty"` // from config + EmailAddress string `json:"EmailAddress,omitempty"` + Tags map[string]string `json:"Tags,omitempty"` + Roles map[string]*AWSRole `json:"Roles,omitempty"` + DefaultRegion string `json:"DefaultRegion,omitempty"` +} + +type AWSRole struct { + Arn string `json:"Arn"` + DefaultRegion string `json:"DefaultRegion,omitempty"` + Expires int64 `json:"Expires,omitempty"` // Seconds since Unix Epoch + Profile string `json:"Profile,omitempty"` + Tags map[string]string `json:"Tags,omitempty"` + Via string `json:"Via,omitempty"` +} + +// AccountIds returns all the configured AWS SSO AccountIds +func (r *Roles) AccountIds() []int64 { + ret := []int64{} + for id := range r.Accounts { + ret = append(ret, id) + } + return ret +} + +// AllRoles returns all the Roles as a flat list +func (r *Roles) GetAllRoles() []*AWSRoleFlat { + ret := []*AWSRoleFlat{} + for _, id := range r.AccountIds() { + for roleName := range r.Accounts[id].Roles { + flat, _ := r.GetRole(id, roleName) + ret = append(ret, flat) + } + } + return ret +} + +// GetAccountRoles returns all the roles for a given account +func (r *Roles) GetAccountRoles(accountId int64) map[string]*AWSRoleFlat { + ret := map[string]*AWSRoleFlat{} + account := r.Accounts[accountId] + if account == nil { + return ret + } + for roleName := range account.Roles { + flat, _ := r.GetRole(accountId, roleName) + ret[roleName] = flat + } + return ret +} + +// GetAllTags returns all the unique key/tag pairs for every role +func (r *Roles) GetAllTags() *TagsList { + ret := TagsList{} + fList := r.GetAllRoles() + for _, role := range fList { + for k, v := range role.Tags { + ret.Add(k, v) + } + } + return &ret +} + +// GetRoleTags returns all the tags for each role +func (r *Roles) GetRoleTags() *RoleTags { + ret := RoleTags{} + fList := r.GetAllRoles() + for _, role := range fList { + ret[role.Arn] = map[string]string{} + for k, v := range role.Tags { + ret[role.Arn][k] = v + } + } + return &ret +} + +// Role returns the specified role as an AWSRoleFlat +func (r *Roles) GetRole(accountId int64, roleName string) (*AWSRoleFlat, error) { + account, ok := r.Accounts[accountId] + if !ok { + return &AWSRoleFlat{}, fmt.Errorf("Invalid AWS AccountID: %d", accountId) + } + for thisRoleName, role := range account.Roles { + if thisRoleName == roleName { + flat := AWSRoleFlat{ + AccountId: accountId, + AccountName: account.Name, + AccountAlias: account.Alias, + EmailAddress: account.EmailAddress, + Expires: role.Expires, + Arn: role.Arn, + RoleName: roleName, + Profile: role.Profile, + DefaultRegion: r.DefaultRegion, + SSO: r.ssoName, + SSORegion: r.SSORegion, + StartUrl: r.StartUrl, + Tags: map[string]string{}, + Via: role.Via, + } + + // copy over account tags + for k, v := range account.Tags { + flat.Tags[k] = v + } + // override account values with more specific role values + if account.DefaultRegion != "" { + flat.DefaultRegion = account.DefaultRegion + } + if role.DefaultRegion != "" { + flat.DefaultRegion = role.DefaultRegion + } + // Automatic tags + flat.Tags["AccountID"], _ = utils.AccountIdToString(accountId) + flat.Tags["Email"] = account.EmailAddress + + if account.Alias != "" { + flat.Tags["AccountAlias"] = account.Alias + } + + if flat.AccountName != "" { + flat.Tags["AccountName"] = flat.AccountName + } + + if role.Profile != "" { + flat.Tags["Profile"] = role.Profile + } + + if role.Via != "" { + flat.Tags["Via"] = role.Via + } + + // finally override role specific tags + for k, v := range role.Tags { + flat.Tags[k] = v + } + return &flat, nil + } + } + return &AWSRoleFlat{}, fmt.Errorf("Unable to find role %d:%s", accountId, roleName) +} + +// GetRoleChain figures out the AssumeRole chain required to assume the given role +func (r *Roles) GetRoleChain(accountId int64, roleName string) []*AWSRoleFlat { + ret := []*AWSRoleFlat{} + + f, err := r.GetRole(accountId, roleName) + if err != nil { + log.WithError(err).Fatalf("Unable to get role: %s", utils.MakeRoleARN(accountId, roleName)) + } + ret = append(ret, f) + for f.Via != "" { + aId, rName, err := utils.ParseRoleARN(f.Via) + if err != nil { + log.WithError(err).Fatalf("Unable to parse '%s'", f.Via) + } + f, err = r.GetRole(aId, rName) + if err != nil { + log.WithError(err).Fatalf("Unable to get role: %s", utils.MakeRoleARN(aId, rName)) + } + ret = append([]*AWSRoleFlat{f}, ret...) // prepend + } + + return ret +} + +// MatchingRoles returns all the roles matching the given tags +func (r *Roles) MatchingRoles(tags map[string]string) []*AWSRoleFlat { + ret := []*AWSRoleFlat{} + for _, role := range r.GetAllRoles() { + matches := true + for k, v := range tags { + if roleVal, ok := role.Tags[k]; ok { + if roleVal != v { + matches = false + } + } else { + matches = false + } + if !matches { + break + } + } + if matches { + ret = append(ret, role) + } + } + return ret +} + +// MatchingRolesWithTagKey returns the roles that have the tag key +func (r *Roles) MatchingRolesWithTagKey(key string) []*AWSRoleFlat { + ret := []*AWSRoleFlat{} + for _, role := range r.GetAllRoles() { + for k := range role.Tags { + if k == key { + ret = append(ret, role) + break + } + } + } + return ret +} + +// This is what we always return for a role definition +type AWSRoleFlat struct { + Id int `header:"Id"` + AccountId int64 `json:"AccountId" header:"AccountId"` + AccountName string `json:"AccountName" header:"AccountName"` + AccountAlias string `json:"AccountAlias" header:"AccountAlias"` + EmailAddress string `json:"EmailAddress" header:"EmailAddress"` + Expires int64 `json:"Expires" header:"ExpiresEpoch"` + ExpiresStr string `json:"-" header:"Expires"` + Arn string `json:"Arn" header:"ARN"` + RoleName string `json:"RoleName" header:"Role"` + Profile string `json:"Profile" header:"Profile"` + DefaultRegion string `json:"DefaultRegion" header:"DefaultRegion"` + SSO string `json:"SSO" header:"SSO"` + SSORegion string `json:"SSORegion" header:"SSORegion"` + StartUrl string `json:"StartUrl" header:"StartUrl"` + Tags map[string]string `json:"Tags"` // not supported by GenerateTable + Via string `json:"Via,omitempty" header:"Via"` + // SelectTags map[string]string // tags without spaces +} + +func (f AWSRoleFlat) GetHeader(fieldName string) (string, error) { + v := reflect.ValueOf(f) + return gotable.GetHeaderTag(v, fieldName) +} + +// IsExpired returns if this role has expired or has no creds available +func (r *AWSRoleFlat) IsExpired() bool { + if r.Expires == 0 { + return true + } + d := time.Until(time.Unix(r.Expires, 0)) + return d <= 0 +} + +// ExpiresIn returns how long until this role expires as a string +func (r *AWSRoleFlat) ExpiresIn() (string, error) { + return utils.TimeRemain(r.Expires, false) +} + +// RoleProfile returns either the user-defined Profile value for the role from +// the config.yaml or the generated Profile using the ProfileFormat template +func (r *AWSRoleFlat) ProfileName(s *Settings) (string, error) { + if len(r.Profile) > 0 { + return r.Profile, nil + } + + format := s.ProfileFormat + if len(format) == 0 { + format = DEFAULT_PROFILE_TEMPLATE + } + funcMap := template.FuncMap{ + "AccountIdStr": accountIdToStr, + "EmptyString": emptyString, + "FirstItem": firstItem, + "StringsJoin": stringsJoin, + "StringReplace": stringReplace, + } + templ, err := template.New("main").Funcs(funcMap).Parse(format) + if err != nil { + return "", err + } + + buf := new(bytes.Buffer) + 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 buf.String(), nil +} + +const DEFAULT_PROFILE_TEMPLATE = "{{AccountIdStr .AccountId}}:{{.RoleName}}" + +func emptyString(str string) bool { + return str == "" +} + +func firstItem(items ...string) string { + for _, v := range items { + if v != "" { + return v + } + } + return "" +} + +func accountIdToStr(id int64) string { + i, _ := utils.AccountIdToString(id) + return i +} + +func stringsJoin(x string, items ...string) string { + l := []string{} + for _, v := range items { + if len(v) > 0 { + l = append(l, v) + } + } + return strings.Join(l, x) +} + +func stringReplace(search, replace, str string) string { + return strings.ReplaceAll(str, search, replace) +} diff --git a/sso/cache_roles_test.go b/sso/cache_roles_test.go new file mode 100644 index 00000000..9f2e78e6 --- /dev/null +++ b/sso/cache_roles_test.go @@ -0,0 +1,165 @@ +package sso + +/* + * 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 ( + "io/ioutil" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/synfinatic/aws-sso-cli/storage" +) + +const TEST_JSON_STORE_FILE = "../storage/testdata/store.json" + +type CacheRolesTestSuite struct { + suite.Suite + cache *Cache + cacheFile string + settings *Settings + storage storage.SecureStorage + jsonFile string +} + +func TestCacheRolesTestSuite(t *testing.T) { + // copy our cache test file to a temp file + f, err := os.CreateTemp("", "*") + assert.NoError(t, err) + f.Close() + + settings := &Settings{ + HistoryLimit: 1, + HistoryMinutes: 90, + DefaultSSO: "Default", + cacheFile: f.Name(), + } + + // cache + input, err := ioutil.ReadFile(TEST_CACHE_FILE) + assert.NoError(t, err) + + err = ioutil.WriteFile(f.Name(), input, 0600) + assert.NoError(t, err) + + c, err := OpenCache(f.Name(), settings) + assert.NoError(t, err) + + // secure store + f2, err := os.CreateTemp("", "*") + assert.Nil(t, err) + + jsonFile := f2.Name() + f2.Close() + + input, err = ioutil.ReadFile(TEST_JSON_STORE_FILE) + assert.Nil(t, err) + + err = ioutil.WriteFile(jsonFile, input, 0600) + assert.Nil(t, err) + + sstore, err := storage.OpenJsonStore(jsonFile) + assert.Nil(t, err) + + defaults := map[string]interface{}{} + over := OverrideSettings{} + set, err := LoadSettings(TEST_SETTINGS_FILE, TEST_CACHE_FILE, defaults, over) + assert.NoError(t, err) + + s := &CacheRolesTestSuite{ + cache: c, + cacheFile: f.Name(), + settings: set, + storage: sstore, + jsonFile: jsonFile, + } + suite.Run(t, s) +} + +func (suite *CacheRolesTestSuite) TearDownAllSuite() { + os.Remove(suite.cacheFile) + os.Remove(suite.jsonFile) +} + +func (suite *CacheRolesTestSuite) TestAccountIds() { + t := suite.T() + roles := suite.cache.SSO[suite.cache.ssoName].Roles + + assert.NotEmpty(t, roles.AccountIds()) + assert.Contains(t, roles.AccountIds(), int64(258234615182)) + assert.NotContains(t, roles.AccountIds(), int64(2582346)) +} + +func (suite *CacheRolesTestSuite) TestGetAllRoles() { + t := suite.T() + + roles := suite.cache.SSO[suite.cache.ssoName].Roles + flat := roles.GetAllRoles() + assert.NotEmpty(t, flat) +} + +func (suite *CacheRolesTestSuite) TestGetAccountRoles() { + t := suite.T() + roles := suite.cache.SSO[suite.cache.ssoName].Roles + + flat := roles.GetAccountRoles(258234615182) + assert.NotEmpty(t, flat) + + flat = roles.GetAccountRoles(258234615) + assert.Empty(t, flat) +} + +func (suite *CacheRolesTestSuite) TestGetAllTags() { + t := suite.T() + roles := suite.cache.SSO[suite.cache.ssoName].Roles + + tags := *(roles.GetAllTags()) + assert.NotEmpty(t, tags) + assert.Contains(t, tags["Email"], "control-tower-dev-aws@ourcompany.com") + assert.NotContains(t, tags["Email"], "foobar@ourcompany.com") +} + +func (suite *CacheRolesTestSuite) TestGetRoleTags() { + t := suite.T() + roles := suite.cache.SSO[suite.cache.ssoName].Roles + + tags := *(roles.GetRoleTags()) + assert.NotEmpty(t, tags) + arn := "arn:aws:iam::258234615182:role/AWSAdministratorAccess" + assert.Contains(t, tags, arn) + assert.NotContains(t, tags, "foobar") + assert.Contains(t, tags[arn]["Email"], "control-tower-dev-aws@ourcompany.com") + assert.NotContains(t, tags[arn]["Email"], "foobar@ourcompany.com") +} + +func (suite *CacheRolesTestSuite) TestGetRole() { + t := suite.T() + roles := suite.cache.SSO[suite.cache.ssoName].Roles + + 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) + p, err := r.ProfileName(suite.settings) + assert.NoError(t, err) + assert.Equal(t, "OurCompany Control Tower Playground/AWSAdministratorAccess", p) + +} diff --git a/sso/cache_test.go b/sso/cache_test.go index 04101059..46e63ca5 100644 --- a/sso/cache_test.go +++ b/sso/cache_test.go @@ -1,5 +1,23 @@ package sso +/* + * 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 ( "io/ioutil" "os" diff --git a/storage/storage.go b/storage/storage.go index b5b9b74b..f3a58a6b 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -81,13 +81,12 @@ func (r *RoleCredentials) RoleArn() string { // ExpireEpoch return seconds since unix epoch when we expire func (r *RoleCredentials) ExpireEpoch() int64 { - return time.UnixMilli(r.Expiration).Unix() + return time.Unix(r.Expiration, 0).Unix() } // ExpireString returns the time the creds expire in the format of "2006-01-02 15:04:05.999999999 -0700 MST" func (r *RoleCredentials) ExpireString() string { - // apparently Expiration is in ms??? - return time.UnixMilli(r.Expiration).String() + return time.Unix(r.Expiration, 0).String() } // Expired returns if these role creds have expired or will expire in the next minute @@ -96,6 +95,11 @@ func (r *RoleCredentials) Expired() bool { return r.Expiration <= now } +// Return expire time in ISO8601 / RFC3339 format +func (r *RoleCredentials) ExpireISO8601() string { + return time.Unix(r.ExpireEpoch(), 0).Format(time.RFC3339) +} + // AccountIdStr returns our AccountId as a string func (r *RoleCredentials) AccountIdStr() string { s, err := utils.AccountIdToString(r.AccountId) diff --git a/storage/storage_test.go b/storage/storage_test.go index 85e8c486..fbc5b996 100644 --- a/storage/storage_test.go +++ b/storage/storage_test.go @@ -91,16 +91,26 @@ func TestExpireEpoch(t *testing.T) { } assert.Equal(t, int64(0), x.ExpireEpoch()) - x.Expiration = time.Now().UnixMilli() - assert.Equal(t, time.UnixMilli(x.Expiration).Unix(), x.ExpireEpoch()) + x.Expiration = time.Now().Unix() + assert.Equal(t, time.Unix(x.Expiration, 0).Unix(), x.ExpireEpoch()) } func TestExpireString(t *testing.T) { x := RoleCredentials{ Expiration: 0, } - assert.Equal(t, time.UnixMilli(0).String(), x.ExpireString()) + assert.Equal(t, time.Unix(0, 0).String(), x.ExpireString()) - x.Expiration = time.Now().UnixMilli() - assert.Equal(t, time.UnixMilli(x.Expiration).String(), x.ExpireString()) + x.Expiration = time.Now().Unix() + assert.Equal(t, time.Unix(x.Expiration, 0).String(), x.ExpireString()) +} + +func TestExpireISO8601(t *testing.T) { + x := RoleCredentials{ + Expiration: 0, + } + assert.Equal(t, time.Unix(0, 0).Format(time.RFC3339), x.ExpireISO8601()) + + x.Expiration = time.Now().Unix() + assert.Equal(t, time.Unix(x.Expiration, 0).Format(time.RFC3339), x.ExpireISO8601()) }