diff --git a/.changelog/40164.txt b/.changelog/40164.txt new file mode 100644 index 00000000000..34a1c10947f --- /dev/null +++ b/.changelog/40164.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +aws_iam_organizations_features +``` diff --git a/internal/acctest/acctest.go b/internal/acctest/acctest.go index a5d9a723224..231d50bbeb6 100644 --- a/internal/acctest/acctest.go +++ b/internal/acctest/acctest.go @@ -12,8 +12,8 @@ import ( "net" "os" "os/exec" - "reflect" "regexp" + "slices" "strconv" "strings" "sync" @@ -31,7 +31,6 @@ import ( dstypes "github.com/aws/aws-sdk-go-v2/service/directoryservice/types" ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/aws/aws-sdk-go-v2/service/iam" - iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/aws/aws-sdk-go-v2/service/inspector2" inspector2types "github.com/aws/aws-sdk-go-v2/service/inspector2/types" organizationstypes "github.com/aws/aws-sdk-go-v2/service/organizations/types" @@ -1122,6 +1121,20 @@ func PreCheckOrganizationsEnabled(ctx context.Context, t *testing.T) *organizati return PreCheckOrganizationsEnabledWithProvider(ctx, t, func() *schema.Provider { return Provider }) } +func PreCheckOrganizationsEnabledServicePrincipal(ctx context.Context, t *testing.T, servicePrincipalName string) { + t.Helper() + + servicePrincipalNames, err := tforganizations.FindEnabledServicePrincipalNames(ctx, Provider.Meta().(*conns.AWSClient).OrganizationsClient(ctx)) + + if err != nil { + t.Fatalf("reading Organization service principals: %s", err) + } + + if !slices.Contains(servicePrincipalNames, servicePrincipalName) { + t.Skipf("trusted access for %s must be enabled in AWS Organizations", servicePrincipalName) + } +} + func PreCheckOrganizationsEnabledWithProvider(ctx context.Context, t *testing.T, providerF ProviderFunc) *organizationstypes.Organization { t.Helper() @@ -1270,7 +1283,7 @@ func PreCheckIAMServiceLinkedRoleWithProvider(ctx context.Context, t *testing.T, input := &iam.ListRolesInput{ PathPrefix: aws.String(pathPrefix), } - var role iamtypes.Role + var roleFound bool pages := iam.NewListRolesPaginator(conn, input) for pages.HasMorePages() { @@ -1282,13 +1295,13 @@ func PreCheckIAMServiceLinkedRoleWithProvider(ctx context.Context, t *testing.T, t.Fatalf("listing IAM roles: %s", err) } - for _, r := range page.Roles { - role = r + if len(page.Roles) > 0 { + roleFound = true break } } - if reflect.ValueOf(role).IsZero() { + if !roleFound { t.Skipf("skipping tests; missing IAM service-linked role %s. Please create the role and retry", pathPrefix) } } diff --git a/internal/service/iam/exports_test.go b/internal/service/iam/exports_test.go index 41cd72221c9..d75fb25008c 100644 --- a/internal/service/iam/exports_test.go +++ b/internal/service/iam/exports_test.go @@ -14,6 +14,7 @@ var ( ResourceGroupPolicyAttachment = resourceGroupPolicyAttachment ResourceInstanceProfile = resourceInstanceProfile ResourceOpenIDConnectProvider = resourceOpenIDConnectProvider + ResourceOrganizationsFeatures = newOrganizationsFeaturesResource ResourcePolicy = resourcePolicy ResourcePolicyAttachment = resourcePolicyAttachment ResourceRolePolicy = resourceRolePolicy @@ -45,6 +46,7 @@ var ( FindGroupPolicyAttachmentsByName = findGroupPolicyAttachmentsByName FindInstanceProfileByName = findInstanceProfileByName FindOpenIDConnectProviderByARN = findOpenIDConnectProviderByARN + FindOrganizationsFeatures = findOrganizationsFeatures FindPolicyByARN = findPolicyByARN FindRolePoliciesByName = findRolePoliciesByName FindRolePolicyAttachmentsByName = findRolePolicyAttachmentsByName diff --git a/internal/service/iam/organizations_features.go b/internal/service/iam/organizations_features.go new file mode 100644 index 00000000000..43468a6c6e3 --- /dev/null +++ b/internal/service/iam/organizations_features.go @@ -0,0 +1,250 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package iam + +import ( + "context" + "fmt" + "slices" + + "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/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "github.com/hashicorp/terraform-provider-aws/internal/errs" + "github.com/hashicorp/terraform-provider-aws/internal/errs/fwdiag" + "github.com/hashicorp/terraform-provider-aws/internal/framework" + fwflex "github.com/hashicorp/terraform-provider-aws/internal/framework/flex" + fwtypes "github.com/hashicorp/terraform-provider-aws/internal/framework/types" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + itypes "github.com/hashicorp/terraform-provider-aws/internal/types" + "github.com/hashicorp/terraform-provider-aws/names" +) + +// @FrameworkResource("aws_iam_organizations_features", name="Organizations Features") +func newOrganizationsFeaturesResource(context.Context) (resource.ResourceWithConfigure, error) { + r := &organizationsFeaturesResource{} + + return r, nil +} + +type organizationsFeaturesResource struct { + framework.ResourceWithConfigure + framework.WithImportByID +} + +func (*organizationsFeaturesResource) Metadata(_ context.Context, request resource.MetadataRequest, response *resource.MetadataResponse) { + response.TypeName = "aws_iam_organizations_features" +} + +func (r *organizationsFeaturesResource) Schema(ctx context.Context, request resource.SchemaRequest, response *resource.SchemaResponse) { + response.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "enabled_features": schema.SetAttribute{ + CustomType: fwtypes.NewSetTypeOf[fwtypes.StringEnum[awstypes.FeatureType]](ctx), + Required: true, + ElementType: fwtypes.StringEnumType[awstypes.FeatureType](), + }, + names.AttrID: framework.IDAttribute(), + }, + } +} + +func (r *organizationsFeaturesResource) Create(ctx context.Context, request resource.CreateRequest, response *resource.CreateResponse) { + var data organizationsFeaturesResourceModel + response.Diagnostics.Append(request.Plan.Get(ctx, &data)...) + if response.Diagnostics.HasError() { + return + } + + conn := r.Meta().IAMClient(ctx) + + var enabledFeatures []awstypes.FeatureType + response.Diagnostics.Append(fwflex.Expand(ctx, data.EnabledFeatures, &enabledFeatures)...) + if response.Diagnostics.HasError() { + return + } + + if err := updateOrganizationFeatures(ctx, conn, enabledFeatures, []awstypes.FeatureType{}); err != nil { + response.Diagnostics.AddError("creating IAM Organizations Features", err.Error()) + + return + } + + output, err := findOrganizationsFeatures(ctx, conn) + + if err != nil { + response.Diagnostics.AddError("reading IAM Organizations Features", err.Error()) + + return + } + + // Set values for unknowns. + data.OrganizationID = fwflex.StringToFramework(ctx, output.OrganizationId) + + response.Diagnostics.Append(response.State.Set(ctx, data)...) +} + +func (r *organizationsFeaturesResource) Read(ctx context.Context, request resource.ReadRequest, response *resource.ReadResponse) { + var data organizationsFeaturesResourceModel + response.Diagnostics.Append(request.State.Get(ctx, &data)...) + if response.Diagnostics.HasError() { + return + } + + conn := r.Meta().IAMClient(ctx) + + output, err := findOrganizationsFeatures(ctx, conn) + + if tfresource.NotFound(err) { + response.Diagnostics.Append(fwdiag.NewResourceNotFoundWarningDiagnostic(err)) + response.State.RemoveResource(ctx) + + return + } + + if err != nil { + response.Diagnostics.AddError(fmt.Sprintf("reading IAM Organizations Features (%s)", data.OrganizationID.ValueString()), err.Error()) + + return + } + + response.Diagnostics.Append(fwflex.Flatten(ctx, output.EnabledFeatures, &data.EnabledFeatures)...) + if response.Diagnostics.HasError() { + return + } + + response.Diagnostics.Append(response.State.Set(ctx, &data)...) +} + +func (r *organizationsFeaturesResource) Update(ctx context.Context, request resource.UpdateRequest, response *resource.UpdateResponse) { + var old, new organizationsFeaturesResourceModel + response.Diagnostics.Append(request.Plan.Get(ctx, &new)...) + if response.Diagnostics.HasError() { + return + } + response.Diagnostics.Append(request.State.Get(ctx, &old)...) + if response.Diagnostics.HasError() { + return + } + + var oldFeatures, newFeatures []awstypes.FeatureType + response.Diagnostics.Append(fwflex.Expand(ctx, old.EnabledFeatures, &oldFeatures)...) + if response.Diagnostics.HasError() { + return + } + response.Diagnostics.Append(fwflex.Expand(ctx, new.EnabledFeatures, &newFeatures)...) + if response.Diagnostics.HasError() { + return + } + + conn := r.Meta().IAMClient(ctx) + + if err := updateOrganizationFeatures(ctx, conn, newFeatures, oldFeatures); err != nil { + response.Diagnostics.AddError(fmt.Sprintf("updating IAM Organizations Features (%s)", new.OrganizationID.ValueString()), err.Error()) + + return + } + + response.Diagnostics.Append(response.State.Set(ctx, &new)...) +} + +func (r *organizationsFeaturesResource) Delete(ctx context.Context, request resource.DeleteRequest, response *resource.DeleteResponse) { + var data organizationsFeaturesResourceModel + response.Diagnostics.Append(request.State.Get(ctx, &data)...) + if response.Diagnostics.HasError() { + return + } + + conn := r.Meta().IAMClient(ctx) + + var enabledFeatures []awstypes.FeatureType + response.Diagnostics.Append(fwflex.Expand(ctx, data.EnabledFeatures, &enabledFeatures)...) + if response.Diagnostics.HasError() { + return + } + + if err := updateOrganizationFeatures(ctx, conn, []awstypes.FeatureType{}, enabledFeatures); err != nil { + response.Diagnostics.AddError(fmt.Sprintf("deleting IAM Organizations Features (%s)", data.OrganizationID.ValueString()), err.Error()) + + return + } +} + +type organizationsFeaturesResourceModel struct { + EnabledFeatures fwtypes.SetValueOf[fwtypes.StringEnum[awstypes.FeatureType]] `tfsdk:"enabled_features"` + OrganizationID types.String `tfsdk:"id"` +} + +func findOrganizationsFeatures(ctx context.Context, conn *iam.Client) (*iam.ListOrganizationsFeaturesOutput, error) { + input := &iam.ListOrganizationsFeaturesInput{} + + output, err := conn.ListOrganizationsFeatures(ctx, input) + + if errs.IsA[*awstypes.OrganizationNotFoundException](err) { + return nil, &retry.NotFoundError{ + LastError: err, + LastRequest: input, + } + } + + if err != nil { + return nil, err + } + + if output == nil || len(output.EnabledFeatures) == 0 { + return nil, tfresource.NewEmptyResultError(input) + } + + return output, nil +} + +func updateOrganizationFeatures(ctx context.Context, conn *iam.Client, new, old []awstypes.FeatureType) error { + toEnable := itypes.Set[awstypes.FeatureType](new).Difference(old) + toDisable := itypes.Set[awstypes.FeatureType](old).Difference(new) + + if slices.Contains(toEnable, awstypes.FeatureTypeRootCredentialsManagement) { + input := &iam.EnableOrganizationsRootCredentialsManagementInput{} + + _, err := conn.EnableOrganizationsRootCredentialsManagement(ctx, input) + + if err != nil { + return fmt.Errorf("enabling Organizations root credentials management: %w", err) + } + } + + if slices.Contains(toEnable, awstypes.FeatureTypeRootSessions) { + input := &iam.EnableOrganizationsRootSessionsInput{} + + _, err := conn.EnableOrganizationsRootSessions(ctx, input) + + if err != nil { + return fmt.Errorf("enabling Organizations root sessions: %w", err) + } + } + + if slices.Contains(toDisable, awstypes.FeatureTypeRootCredentialsManagement) { + input := &iam.DisableOrganizationsRootCredentialsManagementInput{} + + _, err := conn.DisableOrganizationsRootCredentialsManagement(ctx, input) + + if err != nil { + return fmt.Errorf("disabling Organizations root credentials management: %w", err) + } + } + + if slices.Contains(toDisable, awstypes.FeatureTypeRootSessions) { + input := &iam.DisableOrganizationsRootSessionsInput{} + + _, err := conn.DisableOrganizationsRootSessions(ctx, input) + + if err != nil { + return fmt.Errorf("disabling Organizations root sessions: %w", err) + } + } + + return nil +} diff --git a/internal/service/iam/organizations_features_test.go b/internal/service/iam/organizations_features_test.go new file mode 100644 index 00000000000..38088d23dc1 --- /dev/null +++ b/internal/service/iam/organizations_features_test.go @@ -0,0 +1,165 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package iam_test + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/hashicorp/terraform-provider-aws/internal/acctest" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + tfiam "github.com/hashicorp/terraform-provider-aws/internal/service/iam" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + "github.com/hashicorp/terraform-provider-aws/names" +) + +func TestAccIAMOrganizationsFeatures_serial(t *testing.T) { + t.Parallel() + + testCases := map[string]func(t *testing.T){ + acctest.CtBasic: testAccOrganizationsFeatures_basic, + "update": testAccOrganizationsFeatures_update, + } + + acctest.RunSerialTests1Level(t, testCases, 0) +} + +func testAccOrganizationsFeatures_basic(t *testing.T) { + ctx := acctest.Context(t) + resourceName := "aws_iam_organizations_features.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + acctest.PreCheckOrganizationManagementAccount(ctx, t) + acctest.PreCheckOrganizationsEnabledServicePrincipal(ctx, t, "iam.amazonaws.com") + }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckOrganizationsFeaturesDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccOrganizationsFeaturesConfig_basic([]string{"RootCredentialsManagement", "RootSessions"}), + Check: resource.ComposeTestCheckFunc( + testAccCheckOrganizationsFeaturesExists(ctx, resourceName), + ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("enabled_features"), knownvalue.SetSizeExact(2)), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("enabled_features"), knownvalue.SetExact([]knownvalue.Check{ + knownvalue.StringExact("RootCredentialsManagement"), + knownvalue.StringExact("RootSessions"), + })), + }, + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: false, + }, + }, + }) +} + +func testAccOrganizationsFeatures_update(t *testing.T) { + ctx := acctest.Context(t) + resourceName := "aws_iam_organizations_features.test" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + acctest.PreCheckOrganizationManagementAccount(ctx, t) + acctest.PreCheckOrganizationsEnabledServicePrincipal(ctx, t, "iam.amazonaws.com") + }, + ErrorCheck: acctest.ErrorCheck(t, names.IAMServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckOrganizationsFeaturesDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccOrganizationsFeaturesConfig_basic([]string{"RootCredentialsManagement"}), + Check: resource.ComposeTestCheckFunc( + testAccCheckOrganizationsFeaturesExists(ctx, resourceName), + ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("enabled_features"), knownvalue.SetSizeExact(1)), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("enabled_features"), knownvalue.SetExact([]knownvalue.Check{ + knownvalue.StringExact("RootCredentialsManagement"), + })), + }, + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: false, + }, + { + Config: testAccOrganizationsFeaturesConfig_basic([]string{"RootSessions"}), + Check: resource.ComposeTestCheckFunc( + testAccCheckOrganizationsFeaturesExists(ctx, resourceName), + ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("enabled_features"), knownvalue.SetSizeExact(1)), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New("enabled_features"), knownvalue.SetExact([]knownvalue.Check{ + knownvalue.StringExact("RootSessions"), + })), + }, + }, + }, + }) +} + +func testAccCheckOrganizationsFeaturesDestroy(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_organizations_features" { + continue + } + + _, err := tfiam.FindOrganizationsFeatures(ctx, conn) + + if tfresource.NotFound(err) { + continue + } + + if err != nil { + return err + } + + return fmt.Errorf("IAM Organizations Features %s still exists", rs.Primary.ID) + } + + return nil + } +} + +func testAccCheckOrganizationsFeaturesExists(ctx context.Context, n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + _, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).IAMClient(ctx) + + _, err := tfiam.FindOrganizationsFeatures(ctx, conn) + + return err + } +} + +func testAccOrganizationsFeaturesConfig_basic(features []string) string { + return fmt.Sprintf(` +resource "aws_iam_organizations_features" "test" { + enabled_features = [%[1]s] +} +`, fmt.Sprintf(`"%s"`, strings.Join(features, `", "`))) +} diff --git a/internal/service/iam/service_package_gen.go b/internal/service/iam/service_package_gen.go index 8029167973b..d16f91c483a 100644 --- a/internal/service/iam/service_package_gen.go +++ b/internal/service/iam/service_package_gen.go @@ -20,6 +20,10 @@ func (p *servicePackage) FrameworkDataSources(ctx context.Context) []*types.Serv func (p *servicePackage) FrameworkResources(ctx context.Context) []*types.ServicePackageFrameworkResource { return []*types.ServicePackageFrameworkResource{ + { + Factory: newOrganizationsFeaturesResource, + Name: "Organizations Features", + }, { Factory: newResourceGroupPoliciesExclusive, Name: "Group Policies Exclusive", diff --git a/website/docs/r/iam_organizations_features.html.markdown b/website/docs/r/iam_organizations_features.html.markdown new file mode 100644 index 00000000000..d3995528a76 --- /dev/null +++ b/website/docs/r/iam_organizations_features.html.markdown @@ -0,0 +1,58 @@ +--- +subcategory: "IAM (Identity & Access Management)" +layout: "aws" +page_title: "AWS: aws_iam_organizations_features" +description: |- + Manages centralized root access features. +--- + +# Resource: aws_iam_organizations_features + +Manages centralized root access features across AWS member accounts managed using AWS Organizations. More information about managing root access in IAM can be found in the [Centralize root access for member accounts](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-enable-root-access.html). + +~> **NOTE:** The AWS account utilizing this resource must be an Organizations management account. Also, you must enable trusted access for AWS Identity and Access Management in AWS Organizations. + +## Example Usage + +```terraform +resource "aws_organizations_organization" "example" { + aws_service_access_principals = ["iam.amazonaws.com"] + feature_set = "ALL" +} + +resource "aws_iam_organizations_features" "example" { + enabled_features = [ + "RootCredentialsManagement", + "RootSessions" + ] +} +``` + +## Argument Reference + +The following arguments are required: + +* `enabled_features` - (Required) List of IAM features to enable. Valid values are `RootCredentialsManagement` and `RootSessions`. + +## Attribute Reference + +This resource exports the following attributes in addition to the arguments above: + +* `id` - AWS Organization identifier. + +## Import + +In Terraform v1.5.0 and later, use an [`import` block](https://developer.hashicorp.com/terraform/language/import) to import root access features using the `id`. For example: + +```terraform +import { + to = aws_iam_organizations_features.example + id = "o-1234567" +} +``` + +Using `terraform import`, import root access features using the `id`. For example: + +```console +% terraform import aws_iam_organizations_features.example o-1234567 +```