From ec9afbdfa981272b2330ad95e7bdbd8bf5c5cd3b Mon Sep 17 00:00:00 2001 From: Jared Baker Date: Tue, 15 Oct 2024 15:51:29 -0400 Subject: [PATCH] r/aws_iam_group_policy_attachments_exclusive: new resource This resource will allow practitioners to retain exclusive ownership of customer managed policy attachments to IAM groups via Terraform. ```console % make testacc PKG=iam TESTS=TestAccIAMGroupPolicyAttachmentsExclusive_ make: Verifying source code with gofmt... ==> Checking that code complies with gofmt requirements... TF_ACC=1 go1.23.2 test ./internal/service/iam/... -v -count 1 -parallel 20 -run='TestAccIAMGroupPolicyAttachmentsExclusive_' -timeout 360m 2024/10/15 15:41:33 Initializing Terraform AWS Provider... --- PASS: TestAccIAMGroupPolicyAttachmentsExclusive_empty (14.72s) --- PASS: TestAccIAMGroupPolicyAttachmentsExclusive_disappears_Policy (15.88s) --- PASS: TestAccIAMGroupPolicyAttachmentsExclusive_disappears_Group (15.92s) --- PASS: TestAccIAMGroupPolicyAttachmentsExclusive_basic (16.48s) --- PASS: TestAccIAMGroupPolicyAttachmentsExclusive_outOfBandRemoval (23.58s) --- PASS: TestAccIAMGroupPolicyAttachmentsExclusive_outOfBandAddition (24.17s) --- PASS: TestAccIAMGroupPolicyAttachmentsExclusive_multiple (24.29s) PASS ok github.com/hashicorp/terraform-provider-aws/internal/service/iam 30.887s ``` --- .changelog/39732.txt | 3 + internal/service/iam/exports_test.go | 1 + .../iam/group_policy_attachments_exclusive.go | 211 +++++++ ...group_policy_attachments_exclusive_test.go | 543 ++++++++++++++++++ internal/service/iam/service_package_gen.go | 4 + ...policy_attachments_exclusive.html.markdown | 64 +++ 6 files changed, 826 insertions(+) create mode 100644 .changelog/39732.txt create mode 100644 internal/service/iam/group_policy_attachments_exclusive.go create mode 100644 internal/service/iam/group_policy_attachments_exclusive_test.go create mode 100644 website/docs/r/iam_group_policy_attachments_exclusive.html.markdown diff --git a/.changelog/39732.txt b/.changelog/39732.txt new file mode 100644 index 000000000000..bacc3cfc8920 --- /dev/null +++ b/.changelog/39732.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +aws_iam_group_policy_attachments_exclusive +``` diff --git a/internal/service/iam/exports_test.go b/internal/service/iam/exports_test.go index 2b9cd7d3ec36..165396520bea 100644 --- a/internal/service/iam/exports_test.go +++ b/internal/service/iam/exports_test.go @@ -42,6 +42,7 @@ var ( FindEntitiesForPolicyByARN = findEntitiesForPolicyByARN FindGroupByName = findGroupByName FindGroupPoliciesByName = findGroupPoliciesByName + FindGroupPolicyAttachmentsByName = findGroupPolicyAttachmentsByName FindInstanceProfileByName = findInstanceProfileByName FindOpenIDConnectProviderByARN = findOpenIDConnectProviderByARN FindPolicyByARN = findPolicyByARN diff --git a/internal/service/iam/group_policy_attachments_exclusive.go b/internal/service/iam/group_policy_attachments_exclusive.go new file mode 100644 index 000000000000..61e72969c008 --- /dev/null +++ b/internal/service/iam/group_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_group_policy_attachments_exclusive", name="Group Policy Attachments Exclusive") +func newResourceGroupPolicyAttachmentsExclusive(_ context.Context) (resource.ResourceWithConfigure, error) { + return &resourceGroupPolicyAttachmentsExclusive{}, nil +} + +const ( + ResNameGroupPolicyAttachmentsExclusive = "Group Policy Attachments Exclusive" +) + +type resourceGroupPolicyAttachmentsExclusive struct { + framework.ResourceWithConfigure + framework.WithNoOpDelete +} + +func (r *resourceGroupPolicyAttachmentsExclusive) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "aws_iam_group_policy_attachments_exclusive" +} + +func (r *resourceGroupPolicyAttachmentsExclusive) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + names.AttrGroupName: schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "policy_arns": schema.SetAttribute{ + ElementType: types.StringType, + Required: true, + }, + }, + } +} + +func (r *resourceGroupPolicyAttachmentsExclusive) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan resourceGroupPolicyAttachmentsExclusiveData + 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.GroupName.ValueString(), policyARNs) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.IAM, create.ErrActionCreating, ResNameGroupPolicyAttachmentsExclusive, plan.GroupName.String(), err), + err.Error(), + ) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} + +func (r *resourceGroupPolicyAttachmentsExclusive) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + conn := r.Meta().IAMClient(ctx) + + var state resourceGroupPolicyAttachmentsExclusiveData + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + out, err := findGroupPolicyAttachmentsByName(ctx, conn, state.GroupName.ValueString()) + if tfresource.NotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.IAM, create.ErrActionReading, ResNameGroupPolicyAttachmentsExclusive, state.GroupName.String(), err), + err.Error(), + ) + return + } + + state.PolicyARNs = flex.FlattenFrameworkStringValueSetLegacy(ctx, out) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *resourceGroupPolicyAttachmentsExclusive) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state resourceGroupPolicyAttachmentsExclusiveData + 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.GroupName.ValueString(), policyARNs) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.IAM, create.ErrActionUpdating, ResNameGroupPolicyAttachmentsExclusive, plan.GroupName.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 group will be added. Policies attached to the group but not configured +// on this resource will be removed. +func (r *resourceGroupPolicyAttachmentsExclusive) syncAttachments(ctx context.Context, groupName string, want []string) error { + conn := r.Meta().IAMClient(ctx) + + have, err := findGroupPolicyAttachmentsByName(ctx, conn, groupName) + if err != nil { + return err + } + + create, remove, _ := intflex.DiffSlices(have, want, func(s1, s2 string) bool { return s1 == s2 }) + + for _, arn := range create { + err := attachPolicyToGroup(ctx, conn, groupName, arn) + if err != nil { + return err + } + } + + for _, arn := range remove { + err := detachPolicyFromGroup(ctx, conn, groupName, arn) + if err != nil { + return err + } + } + + return nil +} + +func (r *resourceGroupPolicyAttachmentsExclusive) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root(names.AttrGroupName), req, resp) +} + +func findGroupPolicyAttachmentsByName(ctx context.Context, conn *iam.Client, groupName string) ([]string, error) { + in := &iam.ListAttachedGroupPoliciesInput{ + GroupName: aws.String(groupName), + } + + var policyARNs []string + paginator := iam.NewListAttachedGroupPoliciesPaginator(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 resourceGroupPolicyAttachmentsExclusiveData struct { + GroupName types.String `tfsdk:"group_name"` + PolicyARNs types.Set `tfsdk:"policy_arns"` +} diff --git a/internal/service/iam/group_policy_attachments_exclusive_test.go b/internal/service/iam/group_policy_attachments_exclusive_test.go new file mode 100644 index 000000000000..df81410143e4 --- /dev/null +++ b/internal/service/iam/group_policy_attachments_exclusive_test.go @@ -0,0 +1,543 @@ +// 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/aws" + "github.com/aws/aws-sdk-go-v2/service/iam" + "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 TestAccIAMGroupPolicyAttachmentsExclusive_basic(t *testing.T) { + ctx := acctest.Context(t) + + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_group_policy_attachments_exclusive.test" + groupResourceName := "aws_iam_group.test" + attachmentResourceName := "aws_iam_group_policy_attachment.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckGroupPolicyAttachmentsExclusiveDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccGroupPolicyAttachmentsExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckGroupPolicyAttachmentExists(ctx, attachmentResourceName), + testAccCheckGroupPolicyAttachmentCount(ctx, rName, 1), + testAccCheckGroupPolicyAttachmentsExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, names.AttrGroupName, groupResourceName, names.AttrName), + resource.TestCheckTypeSetElemAttrPair(resourceName, "policy_arns.*", attachmentResourceName, "policy_arn"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateIdFunc: testAccGroupPolicyAttachmentsExclusiveImportStateIdFunc(resourceName), + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: names.AttrGroupName, + }, + }, + }) +} + +func TestAccIAMGroupPolicyAttachmentsExclusive_disappears_Group(t *testing.T) { + ctx := acctest.Context(t) + + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_group_policy_attachments_exclusive.test" + groupResourceName := "aws_iam_group.test" + attachmentResourceName := "aws_iam_group_policy_attachment.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckGroupPolicyAttachmentsExclusiveDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccGroupPolicyAttachmentsExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckGroupPolicyAttachmentExists(ctx, attachmentResourceName), + testAccCheckGroupPolicyAttachmentCount(ctx, rName, 1), + testAccCheckGroupPolicyAttachmentsExclusiveExists(ctx, resourceName), + // Managed policies must be detached before group can be deleted + acctest.CheckResourceDisappears(ctx, acctest.Provider, tfiam.ResourceGroupPolicyAttachment(), attachmentResourceName), + acctest.CheckResourceDisappears(ctx, acctest.Provider, tfiam.ResourceGroup(), groupResourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func TestAccIAMGroupPolicyAttachmentsExclusive_disappears_Policy(t *testing.T) { + ctx := acctest.Context(t) + + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_group_policy_attachments_exclusive.test" + policyResourceName := "aws_iam_policy.test" + attachmentResourceName := "aws_iam_group_policy_attachment.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckGroupPolicyAttachmentsExclusiveDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccGroupPolicyAttachmentsExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckGroupPolicyAttachmentExists(ctx, attachmentResourceName), + testAccCheckGroupPolicyAttachmentCount(ctx, rName, 1), + testAccCheckGroupPolicyAttachmentsExclusiveExists(ctx, resourceName), + // Managed policy must be detached before it can be deleted + acctest.CheckResourceDisappears(ctx, acctest.Provider, tfiam.ResourceGroupPolicyAttachment(), attachmentResourceName), + acctest.CheckResourceDisappears(ctx, acctest.Provider, tfiam.ResourcePolicy(), policyResourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func TestAccIAMGroupPolicyAttachmentsExclusive_multiple(t *testing.T) { + ctx := acctest.Context(t) + + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_group_policy_attachments_exclusive.test" + groupResourceName := "aws_iam_group.test" + attachmentResourceName := "aws_iam_group_policy_attachment.test" + attachmentResourceName2 := "aws_iam_group_policy_attachment.test2" + attachmentResourceName3 := "aws_iam_group_policy_attachment.test3" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckGroupPolicyAttachmentsExclusiveDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccGroupPolicyAttachmentsExclusiveConfig_multiple(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckGroupPolicyAttachmentExists(ctx, attachmentResourceName), + testAccCheckGroupPolicyAttachmentExists(ctx, attachmentResourceName2), + testAccCheckGroupPolicyAttachmentExists(ctx, attachmentResourceName3), + testAccCheckGroupPolicyAttachmentCount(ctx, rName, 3), + testAccCheckGroupPolicyAttachmentsExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, names.AttrGroupName, groupResourceName, 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: testAccGroupPolicyAttachmentsExclusiveImportStateIdFunc(resourceName), + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: names.AttrGroupName, + }, + { + Config: testAccGroupPolicyAttachmentsExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckGroupPolicyAttachmentExists(ctx, attachmentResourceName), + testAccCheckGroupPolicyAttachmentCount(ctx, rName, 1), + testAccCheckGroupPolicyAttachmentsExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, names.AttrGroupName, groupResourceName, names.AttrName), + resource.TestCheckTypeSetElemAttrPair(resourceName, "policy_arns.*", attachmentResourceName, "policy_arn"), + ), + }, + }, + }) +} + +func TestAccIAMGroupPolicyAttachmentsExclusive_empty(t *testing.T) { + ctx := acctest.Context(t) + + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_group_policy_attachments_exclusive.test" + groupResourceName := "aws_iam_group.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckGroupPolicyAttachmentsExclusiveDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccGroupPolicyAttachmentsExclusiveConfig_empty(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckGroupPolicyAttachmentsExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, names.AttrGroupName, groupResourceName, 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 TestAccIAMGroupPolicyAttachmentsExclusive_outOfBandRemoval(t *testing.T) { + ctx := acctest.Context(t) + + var group types.Group + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_group_policy_attachments_exclusive.test" + groupResourceName := "aws_iam_group.test" + attachmentResourceName := "aws_iam_group_policy_attachment.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckGroupDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccGroupPolicyAttachmentsExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckGroupExists(ctx, groupResourceName, &group), + testAccCheckGroupPolicyAttachmentExists(ctx, attachmentResourceName), + testAccCheckGroupPolicyAttachmentCount(ctx, rName, 1), + testAccCheckGroupPolicyAttachmentsExclusiveExists(ctx, resourceName), + testAccCheckGroupPolicyDetachManagedPolicy(ctx, &group, rName), + ), + ExpectNonEmptyPlan: true, + }, + { + Config: testAccGroupPolicyAttachmentsExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckGroupExists(ctx, groupResourceName, &group), + testAccCheckGroupPolicyAttachmentExists(ctx, attachmentResourceName), + testAccCheckGroupPolicyAttachmentCount(ctx, rName, 1), + testAccCheckGroupPolicyAttachmentsExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, names.AttrGroupName, groupResourceName, names.AttrName), + resource.TestCheckResourceAttr(resourceName, "policy_arns.#", acctest.Ct1), + ), + }, + }, + }) +} + +// A managed policy added out of band should be removed +func TestAccIAMGroupPolicyAttachmentsExclusive_outOfBandAddition(t *testing.T) { + ctx := acctest.Context(t) + + var group types.Group + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + oobPolicyName := rName + "-out-of-band" + resourceName := "aws_iam_group_policy_attachments_exclusive.test" + groupResourceName := "aws_iam_group.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckGroupDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccGroupPolicyAttachmentsExclusiveConfig_outOfBandAddition(rName, oobPolicyName), + Check: resource.ComposeTestCheckFunc( + testAccCheckGroupExists(ctx, groupResourceName, &group), + testAccCheckGroupPolicyAttachmentsExclusiveExists(ctx, resourceName), + testAccCheckGroupPolicyAttachManagedPolicy(ctx, &group, oobPolicyName), + ), + ExpectNonEmptyPlan: true, + }, + { + Config: testAccGroupPolicyAttachmentsExclusiveConfig_outOfBandAddition(rName, oobPolicyName), + Check: resource.ComposeTestCheckFunc( + testAccCheckGroupExists(ctx, groupResourceName, &group), + testAccCheckGroupPolicyAttachmentsExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, names.AttrGroupName, groupResourceName, names.AttrName), + resource.TestCheckResourceAttr(resourceName, "policy_arns.#", acctest.Ct1), + ), + }, + }, + }) +} + +func testAccCheckGroupPolicyAttachmentsExclusiveDestroy(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_group_policy_attachments_exclusive" { + continue + } + + groupName := rs.Primary.Attributes[names.AttrGroupName] + _, err := tfiam.FindGroupPolicyAttachmentsByName(ctx, conn, groupName) + if errs.IsA[*types.NoSuchEntityException](err) { + return nil + } + if err != nil { + return create.Error(names.IAM, create.ErrActionCheckingDestroyed, tfiam.ResNameGroupPolicyAttachmentsExclusive, groupName, err) + } + + return create.Error(names.IAM, create.ErrActionCheckingDestroyed, tfiam.ResNameGroupPolicyAttachmentsExclusive, groupName, errors.New("not destroyed")) + } + + return nil + } +} + +func testAccCheckGroupPolicyAttachmentsExclusiveExists(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.ResNameGroupPolicyAttachmentsExclusive, name, errors.New("not found")) + } + + groupName := rs.Primary.Attributes[names.AttrGroupName] + if groupName == "" { + return create.Error(names.IAM, create.ErrActionCheckingExistence, tfiam.ResNameGroupPolicyAttachmentsExclusive, name, errors.New("not set")) + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).IAMClient(ctx) + out, err := tfiam.FindGroupPolicyAttachmentsByName(ctx, conn, groupName) + if err != nil { + return create.Error(names.IAM, create.ErrActionCheckingExistence, tfiam.ResNameGroupPolicyAttachmentsExclusive, groupName, err) + } + + policyCount := rs.Primary.Attributes["policy_arns.#"] + if policyCount != fmt.Sprint(len(out)) { + return create.Error(names.IAM, create.ErrActionCheckingExistence, tfiam.ResNameGroupPolicyAttachmentsExclusive, groupName, errors.New("unexpected policy_arns count")) + } + + return nil + } +} + +func testAccGroupPolicyAttachmentsExclusiveImportStateIdFunc(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[names.AttrGroupName], nil + } +} + +func testAccCheckGroupPolicyDetachManagedPolicy(ctx context.Context, group *types.Group, policyName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.AWSClient).IAMClient(ctx) + + var managedARN string + input := &iam.ListAttachedGroupPoliciesInput{ + GroupName: group.GroupName, + } + + pages := iam.NewListAttachedGroupPoliciesPaginator(conn, input) + for pages.HasMorePages() { + page, err := pages.NextPage(ctx) + + if err != nil && !errs.IsA[*types.NoSuchEntityException](err) { + return fmt.Errorf("finding managed policy (%s): %w", policyName, err) + } + + if err != nil { + return err + } + + for _, v := range page.AttachedPolicies { + if *v.PolicyName == policyName { + managedARN = *v.PolicyArn + break + } + } + } + + if managedARN == "" { + return fmt.Errorf("managed policy (%s) not found", policyName) + } + + _, err := conn.DetachGroupPolicy(ctx, &iam.DetachGroupPolicyInput{ + PolicyArn: aws.String(managedARN), + GroupName: group.GroupName, + }) + + return err + } +} + +func testAccCheckGroupPolicyAttachManagedPolicy(ctx context.Context, group *types.Group, policyName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.AWSClient).IAMClient(ctx) + + var managedARN string + input := &iam.ListPoliciesInput{ + PathPrefix: aws.String("/tf-testing/"), + PolicyUsageFilter: types.PolicyUsageType("PermissionsPolicy"), + Scope: types.PolicyScopeType("Local"), + } + + pages := iam.NewListPoliciesPaginator(conn, input) + for pages.HasMorePages() { + page, err := pages.NextPage(ctx) + + if err != nil && !errs.IsA[*types.NoSuchEntityException](err) { + return fmt.Errorf("finding managed policy (%s): %w", policyName, err) + } + + if err != nil { + return err + } + + for _, v := range page.Policies { + if *v.PolicyName == policyName { + managedARN = *v.Arn + break + } + } + } + + if managedARN == "" { + return fmt.Errorf("managed policy (%s) not found", policyName) + } + + _, err := conn.AttachGroupPolicy(ctx, &iam.AttachGroupPolicyInput{ + PolicyArn: aws.String(managedARN), + GroupName: group.GroupName, + }) + + return err + } +} + +func testAccGroupPolicyAttachmentsExclusiveConfigBase(rName string) string { + return fmt.Sprintf(` +data "aws_iam_policy_document" "managed" { + statement { + actions = ["sts:GetCallerIdentity"] + resources = ["*"] + } +} + +resource "aws_iam_group" "test" { + name = %[1]q +} + +resource "aws_iam_policy" "test" { + name = %[1]q + policy = data.aws_iam_policy_document.managed.json +} + +resource "aws_iam_group_policy_attachment" "test" { + group = aws_iam_group.test.name + policy_arn = aws_iam_policy.test.arn +} +`, rName) +} + +func testAccGroupPolicyAttachmentsExclusiveConfig_basic(rName string) string { + return acctest.ConfigCompose( + testAccGroupPolicyAttachmentsExclusiveConfigBase(rName), ` +resource "aws_iam_group_policy_attachments_exclusive" "test" { + group_name = aws_iam_group.test.name + policy_arns = [aws_iam_group_policy_attachment.test.policy_arn] +} +`, + ) +} + +func testAccGroupPolicyAttachmentsExclusiveConfig_multiple(rName string) string { + return acctest.ConfigCompose( + testAccGroupPolicyAttachmentsExclusiveConfigBase(rName), + fmt.Sprintf(` +resource "aws_iam_policy" "test2" { + name = "%[1]s-2" + policy = data.aws_iam_policy_document.managed.json +} + +resource "aws_iam_group_policy_attachment" "test2" { + group = aws_iam_group.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_group_policy_attachment" "test3" { + group = aws_iam_group.test.name + policy_arn = aws_iam_policy.test3.arn +} + +resource "aws_iam_group_policy_attachments_exclusive" "test" { + group_name = aws_iam_group.test.name + policy_arns = [ + aws_iam_group_policy_attachment.test.policy_arn, + aws_iam_group_policy_attachment.test2.policy_arn, + aws_iam_group_policy_attachment.test3.policy_arn, + ] +} +`, rName)) +} + +func testAccGroupPolicyAttachmentsExclusiveConfig_empty(rName string) string { + return acctest.ConfigCompose( + testAccGroupPolicyAttachmentsExclusiveConfigBase(rName), ` +resource "aws_iam_group_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_group_policy_attachment.test) + # which the test can check for. + depends_on = [aws_iam_group_policy_attachment.test] + + group_name = aws_iam_group.test.name + policy_arns = [] +} +`, + ) +} + +func testAccGroupPolicyAttachmentsExclusiveConfig_outOfBandAddition(rName, oobPolicyName string) string { + return acctest.ConfigCompose( + testAccGroupPolicyAttachmentsExclusiveConfigBase(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_group_policy_attachments_exclusive" "test" { + group_name = aws_iam_group.test.name + policy_arns = [aws_iam_group_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 4f5fb877e4d7..2d6ef469c67d 100644 --- a/internal/service/iam/service_package_gen.go +++ b/internal/service/iam/service_package_gen.go @@ -24,6 +24,10 @@ func (p *servicePackage) FrameworkResources(ctx context.Context) []*types.Servic Factory: newResourceGroupPoliciesExclusive, Name: "Group Policies Exclusive", }, + { + Factory: newResourceGroupPolicyAttachmentsExclusive, + Name: "Group Policy Attachments Exclusive", + }, { Factory: newResourceRolePoliciesExclusive, Name: "Role Policies Exclusive", diff --git a/website/docs/r/iam_group_policy_attachments_exclusive.html.markdown b/website/docs/r/iam_group_policy_attachments_exclusive.html.markdown new file mode 100644 index 000000000000..eab1a486249e --- /dev/null +++ b/website/docs/r/iam_group_policy_attachments_exclusive.html.markdown @@ -0,0 +1,64 @@ +--- +subcategory: "IAM (Identity & Access Management)" +layout: "aws" +page_title: "AWS: aws_iam_group_policy_attachments_exclusive" +description: |- + Terraform resource for maintaining exclusive management of customer managed policies assigned to an AWS IAM (Identity & Access Management) group. +--- +# Resource: aws_iam_group_policy_attachments_exclusive + +Terraform resource for maintaining exclusive management of customer managed policies assigned to an AWS IAM (Identity & Access Management) group. + +!> This resource takes exclusive ownership over customer managed policies assigned to a group. This includes removal of customer managed policies which are not explicitly configured. To prevent persistent drift, ensure any `aws_iam_group_policy_attachment` resources managed alongside this resource are included in the `policy_arns` argument. + +## Example Usage + +### Basic Usage + +```terraform +resource "aws_iam_group_policy_attachments_exclusive" "example" { + group_name = aws_iam_group.example.name + policy_arns = [aws_iam_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 group 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_group_policy_attachments_exclusive" "example" { + group_name = aws_iam_group.example.name + policy_arns = [] +} +``` + +## Argument Reference + +The following arguments are required: + +* `group_name` - (Required) IAM group name. +* `policy_arns` - (Required) A list of customer managed policy ARNs to be attached to the group. Policies attached to this group 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 `group_name`. For example: + +```terraform +import { + to = aws_iam_group_policy_attachments_exclusive.example + id = "MyGroup" +} +``` + +Using `terraform import`, import exclusive management of customer managed policy assignments using the `group_name`. For example: + +```console +% terraform import aws_iam_group_policy_attachments_exclusive.example MyGroup +```