From a42fd9577b8ea3b8f139398ecb7a56650e574fd9 Mon Sep 17 00:00:00 2001 From: James Kwon Date: Mon, 31 Jul 2023 17:47:44 -0400 Subject: [PATCH] refactor IAM Groups --- aws/aws.go | 2 +- aws/iam_group.go | 68 ++++------- aws/iam_group_test.go | 271 ++++++++++++++++++----------------------- aws/iam_group_types.go | 12 +- 4 files changed, 147 insertions(+), 206 deletions(-) diff --git a/aws/aws.go b/aws/aws.go index 6f2f9836..9c6ae805 100644 --- a/aws/aws.go +++ b/aws/aws.go @@ -2022,7 +2022,7 @@ func GetAllResources(targetRegions []string, excludeAfter time.Time, resourceTyp } if IsNukeable(iamGroups.ResourceName(), resourceTypes) { start := time.Now() - groupNames, err := getAllIamGroups(session, excludeAfter, configObj) + groupNames, err := iamGroups.getAll(configObj) if err != nil { return nil, errors.WithStackTrace(err) } diff --git a/aws/iam_group.go b/aws/iam_group.go index 8c68e391..b91e1928 100644 --- a/aws/iam_group.go +++ b/aws/iam_group.go @@ -1,30 +1,28 @@ package aws import ( - "github.com/gruntwork-io/cloud-nuke/report" - "github.com/gruntwork-io/cloud-nuke/telemetry" - commonTelemetry "github.com/gruntwork-io/go-commons/telemetry" - "sync" - "time" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/iam" "github.com/gruntwork-io/cloud-nuke/config" "github.com/gruntwork-io/cloud-nuke/logging" + "github.com/gruntwork-io/cloud-nuke/report" + "github.com/gruntwork-io/cloud-nuke/telemetry" + commonTelemetry "github.com/gruntwork-io/go-commons/telemetry" "github.com/gruntwork-io/gruntwork-cli/errors" "github.com/hashicorp/go-multierror" + "sync" ) -func getAllIamGroups(session *session.Session, excludeAfter time.Time, configObj config.Config) ([]*string, error) { - svc := iam.New(session) - +func (ig IAMGroups) getAll(configObj config.Config) ([]*string, error) { var allIamGroups []*string - err := svc.ListGroupsPages( + err := ig.Client.ListGroupsPages( &iam.ListGroupsInput{}, func(page *iam.ListGroupsOutput, lastPage bool) bool { for _, iamGroup := range page.Groups { - if shouldIncludeIamGroup(iamGroup, excludeAfter, configObj) { + if configObj.IAMGroups.ShouldInclude(config.ResourceValue{ + Time: iamGroup.CreateDate, + Name: iamGroup.GroupName, + }) { allIamGroups = append(allIamGroups, iamGroup.GroupName) } } @@ -34,13 +32,12 @@ func getAllIamGroups(session *session.Session, excludeAfter time.Time, configObj if err != nil { return nil, errors.WithStackTrace(err) } + return allIamGroups, nil } -// nukeAllIamGroups - delete all IAM groups. Caller is responsible for pagination (no more than 100/request) -func nukeAllIamGroups(session *session.Session, groupNames []*string) error { - svc := iam.New(session) - +// nukeAll - delete all IAM groups. Caller is responsible for pagination (no more than 100/request) +func (ig IAMGroups) nukeAll(groupNames []*string) error { if len(groupNames) == 0 { logging.Logger.Debug("No IAM Groups to nuke") return nil @@ -59,7 +56,7 @@ func nukeAllIamGroups(session *session.Session, groupNames []*string) error { errChans := make([]chan error, len(groupNames)) for i, groupName := range groupNames { errChans[i] = make(chan error, 1) - go deleteIamGroupAsync(wg, errChans[i], svc, groupName) + go ig.deleteAsync(wg, errChans[i], groupName) } wg.Wait() @@ -71,9 +68,7 @@ func nukeAllIamGroups(session *session.Session, groupNames []*string) error { logging.Logger.Errorf("[Failed] %s", err) telemetry.TrackEvent(commonTelemetry.EventContext{ EventName: "Error Nuking IAM Group", - }, map[string]interface{}{ - "region": *session.Config.Region, - }) + }, map[string]interface{}{}) } } finalErr := allErrs.ErrorOrNil() @@ -85,7 +80,7 @@ func nukeAllIamGroups(session *session.Session, groupNames []*string) error { } // deleteIamGroup - removes an IAM group from AWS, designed to run as a goroutine -func deleteIamGroupAsync(wg *sync.WaitGroup, errChan chan error, svc *iam.IAM, groupName *string) { +func (ig IAMGroups) deleteAsync(wg *sync.WaitGroup, errChan chan error, groupName *string) { defer wg.Done() var multierr *multierror.Error @@ -93,13 +88,13 @@ func deleteIamGroupAsync(wg *sync.WaitGroup, errChan chan error, svc *iam.IAM, g getGroupInput := &iam.GetGroupInput{ GroupName: groupName, } - grp, err := svc.GetGroup(getGroupInput) + grp, err := ig.Client.GetGroup(getGroupInput) for _, user := range grp.Users { unlinkUserInput := &iam.RemoveUserFromGroupInput{ UserName: user.UserName, GroupName: groupName, } - _, err := svc.RemoveUserFromGroup(unlinkUserInput) + _, err := ig.Client.RemoveUserFromGroup(unlinkUserInput) if err != nil { multierr = multierror.Append(multierr, err) } @@ -107,7 +102,7 @@ func deleteIamGroupAsync(wg *sync.WaitGroup, errChan chan error, svc *iam.IAM, g //Detach any policies on the group allPolicies := []*string{} - err = svc.ListAttachedGroupPoliciesPages(&iam.ListAttachedGroupPoliciesInput{GroupName: groupName}, + err = ig.Client.ListAttachedGroupPoliciesPages(&iam.ListAttachedGroupPoliciesInput{GroupName: groupName}, func(page *iam.ListAttachedGroupPoliciesOutput, lastPage bool) bool { for _, iamPolicy := range page.AttachedPolicies { allPolicies = append(allPolicies, iamPolicy.PolicyArn) @@ -121,12 +116,12 @@ func deleteIamGroupAsync(wg *sync.WaitGroup, errChan chan error, svc *iam.IAM, g GroupName: groupName, PolicyArn: policy, } - _, err = svc.DetachGroupPolicy(unlinkPolicyInput) + _, err = ig.Client.DetachGroupPolicy(unlinkPolicyInput) } // Detach any inline policies on the group allInlinePolicyNames := []*string{} - err = svc.ListGroupPoliciesPages(&iam.ListGroupPoliciesInput{GroupName: groupName}, + err = ig.Client.ListGroupPoliciesPages(&iam.ListGroupPoliciesInput{GroupName: groupName}, func(page *iam.ListGroupPoliciesOutput, lastPage bool) bool { logging.Logger.Info("ListGroupPolicies response page: ", page) for _, policyName := range page.PolicyNames { @@ -138,14 +133,14 @@ func deleteIamGroupAsync(wg *sync.WaitGroup, errChan chan error, svc *iam.IAM, g logging.Logger.Info("inline policies: ", allInlinePolicyNames) for _, policyName := range allInlinePolicyNames { - _, err = svc.DeleteGroupPolicy(&iam.DeleteGroupPolicyInput{ + _, err = ig.Client.DeleteGroupPolicy(&iam.DeleteGroupPolicyInput{ GroupName: groupName, PolicyName: policyName, }) } //Delete the group - _, err = svc.DeleteGroup(&iam.DeleteGroupInput{ + _, err = ig.Client.DeleteGroup(&iam.DeleteGroupInput{ GroupName: groupName, }) if err != nil { @@ -164,23 +159,6 @@ func deleteIamGroupAsync(wg *sync.WaitGroup, errChan chan error, svc *iam.IAM, g errChan <- multierr.ErrorOrNil() } -// check if iam group should be included based on config rules (RegExp and Exclude After) -func shouldIncludeIamGroup(iamGroup *iam.Group, excludeAfter time.Time, configObj config.Config) bool { - if iamGroup == nil { - return false - } - - if excludeAfter.Before(aws.TimeValue(iamGroup.CreateDate)) { - return false - } - - return config.ShouldInclude( - aws.StringValue(iamGroup.GroupName), - configObj.IAMGroups.IncludeRule.NamesRegExp, - configObj.IAMGroups.ExcludeRule.NamesRegExp, - ) -} - // TooManyIamGroupErr Custom Errors type TooManyIamGroupErr struct{} diff --git a/aws/iam_group_test.go b/aws/iam_group_test.go index b1860e69..ec342c33 100644 --- a/aws/iam_group_test.go +++ b/aws/iam_group_test.go @@ -1,194 +1,157 @@ package aws import ( - "github.com/gruntwork-io/cloud-nuke/logging" + "github.com/aws/aws-sdk-go/service/iam/iamiface" "github.com/gruntwork-io/cloud-nuke/telemetry" + "regexp" "testing" "time" awsgo "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/iam" "github.com/gruntwork-io/cloud-nuke/config" - "github.com/gruntwork-io/cloud-nuke/util" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// Test that we can list IAM groups in an AWS account -func TestListIamGroups(t *testing.T) { - telemetry.InitTelemetry("cloud-nuke", "") - t.Parallel() - - region, err := getRandomRegion() - require.NoError(t, err) - - localSession, err := session.NewSession(&awsgo.Config{ - Region: awsgo.String(region), - }) - require.NoError(t, err) - - groupNames, err := getAllIamGroups(localSession, time.Now(), config.Config{}) - require.NoError(t, err) - assert.NotEmpty(t, groupNames) +type mockedIAMGroups struct { + iamiface.IAMAPI + ListGroupsPagesOutput iam.ListGroupsOutput + GetGroupOutput iam.GetGroupOutput + RemoveUserFromGroupOutput iam.RemoveUserFromGroupOutput + DeleteGroupOutput iam.DeleteGroupOutput + ListAttachedGroupPoliciesPagesOutput iam.ListAttachedGroupPoliciesOutput + DetachGroupPolicyOutput iam.DetachGroupPolicyOutput + ListGroupPoliciesOutput iam.ListGroupPoliciesOutput + DeleteGroupPolicyOutput iam.DeleteGroupPolicyOutput } -// Creates an empty IAM group for testing -func createEmptyTestGroup(t *testing.T, session *session.Session, name string) error { - svc := iam.New(session) - - groupInput := &iam.CreateGroupInput{ - GroupName: awsgo.String(name), - } - - _, err := svc.CreateGroup(groupInput) - require.NoError(t, err) +func (m mockedIAMGroups) ListGroupsPages(input *iam.ListGroupsInput, fn func(*iam.ListGroupsOutput, bool) bool) error { + fn(&m.ListGroupsPagesOutput, true) return nil } -// Stores information for cleanup -type groupInfo struct { - GroupName *string - UserName *string - PolicyArn *string +func (m mockedIAMGroups) DeleteGroup(input *iam.DeleteGroupInput) (*iam.DeleteGroupOutput, error) { + return &m.DeleteGroupOutput, nil } -func createNonEmptyTestGroup(t *testing.T, session *session.Session, groupName string, userName string) (*groupInfo, error) { - svc := iam.New(session) - - //Create User - userInput := &iam.CreateUserInput{ - UserName: awsgo.String(userName), - } - - _, err := svc.CreateUser(userInput) - require.NoError(t, err) +func (m mockedIAMGroups) ListAttachedGroupPoliciesPages(input *iam.ListAttachedGroupPoliciesInput, fn func(*iam.ListAttachedGroupPoliciesOutput, bool) bool) error { + fn(&m.ListAttachedGroupPoliciesPagesOutput, true) + return nil +} - //Create Policy - policyOutput, err := svc.CreatePolicy(&iam.CreatePolicyInput{ - PolicyDocument: awsgo.String(`{ - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "VisualEditor0", - "Effect": "Allow", - "Action": "ec2:DescribeInstances", - "Resource": "*" - } - ] - }`), - PolicyName: awsgo.String("policy-" + groupName), - Description: awsgo.String("Policy created by cloud-nuke tests - Should be deleted"), - }) - require.NoError(t, err) +func (m mockedIAMGroups) ListGroupPoliciesPages(input *iam.ListGroupPoliciesInput, fn func(*iam.ListGroupPoliciesOutput, bool) bool) error { + fn(&m.ListGroupPoliciesOutput, true) + return nil +} - //Create Group - groupInput := &iam.CreateGroupInput{ - GroupName: awsgo.String(groupName), - } +func (m mockedIAMGroups) DetachGroupPolicy(input *iam.DetachGroupPolicyInput) (*iam.DetachGroupPolicyOutput, error) { + return &m.DetachGroupPolicyOutput, nil +} - _, err = svc.CreateGroup(groupInput) - require.NoError(t, err) +func (m mockedIAMGroups) DeleteGroupPolicy(input *iam.DeleteGroupPolicyInput) (*iam.DeleteGroupPolicyOutput, error) { + return &m.DeleteGroupPolicyOutput, nil +} - //Add user to Group - userGroupLinkInput := &iam.AddUserToGroupInput{ - GroupName: awsgo.String(groupName), - UserName: awsgo.String(userName), - } - _, err = svc.AddUserToGroup(userGroupLinkInput) - require.NoError(t, err) +func (m mockedIAMGroups) GetGroup(input *iam.GetGroupInput) (*iam.GetGroupOutput, error) { + return &m.GetGroupOutput, nil +} - //Add policy to Group +func (m mockedIAMGroups) RemoveUserFromGroup(input *iam.RemoveUserFromGroupInput) (*iam.RemoveUserFromGroupOutput, error) { + return &m.RemoveUserFromGroupOutput, nil +} - groupPolicyInput := &iam.AttachGroupPolicyInput{ - PolicyArn: policyOutput.Policy.Arn, - GroupName: awsgo.String(groupName), - } - _, err = svc.AttachGroupPolicy(groupPolicyInput) - require.NoError(t, err) +func TestIamGroups_GetAll(t *testing.T) { + telemetry.InitTelemetry("cloud-nuke", "") + t.Parallel() - // Add inline policy to Group - putGroupPolicyInput := &iam.PutGroupPolicyInput{ - GroupName: &groupName, - PolicyDocument: awsgo.String(`{ - "Version": "2012-10-17", - "Statement": [ + testName1 := "group1" + testName2 := "group2" + now := time.Now() + ig := IAMGroups{ + Client: mockedIAMGroups{ + ListGroupsPagesOutput: iam.ListGroupsOutput{ + Groups: []*iam.Group{ { - "Sid": "VisualEditor0", - "Effect": "Allow", - "Action": "ec2:DescribeInstances", - "Resource": "*" - } - ] - }`), - PolicyName: awsgo.String("inline-policy-" + groupName), - } - _, err = svc.PutGroupPolicy(putGroupPolicyInput) - require.NoError(t, err) - - info := &groupInfo{ - GroupName: &groupName, - PolicyArn: policyOutput.Policy.Arn, - UserName: &userName, + GroupName: awsgo.String(testName1), + CreateDate: awsgo.Time(now), + }, + { + GroupName: awsgo.String(testName2), + CreateDate: awsgo.Time(now.Add(1)), + }, + }, + }, + }, } - return info, nil -} - -func deleteGroupExtraResources(session *session.Session, info *groupInfo) { - svc := iam.New(session) - _, err := svc.DeletePolicy(&iam.DeletePolicyInput{ - PolicyArn: info.PolicyArn, - }) - if err != nil { - logging.Logger.Errorf("Unable to delete test policy: %s", *info.PolicyArn) + tests := map[string]struct { + configObj config.ResourceType + expected []string + }{ + "emptyFilter": { + configObj: config.ResourceType{}, + expected: []string{testName1, testName2}, + }, + "nameExclusionFilter": { + configObj: config.ResourceType{ + ExcludeRule: config.FilterRule{ + NamesRegExp: []config.Expression{{ + RE: *regexp.MustCompile(testName1), + }}}, + }, + expected: []string{testName2}, + }, + "timeAfterExclusionFilter": { + configObj: config.ResourceType{ + ExcludeRule: config.FilterRule{ + TimeAfter: awsgo.Time(now), + }}, + expected: []string{testName1}, + }, } - - _, err = svc.DeleteUser(&iam.DeleteUserInput{ - UserName: info.UserName, - }) - if err != nil { - logging.Logger.Errorf("Unable to delete test user: %s", *info.UserName) + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + names, err := ig.getAll(config.Config{ + IAMGroups: tc.configObj, + }) + require.NoError(t, err) + require.Equal(t, tc.expected, awsgo.StringValueSlice(names)) + }) } } -// Test that we can nuke iam groups. -func TestNukeIamGroups(t *testing.T) { +func TestIamGroups_NukeAll(t *testing.T) { telemetry.InitTelemetry("cloud-nuke", "") t.Parallel() - region, err := getRandomRegion() - require.NoError(t, err) - - localSession, err := session.NewSession(&awsgo.Config{ - Region: awsgo.String(region), - }) - require.NoError(t, err) - - //Create test entities - emptyName := "cloud-nuke-test" + util.UniqueID() - err = createEmptyTestGroup(t, localSession, emptyName) - require.NoError(t, err) - - nonEmptyName := "cloud-nuke-test" + util.UniqueID() - userName := "cloud-nuke-test" + util.UniqueID() - info, err := createNonEmptyTestGroup(t, localSession, nonEmptyName, userName) - defer deleteGroupExtraResources(localSession, info) - require.NoError(t, err) - - //Assert test entities exist - groupNames, err := getAllIamGroups(localSession, time.Now(), config.Config{}) - require.NoError(t, err) - assert.Contains(t, awsgo.StringValueSlice(groupNames), nonEmptyName) - assert.Contains(t, awsgo.StringValueSlice(groupNames), emptyName) - - //Nuke test entities - err = nukeAllIamGroups(localSession, []*string{&emptyName, &nonEmptyName}) - require.NoError(t, err) + ig := IAMGroups{ + Client: mockedIAMGroups{ + GetGroupOutput: iam.GetGroupOutput{ + Users: []*iam.User{ + { + UserName: awsgo.String("user1"), + }, + }, + }, + RemoveUserFromGroupOutput: iam.RemoveUserFromGroupOutput{}, + ListAttachedGroupPoliciesPagesOutput: iam.ListAttachedGroupPoliciesOutput{ + AttachedPolicies: []*iam.AttachedPolicy{ + { + PolicyName: awsgo.String("policy1"), + }, + }, + }, + DetachGroupPolicyOutput: iam.DetachGroupPolicyOutput{}, + ListGroupPoliciesOutput: iam.ListGroupPoliciesOutput{ + PolicyNames: []*string{ + awsgo.String("policy2"), + }, + }, + DeleteGroupPolicyOutput: iam.DeleteGroupPolicyOutput{}, + DeleteGroupOutput: iam.DeleteGroupOutput{}, + }, + } - //Assert test entities don't exist anymore - groupNames, err = getAllIamGroups(localSession, time.Now(), config.Config{}) + err := ig.nukeAll([]*string{awsgo.String("group1")}) require.NoError(t, err) - assert.NotContains(t, awsgo.StringValueSlice(groupNames), nonEmptyName) - assert.NotContains(t, awsgo.StringValueSlice(groupNames), emptyName) } diff --git a/aws/iam_group_types.go b/aws/iam_group_types.go index 57be5d7a..6fdea0d0 100644 --- a/aws/iam_group_types.go +++ b/aws/iam_group_types.go @@ -14,24 +14,24 @@ type IAMGroups struct { } // ResourceName - the simple name of the AWS resource -func (u IAMGroups) ResourceName() string { +func (ig IAMGroups) ResourceName() string { return "iam-group" } // ResourceIdentifiers - The IAM GroupNames -func (g IAMGroups) ResourceIdentifiers() []string { - return g.GroupNames +func (ig IAMGroups) ResourceIdentifiers() []string { + return ig.GroupNames } // Tentative batch size to ensure AWS doesn't throttle // There's a global max of 500 groups so it shouldn't take long either way -func (g IAMGroups) MaxBatchSize() int { +func (ig IAMGroups) MaxBatchSize() int { return 49 } // Nuke - Destroy every group in this collection -func (g IAMGroups) Nuke(session *session.Session, identifiers []string) error { - if err := nukeAllIamGroups(session, awsgo.StringSlice(identifiers)); err != nil { +func (ig IAMGroups) Nuke(session *session.Session, identifiers []string) error { + if err := ig.nukeAll(awsgo.StringSlice(identifiers)); err != nil { return errors.WithStackTrace(err) }