Skip to content

Commit

Permalink
Merge pull request #193 from synfinatic/role-chaining
Browse files Browse the repository at this point in the history
Role chaining
  • Loading branch information
synfinatic authored Dec 24, 2021
2 parents 2a5f572 + 506b155 commit 0e41a7a
Show file tree
Hide file tree
Showing 15 changed files with 171 additions and 75 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
### New Features
* Setup now prompts for `LogLevel`
* Suppress bogus warning when saving Role credentials in `wincred` store #183
* Fix `eval` command on Windows using `export` instead of `set` #183
* Add support for role chaining using `Via` tag #38

### Bugs Fixes
* Fix issue with missing colon in parsed/generated Role ARNs for missing AWS region #192
* Incorrect `--level` value now correctly tells user the correct name of the flag
* `exec` command now uses `cmd.exe` when no command is specified

Expand Down
2 changes: 1 addition & 1 deletion cmd/flush_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func (cc *FlushCmd) Run(ctx *RunContext) error {
if err != nil {
return err
}
awssso := sso.NewAWSSSO(s.SSORegion, s.StartUrl, &ctx.Store)
awssso := sso.NewAWSSSO(s, &ctx.Store)

// Deleting the token response invalidates all our STS tokens
err = ctx.Store.DeleteCreateTokenResponse(awssso.StoreKey())
Expand Down
2 changes: 1 addition & 1 deletion cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ func doAuth(ctx *RunContext) *sso.AWSSSO {
if err != nil {
log.Fatalf("%s", err.Error())
}
AwsSSO := sso.NewAWSSSO(s.SSORegion, s.StartUrl, &ctx.Store)
AwsSSO := sso.NewAWSSSO(s, &ctx.Store)
err = AwsSSO.Authenticate(ctx.Settings.UrlAction, ctx.Settings.Browser)
if err != nil {
log.WithError(err).Fatalf("Unable to authenticate")
Expand Down
2 changes: 1 addition & 1 deletion cmd/select.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ func (tc *TagsCompleter) Complete(d prompt.Document) []prompt.Suggest {
}

// https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_iam-quotas.html
var isRoleARN *regexp.Regexp = regexp.MustCompile(`^arn:aws:iam:\d+:role/[a-zA-Z0-9\+=,\.@_-]+$`)
var isRoleARN *regexp.Regexp = regexp.MustCompile(`^arn:aws:iam::\d+:role/[a-zA-Z0-9\+=,\.@_-]+$`)
var NoSpaceAtEnd *regexp.Regexp = regexp.MustCompile(`\s+$`)

func (tc *TagsCompleter) Executor(args string) {
Expand Down
2 changes: 1 addition & 1 deletion cmd/tags_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func (cc *TagsCmd) Run(ctx *RunContext) error {
set := ctx.Settings
if ctx.Cli.Tags.ForceUpdate {
s := set.SSO[ctx.Cli.SSO]
awssso := sso.NewAWSSSO(s.SSORegion, s.StartUrl, &ctx.Store)
awssso := sso.NewAWSSSO(s, &ctx.Store)
err := awssso.Authenticate(ctx.Settings.UrlAction, ctx.Settings.Browser)
if err != nil {
log.WithError(err).Fatalf("Unable to authenticate")
Expand Down
114 changes: 98 additions & 16 deletions sso/awssso.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ import (

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/sso"
"github.com/aws/aws-sdk-go/service/ssooidc"
"github.com/aws/aws-sdk-go/service/sts"
"github.com/davecgh/go-spew/spew"
log "github.com/sirupsen/logrus"
"github.com/synfinatic/aws-sso-cli/storage"
"github.com/synfinatic/aws-sso-cli/utils"
Expand All @@ -48,22 +51,24 @@ type AWSSSO struct {
Token storage.CreateTokenResponse `json:"TokenResponse"`
Accounts []AccountInfo `json:"Accounts"`
Roles map[string][]RoleInfo `json:"Roles"`
SSOConfig *SSOConfig `json:"SSOConfig"`
}

func NewAWSSSO(ssoRegion, startUrl string, store *storage.SecureStorage) *AWSSSO {
func NewAWSSSO(s *SSOConfig, store *storage.SecureStorage) *AWSSSO {
mySession := session.Must(session.NewSession())
oidcSession := ssooidc.New(mySession, aws.NewConfig().WithRegion(ssoRegion))
ssoSession := sso.New(mySession, aws.NewConfig().WithRegion(ssoRegion))
oidcSession := ssooidc.New(mySession, aws.NewConfig().WithRegion(s.SSORegion))
ssoSession := sso.New(mySession, aws.NewConfig().WithRegion(s.SSORegion))

as := AWSSSO{
sso: *ssoSession,
ssooidc: *oidcSession,
store: *store,
ClientName: awsSSOClientName,
ClientType: awsSSOClientType,
SsoRegion: ssoRegion,
StartUrl: startUrl,
SsoRegion: s.SSORegion,
StartUrl: s.StartUrl,
Roles: map[string][]RoleInfo{},
SSOConfig: s,
}
return &as
}
Expand Down Expand Up @@ -293,6 +298,7 @@ type RoleInfo struct {
Region string `yaml:"Region" json:"Region" header:"Region"`
SSORegion string `header:"SSORegion"`
StartUrl string `header:"StartUrl"`
Via string `header:"Via"`
}

func (ri RoleInfo) GetHeader(fieldName string) (string, error) {
Expand All @@ -301,7 +307,8 @@ func (ri RoleInfo) GetHeader(fieldName string) (string, error) {
}

func (ri RoleInfo) RoleArn() string {
return fmt.Sprintf("arn:aws:iam:%s:role/%s", ri.AccountId, ri.RoleName)
a, _ := strconv.ParseInt(ri.AccountId, 10, 64)
return utils.MakeRoleARN(a, ri.RoleName)
}

func (as *AWSSSO) GetRoles(account AccountInfo) ([]RoleInfo, error) {
Expand All @@ -321,6 +328,17 @@ func (as *AWSSSO) GetRoles(account AccountInfo) ([]RoleInfo, error) {
return as.Roles[account.AccountId], err
}
for i, r := range output.RoleList {
var via string

aId, err := strconv.ParseInt(account.AccountId, 10, 64)
if err != nil {
return as.Roles[account.AccountId], fmt.Errorf("Unable to parse accountid %s: %s",
account.AccountId, err.Error())
}
ssoRole, err := as.SSOConfig.GetRole(aId, aws.StringValue(r.RoleName))
if err != nil && len(ssoRole.Via) > 0 {
via = ssoRole.Via
}
as.Roles[account.AccountId] = append(as.Roles[account.AccountId], RoleInfo{
Id: i,
AccountId: aws.StringValue(r.AccountId),
Expand All @@ -329,6 +347,7 @@ func (as *AWSSSO) GetRoles(account AccountInfo) ([]RoleInfo, error) {
EmailAddress: account.EmailAddress,
SSORegion: as.SsoRegion,
StartUrl: as.StartUrl,
Via: via,
})
}
for aws.StringValue(output.NextToken) != "" {
Expand Down Expand Up @@ -412,31 +431,94 @@ func (as *AWSSSO) GetAccounts() ([]AccountInfo, error) {
return as.Accounts, nil
}

// GetRoleCredentials recursively does any sts:AssumeRole calls as necessary for role-chaining
// through `Via` and returns the final set of RoleCredentials for the requested role
func (as *AWSSSO) GetRoleCredentials(accountId int64, role string) (storage.RoleCredentials, error) {
aId, err := utils.AccountIdToString(accountId)
if err != nil {
return storage.RoleCredentials{}, err
}

input := sso.GetRoleCredentialsInput{
AccessToken: aws.String(as.Token.AccessToken),
AccountId: aws.String(aId),
RoleName: aws.String(role),
configRole, err := as.SSOConfig.GetRole(accountId, role)
if err != nil && configRole.Via == "" {
log.Debugf("Getting %s:%s directly", aId, role)
// This are the actual role creds requested through AWS SSO
input := sso.GetRoleCredentialsInput{
AccessToken: aws.String(as.Token.AccessToken),
AccountId: aws.String(aId),
RoleName: aws.String(role),
}
output, err := as.sso.GetRoleCredentials(&input)
if err != nil {
return storage.RoleCredentials{}, err
}

ret := storage.RoleCredentials{
AccountId: accountId,
RoleName: role,
AccessKeyId: aws.StringValue(output.RoleCredentials.AccessKeyId),
SecretAccessKey: aws.StringValue(output.RoleCredentials.SecretAccessKey),
SessionToken: aws.StringValue(output.RoleCredentials.SessionToken),
Expiration: aws.Int64Value(output.RoleCredentials.Expiration),
}

return ret, nil
}

// Need to recursively call sts:AssumeRole in order to retrieve the STS creds for
// the requested role
// role has a Via
log.Debugf("Getting %s:%s via %s", aId, role, configRole.Via)
viaAccountId, viaRole, err := utils.ParseRoleARN(configRole.Via)
if err != nil {
return storage.RoleCredentials{}, fmt.Errorf("Invalid Via %s: %s", configRole.Via, err.Error())
}
output, err := as.sso.GetRoleCredentials(&input)

// recurse
creds, err := as.GetRoleCredentials(viaAccountId, viaRole)
if err != nil {
return storage.RoleCredentials{}, err
}

sessionCreds := credentials.NewStaticCredentials(
creds.AccessKeyId,
creds.SecretAccessKey,
creds.SessionToken,
)
mySession := session.Must(session.NewSessionWithOptions(session.Options{
Config: aws.Config{
Region: aws.String(as.SsoRegion),
Credentials: sessionCreds,
},
}))
stsSession := sts.New(mySession)

input := sts.AssumeRoleInput{
// DurationSeconds: aws.Int64(900),
RoleArn: aws.String(utils.MakeRoleARN(accountId, role)),
RoleSessionName: aws.String(fmt.Sprintf("Via_%s_%s", aId, role)),
}
if configRole.ExternalId != "" {
// Optional vlaue: https://docs.aws.amazon.com/sdk-for-go/api/service/sts/#AssumeRoleInput
input.ExternalId = aws.String(configRole.ExternalId)
}
if configRole.SourceIdentity != "" {
input.SourceIdentity = aws.String(configRole.SourceIdentity)
}

output, err := stsSession.AssumeRole(&input)
if err != nil {
return storage.RoleCredentials{}, err
}
log.Debugf("%s", spew.Sdump(output))
ret := storage.RoleCredentials{
AccountId: accountId,
RoleName: role,
AccessKeyId: aws.StringValue(output.RoleCredentials.AccessKeyId),
SecretAccessKey: aws.StringValue(output.RoleCredentials.SecretAccessKey),
SessionToken: aws.StringValue(output.RoleCredentials.SessionToken),
Expiration: aws.Int64Value(output.RoleCredentials.Expiration),
AccessKeyId: aws.StringValue(output.Credentials.AccessKeyId),
SecretAccessKey: aws.StringValue(output.Credentials.SecretAccessKey),
SessionToken: aws.StringValue(output.Credentials.SessionToken),
Expiration: aws.TimeValue(output.Credentials.Expiration).Unix(),
}

return ret, nil
}

Expand Down
6 changes: 3 additions & 3 deletions sso/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import (

const (
TEST_CACHE_FILE = "./testdata/cache.json"
TEST_ROLE_ARN = "arn:aws:iam:707513610766:role/AWSAdministratorAccess"
INVALID_ACCOUNT_ARN = "arn:aws:iam:707513618766:role/AWSAdministratorAccess"
INVALID_ROLE_ARN = "arn:aws:iam:707513610766:role/AdministratorAccess"
TEST_ROLE_ARN = "arn:aws:iam::707513610766:role/AWSAdministratorAccess"
INVALID_ACCOUNT_ARN = "arn:aws:iam::707513618766:role/AWSAdministratorAccess"
INVALID_ROLE_ARN = "arn:aws:iam::707513610766:role/AdministratorAccess"
)

type CacheTestSuite struct {
Expand Down
39 changes: 26 additions & 13 deletions sso/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,14 @@ type SSOAccount struct {
}

type SSORole struct {
account *SSOAccount // pointer back up
ARN string `yaml:"ARN"`
Profile string `koanf:"Profile" yaml:"Profile,omitempty"`
Tags map[string]string `koanf:"Tags" yaml:"Tags,omitempty"`
DefaultRegion string `koanf:"DefaultRegion" yaml:"DefaultRegion,omitempty"`
Via string `koanf:"Via" yaml:"Via,omitempty"`
account *SSOAccount // pointer back up
ARN string `yaml:"ARN"`
Profile string `koanf:"Profile" yaml:"Profile,omitempty"`
Tags map[string]string `koanf:"Tags" yaml:"Tags,omitempty"`
DefaultRegion string `koanf:"DefaultRegion" yaml:"DefaultRegion,omitempty"`
Via string `koanf:"Via" yaml:"Via,omitempty"`
ExternalId string `koanf:"ExternalId" yaml:"ExternalId,omitempty"`
SourceIdentity string `koanf:"SourceIdentity" yaml:"SourceIdentity,omitempty"`
}

// GetDefaultRegion scans the config settings file to pick the most local DefaultRegion from the tree
Expand Down Expand Up @@ -367,6 +369,17 @@ func (s *SSOConfig) GetRoleMatches(tags map[string]string) []*SSORole {
return match
}

// GetRole returns the matching role if it exists
func (s *SSOConfig) GetRole(accountId int64, role string) (*SSORole, error) {
if a, ok := s.Accounts[accountId]; ok {
if r, ok := a.Roles[role]; ok {
return r, nil
}
}
a, _ := utils.AccountIdToString(accountId)
return &SSORole{}, fmt.Errorf("Unable to find %s:%s", a, role)
}

// HasRole returns true/false if the given Account has the provided arn
func (a *SSOAccount) HasRole(arn string) bool {
hasRole := false
Expand Down Expand Up @@ -440,19 +453,19 @@ func (r *SSORole) GetRoleName() string {

// GetAccountId returns the accountId portion of the ARN or empty string on error
func (r *SSORole) GetAccountId() string {
s := strings.Split(r.ARN, ":")
if len(s) < 4 {
log.Errorf("Role.ARN is missing the account field: '%v'\n%v", r.ARN, *r)
a, err := utils.AccountIdToString(r.GetAccountId64())
if err != nil {
log.WithError(err).Errorf("Unable to parse AccountId '%s'", a)
return ""
}
return s[3]
return a
}

// GetAccountId64 returns the accountId portion of the ARN
func (r *SSORole) GetAccountId64() int64 {
i, err := strconv.ParseInt(r.GetAccountId(), 10, 64)
a, _, err := utils.ParseRoleARN(r.ARN)
if err != nil {
log.WithError(err).Panicf("Unable to decode account id for %s", r.ARN)
log.WithError(err).Panicf("Unable to parse %s", r.ARN)
}
return i
return a
}
6 changes: 3 additions & 3 deletions sso/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ const (
)

var TEST_GET_ROLE_ARN []string = []string{
"arn:aws:iam:258234615182:role/AWSAdministratorAccess",
"arn:aws:iam:258234615182:role/LimitedAccess",
"arn:aws:iam:833365043586:role/AWSAdministratorAccess",
"arn:aws:iam::258234615182:role/AWSAdministratorAccess",
"arn:aws:iam::258234615182:role/LimitedAccess",
"arn:aws:iam::833365043586:role/AWSAdministratorAccess",
}

type SettingsTestSuite struct {
Expand Down
Loading

0 comments on commit 0e41a7a

Please sign in to comment.