diff --git a/.changelog/39731.txt b/.changelog/39731.txt new file mode 100644 index 000000000000..63cbd4609d5a --- /dev/null +++ b/.changelog/39731.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +aws_iam_user_policy_attachments_exclusive +``` diff --git a/internal/service/iam/exports_test.go b/internal/service/iam/exports_test.go index 2b9cd7d3ec36..6d7a9a921f41 100644 --- a/internal/service/iam/exports_test.go +++ b/internal/service/iam/exports_test.go @@ -52,6 +52,7 @@ var ( FindSSHPublicKeyByThreePartKey = findSSHPublicKeyByThreePartKey FindUserByName = findUserByName FindUserPoliciesByName = findUserPoliciesByName + FindUserPolicyAttachmentsByName = findUserPolicyAttachmentsByName FindVirtualMFADeviceBySerialNumber = findVirtualMFADeviceBySerialNumber SESSMTPPasswordFromSecretKeySigV4 = sesSMTPPasswordFromSecretKeySigV4 ) diff --git a/internal/service/iam/service_package_gen.go b/internal/service/iam/service_package_gen.go index 95f76ea60bde..ff8813b83faf 100644 --- a/internal/service/iam/service_package_gen.go +++ b/internal/service/iam/service_package_gen.go @@ -36,6 +36,10 @@ func (p *servicePackage) FrameworkResources(ctx context.Context) []*types.Servic Factory: newResourceUserPoliciesExclusive, Name: "User Policies Exclusive", }, + { + Factory: newResourceUserPolicyAttachmentsExclusive, + Name: "User Policy Attachments Exclusive", + }, } } diff --git a/internal/service/iam/user_policy_attachments_exclusive.go b/internal/service/iam/user_policy_attachments_exclusive.go new file mode 100644 index 000000000000..3c2f3dd93938 --- /dev/null +++ b/internal/service/iam/user_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_user_policy_attachments_exclusive", name="User Policy Attachments Exclusive") +func newResourceUserPolicyAttachmentsExclusive(_ context.Context) (resource.ResourceWithConfigure, error) { + return &resourceUserPolicyAttachmentsExclusive{}, nil +} + +const ( + ResNameUserPolicyAttachmentsExclusive = "User Policy Attachments Exclusive" +) + +type resourceUserPolicyAttachmentsExclusive struct { + framework.ResourceWithConfigure + framework.WithNoOpDelete +} + +func (r *resourceUserPolicyAttachmentsExclusive) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "aws_iam_user_policy_attachments_exclusive" +} + +func (r *resourceUserPolicyAttachmentsExclusive) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + names.AttrUserName: schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "policy_arns": schema.SetAttribute{ + ElementType: types.StringType, + Required: true, + }, + }, + } +} + +func (r *resourceUserPolicyAttachmentsExclusive) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan resourceUserPolicyAttachmentsExclusiveData + 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.UserName.ValueString(), policyARNs) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.IAM, create.ErrActionCreating, ResNameUserPolicyAttachmentsExclusive, plan.UserName.String(), err), + err.Error(), + ) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} + +func (r *resourceUserPolicyAttachmentsExclusive) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + conn := r.Meta().IAMClient(ctx) + + var state resourceUserPolicyAttachmentsExclusiveData + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + out, err := findUserPolicyAttachmentsByName(ctx, conn, state.UserName.ValueString()) + if tfresource.NotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.IAM, create.ErrActionReading, ResNameUserPolicyAttachmentsExclusive, state.UserName.String(), err), + err.Error(), + ) + return + } + + state.PolicyARNs = flex.FlattenFrameworkStringValueSetLegacy(ctx, out) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *resourceUserPolicyAttachmentsExclusive) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state resourceUserPolicyAttachmentsExclusiveData + 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.UserName.ValueString(), policyARNs) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.IAM, create.ErrActionUpdating, ResNameUserPolicyAttachmentsExclusive, plan.UserName.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 user will be added. Policies attached to the user but not configured +// on this resource will be removed. +func (r *resourceUserPolicyAttachmentsExclusive) syncAttachments(ctx context.Context, userName string, want []string) error { + conn := r.Meta().IAMClient(ctx) + + have, err := findUserPolicyAttachmentsByName(ctx, conn, userName) + if err != nil { + return err + } + + create, remove, _ := intflex.DiffSlices(have, want, func(s1, s2 string) bool { return s1 == s2 }) + + for _, arn := range create { + err := attachPolicyToUser(ctx, conn, userName, arn) + if err != nil { + return err + } + } + + for _, arn := range remove { + err := detachPolicyFromUser(ctx, conn, userName, arn) + if err != nil { + return err + } + } + + return nil +} + +func (r *resourceUserPolicyAttachmentsExclusive) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root(names.AttrUserName), req, resp) +} + +func findUserPolicyAttachmentsByName(ctx context.Context, conn *iam.Client, userName string) ([]string, error) { + in := &iam.ListAttachedUserPoliciesInput{ + UserName: aws.String(userName), + } + + var policyARNs []string + paginator := iam.NewListAttachedUserPoliciesPaginator(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 resourceUserPolicyAttachmentsExclusiveData struct { + UserName types.String `tfsdk:"user_name"` + PolicyARNs types.Set `tfsdk:"policy_arns"` +} diff --git a/internal/service/iam/user_policy_attachments_exclusive_test.go b/internal/service/iam/user_policy_attachments_exclusive_test.go new file mode 100644 index 000000000000..1e758e01a6a6 --- /dev/null +++ b/internal/service/iam/user_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 TestAccIAMUserPolicyAttachmentsExclusive_basic(t *testing.T) { + ctx := acctest.Context(t) + + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_user_policy_attachments_exclusive.test" + userResourceName := "aws_iam_user.test" + attachmentResourceName := "aws_iam_user_policy_attachment.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckUserPolicyAttachmentsExclusiveDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccUserPolicyAttachmentsExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckUserPolicyAttachmentExists(ctx, attachmentResourceName), + testAccCheckUserPolicyAttachmentCount(ctx, rName, 1), + testAccCheckUserPolicyAttachmentsExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, names.AttrUserName, userResourceName, names.AttrName), + resource.TestCheckTypeSetElemAttrPair(resourceName, "policy_arns.*", attachmentResourceName, "policy_arn"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateIdFunc: testAccUserPolicyAttachmentsExclusiveImportStateIdFunc(resourceName), + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: names.AttrUserName, + }, + }, + }) +} + +func TestAccIAMUserPolicyAttachmentsExclusive_disappears_User(t *testing.T) { + ctx := acctest.Context(t) + + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_user_policy_attachments_exclusive.test" + userResourceName := "aws_iam_user.test" + attachmentResourceName := "aws_iam_user_policy_attachment.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckUserPolicyAttachmentsExclusiveDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccUserPolicyAttachmentsExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckUserPolicyAttachmentExists(ctx, attachmentResourceName), + testAccCheckUserPolicyAttachmentCount(ctx, rName, 1), + testAccCheckUserPolicyAttachmentsExclusiveExists(ctx, resourceName), + // Managed policies must be detached before user can be deleted + acctest.CheckResourceDisappears(ctx, acctest.Provider, tfiam.ResourceUserPolicyAttachment(), attachmentResourceName), + acctest.CheckResourceDisappears(ctx, acctest.Provider, tfiam.ResourceUser(), userResourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func TestAccIAMUserPolicyAttachmentsExclusive_disappears_Policy(t *testing.T) { + ctx := acctest.Context(t) + + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_user_policy_attachments_exclusive.test" + policyResourceName := "aws_iam_policy.test" + attachmentResourceName := "aws_iam_user_policy_attachment.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckUserPolicyAttachmentsExclusiveDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccUserPolicyAttachmentsExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckUserPolicyAttachmentExists(ctx, attachmentResourceName), + testAccCheckUserPolicyAttachmentCount(ctx, rName, 1), + testAccCheckUserPolicyAttachmentsExclusiveExists(ctx, resourceName), + // Managed policy must be detached before it can be deleted + acctest.CheckResourceDisappears(ctx, acctest.Provider, tfiam.ResourceUserPolicyAttachment(), attachmentResourceName), + acctest.CheckResourceDisappears(ctx, acctest.Provider, tfiam.ResourcePolicy(), policyResourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func TestAccIAMUserPolicyAttachmentsExclusive_multiple(t *testing.T) { + ctx := acctest.Context(t) + + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_user_policy_attachments_exclusive.test" + userResourceName := "aws_iam_user.test" + attachmentResourceName := "aws_iam_user_policy_attachment.test" + attachmentResourceName2 := "aws_iam_user_policy_attachment.test2" + attachmentResourceName3 := "aws_iam_user_policy_attachment.test3" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckUserPolicyAttachmentsExclusiveDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccUserPolicyAttachmentsExclusiveConfig_multiple(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckUserPolicyAttachmentExists(ctx, attachmentResourceName), + testAccCheckUserPolicyAttachmentExists(ctx, attachmentResourceName2), + testAccCheckUserPolicyAttachmentExists(ctx, attachmentResourceName3), + testAccCheckUserPolicyAttachmentCount(ctx, rName, 3), + testAccCheckUserPolicyAttachmentsExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, names.AttrUserName, userResourceName, 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: testAccUserPolicyAttachmentsExclusiveImportStateIdFunc(resourceName), + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: names.AttrUserName, + }, + { + Config: testAccUserPolicyAttachmentsExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckUserPolicyAttachmentExists(ctx, attachmentResourceName), + testAccCheckUserPolicyAttachmentCount(ctx, rName, 1), + testAccCheckUserPolicyAttachmentsExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, names.AttrUserName, userResourceName, names.AttrName), + resource.TestCheckTypeSetElemAttrPair(resourceName, "policy_arns.*", attachmentResourceName, "policy_arn"), + ), + }, + }, + }) +} + +func TestAccIAMUserPolicyAttachmentsExclusive_empty(t *testing.T) { + ctx := acctest.Context(t) + + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_user_policy_attachments_exclusive.test" + userResourceName := "aws_iam_user.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckUserPolicyAttachmentsExclusiveDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccUserPolicyAttachmentsExclusiveConfig_empty(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckUserPolicyAttachmentsExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, names.AttrUserName, userResourceName, 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 TestAccIAMUserPolicyAttachmentsExclusive_outOfBandRemoval(t *testing.T) { + ctx := acctest.Context(t) + + var user types.User + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_user_policy_attachments_exclusive.test" + userResourceName := "aws_iam_user.test" + attachmentResourceName := "aws_iam_user_policy_attachment.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckUserDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccUserPolicyAttachmentsExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckUserExists(ctx, userResourceName, &user), + testAccCheckUserPolicyAttachmentExists(ctx, attachmentResourceName), + testAccCheckUserPolicyAttachmentCount(ctx, rName, 1), + testAccCheckUserPolicyAttachmentsExclusiveExists(ctx, resourceName), + testAccCheckUserPolicyDetachManagedPolicy(ctx, &user, rName), + ), + ExpectNonEmptyPlan: true, + }, + { + Config: testAccUserPolicyAttachmentsExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckUserExists(ctx, userResourceName, &user), + testAccCheckUserPolicyAttachmentExists(ctx, attachmentResourceName), + testAccCheckUserPolicyAttachmentCount(ctx, rName, 1), + testAccCheckUserPolicyAttachmentsExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, names.AttrUserName, userResourceName, names.AttrName), + resource.TestCheckResourceAttr(resourceName, "policy_arns.#", acctest.Ct1), + ), + }, + }, + }) +} + +// A managed policy added out of band should be removed +func TestAccIAMUserPolicyAttachmentsExclusive_outOfBandAddition(t *testing.T) { + ctx := acctest.Context(t) + + var user types.User + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + oobPolicyName := rName + "-out-of-band" + resourceName := "aws_iam_user_policy_attachments_exclusive.test" + userResourceName := "aws_iam_user.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckUserDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccUserPolicyAttachmentsExclusiveConfig_outOfBandAddition(rName, oobPolicyName), + Check: resource.ComposeTestCheckFunc( + testAccCheckUserExists(ctx, userResourceName, &user), + testAccCheckUserPolicyAttachmentsExclusiveExists(ctx, resourceName), + testAccCheckUserPolicyAttachManagedPolicy(ctx, &user, oobPolicyName), + ), + ExpectNonEmptyPlan: true, + }, + { + Config: testAccUserPolicyAttachmentsExclusiveConfig_outOfBandAddition(rName, oobPolicyName), + Check: resource.ComposeTestCheckFunc( + testAccCheckUserExists(ctx, userResourceName, &user), + testAccCheckUserPolicyAttachmentsExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, names.AttrUserName, userResourceName, names.AttrName), + resource.TestCheckResourceAttr(resourceName, "policy_arns.#", acctest.Ct1), + ), + }, + }, + }) +} + +func testAccCheckUserPolicyAttachmentsExclusiveDestroy(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_user_policy_attachments_exclusive" { + continue + } + + userName := rs.Primary.Attributes[names.AttrUserName] + _, err := tfiam.FindUserPolicyAttachmentsByName(ctx, conn, userName) + if errs.IsA[*types.NoSuchEntityException](err) { + return nil + } + if err != nil { + return create.Error(names.IAM, create.ErrActionCheckingDestroyed, tfiam.ResNameUserPolicyAttachmentsExclusive, userName, err) + } + + return create.Error(names.IAM, create.ErrActionCheckingDestroyed, tfiam.ResNameUserPolicyAttachmentsExclusive, userName, errors.New("not destroyed")) + } + + return nil + } +} + +func testAccCheckUserPolicyAttachmentsExclusiveExists(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.ResNameUserPolicyAttachmentsExclusive, name, errors.New("not found")) + } + + userName := rs.Primary.Attributes[names.AttrUserName] + if userName == "" { + return create.Error(names.IAM, create.ErrActionCheckingExistence, tfiam.ResNameUserPolicyAttachmentsExclusive, name, errors.New("not set")) + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).IAMClient(ctx) + out, err := tfiam.FindUserPolicyAttachmentsByName(ctx, conn, userName) + if err != nil { + return create.Error(names.IAM, create.ErrActionCheckingExistence, tfiam.ResNameUserPolicyAttachmentsExclusive, userName, err) + } + + policyCount := rs.Primary.Attributes["policy_arns.#"] + if policyCount != fmt.Sprint(len(out)) { + return create.Error(names.IAM, create.ErrActionCheckingExistence, tfiam.ResNameUserPolicyAttachmentsExclusive, userName, errors.New("unexpected policy_arns count")) + } + + return nil + } +} + +func testAccUserPolicyAttachmentsExclusiveImportStateIdFunc(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.AttrUserName], nil + } +} + +func testAccCheckUserPolicyDetachManagedPolicy(ctx context.Context, user *types.User, policyName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.AWSClient).IAMClient(ctx) + + var managedARN string + input := &iam.ListAttachedUserPoliciesInput{ + UserName: user.UserName, + } + + pages := iam.NewListAttachedUserPoliciesPaginator(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.DetachUserPolicy(ctx, &iam.DetachUserPolicyInput{ + PolicyArn: aws.String(managedARN), + UserName: user.UserName, + }) + + return err + } +} + +func testAccCheckUserPolicyAttachManagedPolicy(ctx context.Context, user *types.User, 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.AttachUserPolicy(ctx, &iam.AttachUserPolicyInput{ + PolicyArn: aws.String(managedARN), + UserName: user.UserName, + }) + + return err + } +} + +func testAccUserPolicyAttachmentsExclusiveConfigBase(rName string) string { + return fmt.Sprintf(` +data "aws_iam_policy_document" "managed" { + statement { + actions = ["sts:GetCallerIdentity"] + resources = ["*"] + } +} + +resource "aws_iam_user" "test" { + name = %[1]q +} + +resource "aws_iam_policy" "test" { + name = %[1]q + policy = data.aws_iam_policy_document.managed.json +} + +resource "aws_iam_user_policy_attachment" "test" { + user = aws_iam_user.test.name + policy_arn = aws_iam_policy.test.arn +} +`, rName) +} + +func testAccUserPolicyAttachmentsExclusiveConfig_basic(rName string) string { + return acctest.ConfigCompose( + testAccUserPolicyAttachmentsExclusiveConfigBase(rName), + ` +resource "aws_iam_user_policy_attachments_exclusive" "test" { + user_name = aws_iam_user.test.name + policy_arns = [aws_iam_user_policy_attachment.test.policy_arn] +} +`) +} + +func testAccUserPolicyAttachmentsExclusiveConfig_multiple(rName string) string { + return acctest.ConfigCompose( + testAccUserPolicyAttachmentsExclusiveConfigBase(rName), + fmt.Sprintf(` +resource "aws_iam_policy" "test2" { + name = "%[1]s-2" + policy = data.aws_iam_policy_document.managed.json +} + +resource "aws_iam_user_policy_attachment" "test2" { + user = aws_iam_user.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_user_policy_attachment" "test3" { + user = aws_iam_user.test.name + policy_arn = aws_iam_policy.test3.arn +} + +resource "aws_iam_user_policy_attachments_exclusive" "test" { + user_name = aws_iam_user.test.name + policy_arns = [ + aws_iam_user_policy_attachment.test.policy_arn, + aws_iam_user_policy_attachment.test2.policy_arn, + aws_iam_user_policy_attachment.test3.policy_arn, + ] +} +`, rName)) +} + +func testAccUserPolicyAttachmentsExclusiveConfig_empty(rName string) string { + return acctest.ConfigCompose( + testAccUserPolicyAttachmentsExclusiveConfigBase(rName), + ` +resource "aws_iam_user_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_user_policy_attachment.test) + # which the test can check for. + depends_on = [aws_iam_user_policy_attachment.test] + + user_name = aws_iam_user.test.name + policy_arns = [] +} +`) +} + +func testAccUserPolicyAttachmentsExclusiveConfig_outOfBandAddition(rName, oobPolicyName string) string { + return acctest.ConfigCompose( + testAccUserPolicyAttachmentsExclusiveConfigBase(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_user_policy_attachments_exclusive" "test" { + user_name = aws_iam_user.test.name + policy_arns = [aws_iam_user_policy_attachment.test.policy_arn] +} +`, oobPolicyName)) +} diff --git a/website/docs/r/iam_role_policy_attachments_exclusive.html.markdown b/website/docs/r/iam_role_policy_attachments_exclusive.html.markdown index 9b8c3bc36565..52e894d85483 100644 --- a/website/docs/r/iam_role_policy_attachments_exclusive.html.markdown +++ b/website/docs/r/iam_role_policy_attachments_exclusive.html.markdown @@ -18,7 +18,7 @@ Terraform resource for maintaining exclusive management of customer managed poli ```terraform resource "aws_iam_role_policy_attachments_exclusive" "example" { role_name = aws_iam_role.example.name - policy_arns = [aws_iam_role_policy.example.arn] + policy_arns = [aws_iam_policy.example.arn] } ``` diff --git a/website/docs/r/iam_user_policy_attachments_exclusive.html.markdown b/website/docs/r/iam_user_policy_attachments_exclusive.html.markdown new file mode 100644 index 000000000000..029eb5b04392 --- /dev/null +++ b/website/docs/r/iam_user_policy_attachments_exclusive.html.markdown @@ -0,0 +1,64 @@ +--- +subcategory: "IAM (Identity & Access Management)" +layout: "aws" +page_title: "AWS: aws_iam_user_policy_attachments_exclusive" +description: |- + Terraform resource for maintaining exclusive management of customer managed policies assigned to an AWS IAM (Identity & Access Management) user. +--- +# Resource: aws_iam_user_policy_attachments_exclusive + +Terraform resource for maintaining exclusive management of customer managed policies assigned to an AWS IAM (Identity & Access Management) user. + +!> This resource takes exclusive ownership over customer managed policies assigned to a user. This includes removal of customer managed policies which are not explicitly configured. To prevent persistent drift, ensure any `aws_iam_user_policy_attachment` resources managed alongside this resource are included in the `policy_arns` argument. + +## Example Usage + +### Basic Usage + +```terraform +resource "aws_iam_user_policy_attachments_exclusive" "example" { + user_name = aws_iam_user.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 user 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_user_policy_attachments_exclusive" "example" { + user_name = aws_iam_user.example.name + policy_arns = [] +} +``` + +## Argument Reference + +The following arguments are required: + +* `user_name` - (Required) IAM user name. +* `policy_arns` - (Required) A list of customer managed policy ARNs to be attached to the user. Policies attached to this user 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 `user_name`. For example: + +```terraform +import { + to = aws_iam_user_policy_attachments_exclusive.example + id = "MyUser" +} +``` + +Using `terraform import`, import exclusive management of customer managed policy assignments using the `user_name`. For example: + +```console +% terraform import aws_iam_user_policy_attachments_exclusive.example MyUser +```