From 818bd237a2f85a550fbbd73cc7ff000879a7ca99 Mon Sep 17 00:00:00 2001 From: Jesse Li Date: Mon, 25 Nov 2024 11:39:09 -0500 Subject: [PATCH 1/2] AUTH-6588 Support Access apps `destinations` field --- .changelog/4661.txt | 3 + docs/resources/access_application.md | 16 +++- .../zero_trust_access_application.md | 16 +++- .../resource_cloudflare_access_application.go | 48 +++++++++- ...urce_cloudflare_access_application_test.go | 88 +++++++++++++++++++ .../schema_cloudflare_access_application.go | 81 ++++++++++++++++- 6 files changed, 244 insertions(+), 8 deletions(-) create mode 100644 .changelog/4661.txt diff --git a/.changelog/4661.txt b/.changelog/4661.txt new file mode 100644 index 0000000000..f1f7d74af2 --- /dev/null +++ b/.changelog/4661.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +resource/access_application: add support for destinations and domain_type +``` diff --git a/docs/resources/access_application.md b/docs/resources/access_application.md index 51ecf9a242..f86241e53d 100644 --- a/docs/resources/access_application.md +++ b/docs/resources/access_application.md @@ -90,7 +90,9 @@ resource "cloudflare_zero_trust_access_application" "infra-app-example" { - `custom_deny_url` (String) Option that redirects to a custom URL when a user is denied access to the application via identity based rules. - `custom_non_identity_deny_url` (String) Option that redirects to a custom URL when a user is denied access to the application via non identity rules. - `custom_pages` (Set of String) The custom pages selected for the application. +- `destinations` (Block List) A destination secured by Access. Only present for self_hosted, vnc, and ssh applications. Always includes the value set as `domain`. Supersedes `self_hosted_domains` to allow for more flexibility in defining different types of destinations. Conflicts with `self_hosted_domains`. (see [below for nested schema](#nestedblock--destinations)) - `domain` (String) The primary hostname and path that Access will secure. If the app is visible in the App Launcher dashboard, this is the domain that will be displayed. +- `domain_type` (String) The type of the primary domain. Available values: `public`, `private`. - `enable_binding_cookie` (Boolean) Option to provide increased security against compromised authorization tokens and CSRF attacks by requiring an additional "binding" cookie on requests. Defaults to `false`. - `footer_links` (Block Set) The footer links of the app launcher. (see [below for nested schema](#nestedblock--footer_links)) - `header_bg_color` (String) The background color of the header bar in the app launcher. @@ -103,7 +105,7 @@ resource "cloudflare_zero_trust_access_application" "infra-app-example" { - `saas_app` (Block List, Max: 1) SaaS configuration for the Access Application. (see [below for nested schema](#nestedblock--saas_app)) - `same_site_cookie_attribute` (String) Defines the same-site cookie setting for access tokens. Available values: `none`, `lax`, `strict`. - `scim_config` (Block List, Max: 1) Configuration for provisioning to this application via SCIM. This is currently in closed beta. (see [below for nested schema](#nestedblock--scim_config)) -- `self_hosted_domains` (Set of String) List of domains that access will secure. Only present for self_hosted, vnc, and ssh applications. Always includes the value set as `domain`. +- `self_hosted_domains` (Set of String, Deprecated) List of public domains secured by Access. Only present for self_hosted, vnc, and ssh applications. Always includes the value set as `domain`. Deprecated in favor of `destinations` and will be removed in the next major version. Conflicts with `destinations`. - `service_auth_401_redirect` (Boolean) Option to return a 401 status code in service authentication rules on failed requests. Defaults to `false`. - `session_duration` (String) How often a user will be forced to re-authorise. Must be in the format `48h` or `2h45m`. Defaults to `24h`. - `skip_app_launcher_login_page` (Boolean) Option to skip the App Launcher landing page. Defaults to `false`. @@ -133,6 +135,18 @@ Optional: - `max_age` (Number) The maximum time a preflight request will be cached. + +### Nested Schema for `destinations` + +Required: + +- `uri` (String) The URI of the destination. Public destinations can include a domain and path with wildcards. Private destinations are an early access feature and gated behind a feature flag. Private destinations support private IPv4, IPv6, and Server Name Indications (SNI) with optional port ranges. + +Optional: + +- `type` (String) The destination type. Available values: `public`, `private`. Defaults to `public`. + + ### Nested Schema for `footer_links` diff --git a/docs/resources/zero_trust_access_application.md b/docs/resources/zero_trust_access_application.md index 14e48e0773..c2762f49c6 100644 --- a/docs/resources/zero_trust_access_application.md +++ b/docs/resources/zero_trust_access_application.md @@ -71,7 +71,9 @@ resource "cloudflare_zero_trust_access_application" "staging_app" { - `custom_deny_url` (String) Option that redirects to a custom URL when a user is denied access to the application via identity based rules. - `custom_non_identity_deny_url` (String) Option that redirects to a custom URL when a user is denied access to the application via non identity rules. - `custom_pages` (Set of String) The custom pages selected for the application. +- `destinations` (Block List) A destination secured by Access. Only present for self_hosted, vnc, and ssh applications. Always includes the value set as `domain`. Supersedes `self_hosted_domains` to allow for more flexibility in defining different types of destinations. Conflicts with `self_hosted_domains`. (see [below for nested schema](#nestedblock--destinations)) - `domain` (String) The primary hostname and path that Access will secure. If the app is visible in the App Launcher dashboard, this is the domain that will be displayed. +- `domain_type` (String) The type of the primary domain. Available values: `public`, `private`. - `enable_binding_cookie` (Boolean) Option to provide increased security against compromised authorization tokens and CSRF attacks by requiring an additional "binding" cookie on requests. Defaults to `false`. - `footer_links` (Block Set) The footer links of the app launcher. (see [below for nested schema](#nestedblock--footer_links)) - `header_bg_color` (String) The background color of the header bar in the app launcher. @@ -84,7 +86,7 @@ resource "cloudflare_zero_trust_access_application" "staging_app" { - `saas_app` (Block List, Max: 1) SaaS configuration for the Access Application. (see [below for nested schema](#nestedblock--saas_app)) - `same_site_cookie_attribute` (String) Defines the same-site cookie setting for access tokens. Available values: `none`, `lax`, `strict`. - `scim_config` (Block List, Max: 1) Configuration for provisioning to this application via SCIM. This is currently in closed beta. (see [below for nested schema](#nestedblock--scim_config)) -- `self_hosted_domains` (Set of String) List of domains that access will secure. Only present for self_hosted, vnc, and ssh applications. Always includes the value set as `domain`. +- `self_hosted_domains` (Set of String, Deprecated) List of public domains secured by Access. Only present for self_hosted, vnc, and ssh applications. Always includes the value set as `domain`. Deprecated in favor of `destinations` and will be removed in the next major version. Conflicts with `destinations`. - `service_auth_401_redirect` (Boolean) Option to return a 401 status code in service authentication rules on failed requests. Defaults to `false`. - `session_duration` (String) How often a user will be forced to re-authorise. Must be in the format `48h` or `2h45m`. Defaults to `24h`. - `skip_app_launcher_login_page` (Boolean) Option to skip the App Launcher landing page. Defaults to `false`. @@ -114,6 +116,18 @@ Optional: - `max_age` (Number) The maximum time a preflight request will be cached. + +### Nested Schema for `destinations` + +Required: + +- `uri` (String) The URI of the destination. Public destinations can include a domain and path with wildcards. Private destinations are an early access feature and gated behind a feature flag. Private destinations support private IPv4, IPv6, and Server Name Indications (SNI) with optional port ranges. + +Optional: + +- `type` (String) The destination type. Available values: `public`, `private`. Defaults to `public`. + + ### Nested Schema for `footer_links` diff --git a/internal/sdkv2provider/resource_cloudflare_access_application.go b/internal/sdkv2provider/resource_cloudflare_access_application.go index 22c22d8eb1..f543ac191f 100644 --- a/internal/sdkv2provider/resource_cloudflare_access_application.go +++ b/internal/sdkv2provider/resource_cloudflare_access_application.go @@ -88,7 +88,23 @@ func resourceCloudflareAccessApplicationCreate(ctx context.Context, d *schema.Re } if value, ok := d.GetOk("self_hosted_domains"); ok { - newAccessApplication.SelfHostedDomains = expandInterfaceToStringList(value.(*schema.Set).List()) + selfHostedDomains := expandInterfaceToStringList(value.(*schema.Set).List()) + destinations := make([]cloudflare.AccessDestination, len(selfHostedDomains)) + for i, uri := range selfHostedDomains { + destinations[i] = cloudflare.AccessDestination{ + Type: cloudflare.AccessDestinationPublic, + URI: uri, + } + } + newAccessApplication.Destinations = destinations + } + + if value, ok := d.GetOk("destinations"); ok { + destinations, err := convertDestinationsToStruct(value.([]interface{})) + if err != nil { + return diag.FromErr(err) + } + newAccessApplication.Destinations = destinations } if _, ok := d.GetOk("cors_headers"); ok { @@ -258,7 +274,17 @@ func resourceCloudflareAccessApplicationRead(ctx context.Context, d *schema.Reso } if _, ok := d.GetOk("self_hosted_domains"); ok { - d.Set("self_hosted_domains", accessApplication.SelfHostedDomains) + publicDomains := make([]string, 0, len(accessApplication.Destinations)) + for _, dest := range accessApplication.Destinations { + if dest.Type == cloudflare.AccessDestinationPublic { + publicDomains = append(publicDomains, dest.URI) + } + } + d.Set("self_hosted_domains", publicDomains) + } + + if _, ok := d.GetOk("destinations"); ok { + d.Set("destinations", convertDestinationsToSchema(accessApplication.Destinations)) } scimConfig := convertScimConfigStructToSchema(accessApplication.SCIMConfig) @@ -320,7 +346,23 @@ func resourceCloudflareAccessApplicationUpdate(ctx context.Context, d *schema.Re } if value, ok := d.GetOk("self_hosted_domains"); ok { - updatedAccessApplication.SelfHostedDomains = expandInterfaceToStringList(value.(*schema.Set).List()) + selfHostedDomains := expandInterfaceToStringList(value.(*schema.Set).List()) + destinations := make([]cloudflare.AccessDestination, len(selfHostedDomains)) + for i, uri := range selfHostedDomains { + destinations[i] = cloudflare.AccessDestination{ + Type: cloudflare.AccessDestinationPublic, + URI: uri, + } + } + updatedAccessApplication.Destinations = destinations + } + + if value, ok := d.GetOk("destinations"); ok { + destinations, err := convertDestinationsToStruct(value.([]interface{})) + if err != nil { + return diag.FromErr(err) + } + updatedAccessApplication.Destinations = destinations } if d.HasChange("policies") { diff --git a/internal/sdkv2provider/resource_cloudflare_access_application_test.go b/internal/sdkv2provider/resource_cloudflare_access_application_test.go index a101aaa5d8..d220fe29e2 100644 --- a/internal/sdkv2provider/resource_cloudflare_access_application_test.go +++ b/internal/sdkv2provider/resource_cloudflare_access_application_test.go @@ -948,6 +948,58 @@ func TestAccCloudflareAccessApplication_WithTargetContexts(t *testing.T) { }) } +func TestAccCloudflareAccessApplication_WithDestinations(t *testing.T) { + rnd := generateRandomResourceName() + name := fmt.Sprintf("cloudflare_zero_trust_access_application.%s", rnd) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccPreCheckAccount(t) + }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckCloudflareAccessApplicationDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCloudflareAccessApplicationWithDestinations(rnd, domain, cloudflare.AccountIdentifier(accountID)), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(name, consts.AccountIDSchemaKey, accountID), + resource.TestCheckResourceAttr(name, "name", rnd), + resource.TestCheckResourceAttr(name, "destinations.#", "2"), + resource.TestCheckResourceAttr(name, "destinations.0.type", "public"), + resource.TestCheckResourceAttr(name, "destinations.0.uri", fmt.Sprintf("d1.%s.%s", rnd, domain)), + resource.TestCheckResourceAttr(name, "destinations.1.type", "public"), + resource.TestCheckResourceAttr(name, "destinations.1.uri", fmt.Sprintf("d2.%s.%s", rnd, domain)), + resource.TestCheckResourceAttr(name, "name", rnd), + resource.TestCheckResourceAttr(name, "type", "self_hosted"), + resource.TestCheckResourceAttr(name, "session_duration", "24h"), + resource.TestCheckResourceAttr(name, "cors_headers.#", "0"), + resource.TestCheckResourceAttr(name, "sass_app.#", "0"), + resource.TestCheckResourceAttr(name, "auto_redirect_to_identity", "false"), + ), + }, + { + Config: testAccCloudflareAccessApplicationWithDestinations2(rnd, domain, cloudflare.AccountIdentifier(accountID)), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(name, consts.AccountIDSchemaKey, accountID), + resource.TestCheckResourceAttr(name, "name", rnd), + resource.TestCheckResourceAttr(name, "destinations.#", "2"), + resource.TestCheckResourceAttr(name, "destinations.0.type", "public"), + resource.TestCheckResourceAttr(name, "destinations.0.uri", fmt.Sprintf("d3.%s.%s", rnd, domain)), + resource.TestCheckResourceAttr(name, "destinations.1.type", "public"), + resource.TestCheckResourceAttr(name, "destinations.1.uri", fmt.Sprintf("d4.%s.%s", rnd, domain)), + resource.TestCheckResourceAttr(name, "name", rnd), + resource.TestCheckResourceAttr(name, "type", "self_hosted"), + resource.TestCheckResourceAttr(name, "session_duration", "24h"), + resource.TestCheckResourceAttr(name, "cors_headers.#", "0"), + resource.TestCheckResourceAttr(name, "sass_app.#", "0"), + resource.TestCheckResourceAttr(name, "auto_redirect_to_identity", "false"), + ), + }, + }, + }) +} + func TestAccCloudflareAccessApplication_WithSelfHostedDomains(t *testing.T) { rnd := generateRandomResourceName() name := fmt.Sprintf("cloudflare_zero_trust_access_application.%s", rnd) @@ -1443,6 +1495,42 @@ resource "cloudflare_zero_trust_access_application" "%[1]s" { `, rnd, domain, identifier.Type, identifier.Identifier) } +func testAccCloudflareAccessApplicationWithDestinations(rnd string, domain string, identifier *cloudflare.ResourceContainer) string { + return fmt.Sprintf(` +resource "cloudflare_zero_trust_access_application" "%[1]s" { + %[3]s_id = "%[4]s" + name = "%[1]s" + type = "self_hosted" + session_duration = "24h" + auto_redirect_to_identity = false + destinations { + uri = "d1.%[1]s.%[2]s" + } + destinations { + uri = "d2.%[1]s.%[2]s" + } +} +`, rnd, domain, identifier.Type, identifier.Identifier) +} + +func testAccCloudflareAccessApplicationWithDestinations2(rnd string, domain string, identifier *cloudflare.ResourceContainer) string { + return fmt.Sprintf(` +resource "cloudflare_zero_trust_access_application" "%[1]s" { + %[3]s_id = "%[4]s" + name = "%[1]s" + type = "self_hosted" + session_duration = "24h" + auto_redirect_to_identity = false + destinations { + uri = "d3.%[1]s.%[2]s" + } + destinations { + uri = "d4.%[1]s.%[2]s" + } +} +`, rnd, domain, identifier.Type, identifier.Identifier) +} + func testAccCloudflareAccessApplicationWithSelfHostedDomains(rnd string, domain string, identifier *cloudflare.ResourceContainer) string { return fmt.Sprintf(` resource "cloudflare_zero_trust_access_application" "%[1]s" { diff --git a/internal/sdkv2provider/schema_cloudflare_access_application.go b/internal/sdkv2provider/schema_cloudflare_access_application.go index eae91df2e2..e5e8967b8d 100644 --- a/internal/sdkv2provider/schema_cloudflare_access_application.go +++ b/internal/sdkv2provider/schema_cloudflare_access_application.go @@ -54,13 +54,53 @@ func resourceCloudflareAccessApplicationSchema() map[string]*schema.Schema { return oldValue == newValue }, }, + "domain_type": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ValidateFunc: validation.StringInSlice([]string{"public", "private"}, false), + Description: fmt.Sprintf("The type of the primary domain. %s", renderAvailableDocumentationValuesStringSlice([]string{"public", "private"})), + DiffSuppressFunc: func(k, oldValue, newValue string, d *schema.ResourceData) bool { + appType := d.Get("type").(string) + // Suppress the diff if it's an app type that doesn't need a `domain` value. + if appType == "infrastructure" { + return true + } + + return oldValue == newValue + }, + }, + "destinations": { + Type: schema.TypeList, + Optional: true, + ConflictsWith: []string{"self_hosted_domains"}, + Description: "A destination secured by Access. Only present for self_hosted, vnc, and ssh applications. Always includes the value set as `domain`. Supersedes `self_hosted_domains` to allow for more flexibility in defining different types of destinations.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "type": { + Type: schema.TypeString, + Default: "public", + Optional: true, + ValidateFunc: validation.StringInSlice([]string{"public", "private"}, false), + Description: fmt.Sprintf("The destination type. %s", renderAvailableDocumentationValuesStringSlice([]string{"public", "private"})), + }, + "uri": { + Type: schema.TypeString, + Required: true, + Description: "The URI of the destination. Public destinations can include a domain and path with wildcards. Private destinations are an early access feature and gated behind a feature flag. Private destinations support private IPv4, IPv6, and Server Name Indications (SNI) with optional port ranges.", + }, + }, + }, + }, "self_hosted_domains": { - Type: schema.TypeSet, - Optional: true, + Type: schema.TypeSet, + Optional: true, + ConflictsWith: []string{"destinations"}, Elem: &schema.Schema{ Type: schema.TypeString, }, - Description: "List of domains that access will secure. Only present for self_hosted, vnc, and ssh applications. Always includes the value set as `domain`", + Description: "List of public domains secured by Access. Only present for self_hosted, vnc, and ssh applications. Always includes the value set as `domain`. Deprecated in favor of `destinations` and will be removed in the next major version.", + Deprecated: "Use `destinations` instead", }, "type": { Type: schema.TypeString, @@ -997,6 +1037,30 @@ func convertSaasSchemaToStruct(d *schema.ResourceData) *cloudflare.SaasApplicati return &SaasConfig } +func convertDestinationsToStruct(destinationPayloads []interface{}) ([]cloudflare.AccessDestination, error) { + destinations := make([]cloudflare.AccessDestination, len(destinationPayloads)) + for i, dp := range destinationPayloads { + dpMap := dp.(map[string]interface{}) + + if dType, ok := dpMap["type"].(string); ok { + switch dType { + case "public": + destinations[i].Type = cloudflare.AccessDestinationPublic + case "private": + destinations[i].Type = cloudflare.AccessDestinationPrivate + default: + return nil, fmt.Errorf("failed to parse destination type: value must be one of public or private") + } + } + + if uri, ok := dpMap["uri"].(string); ok { + destinations[i].URI = uri + } + } + + return destinations, nil +} + func convertTargetContextsToStruct(d *schema.ResourceData) (*[]cloudflare.AccessInfrastructureTargetContext, error) { TargetContexts := []cloudflare.AccessInfrastructureTargetContext{} if value, ok := d.GetOk("target_criteria"); ok { @@ -1447,3 +1511,14 @@ func convertScimConfigMappingsStructsToSchema(mappingsData []*cloudflare.AccessA return mappings } + +func convertDestinationsToSchema(destinations []cloudflare.AccessDestination) []interface{} { + schemas := make([]interface{}, len(destinations)) + for i, dest := range destinations { + schemas[i] = map[string]interface{}{ + "type": string(dest.Type), + "uri": dest.URI, + } + } + return schemas +} From 937a007ebbbbb16213b92a5a368638ad2b687d1b Mon Sep 17 00:00:00 2001 From: Philip Skinner Date: Fri, 22 Nov 2024 10:41:23 +0000 Subject: [PATCH 2/2] According to the documentation the enabled flag must be set to null (or nil) in order for the association to be destroyed. Setting this to false keeps the association in a disabled state instead of destroying the resource. Documentation on this API endpoint can be found here: https://developers.cloudflare.com/api/operations/per-hostname-authenticated-origin-pull-enable-or-disable-a-hostname-for-client-authentication Referencing issue raised on provider: https://github.com/cloudflare/terraform-provider-cloudflare/issues/4648 --- .changelog/4649.txt | 3 +++ .../resource_cloudflare_authenticated_origin_pulls.go | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 .changelog/4649.txt diff --git a/.changelog/4649.txt b/.changelog/4649.txt new file mode 100644 index 0000000000..ca3f4f6e58 --- /dev/null +++ b/.changelog/4649.txt @@ -0,0 +1,3 @@ +```release-note:bug +resource/cloudflare_authenticated_origin_pulls: Fix issue where resources are disabled instead of being destroyed on `tf destroy` +``` diff --git a/internal/sdkv2provider/resource_cloudflare_authenticated_origin_pulls.go b/internal/sdkv2provider/resource_cloudflare_authenticated_origin_pulls.go index 5adc00ed8e..a6719fecdf 100644 --- a/internal/sdkv2provider/resource_cloudflare_authenticated_origin_pulls.go +++ b/internal/sdkv2provider/resource_cloudflare_authenticated_origin_pulls.go @@ -48,7 +48,7 @@ func resourceCloudflareAuthenticatedOriginPullsCreate(ctx context.Context, d *sc conf := []cloudflare.PerHostnameAuthenticatedOriginPullsConfig{{ CertID: aopCert, Hostname: hostname, - Enabled: isEnabled, + Enabled: &isEnabled, }} _, err := client.EditPerHostnameAuthenticatedOriginPullsConfig(ctx, zoneID, conf) if err != nil { @@ -123,7 +123,7 @@ func resourceCloudflareAuthenticatedOriginPullsDelete(ctx context.Context, d *sc conf := []cloudflare.PerHostnameAuthenticatedOriginPullsConfig{{ CertID: aopCert, Hostname: hostname, - Enabled: false, + Enabled: nil, }} _, err := client.EditPerHostnameAuthenticatedOriginPullsConfig(ctx, zoneID, conf) if err != nil {