diff --git a/.changelog/39203.txt b/.changelog/39203.txt new file mode 100644 index 00000000000..b2090c91b61 --- /dev/null +++ b/.changelog/39203.txt @@ -0,0 +1,6 @@ +```release-note:new-resource +aws_iam_role_policies_exclusive +``` +```release-note:note +resource/aws_iam_role: The `inline_policy` argument is deprecated. Use the `aws_iam_role_policy` resource instead. If Terraform should exclusively manage all inline policy associations (the current behavior of this argument), use the `aws_iam_role_policies_exclusive` resource as well. +``` diff --git a/internal/service/iam/exports_test.go b/internal/service/iam/exports_test.go index 85e0bd74d9e..d803c877629 100644 --- a/internal/service/iam/exports_test.go +++ b/internal/service/iam/exports_test.go @@ -44,6 +44,7 @@ var ( FindInstanceProfileByName = findInstanceProfileByName FindOpenIDConnectProviderByARN = findOpenIDConnectProviderByARN FindPolicyByARN = findPolicyByARN + FindRolePoliciesByName = findRolePoliciesByName FindSAMLProviderByARN = findSAMLProviderByARN FindServerCertificateByName = findServerCertificateByName FindSSHPublicKeyByThreePartKey = findSSHPublicKeyByThreePartKey diff --git a/internal/service/iam/role.go b/internal/service/iam/role.go index 48815b2d8cb..4b58c37efee 100644 --- a/internal/service/iam/role.go +++ b/internal/service/iam/role.go @@ -88,9 +88,10 @@ func resourceRole() *schema.Resource { Default: false, }, "inline_policy": { - Type: schema.TypeSet, - Optional: true, - Computed: true, + Type: schema.TypeSet, + Optional: true, + Computed: true, + Deprecated: "Use the aws_iam_role_policy resource instead. If Terraform should exclusively manage all inline policy associations (the current behavior of this argument), use the aws_iam_role_policies_exclusive resource as well.", Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ names.AttrName: { diff --git a/internal/service/iam/role_policies_exclusive.go b/internal/service/iam/role_policies_exclusive.go new file mode 100644 index 00000000000..120b07d0848 --- /dev/null +++ b/internal/service/iam/role_policies_exclusive.go @@ -0,0 +1,217 @@ +// 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_policies_exclusive", name="Role Policies Exclusive") +func newResourceRolePoliciesExclusive(_ context.Context) (resource.ResourceWithConfigure, error) { + return &resourceRolePoliciesExclusive{}, nil +} + +const ( + ResNameRolePoliciesExclusive = "Role Policies Exclusive" +) + +type resourceRolePoliciesExclusive struct { + framework.ResourceWithConfigure + framework.WithNoOpDelete +} + +func (r *resourceRolePoliciesExclusive) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "aws_iam_role_policies_exclusive" +} + +func (r *resourceRolePoliciesExclusive) 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_names": schema.SetAttribute{ + ElementType: types.StringType, + Required: true, + }, + }, + } +} + +func (r *resourceRolePoliciesExclusive) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan resourceRolePoliciesExclusiveData + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + var policyNames []string + resp.Diagnostics.Append(plan.PolicyNames.ElementsAs(ctx, &policyNames, false)...) + if resp.Diagnostics.HasError() { + return + } + + err := r.syncAttachments(ctx, plan.RoleName.ValueString(), policyNames) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.IAM, create.ErrActionCreating, ResNameRolePoliciesExclusive, plan.RoleName.String(), err), + err.Error(), + ) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} + +func (r *resourceRolePoliciesExclusive) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + conn := r.Meta().IAMClient(ctx) + + var state resourceRolePoliciesExclusiveData + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + out, err := findRolePoliciesByName(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, ResNameRolePoliciesExclusive, state.RoleName.String(), err), + err.Error(), + ) + return + } + + state.PolicyNames = flex.FlattenFrameworkStringValueSetLegacy(ctx, out) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *resourceRolePoliciesExclusive) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state resourceRolePoliciesExclusiveData + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if !plan.PolicyNames.Equal(state.PolicyNames) { + var policyNames []string + resp.Diagnostics.Append(plan.PolicyNames.ElementsAs(ctx, &policyNames, false)...) + if resp.Diagnostics.HasError() { + return + } + + err := r.syncAttachments(ctx, plan.RoleName.ValueString(), policyNames) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.IAM, create.ErrActionUpdating, ResNameRolePoliciesExclusive, plan.RoleName.String(), err), + err.Error(), + ) + return + } + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +// syncAttachments handles keeping the configured inline policy attachments +// in sync with the remote resource. +// +// Inline 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 *resourceRolePoliciesExclusive) syncAttachments(ctx context.Context, roleName string, want []string) error { + conn := r.Meta().IAMClient(ctx) + + have, err := findRolePoliciesByName(ctx, conn, roleName) + if err != nil { + return err + } + + create, remove, _ := intflex.DiffSlices(have, want, func(s1, s2 string) bool { return s1 == s2 }) + + for _, name := range create { + in := &iam.PutRolePolicyInput{ + RoleName: aws.String(roleName), + PolicyName: aws.String(name), + } + + _, err := conn.PutRolePolicy(ctx, in) + if err != nil { + return err + } + } + + for _, name := range remove { + in := &iam.DeleteRolePolicyInput{ + RoleName: aws.String(roleName), + PolicyName: aws.String(name), + } + + _, err := conn.DeleteRolePolicy(ctx, in) + if err != nil { + return err + } + } + + return nil +} + +func (r *resourceRolePoliciesExclusive) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("role_name"), req, resp) +} + +func findRolePoliciesByName(ctx context.Context, conn *iam.Client, roleName string) ([]string, error) { + in := &iam.ListRolePoliciesInput{ + RoleName: aws.String(roleName), + } + + var policyNames []string + paginator := iam.NewListRolePoliciesPaginator(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 policyNames, err + } + + policyNames = append(policyNames, page.PolicyNames...) + } + + return policyNames, nil +} + +type resourceRolePoliciesExclusiveData struct { + RoleName types.String `tfsdk:"role_name"` + PolicyNames types.Set `tfsdk:"policy_names"` +} diff --git a/internal/service/iam/role_policies_exclusive_test.go b/internal/service/iam/role_policies_exclusive_test.go new file mode 100644 index 00000000000..a76b6512e8d --- /dev/null +++ b/internal/service/iam/role_policies_exclusive_test.go @@ -0,0 +1,405 @@ +// 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 TestAccIAMRolePoliciesExclusive_basic(t *testing.T) { + ctx := acctest.Context(t) + + var role types.Role + var rolePolicy string + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_role_policies_exclusive.test" + roleResourceName := "aws_iam_role.test" + rolePolicyResourceName := "aws_iam_role_policy.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckRolePoliciesExclusiveDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccRolePoliciesExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckRoleExists(ctx, roleResourceName, &role), + testAccCheckRolePolicyExists(ctx, rolePolicyResourceName, &rolePolicy), + testAccCheckRolePoliciesExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, "role_name", roleResourceName, names.AttrName), + resource.TestCheckTypeSetElemAttrPair(resourceName, "policy_names.*", rolePolicyResourceName, names.AttrName), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateIdFunc: testAccRolePoliciesExclusiveImportStateIdFunc(resourceName), + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: "role_name", + }, + }, + }) +} + +func TestAccIAMRolePoliciesExclusive_disappears_Role(t *testing.T) { + ctx := acctest.Context(t) + + var role types.Role + var rolePolicy string + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_role_policies_exclusive.test" + roleResourceName := "aws_iam_role.test" + rolePolicyResourceName := "aws_iam_role_policy.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckRolePoliciesExclusiveDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccRolePoliciesExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckRoleExists(ctx, roleResourceName, &role), + testAccCheckRolePolicyExists(ctx, rolePolicyResourceName, &rolePolicy), + testAccCheckRolePoliciesExclusiveExists(ctx, resourceName), + // Inline policy must be deleted before the role can be + acctest.CheckResourceDisappears(ctx, acctest.Provider, tfiam.ResourceRolePolicy(), rolePolicyResourceName), + acctest.CheckResourceDisappears(ctx, acctest.Provider, tfiam.ResourceRole(), roleResourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func TestAccIAMRolePoliciesExclusive_multiple(t *testing.T) { + ctx := acctest.Context(t) + + var role types.Role + var rolePolicy string + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_role_policies_exclusive.test" + roleResourceName := "aws_iam_role.test" + rolePolicyResourceName := "aws_iam_role_policy.test" + rolePolicyResourceName2 := "aws_iam_role_policy.test2" + rolePolicyResourceName3 := "aws_iam_role_policy.test3" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckRolePoliciesExclusiveDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccRolePoliciesExclusiveConfig_multiple(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckRoleExists(ctx, roleResourceName, &role), + testAccCheckRolePolicyExists(ctx, rolePolicyResourceName, &rolePolicy), + testAccCheckRolePoliciesExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, "role_name", roleResourceName, names.AttrName), + resource.TestCheckTypeSetElemAttrPair(resourceName, "policy_names.*", rolePolicyResourceName, names.AttrName), + resource.TestCheckTypeSetElemAttrPair(resourceName, "policy_names.*", rolePolicyResourceName2, names.AttrName), + resource.TestCheckTypeSetElemAttrPair(resourceName, "policy_names.*", rolePolicyResourceName3, names.AttrName), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateIdFunc: testAccRolePoliciesExclusiveImportStateIdFunc(resourceName), + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: "role_name", + }, + { + Config: testAccRolePoliciesExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckRoleExists(ctx, roleResourceName, &role), + testAccCheckRolePolicyExists(ctx, rolePolicyResourceName, &rolePolicy), + testAccCheckRolePoliciesExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, "role_name", roleResourceName, names.AttrName), + resource.TestCheckTypeSetElemAttrPair(resourceName, "policy_names.*", rolePolicyResourceName, names.AttrName), + ), + }, + }, + }) +} + +func TestAccIAMRolePoliciesExclusive_empty(t *testing.T) { + ctx := acctest.Context(t) + + var role types.Role + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_role_policies_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: testAccCheckRolePoliciesExclusiveDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccRolePoliciesExclusiveConfig_empty(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckRoleExists(ctx, roleResourceName, &role), + testAccCheckRolePoliciesExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, "role_name", roleResourceName, names.AttrName), + resource.TestCheckResourceAttr(resourceName, "policy_names.#", acctest.Ct0), + ), + // The empty `policy_names` argument in the exclusive lock will remove the + // inline policy defined in this configuration, so a diff is expected + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +// An inline policy removed out of band should be recreated +func TestAccIAMRolePoliciesExclusive_outOfBandRemoval(t *testing.T) { + ctx := acctest.Context(t) + + var role types.Role + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_iam_role_policies_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: testAccRolePoliciesExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckRoleExists(ctx, roleResourceName, &role), + testAccCheckRolePoliciesExclusiveExists(ctx, resourceName), + testAccCheckRolePolicyRemoveInlinePolicy(ctx, &role, rName), + ), + ExpectNonEmptyPlan: true, + }, + { + Config: testAccRolePoliciesExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckRoleExists(ctx, roleResourceName, &role), + testAccCheckRolePoliciesExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, "role_name", roleResourceName, names.AttrName), + resource.TestCheckResourceAttr(resourceName, "policy_names.#", acctest.Ct1), + ), + }, + }, + }) +} + +// An inline policy added out of band should be removed +func TestAccIAMRolePoliciesExclusive_outOfBandAddition(t *testing.T) { + ctx := acctest.Context(t) + + var role types.Role + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + policyName := rName + "-out-of-band" + resourceName := "aws_iam_role_policies_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: testAccRolePoliciesExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckRoleExists(ctx, roleResourceName, &role), + testAccCheckRolePoliciesExclusiveExists(ctx, resourceName), + testAccCheckRolePolicyAddInlinePolicy(ctx, &role, policyName), + ), + ExpectNonEmptyPlan: true, + }, + { + Config: testAccRolePoliciesExclusiveConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckRoleExists(ctx, roleResourceName, &role), + testAccCheckRolePoliciesExclusiveExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, "role_name", roleResourceName, names.AttrName), + resource.TestCheckResourceAttr(resourceName, "policy_names.#", acctest.Ct1), + ), + }, + }, + }) +} + +func testAccCheckRolePoliciesExclusiveDestroy(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_policies_exclusive" { + continue + } + + roleName := rs.Primary.Attributes["role_name"] + _, err := tfiam.FindRolePoliciesByName(ctx, conn, roleName) + if errs.IsA[*types.NoSuchEntityException](err) { + return nil + } + if err != nil { + return create.Error(names.IAM, create.ErrActionCheckingDestroyed, tfiam.ResNameRolePoliciesExclusive, rs.Primary.ID, err) + } + + return create.Error(names.IAM, create.ErrActionCheckingDestroyed, tfiam.ResNameRolePoliciesExclusive, rs.Primary.ID, errors.New("not destroyed")) + } + + return nil + } +} + +func testAccCheckRolePoliciesExclusiveExists(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.ResNameRolePoliciesExclusive, name, errors.New("not found")) + } + + roleName := rs.Primary.Attributes["role_name"] + if roleName == "" { + return create.Error(names.IAM, create.ErrActionCheckingExistence, tfiam.ResNameRolePoliciesExclusive, name, errors.New("not set")) + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).IAMClient(ctx) + out, err := tfiam.FindRolePoliciesByName(ctx, conn, roleName) + if err != nil { + return create.Error(names.IAM, create.ErrActionCheckingExistence, tfiam.ResNameRolePoliciesExclusive, roleName, err) + } + + policyCount := rs.Primary.Attributes["policy_names.#"] + if policyCount != fmt.Sprint(len(out)) { + return create.Error(names.IAM, create.ErrActionCheckingExistence, tfiam.ResNameRolePoliciesExclusive, roleName, errors.New("unexpected policy_names count")) + } + + return nil + } +} + +func testAccRolePoliciesExclusiveImportStateIdFunc(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 testAccRolePoliciesExclusiveConfigBase(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" "inline" { + 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_role_policy" "test" { + name = %[1]q + role = aws_iam_role.test.name + policy = data.aws_iam_policy_document.inline.json +} +`, rName) +} + +func testAccRolePoliciesExclusiveConfig_basic(rName string) string { + return acctest.ConfigCompose( + testAccRolePoliciesExclusiveConfigBase(rName), + ` +resource "aws_iam_role_policies_exclusive" "test" { + role_name = aws_iam_role.test.name + policy_names = [aws_iam_role_policy.test.name] +} +`) +} + +func testAccRolePoliciesExclusiveConfig_multiple(rName string) string { + return acctest.ConfigCompose( + testAccRolePoliciesExclusiveConfigBase(rName), + fmt.Sprintf(` +resource "aws_iam_role_policy" "test2" { + name = "%[1]s-2" + role = aws_iam_role.test.name + policy = data.aws_iam_policy_document.inline.json +} + +resource "aws_iam_role_policy" "test3" { + name = "%[1]s-3" + role = aws_iam_role.test.name + policy = data.aws_iam_policy_document.inline.json +} + +resource "aws_iam_role_policies_exclusive" "test" { + role_name = aws_iam_role.test.name + policy_names = [ + aws_iam_role_policy.test.name, + aws_iam_role_policy.test2.name, + aws_iam_role_policy.test3.name, + ] +} +`, rName)) +} + +func testAccRolePoliciesExclusiveConfig_empty(rName string) string { + return acctest.ConfigCompose( + testAccRolePoliciesExclusiveConfigBase(rName), + ` +resource "aws_iam_role_policies_exclusive" "test" { + # Wait until the inline policy is created, 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.test) + # which the test can check for. + depends_on = [aws_iam_role_policy.test] + + role_name = aws_iam_role.test.name + policy_names = [] +} +`) +} diff --git a/internal/service/iam/service_package_gen.go b/internal/service/iam/service_package_gen.go index f199832ce96..b1bf861b7b8 100644 --- a/internal/service/iam/service_package_gen.go +++ b/internal/service/iam/service_package_gen.go @@ -19,7 +19,12 @@ func (p *servicePackage) FrameworkDataSources(ctx context.Context) []*types.Serv } func (p *servicePackage) FrameworkResources(ctx context.Context) []*types.ServicePackageFrameworkResource { - return []*types.ServicePackageFrameworkResource{} + return []*types.ServicePackageFrameworkResource{ + { + Factory: newResourceRolePoliciesExclusive, + Name: "Role Policies Exclusive", + }, + } } func (p *servicePackage) SDKDataSources(ctx context.Context) []*types.ServicePackageSDKDataSource { diff --git a/website/docs/r/iam_role_policies_exclusive.html.markdown b/website/docs/r/iam_role_policies_exclusive.html.markdown new file mode 100644 index 00000000000..3e5ebe42166 --- /dev/null +++ b/website/docs/r/iam_role_policies_exclusive.html.markdown @@ -0,0 +1,64 @@ +--- +subcategory: "IAM (Identity & Access Management)" +layout: "aws" +page_title: "AWS: aws_iam_role_policies_exclusive" +description: |- + Terraform resource for maintaining exclusive management of inline policies assigned to an AWS IAM (Identity & Access Management) role. +--- +# Resource: aws_iam_role_policies_exclusive + +Terraform resource for maintaining exclusive management of inline policies assigned to an AWS IAM (Identity & Access Management) role. + +!> This resource takes exclusive ownership over inline policies assigned to a role. This includes removal of inline policies which are not explicitly configured. To prevent persistent drift, ensure any `aws_iam_role_policy` resources managed alongside this resource are included in the `policy_names` argument. + +## Example Usage + +### Basic Usage + +```terraform +resource "aws_iam_role_policies_exclusive" "example" { + role_name = aws_iam_role.example.name + policy_names = [aws_iam_role_policy.example.name] +} +``` + +### Disallow Inline Policies + +To automatically remove any configured inline policies, set the `policy_names` argument to an empty list. + +~> This will not __prevent__ inline policies from being assigned to a role via Terraform (or any other interface). This resource enables bringing inline policy assignments into a configured state, however, this reconciliation happens only when `apply` is proactively run. + +```terraform +resource "aws_iam_role_policies_exclusive" "example" { + role_name = aws_iam_role.example.name + policy_names = [] +} +``` + +## Argument Reference + +The following arguments are required: + +* `role_name` - (Required) IAM role name. +* `policy_names` - (Required) A list of inline policy names to be assigned 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 inline policy assignments using the `role_name`. For example: + +```terraform +import { + to = aws_iam_role_policies_exclusive.example + id = "MyRole" +} +``` + +Using `terraform import`, import exclusive management of inline policy assignments using the `role_name`. For example: + +```console +% terraform import aws_iam_role_policies_exclusive.example MyRole +```