From 553c133e30bbd8f3c72701a8bd50fe0722a149cb Mon Sep 17 00:00:00 2001 From: Annie Ku Date: Tue, 16 Jun 2020 14:46:16 -0700 Subject: [PATCH] [feature] added option for configuring AWS config by roles (#22) --- cmd/root.go | 11 +- go.mod | 2 +- go.sum | 4 +- pkg/aws_config_client/completer.go | 152 +++++++++++++++++----- pkg/aws_config_client/completer_test.go | 97 +++++++++++++- pkg/aws_config_client/survey_mock_test.go | 4 +- pkg/aws_config_server/assemble_config.go | 5 +- pkg/aws_config_server/cache.go | 2 - pkg/aws_config_server/list_roles.go | 6 +- pkg/aws_config_server/types.go | 15 +++ pkg/aws_config_server/webserver.go | 4 +- 11 files changed, 244 insertions(+), 58 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index d2613708..c6d35e80 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -47,6 +47,7 @@ var rootCmd = &cobra.Command{ } if verbose { log.SetLevel(log.DebugLevel) + log.SetReportCaller(true) } err = configureLogrusHooks() if err != nil { @@ -72,11 +73,11 @@ func configureLogrusHooks() error { logrus.FatalLevel, logrus.ErrorLevel, }) - if err != nil { - logrus.Errorf("Error configuring Sentry") - return nil - } - log.AddHook(sentryHook) + if err != nil { + logrus.Errorf("Error configuring Sentry") + return nil + } + log.AddHook(sentryHook) return nil } diff --git a/go.mod b/go.mod index a72ffb93..47cc35cc 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.14 require ( github.com/AlecAivazis/survey/v2 v2.0.7 - github.com/aws/aws-sdk-go v1.31.15 + github.com/aws/aws-sdk-go v1.32.1 github.com/blang/semver v3.5.1+incompatible github.com/certifi/gocertifi v0.0.0-20200211180108-c7c1fbc02894 // indirect github.com/chanzuckerberg/go-misc v0.0.0-20200611000103-3caf6f173497 diff --git a/go.sum b/go.sum index 23abf644..faf2c167 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,8 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/aws/aws-lambda-go v1.16.0/go.mod h1:FEwgPLE6+8wcGBTe5cJN3JWurd1Ztm9zN4jsXsjzKKw= github.com/aws/aws-sdk-go v1.30.20/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= -github.com/aws/aws-sdk-go v1.31.15 h1:1Ahi6nvJLg5cjO5i3U7BWh91/zOw//tqOTLpLnIeyss= -github.com/aws/aws-sdk-go v1.31.15/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= +github.com/aws/aws-sdk-go v1.32.1 h1:0dy5DkMKNPH9mLWveAWA9ZTiKIEEvJJA6fbe0eCs19k= +github.com/aws/aws-sdk-go v1.32.1/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= diff --git a/pkg/aws_config_client/completer.go b/pkg/aws_config_client/completer.go index cd081497..46edbfdc 100644 --- a/pkg/aws_config_client/completer.go +++ b/pkg/aws_config_client/completer.go @@ -8,6 +8,7 @@ import ( "github.com/AlecAivazis/survey/v2" server "github.com/chanzuckerberg/aws-oidc/pkg/aws_config_server" "github.com/pkg/errors" + "github.com/sirupsen/logrus" "gopkg.in/ini.v1" ) @@ -42,7 +43,7 @@ func (c *completer) getAccountOptions(accounts []server.AWSAccount) []string { func (c *completer) getRoleOptions(profiles []server.AWSProfile) []string { roleOptions := []string{} for _, profile := range profiles { - roleOptions = append(roleOptions, profile.RoleARN) + roleOptions = append(roleOptions, profile.RoleName) } return roleOptions @@ -117,57 +118,140 @@ func (c *completer) SurveyProfile() (*AWSNamedProfile, error) { return namedProfile, nil } +// SurveyRole will ask a user to configure a default role +func (c *completer) SurveyRoles() ([]*AWSNamedProfile, error) { + // first prompt for roles + roles := c.awsConfig.GetRoleNames() + accounts := c.awsConfig.GetAccounts() + + roleIdx, err := c.prompt.Select( + "Select the AWS role you would like to make default:", + roles, + ) + if err != nil { + return nil, err + } + targetRole := roles[roleIdx] + + configuredProfiles := []*AWSNamedProfile{} + + for _, account := range accounts { + profileName := c.calculateDefaultProfileName(account) + + // get the roles associated with this account + profiles := c.awsConfig.GetProfilesForAccount(account) + for _, profile := range profiles { + + // Initialize a new AWSNamedProfile + currentProfile := AWSNamedProfile{ + AWSProfile: server.AWSProfile{ + ClientID: profile.ClientID, + AWSAccount: profile.AWSAccount, + RoleARN: profile.RoleARN, + IssuerURL: profile.IssuerURL, + }, + } + + currentProfile.Name = fmt.Sprintf("%s-%s", profileName, profile.RoleName) + configuredProfiles = append(configuredProfiles, ¤tProfile) + + if profile.RoleName == targetRole { + defaultProfile := AWSNamedProfile{ + Name: profileName, + AWSProfile: server.AWSProfile{ + ClientID: profile.ClientID, + AWSAccount: profile.AWSAccount, + RoleARN: profile.RoleARN, + IssuerURL: profile.IssuerURL, + }, + } + configuredProfiles = append(configuredProfiles, &defaultProfile) + } + } + } + return configuredProfiles, nil +} + +func (c *completer) SurveyProfiles() ([]*AWSNamedProfile, error) { + collectedProfiles := []*AWSNamedProfile{} + for { + currentProfile, err := c.SurveyProfile() + if err != nil { + return nil, err + } + collectedProfiles = append(collectedProfiles, currentProfile) + cnt, err := c.Continue() + if err != nil { + return nil, err + } + if !cnt { + break + } + } + return collectedProfiles, nil +} + +func (c *completer) Survey() ([]*AWSNamedProfile, error) { + configureOptions := []string{ + "Automatically configure the same role for each account?", + "Configure one role at a time?"} + configureFuncs := []func() ([]*AWSNamedProfile, error){c.SurveyRoles, c.SurveyProfiles} + configureIdx, err := c.prompt.Select("How would you like to configure your AWS config?", configureOptions) + if err != nil { + return nil, err + } + return configureFuncs[configureIdx]() +} + func (c *completer) Continue() (bool, error) { return c.prompt.Confirm("Would you like to configure another profile?", true) } -func (c *completer) writeAWSProfile(out *ini.File, region string, profile *AWSNamedProfile) error { - profileSection := fmt.Sprintf("profile %s", profile.Name) +func (c *completer) writeAWSProfiles(out *ini.File, region string, profiles []*AWSNamedProfile) error { - credsProcessValue := fmt.Sprintf( - "sh -c 'aws-oidc creds-process --issuer-url=%s --client-id=%s --aws-role-arn=%s 2> /dev/tty'", - profile.AWSProfile.IssuerURL, - profile.AWSProfile.ClientID, - profile.AWSProfile.RoleARN, - ) + for _, profile := range profiles { + profileSection := fmt.Sprintf("profile %s", profile.Name) - // First delete sections with this name so old configuration doesn't persist - out.DeleteSection(profileSection) - section, err := out.NewSection(profileSection) - if err != nil { - return errors.Wrapf(err, "Unable to create %s section in AWS Config", profileSection) + credsProcessValue := fmt.Sprintf( + "sh -c 'aws-oidc creds-process --issuer-url=%s --client-id=%s --aws-role-arn=%s 2> /dev/tty'", + profile.AWSProfile.IssuerURL, + profile.AWSProfile.ClientID, + profile.AWSProfile.RoleARN, + ) + + // First delete sections with this name so old configuration doesn't persist + out.DeleteSection(profileSection) + section, err := out.NewSection(profileSection) + if err != nil { + return errors.Wrapf(err, "Unable to create %s section in AWS Config", profileSection) + } + section.Key(AWSConfigSectionOutput).SetValue("json") + section.Key(AWSConfigSectionCredentialProcess).SetValue(credsProcessValue) + section.Key(AWSConfigSectionRegion).SetValue(region) } - section.Key(AWSConfigSectionOutput).SetValue("json") - section.Key(AWSConfigSectionCredentialProcess).SetValue(credsProcessValue) - section.Key(AWSConfigSectionRegion).SetValue(region) return nil } func (c *completer) Loop(out *ini.File) error { + if len(c.awsConfig.Profiles) == 0 { + logrus.Info("You are not authorized for any roles. Please contact your AWS administrator if this is a mistake") + return nil + } + // assume same region for all accounts configured in this run? region, err := c.SurveyRegion() if err != nil { return err } - for { - profile, err := c.SurveyProfile() - if err != nil { - return err - } - - err = c.writeAWSProfile(out, region, profile) - if err != nil { - return err - } + profiles, err := c.Survey() + if err != nil { + return err + } - cnt, err := c.Continue() - if err != nil { - return err - } - if !cnt { - break - } + err = c.writeAWSProfiles(out, region, profiles) + if err != nil { + return err } return nil } diff --git a/pkg/aws_config_client/completer_test.go b/pkg/aws_config_client/completer_test.go index ef0373af..d9f3a2d2 100644 --- a/pkg/aws_config_client/completer_test.go +++ b/pkg/aws_config_client/completer_test.go @@ -10,24 +10,24 @@ import ( "gopkg.in/ini.v1" ) -func TestLoop(t *testing.T) { +func TestSurveyProfiles(t *testing.T) { r := require.New(t) // note how: "Account Name With Spaces" => "account-name-with-spaces" expected := `[profile account-name-with-spaces] output = json credential_process = sh -c 'aws-oidc creds-process --issuer-url=issuer-url --client-id=foo_client_id --aws-role-arn=test1RoleName 2> /dev/tty' -region = us-west-2 +region = test-region [profile my-second-new-profile] output = json credential_process = sh -c 'aws-oidc creds-process --issuer-url=issuer-url --client-id=bar_client_id --aws-role-arn=test1RoleName 2> /dev/tty' -region = us-west-2 +region = test-region [profile test1] output = json credential_process = sh -c 'aws-oidc creds-process --issuer-url=issuer-url --client-id=bar_client_id --aws-role-arn=test2RoleName 2> /dev/tty' -region = us-west-2 +region = test-region ` @@ -41,12 +41,13 @@ region = us-west-2 prompt := &MockPrompt{ selectResponse: []int{ + 1, // select the profile method of configuring 0, 0, // select the first role in the first account 1, 0, // select the first role in the second account 1, 1, // select the second role in the second account }, inputResponse: []string{ - "", // aws region + "test-region", // aws region "", "my-second-new-profile", "", // aws profile names }, confirmResponse: []bool{true, true, false}, @@ -63,6 +64,82 @@ region = us-west-2 r.Equal(expected, generatedConfig.String()) } +func TestSurveyRoles(t *testing.T) { + r := require.New(t) + + expected := `[profile account-name-with-spaces-test1RoleName] +output = json +credential_process = sh -c 'aws-oidc creds-process --issuer-url=issuer-url --client-id=foo_client_id --aws-role-arn=test1RoleName 2> /dev/tty' +region = test-region + +[profile account-name-with-spaces] +output = json +credential_process = sh -c 'aws-oidc creds-process --issuer-url=issuer-url --client-id=foo_client_id --aws-role-arn=test1RoleName 2> /dev/tty' +region = test-region + +[profile test1-test1RoleName] +output = json +credential_process = sh -c 'aws-oidc creds-process --issuer-url=issuer-url --client-id=bar_client_id --aws-role-arn=test1RoleName 2> /dev/tty' +region = test-region + +[profile test1] +output = json +credential_process = sh -c 'aws-oidc creds-process --issuer-url=issuer-url --client-id=bar_client_id --aws-role-arn=test1RoleName 2> /dev/tty' +region = test-region + +[profile test1-test2RoleName] +output = json +credential_process = sh -c 'aws-oidc creds-process --issuer-url=issuer-url --client-id=bar_client_id --aws-role-arn=test2RoleName 2> /dev/tty' +region = test-region + +` + out := ini.Empty() + + prompt := &MockPrompt{ + + selectResponse: []int{ + 0, // select the role method of configuring + 0, // select the first role + }, + inputResponse: []string{ + "test-region", // aws region + }, + confirmResponse: []bool{false}, + } + + c := NewCompleter(prompt, generateDummyData()) + + err := c.Loop(out) + r.NoError(err) + + generatedConfig := bytes.NewBuffer(nil) + _, err = out.WriteTo(generatedConfig) + r.NoError(err) + r.Equal(expected, generatedConfig.String()) +} + +func TestNoRoles(t *testing.T) { + r := require.New(t) + expected := `` + + out := ini.Empty() + prompt := &MockPrompt{ + selectResponse: []int{}, + inputResponse: []string{}, + confirmResponse: []bool{}, + } + + c := NewCompleter(prompt, generateEmptyData()) + + err := c.Loop(out) + r.NoError(err) + + generatedConfig := bytes.NewBuffer(nil) + _, err = out.WriteTo(generatedConfig) + r.NoError(err) + r.Equal(expected, generatedConfig.String()) +} + func TestAWSProfileNameValidator(t *testing.T) { type test struct { input interface{} @@ -100,6 +177,7 @@ func generateDummyData() *server.AWSConfig { }, RoleARN: "test1RoleName", IssuerURL: "issuer-url", + RoleName: "test1RoleName", }, { ClientID: "bar_client_id", @@ -109,6 +187,7 @@ func generateDummyData() *server.AWSConfig { }, RoleARN: "test2RoleName", IssuerURL: "issuer-url", + RoleName: "test2RoleName", }, { ClientID: "foo_client_id", @@ -118,6 +197,7 @@ func generateDummyData() *server.AWSConfig { }, RoleARN: "test1RoleName", IssuerURL: "issuer-url", + RoleName: "test1RoleName", }, { ClientID: "foo_client_id", @@ -127,7 +207,14 @@ func generateDummyData() *server.AWSConfig { }, RoleARN: "test1RoleName", IssuerURL: "issuer-url", + RoleName: "test1RoleName", }, }, } } + +func generateEmptyData() *server.AWSConfig { + return &server.AWSConfig{ + Profiles: []server.AWSProfile{}, + } +} diff --git a/pkg/aws_config_client/survey_mock_test.go b/pkg/aws_config_client/survey_mock_test.go index 32fb267f..73c83149 100644 --- a/pkg/aws_config_client/survey_mock_test.go +++ b/pkg/aws_config_client/survey_mock_test.go @@ -1,6 +1,8 @@ package aws_config_client -import "github.com/AlecAivazis/survey/v2" +import ( + "github.com/AlecAivazis/survey/v2" +) type MockPrompt struct { selectResponse []int diff --git a/pkg/aws_config_server/assemble_config.go b/pkg/aws_config_server/assemble_config.go index 4807bebb..a230de8f 100644 --- a/pkg/aws_config_server/assemble_config.go +++ b/pkg/aws_config_server/assemble_config.go @@ -11,7 +11,6 @@ import ( "github.com/chanzuckerberg/aws-oidc/pkg/okta" cziAWS "github.com/chanzuckerberg/go-misc/aws" "github.com/pkg/errors" - "github.com/sirupsen/logrus" ) type ClientIDToAWSRoles struct { @@ -33,7 +32,6 @@ func (a *ClientIDToAWSRoles) getRoles(ctx context.Context, masterRoles []string, if err != nil { return errors.Wrap(err, "Unable to get list of AWS Profiles") } - logrus.Debugf("function: aws_config_server/assemble_config.go/getRoles(), accountList: %v", accountList) for _, acct := range accountList { // create a new IAM session for each account new_role_arn := arn.ARN{ @@ -42,7 +40,6 @@ func (a *ClientIDToAWSRoles) getRoles(ctx context.Context, masterRoles []string, AccountID: *acct.Id, Resource: fmt.Sprintf("role/%s", workerRole), } - logrus.Debugf("function: aws_config_server/assemble_config.go/getRoles(), new_role_arn: %s", new_role_arn) a.roleARNs[*acct.Name] = new_role_arn } } @@ -64,7 +61,6 @@ func (a *ClientIDToAWSRoles) mapRoles( return errors.Wrapf(err, "%s error", accountName) } - logrus.Debugf("function: aws_config_server/assemble_config.go/mapRoles(), workerRoles: %v", workerRoles) err = clientRoleMapFromProfile(ctx, accountName, workerRoles, oidcProvider, a.clientRoleMapping) if err != nil { return errors.Wrap(err, "Unable to complete mapping between ClientIDs and ConfigProfiles") @@ -96,6 +92,7 @@ func createAWSConfig( ID: config.RoleARN.AccountID, }, IssuerURL: configParams.OIDCProvider, + RoleName: config.RoleName, } awsConfig.Profiles = append(awsConfig.Profiles, profile) } diff --git a/pkg/aws_config_server/cache.go b/pkg/aws_config_server/cache.go index 7ffd0d3e..2f6facac 100644 --- a/pkg/aws_config_server/cache.go +++ b/pkg/aws_config_server/cache.go @@ -74,12 +74,10 @@ func (c *CachedGetClientIDToProfiles) refresh( if err != nil { return errors.Wrap(err, "Unable to get list of RoleARNs accessible by the Master Roles") } - logrus.Debugf("function: aws_config_server/assemble_config.go/GetClientIDToProfiles(), configData.roleARNs: %v", configData.roleARNs) err = configData.mapRoles(ctx, configParams.OIDCProvider) if err != nil { return errors.Wrap(err, "Unable to create mapping needed for config generation") } - logrus.Debugf("function: aws_config_server/assemble_config.go/GetClientIDToProfiles(), configData.clientRoleMapping: %v", configData.clientRoleMapping) c.mu.Lock() defer c.mu.Unlock() diff --git a/pkg/aws_config_server/list_roles.go b/pkg/aws_config_server/list_roles.go index 42bf770e..e461a9c9 100644 --- a/pkg/aws_config_server/list_roles.go +++ b/pkg/aws_config_server/list_roles.go @@ -44,6 +44,7 @@ type Principal struct { type ConfigProfile struct { AcctName string RoleARN arn.ARN + RoleName string } const ignoreAWSError = "AccessDenied" @@ -103,7 +104,7 @@ func clientRoleMapFromProfile( return errors.Wrap(err, "Failed to parse OIDC Provider input as an URL") } oidcProviderHostname := identityProviderURL.Hostname() - logrus.Debugf("function: aws_config_server/list_roles.go/clientRoleMapFromProfile(), oidcProviderHostname: %s", oidcProviderHostname) + logrus.Debugf("oidcProviderHostname: %s", oidcProviderHostname) for _, role := range roles { if role.AssumeRolePolicyDocument == nil { @@ -154,13 +155,14 @@ func clientRoleMapFromProfile( currentConfig := ConfigProfile{ AcctName: acctName, RoleARN: roleARN, + RoleName: *role.RoleName, } if _, ok := clientRoleMapping[clientID]; !ok { clientRoleMapping[clientID] = []ConfigProfile{currentConfig} continue } - logrus.Debug("function: aws_config_server/list_roles.go/clientRoleMapFromProfile(). About to append currentConfig to clientRoleMapping") + logrus.Debug("About to append currentConfig to clientRoleMapping") clientRoleMapping[clientID] = append(clientRoleMapping[clientID], currentConfig) } } diff --git a/pkg/aws_config_server/types.go b/pkg/aws_config_server/types.go index efc195bb..e00a6441 100644 --- a/pkg/aws_config_server/types.go +++ b/pkg/aws_config_server/types.go @@ -36,6 +36,20 @@ func (a *AWSConfig) GetAccounts() []AWSAccount { return accounts } +func (a *AWSConfig) GetRoleNames() []string { + set := map[string]bool{} + roleNames := []string{} + + for _, profile := range a.Profiles { + roleName := profile.RoleName + if _, ok := set[roleName]; !ok { + set[roleName] = true + roleNames = append(roleNames, roleName) + } + } + return roleNames +} + func (a *AWSConfig) GetProfilesForAccount(account AWSAccount) []AWSProfile { profiles := []AWSProfile{} @@ -57,6 +71,7 @@ type AWSProfile struct { AWSAccount AWSAccount `json:"aws_account,omitempty"` RoleARN string `json:"role_arn,omitempty"` IssuerURL string `json:"issuer_url,omitempty"` + RoleName string `json:"role_name,omitempty"` } type AWSAccount struct { diff --git a/pkg/aws_config_server/webserver.go b/pkg/aws_config_server/webserver.go index fa9f1d88..4266300e 100644 --- a/pkg/aws_config_server/webserver.go +++ b/pkg/aws_config_server/webserver.go @@ -129,7 +129,7 @@ func Index( return } - logrus.Debugf("function: aws_config_server/webserver.go/Index(), %s's clientIDs: %s", *email, clientIDs) + logrus.Debugf("%s's clientIDs: %s", *email, clientIDs) clientMapping, err := cachedClientIDtoProfiles.Get(ctx) if err != nil { logrus.Errorf("error: Unable to create mapping from clientID to roleARNs: %s", err) @@ -137,7 +137,7 @@ func Index( return } - logrus.Debugf("function: aws_config_server/webserver.go/Index(), %s's client mapping: %s", *email, clientMapping) + logrus.Debugf("%s's client mapping: %s", *email, clientMapping) awsConfig, err := createAWSConfig(ctx, awsGenerationParams, clientMapping, clientIDs) if err != nil { logrus.Errorf("error: unable to get AWS Config File: %s", err)