From f9c04a915cf101154c324968965a91e16ebcddfe Mon Sep 17 00:00:00 2001 From: Pete Emerson Date: Tue, 28 Feb 2023 08:53:29 -0800 Subject: [PATCH] CORE-381/Add deletion of IAM Service Linked Roles (#415) * This allows IAM Service Linked roles to be deleted. In the Gruntwork Reference Architecture, we need to clean up AWSServiceRoleForAutoScaling. * Add documentation to README This includes docs about IAM Roles, which was previously omitted. * Verify the deletion via the deletion task id * Remove extra limitations on IAM service linked role and add comment to IAM role * Remove duplicate logging * Make sure the role doesn't exist any more --- README.md | 8 ++ aws/aws.go | 20 +++ aws/iam_role.go | 8 +- aws/iam_service_linked_role.go | 190 +++++++++++++++++++++++++++ aws/iam_service_linked_role_test.go | 137 +++++++++++++++++++ aws/iam_service_linked_role_types.go | 36 +++++ config/config.go | 1 + 7 files changed, 397 insertions(+), 3 deletions(-) create mode 100644 aws/iam_service_linked_role.go create mode 100644 aws/iam_service_linked_role_test.go create mode 100644 aws/iam_service_linked_role_types.go diff --git a/README.md b/README.md index 564e265a..fb58ab9b 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ The currently supported functionality includes: - Deleting VPCs in an AWS Account (along with any dependency resources such as ENIs, Egress Only Gateways, and Security Groups. except for default VPCs which is handled by the dedicated `defaults-aws` subcommand) - Inspecting and deleting all IAM users in an AWS account - Inspecting and deleting all IAM roles (and any associated EC2 instance profiles) in an AWS account +- Inspecting and deleting all IAM service-linked roles in an AWS account - Inspecting and deleting all IAM groups in an AWS account - Inspecting and deleting all IAM policies in an AWS account - Inspecting and deleting all customer managed IAM policies in an AWS account @@ -315,6 +316,12 @@ The following resources support the Config file: - IAM Users - Resource type: `iam` - Config key: `IAMUsers` +- IAM Roles + - Resource type: `iam-role` + - Config key: `IAMRoles` +- IAM Service-Linked Roles + - Resource type: `iam-service-linked-role` + - Config key: `IAMServiceLinkedRoles` - Secrets Manager Secrets - Resource type: `secretsmanager` - Config key: `SecretsManager` @@ -530,6 +537,7 @@ To find out what we options are supported in the config file today, consult this | efs | none | ✅ | none | none | | acmpca | none | none | none | none | | iam role | none | ✅ | none | none | +| iam service-linked role | none | ✅ | none | none | | iam policy | none | ✅ | none | none | | sagemaker-notebook-instances | none | ✅ | none | none | | ecr | none | ✅ | none | none | diff --git a/aws/aws.go b/aws/aws.go index f5ed7dfc..8b249f54 100644 --- a/aws/aws.go +++ b/aws/aws.go @@ -1263,6 +1263,25 @@ func GetAllResources(targetRegions []string, excludeAfter time.Time, resourceTyp } // End IAM Roles + // IAM Service Linked Roles + iamServiceLinkedRoles := IAMServiceLinkedRoles{} + if IsNukeable(iamServiceLinkedRoles.ResourceName(), resourceTypes) { + roleNames, err := getAllIamServiceLinkedRoles(session, excludeAfter, configObj) + if err != nil { + ge := report.GeneralError{ + Error: err, + Description: "Unable to retrieve IAM roles", + ResourceType: iamServiceLinkedRoles.ResourceName(), + } + report.RecordError(ge) + } + if len(roleNames) > 0 { + iamServiceLinkedRoles.RoleNames = awsgo.StringValueSlice(roleNames) + globalResources.Resources = append(globalResources.Resources, iamServiceLinkedRoles) + } + } + // End IAM Service Linked Roles + if len(globalResources.Resources) > 0 { account.Resources[GlobalRegion] = globalResources } @@ -1299,6 +1318,7 @@ func ListResourceTypes() []string { IAMRoles{}.ResourceName(), IAMGroups{}.ResourceName(), IAMPolicies{}.ResourceName(), + IAMServiceLinkedRoles{}.ResourceName(), SecretsManagerSecrets{}.ResourceName(), NatGateways{}.ResourceName(), OpenSearchDomains{}.ResourceName(), diff --git a/aws/iam_role.go b/aws/iam_role.go index b87249d0..8d3da7e5 100644 --- a/aws/iam_role.go +++ b/aws/iam_role.go @@ -182,14 +182,16 @@ func shouldIncludeIAMRole(iamRole *iam.Role, excludeAfter time.Time, configObj c return false } + // The OrganizationAccountAccessRole is a special role that is created by AWS Organizations, and is used to allow + // users to access the AWS account. We should not delete this role, so we can filter it out of the Roles found and + // managed by cloud-nuke. if strings.Contains(aws.StringValue(iamRole.RoleName), "OrganizationAccountAccessRole") { return false } - // The arns of AWS-managed IAM roles, which can only be modified or deleted by AWS, contain "aws-service-role", so we can filter them out + // The ARNs of AWS-reserved IAM roles, which can only be modified or deleted by AWS, contain "aws-reserved", so we can filter them out // of the Roles found and managed by cloud-nuke - // The same general rule applies with roles whose arn contains "aws-reserved" - if strings.Contains(aws.StringValue(iamRole.Arn), "aws-service-role") || strings.Contains(aws.StringValue(iamRole.Arn), "aws-reserved") { + if strings.Contains(aws.StringValue(iamRole.Arn), "aws-reserved") { return false } diff --git a/aws/iam_service_linked_role.go b/aws/iam_service_linked_role.go new file mode 100644 index 00000000..e75a93b0 --- /dev/null +++ b/aws/iam_service_linked_role.go @@ -0,0 +1,190 @@ +package aws + +import ( + "errors" + "fmt" + "strings" + "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" + gruntworkerrors "github.com/gruntwork-io/go-commons/errors" + "github.com/hashicorp/go-multierror" +) + +// List all IAM Roles in the AWS account +func getAllIamServiceLinkedRoles(session *session.Session, excludeAfter time.Time, configObj config.Config) ([]*string, error) { + svc := iam.New(session) + + allIAMServiceLinkedRoles := []*string{} + err := svc.ListRolesPages( + &iam.ListRolesInput{}, + func(page *iam.ListRolesOutput, lastPage bool) bool { + for _, iamServiceLinkedRole := range page.Roles { + if shouldIncludeIAMServiceLinkedRole(iamServiceLinkedRole, excludeAfter, configObj) { + allIAMServiceLinkedRoles = append(allIAMServiceLinkedRoles, iamServiceLinkedRole.RoleName) + } + } + return !lastPage + }, + ) + if err != nil { + return nil, gruntworkerrors.WithStackTrace(err) + } + return allIAMServiceLinkedRoles, nil +} + +func deleteIamServiceLinkedRole(svc *iam.IAM, roleName *string) error { + // Deletion ID looks like this: " + //{ + // DeletionTaskId: "task/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling_2/d3c4c9fc-7fd3-4a36-974a-afb0eb78f102" + //} + deletionData, err := svc.DeleteServiceLinkedRole(&iam.DeleteServiceLinkedRoleInput{ + RoleName: roleName, + }) + if err != nil { + return gruntworkerrors.WithStackTrace(err) + } + + // Wait for the deletion to complete + time.Sleep(3 * time.Second) + + var deletionStatus *iam.GetServiceLinkedRoleDeletionStatusOutput + + done := false + for !done { + done = true + // Check if the deletion is complete + deletionStatus, err = svc.GetServiceLinkedRoleDeletionStatus(&iam.GetServiceLinkedRoleDeletionStatusInput{ + DeletionTaskId: deletionData.DeletionTaskId, + }) + if err != nil { + return gruntworkerrors.WithStackTrace(err) + } + if aws.StringValue(deletionStatus.Status) == "IN_PROGRESS" { + logging.Logger.Debugf("Deletion of IAM ServiceLinked Role %s is still in progress", aws.StringValue(roleName)) + done = false + time.Sleep(3 * time.Second) + } + + } + + if aws.StringValue(deletionStatus.Status) != "SUCCEEDED" { + err := fmt.Sprintf("Deletion of IAM ServiceLinked Role %s failed with status %s", aws.StringValue(roleName), aws.StringValue(deletionStatus.Status)) + return gruntworkerrors.WithStackTrace(errors.New(err)) + } + + return nil +} + +// Delete all IAM Roles +func nukeAllIamServiceLinkedRoles(session *session.Session, roleNames []*string) error { + region := aws.StringValue(session.Config.Region) + svc := iam.New(session) + + if len(roleNames) == 0 { + logging.Logger.Debug("No IAM Service Linked Roles to nuke") + return nil + } + + // NOTE: we don't need to do pagination here, because the pagination is handled by the caller to this function, + // based on IAMRoles.MaxBatchSize, however we add a guard here to warn users when the batching fails and has a + // chance of throttling AWS. Since we concurrently make one call for each identifier, we pick 100 for the limit here + // because many APIs in AWS have a limit of 100 requests per second. + if len(roleNames) > 100 { + logging.Logger.Debugf("Nuking too many IAM Service Linked Roles at once (100): halting to avoid hitting AWS API rate limiting") + return TooManyIamRoleErr{} + } + + // There is no bulk delete IAM Roles API, so we delete the batch of IAM roles concurrently using go routines + logging.Logger.Debugf("Deleting all IAM Service Linked Roles in region %s", region) + wg := new(sync.WaitGroup) + wg.Add(len(roleNames)) + errChans := make([]chan error, len(roleNames)) + for i, roleName := range roleNames { + errChans[i] = make(chan error, 1) + go deleteIamServiceLinkedRoleAsync(wg, errChans[i], svc, roleName) + } + wg.Wait() + + // Collect all the errors from the async delete calls into a single error struct. + var allErrs *multierror.Error + for _, errChan := range errChans { + if err := <-errChan; err != nil { + allErrs = multierror.Append(allErrs, err) + logging.Logger.Debugf("[Failed] %s", err) + } + } + finalErr := allErrs.ErrorOrNil() + if finalErr != nil { + return gruntworkerrors.WithStackTrace(finalErr) + } + + for _, roleName := range roleNames { + logging.Logger.Debugf("[OK] IAM Service Linked Role %s was deleted in %s", aws.StringValue(roleName), region) + } + return nil +} + +func shouldIncludeIAMServiceLinkedRole(iamServiceLinkedRole *iam.Role, excludeAfter time.Time, configObj config.Config) bool { + if iamServiceLinkedRole == nil { + return false + } + + if !strings.Contains(aws.StringValue(iamServiceLinkedRole.Arn), "aws-service-role") { + return false + } + + if excludeAfter.Before(*iamServiceLinkedRole.CreateDate) { + return false + } + + return config.ShouldInclude( + aws.StringValue(iamServiceLinkedRole.RoleName), + configObj.IAMServiceLinkedRoles.IncludeRule.NamesRegExp, + configObj.IAMServiceLinkedRoles.ExcludeRule.NamesRegExp, + ) +} + +func deleteIamServiceLinkedRoleAsync(wg *sync.WaitGroup, errChan chan error, svc *iam.IAM, roleName *string) { + defer wg.Done() + + var result *multierror.Error + + // Functions used to really nuke an IAM Role as a role can have many attached + // items we need delete/detach them before actually deleting it. + // NOTE: The actual role deletion should always be the last one. This way we + // can guarantee that it will fail if we forgot to delete/detach an item. + functions := []func(svc *iam.IAM, roleName *string) error{ + deleteIamServiceLinkedRole, + } + + for _, fn := range functions { + if err := fn(svc, roleName); err != nil { + result = multierror.Append(result, err) + } + } + + // Record status of this resource + e := report.Entry{ + Identifier: aws.StringValue(roleName), + ResourceType: "IAM Service Linked Role", + Error: result.ErrorOrNil(), + } + report.Record(e) + + errChan <- result.ErrorOrNil() +} + +// Custom errors + +type TooManyIamServiceLinkedRoleErr struct{} + +func (err TooManyIamServiceLinkedRoleErr) Error() string { + return "Too many IAM Service Linked Roles requested at once" +} diff --git a/aws/iam_service_linked_role_test.go b/aws/iam_service_linked_role_test.go new file mode 100644 index 00000000..cac197d4 --- /dev/null +++ b/aws/iam_service_linked_role_test.go @@ -0,0 +1,137 @@ +package aws + +import ( + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + 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" +) + +func TestListIamServiceLinkedRoles(t *testing.T) { + t.Parallel() + + region, err := getRandomRegion() + require.NoError(t, err) + + session, err := session.NewSession(&awsgo.Config{ + Region: awsgo.String(region)}, + ) + require.NoError(t, err) + + roleNames, err := getAllIamServiceLinkedRoles(session, time.Now(), config.Config{}) + require.NoError(t, err) + + assert.NotEmpty(t, roleNames) +} + +func createTestServiceLinkedRole(t *testing.T, session *session.Session, name, awsServiceName string) error { + svc := iam.New(session) + + input := &iam.CreateServiceLinkedRoleInput{ + AWSServiceName: aws.String(awsServiceName), + Description: aws.String("cloud-nuke-test"), + CustomSuffix: aws.String(name), + } + + _, err := svc.CreateServiceLinkedRole(input) + require.NoError(t, err) + + return nil +} + +func TestCreateIamServiceLinkedRole(t *testing.T) { + t.Parallel() + + region, err := getRandomRegion() + require.NoError(t, err) + + session, err := session.NewSession(&awsgo.Config{ + Region: awsgo.String(region)}, + ) + require.NoError(t, err) + + name := "cloud-nuke-test-" + util.UniqueID() + awsServiceName := "autoscaling.amazonaws.com" + iamServiceLinkedRoleName := "AWSServiceRoleForAutoScaling_" + name + roleNames, err := getAllIamServiceLinkedRoles(session, time.Now(), config.Config{}) + require.NoError(t, err) + assert.NotContains(t, awsgo.StringValueSlice(roleNames), name) + + err = createTestServiceLinkedRole(t, session, name, awsServiceName) + require.NoError(t, err) + + roleNames, err = getAllIamServiceLinkedRoles(session, time.Now(), config.Config{}) + require.NoError(t, err) + //AWSServiceRoleForAutoScaling_cloud-nuke-test + assert.Contains(t, awsgo.StringValueSlice(roleNames), iamServiceLinkedRoleName) +} + +func TestNukeIamServiceLinkedRoles(t *testing.T) { + t.Parallel() + + region, err := getRandomRegion() + require.NoError(t, err) + + session, err := session.NewSession(&awsgo.Config{ + Region: awsgo.String(region)}, + ) + require.NoError(t, err) + + name := "cloud-nuke-test-" + util.UniqueID() + awsServiceName := "autoscaling.amazonaws.com" + iamServiceLinkedRoleName := "AWSServiceRoleForAutoScaling_" + name + + err = createTestServiceLinkedRole(t, session, name, awsServiceName) + require.NoError(t, err) + + err = nukeAllIamServiceLinkedRoles(session, []*string{&iamServiceLinkedRoleName}) + require.NoError(t, err) + + roleNames, err := getAllIamServiceLinkedRoles(session, time.Now(), config.Config{}) + require.NoError(t, err) + + assert.NotContains(t, awsgo.StringValueSlice(roleNames), iamServiceLinkedRoleName) +} + +func TestTimeFilterExclusionNewlyCreatedIamServiceLinkedRole(t *testing.T) { + t.Parallel() + + region, err := getRandomRegion() + require.NoError(t, err) + + session, err := session.NewSession(&awsgo.Config{ + Region: awsgo.String(region)}, + ) + require.NoError(t, err) + + // Assert role didn't exist + name := "cloud-nuke-test-" + util.UniqueID() + awsServiceName := "autoscaling.amazonaws.com" + iamServiceLinkedRoleName := "AWSServiceRoleForAutoScaling_" + name + + roleNames, err := getAllIamServiceLinkedRoles(session, time.Now(), config.Config{}) + require.NoError(t, err) + assert.NotContains(t, awsgo.StringValueSlice(roleNames), name) + + // Creates a role + err = createTestServiceLinkedRole(t, session, name, awsServiceName) + defer nukeAllIamRoles(session, []*string{&name}) + + // Assert role is created + roleNames, err = getAllIamServiceLinkedRoles(session, time.Now(), config.Config{}) + require.NoError(t, err) + assert.Contains(t, awsgo.StringValueSlice(roleNames), iamServiceLinkedRoleName) + + // Assert role doesn't appear when we look at roles older than 1 Hour + olderThan := time.Now().Add(-1 * time.Hour) + roleNames, err = getAllIamServiceLinkedRoles(session, olderThan, config.Config{}) + require.NoError(t, err) + assert.NotContains(t, awsgo.StringValueSlice(roleNames), iamServiceLinkedRoleName) +} diff --git a/aws/iam_service_linked_role_types.go b/aws/iam_service_linked_role_types.go new file mode 100644 index 00000000..e6243674 --- /dev/null +++ b/aws/iam_service_linked_role_types.go @@ -0,0 +1,36 @@ +package aws + +import ( + awsgo "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/gruntwork-io/go-commons/errors" +) + +// IAMServiceLinkedRoles - represents all IAMServiceLinkedRoles on the AWS Account +type IAMServiceLinkedRoles struct { + RoleNames []string +} + +// ResourceName - the simple name of the aws resource +func (r IAMServiceLinkedRoles) ResourceName() string { + return "iam-service-linked-role" +} + +// ResourceIdentifiers - The IAM UserNames +func (r IAMServiceLinkedRoles) ResourceIdentifiers() []string { + return r.RoleNames +} + +// Tentative batch size to ensure AWS doesn't throttle +func (r IAMServiceLinkedRoles) MaxBatchSize() int { + return 49 +} + +// Nuke - nuke 'em all!!! +func (r IAMServiceLinkedRoles) Nuke(session *session.Session, identifiers []string) error { + if err := nukeAllIamServiceLinkedRoles(session, awsgo.StringSlice(identifiers)); err != nil { + return errors.WithStackTrace(err) + } + + return nil +} diff --git a/config/config.go b/config/config.go index 7d17de7c..3ab06166 100644 --- a/config/config.go +++ b/config/config.go @@ -14,6 +14,7 @@ type Config struct { IAMUsers ResourceType `yaml:"IAMUsers"` IAMGroups ResourceType `yaml:"IAMGroups"` IAMPolicies ResourceType `yaml:"IAMPolicies"` + IAMServiceLinkedRoles ResourceType `yaml:"IAMServiceLinkedRoles"` IAMRoles ResourceType `yaml:"IAMRoles"` SecretsManagerSecrets ResourceType `yaml:"SecretsManager"` NatGateway ResourceType `yaml:"NatGateway"`