diff --git a/.changelog/20491.txt b/.changelog/20491.txt new file mode 100644 index 000000000000..ed38fde47dfd --- /dev/null +++ b/.changelog/20491.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +aws_shield_protection_group +``` \ No newline at end of file diff --git a/aws/provider.go b/aws/provider.go index 872662fcedc6..2a57b50317db 100644 --- a/aws/provider.go +++ b/aws/provider.go @@ -1085,6 +1085,7 @@ func Provider() *schema.Provider { "aws_service_discovery_service": resourceAwsServiceDiscoveryService(), "aws_servicequotas_service_quota": resourceAwsServiceQuotasServiceQuota(), "aws_shield_protection": resourceAwsShieldProtection(), + "aws_shield_protection_group": resourceAwsShieldProtectionGroup(), "aws_signer_signing_job": resourceAwsSignerSigningJob(), "aws_signer_signing_profile": resourceAwsSignerSigningProfile(), "aws_signer_signing_profile_permission": resourceAwsSignerSigningProfilePermission(), diff --git a/aws/resource_aws_shield_protection_group.go b/aws/resource_aws_shield_protection_group.go new file mode 100644 index 000000000000..4238ba26d57c --- /dev/null +++ b/aws/resource_aws_shield_protection_group.go @@ -0,0 +1,211 @@ +package aws + +import ( + "fmt" + "log" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/shield" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/keyvaluetags" +) + +func resourceAwsShieldProtectionGroup() *schema.Resource { + return &schema.Resource{ + Create: resourceAwsShieldProtectionGroupCreate, + Read: resourceAwsShieldProtectionGroupRead, + Update: resourceAwsShieldProtectionGroupUpdate, + Delete: resourceAwsShieldProtectionGroupDelete, + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Schema: map[string]*schema.Schema{ + "aggregation": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice(shield.ProtectionGroupAggregation_Values(), false), + }, + "members": { + Type: schema.TypeList, + Optional: true, + MinItems: 0, + MaxItems: 10000, + ConflictsWith: []string{"resource_type"}, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validation.All(validateArn, + validation.StringLenBetween(1, 2048), + ), + }, + }, + "pattern": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice(shield.ProtectionGroupPattern_Values(), false), + }, + "protection_group_id": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringLenBetween(1, 36), + ForceNew: true, + }, + "protection_group_arn": { + Type: schema.TypeString, + Computed: true, + }, + "resource_type": { + Type: schema.TypeString, + Optional: true, + ConflictsWith: []string{"members"}, + ValidateFunc: validation.StringInSlice(shield.ProtectedResourceType_Values(), false), + }, + "tags": tagsSchema(), + "tags_all": tagsSchemaComputed(), + }, + CustomizeDiff: SetTagsDiff, + } +} + +func resourceAwsShieldProtectionGroupCreate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).shieldconn + defaultTagsConfig := meta.(*AWSClient).DefaultTagsConfig + tags := defaultTagsConfig.MergeTags(keyvaluetags.New(d.Get("tags").(map[string]interface{}))) + + protectionGroupID := d.Get("protection_group_id").(string) + input := &shield.CreateProtectionGroupInput{ + Aggregation: aws.String(d.Get("aggregation").(string)), + Pattern: aws.String(d.Get("pattern").(string)), + ProtectionGroupId: aws.String(protectionGroupID), + Tags: tags.IgnoreAws().ShieldTags(), + } + + if v, ok := d.GetOk("members"); ok { + input.Members = expandStringList(v.([]interface{})) + } + + if v, ok := d.GetOk("resource_type"); ok { + input.ResourceType = aws.String(v.(string)) + } + + log.Printf("[DEBUG] Creating Shield Protection Group: %s", input) + _, err := conn.CreateProtectionGroup(input) + + if err != nil { + return fmt.Errorf("error creating Shield Protection Group (%s): %w", protectionGroupID, err) + } + + d.SetId(protectionGroupID) + + return resourceAwsShieldProtectionGroupRead(d, meta) +} + +func resourceAwsShieldProtectionGroupRead(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).shieldconn + defaultTagsConfig := meta.(*AWSClient).DefaultTagsConfig + ignoreTagsConfig := meta.(*AWSClient).IgnoreTagsConfig + + input := &shield.DescribeProtectionGroupInput{ + ProtectionGroupId: aws.String(d.Id()), + } + + resp, err := conn.DescribeProtectionGroup(input) + + if !d.IsNewResource() && tfawserr.ErrCodeEquals(err, shield.ErrCodeResourceNotFoundException) { + log.Printf("[WARN] Shield Protection Group (%s) not found, removing from state", d.Id()) + d.SetId("") + return nil + } + + if err != nil { + return fmt.Errorf("error reading Shield Protection Group (%s): %w", d.Id(), err) + } + + arn := aws.StringValue(resp.ProtectionGroup.ProtectionGroupArn) + d.Set("protection_group_arn", arn) + d.Set("aggregation", resp.ProtectionGroup.Aggregation) + d.Set("protection_group_id", resp.ProtectionGroup.ProtectionGroupId) + d.Set("pattern", resp.ProtectionGroup.Pattern) + + if resp.ProtectionGroup.Members != nil { + d.Set("members", resp.ProtectionGroup.Members) + } + + if resp.ProtectionGroup.ResourceType != nil { + d.Set("resource_type", resp.ProtectionGroup.ResourceType) + } + + tags, err := keyvaluetags.ShieldListTags(conn, arn) + + if err != nil { + return fmt.Errorf("error listing tags for Shield Protection Group (%s): %w", arn, err) + } + + tags = tags.IgnoreAws().IgnoreConfig(ignoreTagsConfig) + + //lintignore:AWSR002 + if err := d.Set("tags", tags.RemoveDefaultConfig(defaultTagsConfig).Map()); err != nil { + return fmt.Errorf("error setting tags: %w", err) + } + + if err := d.Set("tags_all", tags.Map()); err != nil { + return fmt.Errorf("error setting tags_all: %w", err) + } + + return nil +} + +func resourceAwsShieldProtectionGroupUpdate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).shieldconn + + input := &shield.UpdateProtectionGroupInput{ + Aggregation: aws.String(d.Get("aggregation").(string)), + Pattern: aws.String(d.Get("pattern").(string)), + ProtectionGroupId: aws.String(d.Id()), + } + + if v, ok := d.GetOk("members"); ok { + input.Members = expandStringList(v.([]interface{})) + } + + if v, ok := d.GetOk("resource_type"); ok { + input.ResourceType = aws.String(v.(string)) + } + + log.Printf("[DEBUG] Updating Shield Protection Group: %s", input) + _, err := conn.UpdateProtectionGroup(input) + + if err != nil { + return fmt.Errorf("error updating Shield Protection Group (%s): %w", d.Id(), err) + } + + if d.HasChange("tags_all") { + o, n := d.GetChange("tags_all") + if err := keyvaluetags.ShieldUpdateTags(conn, d.Get("arn").(string), o, n); err != nil { + return fmt.Errorf("error updating tags: %w", err) + } + } + + return resourceAwsShieldProtectionGroupRead(d, meta) +} + +func resourceAwsShieldProtectionGroupDelete(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).shieldconn + + log.Printf("[DEBUG] Deletinh Shield Protection Group: %s", d.Id()) + _, err := conn.DeleteProtectionGroup(&shield.DeleteProtectionGroupInput{ + ProtectionGroupId: aws.String(d.Id()), + }) + + if tfawserr.ErrCodeEquals(err, shield.ErrCodeResourceNotFoundException) { + return nil + } + + if err != nil { + return fmt.Errorf("error deleting Shield Protection Group (%s): %w", d.Id(), err) + } + + return nil +} diff --git a/aws/resource_aws_shield_protection_group_test.go b/aws/resource_aws_shield_protection_group_test.go new file mode 100644 index 000000000000..0b144b440d85 --- /dev/null +++ b/aws/resource_aws_shield_protection_group_test.go @@ -0,0 +1,333 @@ +package aws + +import ( + "fmt" + "regexp" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/shield" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccAWSShieldProtectionGroup_basic(t *testing.T) { + resourceName := "aws_shield_protection_group.test" + rName := acctest.RandomWithPrefix("tf-acc-test") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccPartitionHasServicePreCheck(shield.EndpointsID, t) + testAccPreCheckAWSShield(t) + }, + ErrorCheck: testAccErrorCheck(t, shield.EndpointsID), + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSShieldProtectionGroupDestroy, + Steps: []resource.TestStep{ + { + Config: testAccShieldProtectionGroupConfig_basic_all(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSShieldProtectionGroupExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "aggregation", shield.ProtectionGroupAggregationMax), + resource.TestCheckNoResourceAttr(resourceName, "members"), + resource.TestCheckResourceAttr(resourceName, "pattern", shield.ProtectionGroupPatternAll), + resource.TestCheckNoResourceAttr(resourceName, "resource_type"), + resource.TestCheckResourceAttr(resourceName, "tags.%", "0"), + resource.TestCheckResourceAttr(resourceName, "tags_all.%", "0"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccAWSShieldProtectionGroup_disappears(t *testing.T) { + resourceName := "aws_shield_protection_group.test" + rName := acctest.RandomWithPrefix("tf-acc-test") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccPartitionHasServicePreCheck(shield.EndpointsID, t) + testAccPreCheckAWSShield(t) + }, + ErrorCheck: testAccErrorCheck(t, shield.EndpointsID), + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSShieldProtectionGroupDestroy, + Steps: []resource.TestStep{ + { + Config: testAccShieldProtectionGroupConfig_basic_all(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSShieldProtectionGroupExists(resourceName), + testAccCheckResourceDisappears(testAccProvider, resourceAwsShieldProtectionGroup(), resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func TestAccAWSShieldProtectionGroup_aggregation(t *testing.T) { + resourceName := "aws_shield_protection_group.test" + rName := acctest.RandomWithPrefix("tf-acc-test") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccPartitionHasServicePreCheck(shield.EndpointsID, t) + testAccPreCheckAWSShield(t) + }, + ErrorCheck: testAccErrorCheck(t, shield.EndpointsID), + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSShieldProtectionGroupDestroy, + Steps: []resource.TestStep{ + { + Config: testAccShieldProtectionGroupConfig_aggregation(rName, shield.ProtectionGroupAggregationMean), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSShieldProtectionGroupExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "aggregation", shield.ProtectionGroupAggregationMean), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccShieldProtectionGroupConfig_aggregation(rName, shield.ProtectionGroupAggregationSum), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSShieldProtectionGroupExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "aggregation", shield.ProtectionGroupAggregationSum), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccAWSShieldProtectionGroup_members(t *testing.T) { + resourceName := "aws_shield_protection_group.test" + rName := acctest.RandomWithPrefix("tf-acc-test") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccPartitionHasServicePreCheck(shield.EndpointsID, t) + testAccPreCheckAWSShield(t) + }, + ErrorCheck: testAccErrorCheck(t, shield.EndpointsID), + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSShieldProtectionGroupDestroy, + Steps: []resource.TestStep{ + { + Config: testAccShieldProtectionGroupConfig_members(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSShieldProtectionGroupExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "pattern", shield.ProtectionGroupPatternArbitrary), + resource.TestCheckResourceAttr(resourceName, "members.#", "1"), + testAccMatchResourceAttrRegionalARN(resourceName, "members.0", "ec2", regexp.MustCompile(`eip-allocation/eipalloc-.+`)), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccAWSShieldProtectionGroup_protectionGroupId(t *testing.T) { + resourceName := "aws_shield_protection_group.test" + testID1 := acctest.RandomWithPrefix("tf-acc-test") + testID2 := acctest.RandomWithPrefix("tf-acc-test") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccPartitionHasServicePreCheck(shield.EndpointsID, t) + testAccPreCheckAWSShield(t) + }, + ErrorCheck: testAccErrorCheck(t, shield.EndpointsID), + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSShieldProtectionGroupDestroy, + Steps: []resource.TestStep{ + { + Config: testAccShieldProtectionGroupConfig_basic_all(testID1), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSShieldProtectionGroupExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "protection_group_id", testID1), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccShieldProtectionGroupConfig_basic_all(testID2), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSShieldProtectionGroupExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "protection_group_id", testID2), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccAWSShieldProtectionGroup_resourceType(t *testing.T) { + resourceName := "aws_shield_protection_group.test" + rName := acctest.RandomWithPrefix("tf-acc-test") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccPartitionHasServicePreCheck(shield.EndpointsID, t) + testAccPreCheckAWSShield(t) + }, + ErrorCheck: testAccErrorCheck(t, shield.EndpointsID), + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSShieldProtectionGroupDestroy, + Steps: []resource.TestStep{ + { + Config: testAccShieldProtectionGroupConfig_resourceType(rName, shield.ProtectedResourceTypeElasticIpAllocation), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSShieldProtectionGroupExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "pattern", shield.ProtectionGroupPatternByResourceType), + resource.TestCheckResourceAttr(resourceName, "resource_type", shield.ProtectedResourceTypeElasticIpAllocation), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccShieldProtectionGroupConfig_resourceType(rName, shield.ProtectedResourceTypeApplicationLoadBalancer), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSShieldProtectionGroupExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "pattern", shield.ProtectionGroupPatternByResourceType), + resource.TestCheckResourceAttr(resourceName, "resource_type", shield.ProtectedResourceTypeApplicationLoadBalancer), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccCheckAWSShieldProtectionGroupDestroy(s *terraform.State) error { + shieldconn := testAccProvider.Meta().(*AWSClient).shieldconn + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_shield_protection_group" { + continue + } + + input := &shield.DescribeProtectionGroupInput{ + ProtectionGroupId: aws.String(rs.Primary.ID), + } + + resp, err := shieldconn.DescribeProtectionGroup(input) + + if tfawserr.ErrCodeEquals(err, shield.ErrCodeResourceNotFoundException) { + continue + } + + if err != nil { + return err + } + + if resp != nil && resp.ProtectionGroup != nil && aws.StringValue(resp.ProtectionGroup.ProtectionGroupId) == rs.Primary.ID { + return fmt.Errorf("The Shield protection group with ID %v still exists", rs.Primary.ID) + } + } + + return nil +} + +func testAccCheckAWSShieldProtectionGroupExists(name string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[name] + if !ok { + return fmt.Errorf("Not found: %s", name) + } + + conn := testAccProvider.Meta().(*AWSClient).shieldconn + + input := &shield.DescribeProtectionGroupInput{ + ProtectionGroupId: aws.String(rs.Primary.ID), + } + + _, err := conn.DescribeProtectionGroup(input) + + if err != nil { + return err + } + + return nil + } +} + +func testAccShieldProtectionGroupConfig_basic_all(rName string) string { + return fmt.Sprintf(` +resource "aws_shield_protection_group" "test" { + protection_group_id = "%s" + aggregation = "MAX" + pattern = "ALL" +} +`, rName) +} + +func testAccShieldProtectionGroupConfig_aggregation(rName string, aggregation string) string { + return fmt.Sprintf(` +resource "aws_shield_protection_group" "test" { + protection_group_id = "%[1]s" + aggregation = "%[2]s" + pattern = "ALL" +} +`, rName, aggregation) +} + +func testAccShieldProtectionGroupConfig_resourceType(rName string, resType string) string { + return fmt.Sprintf(` +resource "aws_shield_protection_group" "test" { + protection_group_id = "%[1]s" + aggregation = "MAX" + pattern = "BY_RESOURCE_TYPE" + resource_type = "%[2]s" +} +`, rName, resType) +} + +func testAccShieldProtectionGroupConfig_members(rName string) string { + return composeConfig(testAccShieldProtectionElasticIPAddressConfig(rName), fmt.Sprintf(` +resource "aws_shield_protection_group" "test" { + depends_on = [aws_shield_protection.acctest] + + protection_group_id = "%[1]s" + aggregation = "MAX" + pattern = "ARBITRARY" + members = ["arn:${data.aws_partition.current.partition}:ec2:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:eip-allocation/${aws_eip.acctest.id}"] +} +`, rName)) +} diff --git a/website/docs/r/shield_protection_group.html.markdown b/website/docs/r/shield_protection_group.html.markdown new file mode 100644 index 000000000000..5228b4f81ed0 --- /dev/null +++ b/website/docs/r/shield_protection_group.html.markdown @@ -0,0 +1,87 @@ +--- +subcategory: "Shield" +layout: "aws" +page_title: "AWS: aws_shield_protection_group" +description: |- + Creates a grouping of protected resources so they can be handled as a collective. +--- + +# Resource: aws_shield_protection_group + +Creates a grouping of protected resources so they can be handled as a collective. +This resource grouping improves the accuracy of detection and reduces false positives. For more information see +[Managing AWS Shield Advanced protection groups](https://docs.aws.amazon.com/waf/latest/developerguide/manage-protection-group.html) + +## Example Usage + +### Create protection group for all resources + +```terraform +resource "aws_shield_protection_group" "example" { + protection_group_id = "example" + aggregation = "MAX" + pattern = "ALL" +} +``` + +### Create protection group for arbitrary number of resources + +```terraform +data "aws_region" "current" {} +data "aws_caller_identity" "current" {} + +resource "aws_eip" "example" { + vpc = true +} + +resource "aws_shield_protection" "example" { + name = "example" + resource_arn = "arn:aws:ec2:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:eip-allocation/${aws_eip.example.id}" +} + +resource "aws_shield_protection_group" "example" { + depends_on = [aws_shield_protection.example] + + protection_group_id = "example" + aggregation = "MEAN" + pattern = "ARBITRARY" + members = ["arn:aws:ec2:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:eip-allocation/${aws_eip.example.id}"] +} +``` + +### Create protection group for a type of resource + +```terraform +resource "aws_shield_protection_group" "example" { + protection_group_id = "example" + aggregation = "SUM" + pattern = "BY_RESOURCE_TYPE" + resource_type = "ELASTIC_IP_ALLOCATION" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `aggregation` - (Required) Defines how AWS Shield combines resource data for the group in order to detect, mitigate, and report events. +* `members` - (Optional) The Amazon Resource Names (ARNs) of the resources to include in the protection group. You must set this when you set `pattern` to ARBITRARY and you must not set it for any other `pattern` setting. +* `pattern` - (Required) The criteria to use to choose the protected resources for inclusion in the group. +* `protection_group_id` - (Required) The name of the protection group. +* `resource_type` - (Optional) The resource type to include in the protection group. You must set this when you set `pattern` to BY_RESOURCE_TYPE and you must not set it for any other `pattern` setting. +* `tags` - (Optional) Key-value map of resource tags. If configured with a provider [`default_tags` configuration block](/docs/providers/aws/index.html#default_tags-configuration-block) present, tags with matching keys will overwrite those defined at the provider-level. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `protection_group_arn` - The ARN (Amazon Resource Name) of the protection group. +* `tags_all` - A map of tags assigned to the resource, including those inherited from the provider [`default_tags` configuration block](/docs/providers/aws/index.html#default_tags-configuration-block). + +## Import + +Shield protection group resources can be imported by specifying their protection group id. + +``` +$ terraform import aws_shield_protection_group.example example +```