diff --git a/.changelog/6857.txt b/.changelog/6857.txt new file mode 100644 index 0000000000..1966029e08 --- /dev/null +++ b/.changelog/6857.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +compute: Added fields to resource `google_compute_security_policy` to support Cloud Armor bot management +``` diff --git a/google/resource_compute_security_policy.go b/google/resource_compute_security_policy.go index 50fa7ac8a4..b83e22c9c9 100644 --- a/google/resource_compute_security_policy.go +++ b/google/resource_compute_security_policy.go @@ -292,6 +292,35 @@ func resourceComputeSecurityPolicy() *schema.Resource { }, Description: `Parameters defining the redirect action. Cannot be specified for any other actions.`, }, + "header_action": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Description: `Additional actions that are performed on headers.`, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "request_headers_to_adds": { + Type: schema.TypeList, + Required: true, + Description: `The list of request headers to add or overwrite if they're already present.`, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "header_name": { + Type: schema.TypeString, + Required: true, + Description: `The name of the header to set.`, + }, + "header_value": { + Type: schema.TypeString, + Optional: true, + Description: `The value to set the named header to.`, + }, + }, + }, + }, + }, + }, + }, }, }, Description: `The set of rules that belong to this policy. There must always be a default rule (rule with priority 2147483647 and match "*"). If no rules are provided when creating a security policy, a default rule with action "allow" will be added.`, @@ -384,6 +413,21 @@ func resourceComputeSecurityPolicy() *schema.Resource { }, }, }, + "recaptcha_options_config": { + Type: schema.TypeList, + Optional: true, + Description: `reCAPTCHA configuration options to be applied for the security policy.`, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "redirect_site_key": { + Type: schema.TypeString, + Required: true, + Description: `A field to supply a reCAPTCHA site key to be used for all the rules using the redirect action with the type of GOOGLE_RECAPTCHA under the security policy. The specified site key needs to be created from the reCAPTCHA API. The user is responsible for the validity of the specified site key. If not specified, a Google-managed site key is used.`, + }, + }, + }, + }, }, UseJSONNumber: true, @@ -442,6 +486,10 @@ func resourceComputeSecurityPolicyCreate(d *schema.ResourceData, meta interface{ log.Printf("[DEBUG] SecurityPolicy insert request: %#v", securityPolicy) + if v, ok := d.GetOk("recaptcha_options_config"); ok { + securityPolicy.RecaptchaOptionsConfig = expandSecurityPolicyRecaptchaOptionsConfig(v.([]interface{}), d) + } + client := config.NewComputeClient(userAgent) op, err := client.SecurityPolicies.Insert(project, securityPolicy).Do() @@ -514,6 +562,10 @@ func resourceComputeSecurityPolicyRead(d *schema.ResourceData, meta interface{}) return fmt.Errorf("Error setting adaptive_protection_config: %s", err) } + if err := d.Set("recaptcha_options_config", flattenSecurityPolicyRecaptchaOptionConfig(securityPolicy.RecaptchaOptionsConfig)); err != nil { + return fmt.Errorf("Error setting recaptcha_options_config: %s", err) + } + return nil } @@ -555,6 +607,11 @@ func resourceComputeSecurityPolicyUpdate(d *schema.ResourceData, meta interface{ securityPolicy.ForceSendFields = append(securityPolicy.ForceSendFields, "AdaptiveProtectionConfig", "adaptiveProtectionConfig.layer7DdosDefenseConfig.enable", "adaptiveProtectionConfig.layer7DdosDefenseConfig.ruleVisibility") } + if d.HasChange("recaptcha_options_config") { + securityPolicy.RecaptchaOptionsConfig = expandSecurityPolicyRecaptchaOptionsConfig(d.Get("recaptcha_options_config").([]interface{}), d) + securityPolicy.ForceSendFields = append(securityPolicy.ForceSendFields, "RecaptchaOptionsConfig") + } + if len(securityPolicy.ForceSendFields) > 0 { client := config.NewComputeClient(userAgent) @@ -685,6 +742,7 @@ func expandSecurityPolicyRule(raw interface{}) *compute.SecurityPolicyRule { Match: expandSecurityPolicyMatch(data["match"].([]interface{})), RateLimitOptions: expandSecurityPolicyRuleRateLimitOptions(data["rate_limit_options"].([]interface{})), RedirectOptions: expandSecurityPolicyRuleRedirectOptions(data["redirect_options"].([]interface{})), + HeaderAction: expandSecurityPolicyRuleHeaderAction(data["header_action"].([]interface{})), ForceSendFields: []string{"Description", "Preview"}, } } @@ -739,8 +797,8 @@ func flattenSecurityPolicyRules(rules []*compute.SecurityPolicyRule) []map[strin "match": flattenMatch(rule.Match), "rate_limit_options": flattenSecurityPolicyRuleRateLimitOptions(rule.RateLimitOptions), "redirect_options": flattenSecurityPolicyRedirectOptions(rule.RedirectOptions), + "header_action": flattenSecurityPolicyRuleHeaderAction(rule.HeaderAction), } - rulesSchema = append(rulesSchema, data) } return rulesSchema @@ -975,6 +1033,99 @@ func flattenSecurityPolicyRedirectOptions(conf *compute.SecurityPolicyRuleRedire return []map[string]interface{}{data} } +func expandSecurityPolicyRecaptchaOptionsConfig(configured []interface{}, d *schema.ResourceData) *compute.SecurityPolicyRecaptchaOptionsConfig { + if len(configured) == 0 || configured[0] == nil { + return nil + } + + data := configured[0].(map[string]interface{}) + + return &compute.SecurityPolicyRecaptchaOptionsConfig{ + RedirectSiteKey: data["redirect_site_key"].(string), + ForceSendFields: []string{"RedirectSiteKey"}, + } +} + +func flattenSecurityPolicyRecaptchaOptionConfig(conf *compute.SecurityPolicyRecaptchaOptionsConfig) []map[string]interface{} { + if conf == nil { + return nil + } + + data := map[string]interface{}{ + "redirect_site_key": conf.RedirectSiteKey, + } + + return []map[string]interface{}{data} +} + +func expandSecurityPolicyRuleHeaderAction(configured []interface{}) *compute.SecurityPolicyRuleHttpHeaderAction { + if len(configured) == 0 || configured[0] == nil { + // If header action is unset, return an empty object; this ensures the header action can be cleared + return &compute.SecurityPolicyRuleHttpHeaderAction{} + } + + data := configured[0].(map[string]interface{}) + + return &compute.SecurityPolicyRuleHttpHeaderAction{ + RequestHeadersToAdds: expandSecurityPolicyRequestHeadersToAdds(data["request_headers_to_adds"].([]interface{})), + } +} + +func expandSecurityPolicyRequestHeadersToAdds(configured []interface{}) []*compute.SecurityPolicyRuleHttpHeaderActionHttpHeaderOption { + transformed := make([]*compute.SecurityPolicyRuleHttpHeaderActionHttpHeaderOption, 0, len(configured)) + + for _, raw := range configured { + transformed = append(transformed, expandSecurityPolicyRequestHeader(raw)) + } + + return transformed +} + +func expandSecurityPolicyRequestHeader(configured interface{}) *compute.SecurityPolicyRuleHttpHeaderActionHttpHeaderOption { + data := configured.(map[string]interface{}) + + return &compute.SecurityPolicyRuleHttpHeaderActionHttpHeaderOption{ + HeaderName: data["header_name"].(string), + HeaderValue: data["header_value"].(string), + } +} + +func flattenSecurityPolicyRuleHeaderAction(conf *compute.SecurityPolicyRuleHttpHeaderAction) []map[string]interface{} { + if conf == nil || conf.RequestHeadersToAdds == nil { + return nil + } + + transformed := map[string]interface{}{ + "request_headers_to_adds": flattenSecurityPolicyRequestHeadersToAdds(conf.RequestHeadersToAdds), + } + + return []map[string]interface{}{transformed} +} + +func flattenSecurityPolicyRequestHeadersToAdds(conf []*compute.SecurityPolicyRuleHttpHeaderActionHttpHeaderOption) []map[string]interface{} { + if conf == nil || len(conf) == 0 { + return nil + } + + transformed := make([]map[string]interface{}, 0, len(conf)) + for _, raw := range conf { + transformed = append(transformed, flattenSecurityPolicyRequestHeader(raw)) + } + + return transformed +} + +func flattenSecurityPolicyRequestHeader(conf *compute.SecurityPolicyRuleHttpHeaderActionHttpHeaderOption) map[string]interface{} { + if conf == nil { + return nil + } + + return map[string]interface{}{ + "header_name": conf.HeaderName, + "header_value": conf.HeaderValue, + } +} + func resourceSecurityPolicyStateImporter(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { config := meta.(*Config) if err := parseImportId([]string{"projects/(?P[^/]+)/global/securityPolicies/(?P[^/]+)", "(?P[^/]+)/(?P[^/]+)", "(?P[^/]+)"}, d, config); err != nil { diff --git a/google/resource_compute_security_policy_test.go b/google/resource_compute_security_policy_test.go index 99f444afa3..37c374ab1b 100644 --- a/google/resource_compute_security_policy_test.go +++ b/google/resource_compute_security_policy_test.go @@ -232,6 +232,184 @@ func TestAccComputeSecurityPolicy_withRateLimitWithRedirectOptions(t *testing.T) }) } +func TestAccComputeSecurityPolicy_withRecaptchaOptionsConfig(t *testing.T) { + t.Parallel() + + project := getTestProjectFromEnv() + spName := fmt.Sprintf("tf-test-%s", randString(t, 10)) + + vcrTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckComputeSecurityPolicyDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccComputeSecurityPolicy_basic(spName), + }, + { + ResourceName: "google_compute_security_policy.policy", + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccComputeSecurityPolicy_withRecaptchaOptionsConfig(project, spName), + }, + { + ResourceName: "google_compute_security_policy.policy", + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccComputeSecurityPolicy_withRedirectSiteKeyUpdate(project, spName), + }, + { + ResourceName: "google_compute_security_policy.policy", + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccComputeSecurityPolicy_withEmptyRedirectSiteKey(spName), + }, + { + ResourceName: "google_compute_security_policy.policy", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccComputeSecurityPolicy_withHeadAction(t *testing.T) { + t.Parallel() + + spName := fmt.Sprintf("tf-test-%s", randString(t, 10)) + headerName := fmt.Sprintf("tf-test-header-name-%s", randString(t, 10)) + headerNameUpdate := fmt.Sprintf("tf-test-header-name-update-%s", randString(t, 10)) + headerValue := fmt.Sprintf("tf-test-header-value-%s", randString(t, 10)) + headerValueUpdate := fmt.Sprintf("tf-test-header-value-update-%s", randString(t, 10)) + + vcrTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckComputeSecurityPolicyDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccComputeSecurityPolicy_withoutHeadAction(spName), + }, + { + ResourceName: "google_compute_security_policy.policy", + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccComputeSecurityPolicy_withHeadAction(spName, headerName, headerValue), + }, + { + ResourceName: "google_compute_security_policy.policy", + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccComputeSecurityPolicy_withHeadAction(spName, headerNameUpdate, headerValueUpdate), + }, + { + ResourceName: "google_compute_security_policy.policy", + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccComputeSecurityPolicy_withMultipleHeaders(spName), + }, + { + ResourceName: "google_compute_security_policy.policy", + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccComputeSecurityPolicy_withoutHeadAction(spName), + }, + { + ResourceName: "google_compute_security_policy.policy", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} +func testAccComputeSecurityPolicy_withRecaptchaOptionsConfig(project, spName string) string { + return fmt.Sprintf(` +resource "google_recaptcha_enterprise_key" "primary" { + display_name = "test" + + labels = { + label-one = "value-one" + } + + project = "%s" + + web_settings { + integration_type = "INVISIBLE" + allow_all_domains = true + allowed_domains = ["localhost"] + } +} + +resource "google_compute_security_policy" "policy" { + name = "%s" + description = "basic security policy" + type = "CLOUD_ARMOR" + + recaptcha_options_config { + redirect_site_key = google_recaptcha_enterprise_key.primary.name + } +} +`, project, spName) +} + +func testAccComputeSecurityPolicy_withRedirectSiteKeyUpdate(project, spName string) string { + return fmt.Sprintf(` +resource "google_recaptcha_enterprise_key" "primary1" { + display_name = "test" + + labels = { + label-one = "value-one" + } + + project = "%s" + + web_settings { + integration_type = "INVISIBLE" + allow_all_domains = true + allowed_domains = ["localhost"] + } +} + +resource "google_compute_security_policy" "policy" { + name = "%s" + description = "basic security policy" + type = "CLOUD_ARMOR" + + recaptcha_options_config { + redirect_site_key = google_recaptcha_enterprise_key.primary1.name + } +} +`, project, spName) +} + +func testAccComputeSecurityPolicy_withEmptyRedirectSiteKey(spName string) string { + return fmt.Sprintf(` +resource "google_compute_security_policy" "policy" { + name = "%s" + description = "basic security policy" + type = "CLOUD_ARMOR" + + recaptcha_options_config { + redirect_site_key = "" + } +} +`, spName) +} + func testAccCheckComputeSecurityPolicyDestroyProducer(t *testing.T) func(s *terraform.State) error { return func(s *terraform.State) error { config := googleProviderConfig(t) @@ -423,6 +601,120 @@ resource "google_compute_security_policy" "policy" { `, spName) } +func testAccComputeSecurityPolicy_withoutHeadAction(spName string) string { + return fmt.Sprintf(` +resource "google_compute_security_policy" "policy" { + name = "%s" + + rule { + action = "allow" + priority = "2147483647" + match { + versioned_expr = "SRC_IPS_V1" + config { + src_ip_ranges = ["*"] + } + } + description = "default rule" + } + + rule { + action = "allow" + priority = "1000" + match { + expr { + expression = "request.path.matches(\"/login.html\") && token.recaptcha_session.score < 0.2" + } + } + } +} +`, spName) +} + +func testAccComputeSecurityPolicy_withHeadAction(spName, headerName, headerValue string) string { + return fmt.Sprintf(` +resource "google_compute_security_policy" "policy" { + name = "%s" + + rule { + action = "allow" + priority = "2147483647" + match { + versioned_expr = "SRC_IPS_V1" + config { + src_ip_ranges = ["*"] + } + } + description = "default rule" + } + + rule { + action = "allow" + priority = "1000" + match { + expr { + expression = "request.path.matches(\"/login.html\") && token.recaptcha_session.score < 0.2" + } + } + + header_action { + request_headers_to_adds { + header_name = "%s" + header_value = "%s" + } + } + } +} +`, spName, headerName, headerValue) +} + +func testAccComputeSecurityPolicy_withMultipleHeaders(spName string) string { + return fmt.Sprintf(` +resource "google_compute_security_policy" "policy" { + name = "%s" + + rule { + action = "allow" + priority = "2147483647" + match { + versioned_expr = "SRC_IPS_V1" + config { + src_ip_ranges = ["*"] + } + } + description = "default rule" + } + + rule { + action = "allow" + priority = "1000" + match { + expr { + expression = "request.path.matches(\"/login.html\") && token.recaptcha_session.score < 0.2" + } + } + + header_action { + request_headers_to_adds { + header_name = "reCAPTCHA-Warning" + header_value = "high" + } + + request_headers_to_adds { + header_name = "X-Hello" + header_value = "World" + } + + request_headers_to_adds { + header_name = "X-Resource" + header_value = "test" + } + } + } +} +`, spName) +} + func testAccComputeSecurityPolicy_withAdvancedOptionsConfig(spName string) string { return fmt.Sprintf(` resource "google_compute_security_policy" "policy" { diff --git a/website/docs/r/compute_security_policy.html.markdown b/website/docs/r/compute_security_policy.html.markdown index 2c596f69d1..eafe158a26 100644 --- a/website/docs/r/compute_security_policy.html.markdown +++ b/website/docs/r/compute_security_policy.html.markdown @@ -45,6 +45,78 @@ resource "google_compute_security_policy" "policy" { } ``` +## Example Usage - With reCAPTCHA configuration options + +```hcl +resource "google_recaptcha_enterprise_key" "primary" { + display_name = "display-name" + + labels = { + label-one = "value-one" + } + + project = "my-project-name" + + web_settings { + integration_type = "INVISIBLE" + allow_all_domains = true + allowed_domains = ["localhost"] + } +} + +resource "google_compute_security_policy" "policy" { + name = "my-policy" + description = "basic security policy" + type = "CLOUD_ARMOR" + + recaptcha_options_config { + redirect_site_key = google_recaptcha_enterprise_key.primary.name + } +} +``` + +## Example Usage - With header actions + +```hcl +resource "google_compute_security_policy" "policy" { + name = "my-policy" + + rule { + action = "allow" + priority = "2147483647" + match { + versioned_expr = "SRC_IPS_V1" + config { + src_ip_ranges = ["*"] + } + } + description = "default rule" + } + + rule { + action = "allow" + priority = "1000" + match { + expr { + expression = "request.path.matches(\"/login.html\") && token.recaptcha_session.score < 0.2" + } + } + + header_action { + request_headers_to_adds { + header_name = "reCAPTCHA-Warning" + header_value = "high" + } + + request_headers_to_adds { + header_name = "X-Resource" + header_value = "test" + } + } + } +} +``` + ## Argument Reference The following arguments are supported: @@ -67,6 +139,8 @@ The following arguments are supported: * `adaptive_protection_config` - (Optional) Configuration for [Google Cloud Armor Adaptive Protection](https://cloud.google.com/armor/docs/adaptive-protection-overview?hl=en). Structure is [documented below](#nested_adaptive_protection_config). +* `recaptcha_options_config` - (Optional) [reCAPTCHA Configuration Options](https://cloud.google.com/armor/docs/configure-security-policies?hl=en#use_a_manual_challenge_to_distinguish_between_human_or_automated_clients). Structure is [documented below](#nested_recaptcha_options_config). + * `type` - The type indicates the intended use of the security policy. This field can be set only at resource creation time. * CLOUD_ARMOR - Cloud Armor backend security policies can be configured to filter incoming HTTP requests targeting backend services. They filter requests before they hit the origin servers. @@ -124,6 +198,9 @@ The following arguments are supported: * `redirect_options` - (Optional) Can be specified if the `action` is "redirect". Cannot be specified for other actions. Structure is [documented below](#nested_redirect_options). +* `header_action` - (Optional) + Additional actions that are performed on headers. Structure is [documented below](#nested_header_action). + The `match` block supports: * `config` - (Optional) The configuration options available when specifying `versioned_expr`. @@ -203,6 +280,8 @@ The following arguments are supported: * `exceed_action` - (Optional) When a request is denied, returns the HTTP response code specified. Valid options are "deny()" where valid values for status are 403, 404, 429, and 502. + +* `exceed_redirect_options` - (Optional) Parameters defining the redirect action that is used as the exceed action. Cannot be specified if the exceed action is not redirect. Structure is [documented below](#nested_exceed_redirect_options). * `rate_limit_threshold` - (Optional) Threshold at which to begin ratelimiting. Structure is [documented below](#nested_threshold). @@ -212,6 +291,12 @@ The following arguments are supported: * `interval_sec` - (Optional) Interval over which the threshold is computed. +* The `exceed_redirect_options` block supports: + +* `type` - (Required) Type of the redirect action. + +* `target` - (Optional) Target for the redirect action. This is required if the type is EXTERNAL_302 and cannot be specified for GOOGLE_RECAPTCHA. + The `redirect_options` block supports: * `type` - (Required) Type of redirect action. @@ -221,6 +306,16 @@ The following arguments are supported: * `target` - (Optional) External redirection target when "EXTERNAL_302" is set in 'type'. + The `header_action` block supports: + +* `request_headers_to_adds` - (Required) The list of request headers to add or overwrite if they're already present. Structure is [documented below](#nested_request_headers_to_adds). + + The `request_headers_to_adds` block supports: + +* `header_name` - (Required) The name of the header to set. + +* `header_value` - (Optional) The value to set the named header to. + The `adaptive_protection_config` block supports: * `layer_7_ddos_defense_config` - (Optional) Configuration for [Google Cloud Armor Adaptive Protection Layer 7 DDoS Defense](https://cloud.google.com/armor/docs/adaptive-protection-overview?hl=en). Structure is [documented below](#nested_layer_7_ddos_defense_config). @@ -231,6 +326,10 @@ The following arguments are supported: * `rule_visibility` - (Optional) Rule visibility can be one of the following: STANDARD - opaque rules. (default) PREMIUM - transparent rules. +The `recaptcha_options_config` block supports: + +* `redirect_site_key` - (Required) A field to supply a reCAPTCHA site key to be used for all the rules using the redirect action with the type of GOOGLE_RECAPTCHA under the security policy. The specified site key needs to be created from the reCAPTCHA API. The user is responsible for the validity of the specified site key. If not specified, a Google-managed site key is used. + ## Attributes Reference In addition to the arguments listed above, the following computed attributes are