diff --git a/.changelog/39718.txt b/.changelog/39718.txt new file mode 100644 index 000000000000..8488c3751aec --- /dev/null +++ b/.changelog/39718.txt @@ -0,0 +1,6 @@ +```release-note:new-resource +aws_iam_role_policy_attachments_exclusive +``` +```release-note:note +resource/aws_iam_role: The `managed_policy_arns` argument is deprecated. Use the `aws_iam_role_policy_attachments_exclusive` resource instead. +``` diff --git a/internal/service/iam/exports_test.go b/internal/service/iam/exports_test.go index 45719fd98a3c..2b9cd7d3ec36 100644 --- a/internal/service/iam/exports_test.go +++ b/internal/service/iam/exports_test.go @@ -46,6 +46,7 @@ var ( FindOpenIDConnectProviderByARN = findOpenIDConnectProviderByARN FindPolicyByARN = findPolicyByARN FindRolePoliciesByName = findRolePoliciesByName + FindRolePolicyAttachmentsByName = findRolePolicyAttachmentsByName FindSAMLProviderByARN = findSAMLProviderByARN FindServerCertificateByName = findServerCertificateByName FindSSHPublicKeyByThreePartKey = findSSHPublicKeyByThreePartKey diff --git a/internal/service/iam/role.go b/internal/service/iam/role.go index 478eb1012ef1..83f02cec196f 100644 --- a/internal/service/iam/role.go +++ b/internal/service/iam/role.go @@ -131,6 +131,8 @@ func resourceRole() *schema.Resource { Type: schema.TypeSet, Optional: true, Computed: true, + Deprecated: "The managed_policy_arns argument is deprecated. " + + "Use the aws_iam_role_policy_attachments_exclusive resource instead.", Elem: &schema.Schema{ Type: schema.TypeString, ValidateFunc: verify.ValidARN, diff --git a/internal/service/iam/role_policy_attachments_exclusive.go b/internal/service/iam/role_policy_attachments_exclusive.go new file mode 100644 index 000000000000..9af4ee0979c8 --- /dev/null +++ b/internal/service/iam/role_policy_attachments_exclusive.go @@ -0,0 +1,211 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package iam + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + awstypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "github.com/hashicorp/terraform-provider-aws/internal/create" + "github.com/hashicorp/terraform-provider-aws/internal/errs" + intflex "github.com/hashicorp/terraform-provider-aws/internal/flex" + "github.com/hashicorp/terraform-provider-aws/internal/framework" + "github.com/hashicorp/terraform-provider-aws/internal/framework/flex" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + "github.com/hashicorp/terraform-provider-aws/names" +) + +// @FrameworkResource("aws_iam_role_policy_attachments_exclusive", name="Role Policy Attachments Exclusive") +func newResourceRolePolicyAttachmentsExclusive(_ context.Context) (resource.ResourceWithConfigure, error) { + return &resourceRolePolicyAttachmentsExclusive{}, nil +} + +const ( + ResNameRolePolicyAttachmentsExclusive = "Role Policy Attachments Exclusive" +) + +type resourceRolePolicyAttachmentsExclusive struct { + framework.ResourceWithConfigure + framework.WithNoOpDelete +} + +func (r *resourceRolePolicyAttachmentsExclusive) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "aws_iam_role_policy_attachments_exclusive" +} + +func (r *resourceRolePolicyAttachmentsExclusive) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "role_name": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "policy_arns": schema.SetAttribute{ + ElementType: types.StringType, + Required: true, + }, + }, + } +} + +func (r *resourceRolePolicyAttachmentsExclusive) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan resourceRolePolicyAttachmentsExclusiveData + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + var policyARNs []string + resp.Diagnostics.Append(plan.PolicyARNs.ElementsAs(ctx, &policyARNs, false)...) + if resp.Diagnostics.HasError() { + return + } + + err := r.syncAttachments(ctx, plan.RoleName.ValueString(), policyARNs) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.IAM, create.ErrActionCreating, ResNameRolePolicyAttachmentsExclusive, plan.RoleName.String(), err), + err.Error(), + ) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} + +func (r *resourceRolePolicyAttachmentsExclusive) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + conn := r.Meta().IAMClient(ctx) + + var state resourceRolePolicyAttachmentsExclusiveData + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + out, err := findRolePolicyAttachmentsByName(ctx, conn, state.RoleName.ValueString()) + if tfresource.NotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.IAM, create.ErrActionReading, ResNameRolePolicyAttachmentsExclusive, state.RoleName.String(), err), + err.Error(), + ) + return + } + + state.PolicyARNs = flex.FlattenFrameworkStringValueSetLegacy(ctx, out) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *resourceRolePolicyAttachmentsExclusive) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state resourceRolePolicyAttachmentsExclusiveData + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if !plan.PolicyARNs.Equal(state.PolicyARNs) { + var policyARNs []string + resp.Diagnostics.Append(plan.PolicyARNs.ElementsAs(ctx, &policyARNs, false)...) + if resp.Diagnostics.HasError() { + return + } + + err := r.syncAttachments(ctx, plan.RoleName.ValueString(), policyARNs) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.IAM, create.ErrActionUpdating, ResNameRolePolicyAttachmentsExclusive, plan.RoleName.String(), err), + err.Error(), + ) + return + } + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +// syncAttachments handles keeping the configured customer managed policy +// attachments in sync with the remote resource. +// +// Customer managed policies defined on this resource but not attached to +// the role will be added. Policies attached to the role but not configured +// on this resource will be removed. +func (r *resourceRolePolicyAttachmentsExclusive) syncAttachments(ctx context.Context, roleName string, want []string) error { + conn := r.Meta().IAMClient(ctx) + + have, err := findRolePolicyAttachmentsByName(ctx, conn, roleName) + if err != nil { + return err + } + + create, remove, _ := intflex.DiffSlices(have, want, func(s1, s2 string) bool { return s1 == s2 }) + + for _, arn := range create { + err := attachPolicyToRole(ctx, conn, roleName, arn) + if err != nil { + return err + } + } + + for _, arn := range remove { + err := detachPolicyFromRole(ctx, conn, roleName, arn) + if err != nil { + return err + } + } + + return nil +} + +func (r *resourceRolePolicyAttachmentsExclusive) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("role_name"), req, resp) +} + +func findRolePolicyAttachmentsByName(ctx context.Context, conn *iam.Client, roleName string) ([]string, error) { + in := &iam.ListAttachedRolePoliciesInput{ + RoleName: aws.String(roleName), + } + + var policyARNs []string + paginator := iam.NewListAttachedRolePoliciesPaginator(conn, in) + for paginator.HasMorePages() { + page, err := paginator.NextPage(ctx) + if err != nil { + if errs.IsA[*awstypes.NoSuchEntityException](err) { + return nil, &retry.NotFoundError{ + LastError: err, + LastRequest: in, + } + } + return policyARNs, err + } + + for _, p := range page.AttachedPolicies { + if p.PolicyArn != nil { + policyARNs = append(policyARNs, aws.ToString(p.PolicyArn)) + } + } + } + + return policyARNs, nil +} + +type resourceRolePolicyAttachmentsExclusiveData struct { + RoleName types.String `tfsdk:"role_name"` + PolicyARNs types.Set `tfsdk:"policy_arns"` +} diff --git a/internal/service/iam/role_policy_attachments_exclusive_test.go b/internal/service/iam/role_policy_attachments_exclusive_test.go new file mode 100644 index 000000000000..a4e2b67b2234 --- /dev/null +++ b/internal/service/iam/role_policy_attachments_exclusive_test.go @@ -0,0 +1,466 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package iam_test + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/iam/types" + sdkacctest "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-provider-aws/internal/acctest" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + "github.com/hashicorp/terraform-provider-aws/internal/create" + "github.com/hashicorp/terraform-provider-aws/internal/errs" + tfiam "github.com/hashicorp/terraform-provider-aws/internal/service/iam" + "github.com/hashicorp/terraform-provider-aws/names" +) + +func TestAccIAMRolePolicyAttachmentsExclusive_basic(t *testing.T) { + ctx := acctest.Context(t) + + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_role_policy_attachments_exclusive.test" + roleResourceName := "aws_iam_role.test" + attachmentResourceName := "aws_iam_role_policy_attachment.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckRolePolicyAttachmentsExclusiveDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccRolePolicyAttachmentsExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckRolePolicyAttachmentExists(ctx, attachmentResourceName), + testAccCheckRolePolicyAttachmentCount(ctx, rName, 1), + testAccCheckRolePolicyAttachmentsExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, "role_name", roleResourceName, names.AttrName), + resource.TestCheckTypeSetElemAttrPair(resourceName, "policy_arns.*", attachmentResourceName, "policy_arn"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateIdFunc: testAccRolePolicyAttachmentsExclusiveImportStateIdFunc(resourceName), + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: "role_name", + }, + }, + }) +} + +func TestAccIAMRolePolicyAttachmentsExclusive_disappears_Role(t *testing.T) { + ctx := acctest.Context(t) + + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_role_policy_attachments_exclusive.test" + roleResourceName := "aws_iam_role.test" + attachmentResourceName := "aws_iam_role_policy_attachment.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckRolePolicyAttachmentsExclusiveDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccRolePolicyAttachmentsExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckRolePolicyAttachmentExists(ctx, attachmentResourceName), + testAccCheckRolePolicyAttachmentCount(ctx, rName, 1), + testAccCheckRolePolicyAttachmentsExclusiveExists(ctx, resourceName), + // Managed policies must be detached before role can be deleted + acctest.CheckResourceDisappears(ctx, acctest.Provider, tfiam.ResourceRolePolicyAttachment(), attachmentResourceName), + acctest.CheckResourceDisappears(ctx, acctest.Provider, tfiam.ResourceRole(), roleResourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func TestAccIAMRolePolicyAttachmentsExclusive_disappears_Policy(t *testing.T) { + ctx := acctest.Context(t) + + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_role_policy_attachments_exclusive.test" + policyResourceName := "aws_iam_policy.test" + attachmentResourceName := "aws_iam_role_policy_attachment.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckRolePolicyAttachmentsExclusiveDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccRolePolicyAttachmentsExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckRolePolicyAttachmentExists(ctx, attachmentResourceName), + testAccCheckRolePolicyAttachmentCount(ctx, rName, 1), + testAccCheckRolePolicyAttachmentsExclusiveExists(ctx, resourceName), + // Managed policy must be detached before it can be deleted + acctest.CheckResourceDisappears(ctx, acctest.Provider, tfiam.ResourceRolePolicyAttachment(), attachmentResourceName), + acctest.CheckResourceDisappears(ctx, acctest.Provider, tfiam.ResourcePolicy(), policyResourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func TestAccIAMRolePolicyAttachmentsExclusive_multiple(t *testing.T) { + ctx := acctest.Context(t) + + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_role_policy_attachments_exclusive.test" + roleResourceName := "aws_iam_role.test" + attachmentResourceName := "aws_iam_role_policy_attachment.test" + attachmentResourceName2 := "aws_iam_role_policy_attachment.test2" + attachmentResourceName3 := "aws_iam_role_policy_attachment.test3" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckRolePolicyAttachmentsExclusiveDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccRolePolicyAttachmentsExclusiveConfig_multiple(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckRolePolicyAttachmentExists(ctx, attachmentResourceName), + testAccCheckRolePolicyAttachmentExists(ctx, attachmentResourceName2), + testAccCheckRolePolicyAttachmentExists(ctx, attachmentResourceName3), + testAccCheckRolePolicyAttachmentCount(ctx, rName, 3), + testAccCheckRolePolicyAttachmentsExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, "role_name", roleResourceName, names.AttrName), + resource.TestCheckTypeSetElemAttrPair(resourceName, "policy_arns.*", attachmentResourceName, "policy_arn"), + resource.TestCheckTypeSetElemAttrPair(resourceName, "policy_arns.*", attachmentResourceName2, "policy_arn"), + resource.TestCheckTypeSetElemAttrPair(resourceName, "policy_arns.*", attachmentResourceName3, "policy_arn"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateIdFunc: testAccRolePolicyAttachmentsExclusiveImportStateIdFunc(resourceName), + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: "role_name", + }, + { + Config: testAccRolePolicyAttachmentsExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckRolePolicyAttachmentExists(ctx, attachmentResourceName), + testAccCheckRolePolicyAttachmentCount(ctx, rName, 1), + testAccCheckRolePolicyAttachmentsExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, "role_name", roleResourceName, names.AttrName), + resource.TestCheckTypeSetElemAttrPair(resourceName, "policy_arns.*", attachmentResourceName, "policy_arn"), + ), + }, + }, + }) +} + +func TestAccIAMRolePolicyAttachmentsExclusive_empty(t *testing.T) { + ctx := acctest.Context(t) + + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_role_policy_attachments_exclusive.test" + roleResourceName := "aws_iam_role.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckRolePolicyAttachmentsExclusiveDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccRolePolicyAttachmentsExclusiveConfig_empty(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckRolePolicyAttachmentsExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, "role_name", roleResourceName, names.AttrName), + resource.TestCheckResourceAttr(resourceName, "policy_arns.#", acctest.Ct0), + ), + // The empty `policy_arns` argument in the exclusive lock will remove the + // managed policy defined in this configuration, so a diff is expected + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +// A managed policy removed out of band should be recreated +func TestAccIAMRolePolicyAttachmentsExclusive_outOfBandRemoval(t *testing.T) { + ctx := acctest.Context(t) + + var role types.Role + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_role_policy_attachments_exclusive.test" + roleResourceName := "aws_iam_role.test" + attachmentResourceName := "aws_iam_role_policy_attachment.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckRoleDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccRolePolicyAttachmentsExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckRoleExists(ctx, roleResourceName, &role), + testAccCheckRolePolicyAttachmentExists(ctx, attachmentResourceName), + testAccCheckRolePolicyAttachmentCount(ctx, rName, 1), + testAccCheckRolePolicyAttachmentsExclusiveExists(ctx, resourceName), + testAccCheckRolePolicyDetachManagedPolicy(ctx, &role, rName), + ), + ExpectNonEmptyPlan: true, + }, + { + Config: testAccRolePolicyAttachmentsExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckRoleExists(ctx, roleResourceName, &role), + testAccCheckRolePolicyAttachmentExists(ctx, attachmentResourceName), + testAccCheckRolePolicyAttachmentCount(ctx, rName, 1), + testAccCheckRolePolicyAttachmentsExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, "role_name", roleResourceName, names.AttrName), + resource.TestCheckResourceAttr(resourceName, "policy_arns.#", acctest.Ct1), + ), + }, + }, + }) +} + +// A managed policy added out of band should be removed +func TestAccIAMRolePolicyAttachmentsExclusive_outOfBandAddition(t *testing.T) { + ctx := acctest.Context(t) + + var role types.Role + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + oobPolicyName := rName + "-out-of-band" + resourceName := "aws_iam_role_policy_attachments_exclusive.test" + roleResourceName := "aws_iam_role.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckRoleDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccRolePolicyAttachmentsExclusiveConfig_outOfBandAddition(rName, oobPolicyName), + Check: resource.ComposeTestCheckFunc( + testAccCheckRoleExists(ctx, roleResourceName, &role), + testAccCheckRolePolicyAttachmentsExclusiveExists(ctx, resourceName), + testAccCheckRolePolicyAttachManagedPolicy(ctx, &role, oobPolicyName), + ), + ExpectNonEmptyPlan: true, + }, + { + Config: testAccRolePolicyAttachmentsExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckRoleExists(ctx, roleResourceName, &role), + testAccCheckRolePolicyAttachmentsExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, "role_name", roleResourceName, names.AttrName), + resource.TestCheckResourceAttr(resourceName, "policy_arns.#", acctest.Ct1), + ), + }, + }, + }) +} + +func testAccCheckRolePolicyAttachmentsExclusiveDestroy(ctx context.Context) resource.TestCheckFunc { + return func(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.AWSClient).IAMClient(ctx) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_iam_role_policy_attachments_exclusive" { + continue + } + + roleName := rs.Primary.Attributes["role_name"] + _, err := tfiam.FindRolePolicyAttachmentsByName(ctx, conn, roleName) + if errs.IsA[*types.NoSuchEntityException](err) { + return nil + } + if err != nil { + return create.Error(names.IAM, create.ErrActionCheckingDestroyed, tfiam.ResNameRolePolicyAttachmentsExclusive, roleName, err) + } + + return create.Error(names.IAM, create.ErrActionCheckingDestroyed, tfiam.ResNameRolePolicyAttachmentsExclusive, roleName, errors.New("not destroyed")) + } + + return nil + } +} + +func testAccCheckRolePolicyAttachmentsExclusiveExists(ctx context.Context, name string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[name] + if !ok { + return create.Error(names.IAM, create.ErrActionCheckingExistence, tfiam.ResNameRolePolicyAttachmentsExclusive, name, errors.New("not found")) + } + + roleName := rs.Primary.Attributes["role_name"] + if roleName == "" { + return create.Error(names.IAM, create.ErrActionCheckingExistence, tfiam.ResNameRolePolicyAttachmentsExclusive, name, errors.New("not set")) + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).IAMClient(ctx) + out, err := tfiam.FindRolePolicyAttachmentsByName(ctx, conn, roleName) + if err != nil { + return create.Error(names.IAM, create.ErrActionCheckingExistence, tfiam.ResNameRolePolicyAttachmentsExclusive, roleName, err) + } + + policyCount := rs.Primary.Attributes["policy_arns.#"] + if policyCount != fmt.Sprint(len(out)) { + return create.Error(names.IAM, create.ErrActionCheckingExistence, tfiam.ResNameRolePolicyAttachmentsExclusive, roleName, errors.New("unexpected policy_arns count")) + } + + return nil + } +} + +func testAccRolePolicyAttachmentsExclusiveImportStateIdFunc(resourceName string) resource.ImportStateIdFunc { + return func(s *terraform.State) (string, error) { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return "", fmt.Errorf("Not found: %s", resourceName) + } + + return rs.Primary.Attributes["role_name"], nil + } +} + +func testAccRolePolicyAttachmentsExclusiveConfigBase(rName string) string { + return fmt.Sprintf(` +data "aws_iam_policy_document" "trust" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["ec2.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "managed" { + statement { + actions = ["s3:ListBucket"] + resources = ["*"] + } +} + +resource "aws_iam_role" "test" { + name = %[1]q + assume_role_policy = data.aws_iam_policy_document.trust.json +} + +resource "aws_iam_policy" "test" { + name = %[1]q + policy = data.aws_iam_policy_document.managed.json +} + +resource "aws_iam_role_policy_attachment" "test" { + role = aws_iam_role.test.name + policy_arn = aws_iam_policy.test.arn +} +`, rName) +} + +func testAccRolePolicyAttachmentsExclusiveConfig_basic(rName string) string { + return acctest.ConfigCompose( + testAccRolePolicyAttachmentsExclusiveConfigBase(rName), + ` +resource "aws_iam_role_policy_attachments_exclusive" "test" { + role_name = aws_iam_role.test.name + policy_arns = [aws_iam_role_policy_attachment.test.policy_arn] +} +`) +} + +func testAccRolePolicyAttachmentsExclusiveConfig_multiple(rName string) string { + return acctest.ConfigCompose( + testAccRolePolicyAttachmentsExclusiveConfigBase(rName), + fmt.Sprintf(` +resource "aws_iam_policy" "test2" { + name = "%[1]s-2" + policy = data.aws_iam_policy_document.managed.json +} + +resource "aws_iam_role_policy_attachment" "test2" { + role = aws_iam_role.test.name + policy_arn = aws_iam_policy.test2.arn +} + +resource "aws_iam_policy" "test3" { + name = "%[1]s-3" + policy = data.aws_iam_policy_document.managed.json +} + +resource "aws_iam_role_policy_attachment" "test3" { + role = aws_iam_role.test.name + policy_arn = aws_iam_policy.test3.arn +} + +resource "aws_iam_role_policy_attachments_exclusive" "test" { + role_name = aws_iam_role.test.name + policy_arns = [ + aws_iam_role_policy_attachment.test.policy_arn, + aws_iam_role_policy_attachment.test2.policy_arn, + aws_iam_role_policy_attachment.test3.policy_arn, + ] +} +`, rName)) +} + +func testAccRolePolicyAttachmentsExclusiveConfig_empty(rName string) string { + return acctest.ConfigCompose( + testAccRolePolicyAttachmentsExclusiveConfigBase(rName), + ` +resource "aws_iam_role_policy_attachments_exclusive" "test" { + # Wait until the managed policy is attached, then provision + # the exclusive lock which will remove it. This creates a diff on + # on the next plan (to re-create aws_iam_role_policy_attachment.test) + # which the test can check for. + depends_on = [aws_iam_role_policy_attachment.test] + + role_name = aws_iam_role.test.name + policy_arns = [] +} +`) +} + +func testAccRolePolicyAttachmentsExclusiveConfig_outOfBandAddition(rName, oobPolicyName string) string { + return acctest.ConfigCompose( + testAccRolePolicyAttachmentsExclusiveConfigBase(rName), + fmt.Sprintf(` +# This will be attached out-of-band via a test check helper +resource "aws_iam_policy" "test2" { + name = %[1]q + path = "/tf-testing/" + policy = data.aws_iam_policy_document.managed.json +} + +resource "aws_iam_role_policy_attachments_exclusive" "test" { + role_name = aws_iam_role.test.name + policy_arns = [aws_iam_role_policy_attachment.test.policy_arn] +} +`, oobPolicyName)) +} diff --git a/internal/service/iam/service_package_gen.go b/internal/service/iam/service_package_gen.go index 085e2783a039..4f5fb877e4d7 100644 --- a/internal/service/iam/service_package_gen.go +++ b/internal/service/iam/service_package_gen.go @@ -28,6 +28,10 @@ func (p *servicePackage) FrameworkResources(ctx context.Context) []*types.Servic Factory: newResourceRolePoliciesExclusive, Name: "Role Policies Exclusive", }, + { + Factory: newResourceRolePolicyAttachmentsExclusive, + Name: "Role Policy Attachments Exclusive", + }, { Factory: newResourceUserPoliciesExclusive, Name: "User Policies Exclusive", diff --git a/website/docs/r/iam_role.html.markdown b/website/docs/r/iam_role.html.markdown index c2b036c69618..695c747430bc 100644 --- a/website/docs/r/iam_role.html.markdown +++ b/website/docs/r/iam_role.html.markdown @@ -124,6 +124,8 @@ resource "aws_iam_role" "example" { ### Example of Exclusive Managed Policies +~> The `managed_policy_arns` argument is deprecated. Use the [`aws_iam_role_policy_attachments_exclusive`](./iam_role_policy_attachments_exclusive.html.markdown) resource instead. + This example creates an IAM role and attaches two managed IAM policies. If someone attaches another managed policy out-of-band, on the next apply, Terraform will detach that policy. If someone detaches these policies out-of-band, Terraform will attach them again. ```terraform @@ -166,6 +168,8 @@ resource "aws_iam_policy" "policy_two" { ### Example of Removing Managed Policies +~> The `managed_policy_arns` argument is deprecated. Use the [`aws_iam_role_policy_attachments_exclusive`](./iam_role_policy_attachments_exclusive.html.markdown) resource instead. + This example creates an IAM role with an empty `managed_policy_arns` argument. If someone attaches a policy out-of-band, on the next apply, Terraform will detach that policy. ```terraform @@ -189,7 +193,7 @@ The following arguments are optional: * `description` - (Optional) Description of the role. * `force_detach_policies` - (Optional) Whether to force detaching any policies the role has before destroying it. Defaults to `false`. * `inline_policy` - (Optional, **Deprecated**) Configuration block defining an exclusive set of IAM inline policies associated with the IAM role. See below. If no blocks are configured, Terraform will not manage any inline policies in this resource. Configuring one empty block (i.e., `inline_policy {}`) will cause Terraform to remove _all_ inline policies added out of band on `apply`. -* `managed_policy_arns` - (Optional) Set of exclusive IAM managed policy ARNs to attach to the IAM role. If this attribute is not configured, Terraform will ignore policy attachments to this resource. When configured, Terraform will align the role's managed policy attachments with this set by attaching or detaching managed policies. Configuring an empty set (i.e., `managed_policy_arns = []`) will cause Terraform to remove _all_ managed policy attachments. +* `managed_policy_arns` - (Optional, **Deprecated**) Set of exclusive IAM managed policy ARNs to attach to the IAM role. If this attribute is not configured, Terraform will ignore policy attachments to this resource. When configured, Terraform will align the role's managed policy attachments with this set by attaching or detaching managed policies. Configuring an empty set (i.e., `managed_policy_arns = []`) will cause Terraform to remove _all_ managed policy attachments. * `max_session_duration` - (Optional) Maximum session duration (in seconds) that you want to set for the specified role. If you do not specify a value for this setting, the default maximum of one hour is applied. This setting can have a value from 1 hour to 12 hours. * `name` - (Optional, Forces new resource) Friendly name of the role. If omitted, Terraform will assign a random, unique name. See [IAM Identifiers](https://docs.aws.amazon.com/IAM/latest/UserGuide/Using_Identifiers.html) for more information. * `name_prefix` - (Optional, Forces new resource) Creates a unique friendly name beginning with the specified prefix. Conflicts with `name`. diff --git a/website/docs/r/iam_role_policy_attachments_exclusive.html.markdown b/website/docs/r/iam_role_policy_attachments_exclusive.html.markdown new file mode 100644 index 000000000000..9b8c3bc36565 --- /dev/null +++ b/website/docs/r/iam_role_policy_attachments_exclusive.html.markdown @@ -0,0 +1,64 @@ +--- +subcategory: "IAM (Identity & Access Management)" +layout: "aws" +page_title: "AWS: aws_iam_role_policy_attachments_exclusive" +description: |- + Terraform resource for maintaining exclusive management of customer managed policies assigned to an AWS IAM (Identity & Access Management) role. +--- +# Resource: aws_iam_role_policy_attachments_exclusive + +Terraform resource for maintaining exclusive management of customer managed policies assigned to an AWS IAM (Identity & Access Management) role. + +!> This resource takes exclusive ownership over customer managed policies assigned to a role. This includes removal of customer managed policies which are not explicitly configured. To prevent persistent drift, ensure any `aws_iam_role_policy_attachment` resources managed alongside this resource are included in the `policy_arns` argument. + +## Example Usage + +### Basic Usage + +```terraform +resource "aws_iam_role_policy_attachments_exclusive" "example" { + role_name = aws_iam_role.example.name + policy_arns = [aws_iam_role_policy.example.arn] +} +``` + +### Disallow Customer Managed Policies + +To automatically remove any configured customer managed policies, set the `policy_arns` argument to an empty list. + +~> This will not __prevent__ customer managed policies from being assigned to a role via Terraform (or any other interface). This resource enables bringing customer managed policy assignments into a configured state, however, this reconciliation happens only when `apply` is proactively run. + +```terraform +resource "aws_iam_role_policy_attachments_exclusive" "example" { + role_name = aws_iam_role.example.name + policy_arns = [] +} +``` + +## Argument Reference + +The following arguments are required: + +* `role_name` - (Required) IAM role name. +* `policy_arns` - (Required) A list of customer managed policy ARNs to be attached to the role. Policies attached to this role but not configured in this argument will be removed. + +## Attribute Reference + +This resource exports no additional attributes. + +## Import + +In Terraform v1.5.0 and later, use an [`import` block](https://developer.hashicorp.com/terraform/language/import) to exclusively manage customer managed policy assignments using the `role_name`. For example: + +```terraform +import { + to = aws_iam_role_policy_attachments_exclusive.example + id = "MyRole" +} +``` + +Using `terraform import`, import exclusive management of customer managed policy assignments using the `role_name`. For example: + +```console +% terraform import aws_iam_role_policy_attachments_exclusive.example MyRole +```