diff --git a/.changelog/4128.txt b/.changelog/4128.txt new file mode 100644 index 0000000000..da520f8332 --- /dev/null +++ b/.changelog/4128.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +resource/cloudflare_access_application: added target contexts support for access application type infrastructure +``` + +```release-note:enhancement +resource/cloudflare_access_policy: added infrastructure connection rule support for access policy +``` diff --git a/docs/resources/access_application.md b/docs/resources/access_application.md index a8d44fb735..38db3a0938 100644 --- a/docs/resources/access_application.md +++ b/docs/resources/access_application.md @@ -53,6 +53,25 @@ resource "cloudflare_access_application" "staging_app" { max_age = 10 } } + +# Infrastructure application configuration +resource "cloudflare_zero_trust_access_application" "infra-app-example" { + account_id = "0da42c8d2132a9ddaf714f9e7c920711" + name = "infra-app" + type = "infrastructure" + + target_criteria { + port = 22 + protocol = "SSH" + target_attributes { + name = "hostname" + values = ["tfgo-tests-useast", "tfgo-tests-uswest"] + } + } + + # specify existing access policies by id + policies = [] +} ``` ## Schema @@ -90,7 +109,8 @@ resource "cloudflare_access_application" "staging_app" { - `skip_app_launcher_login_page` (Boolean) Option to skip the App Launcher landing page. Defaults to `false`. - `skip_interstitial` (Boolean) Option to skip the authorization interstitial when using the CLI. Defaults to `false`. - `tags` (Set of String) The itags associated with the application. -- `type` (String) The application type. Available values: `app_launcher`, `bookmark`, `biso`, `dash_sso`, `saas`, `self_hosted`, `ssh`, `vnc`, `warp`. Defaults to `self_hosted`. +- `target_criteria` (Block List) A list of mappings to apply to SCIM resources before provisioning them in this application. These can transform or filter the resources to be provisioned. (see [below for nested schema](#nestedblock--target_criteria)) +- `type` (String) The application type. Available values: `app_launcher`, `bookmark`, `biso`, `dash_sso`, `saas`, `self_hosted`, `ssh`, `vnc`, `warp`, `infrastructure`. Defaults to `self_hosted`. - `zone_id` (String) The zone identifier to target for the resource. Conflicts with `account_id`. ### Read-Only @@ -294,6 +314,26 @@ Optional: - `delete` (Boolean) Whether or not this mapping applies to DELETE operations. - `update` (Boolean) Whether or not this mapping applies to update (PATCH/PUT) operations. + + + + +### Nested Schema for `target_criteria` + +Required: + +- `port` (Number) The port that the targets use for the chosen communication protocol. A port cannot be assigned to multiple protocols. +- `protocol` (String) The communication protocol your application secures. +- `target_attributes` (Block List, Min: 1) Contains a map of target attribute keys to target attribute values. (see [below for nested schema](#nestedblock--target_criteria--target_attributes)) + + +### Nested Schema for `target_criteria.target_attributes` + +Required: + +- `name` (String) The key of the attribute. +- `values` (List of String) The values of the attribute. + ## Import Import is supported using the following syntax: diff --git a/docs/resources/access_policy.md b/docs/resources/access_policy.md index ed247a374c..78d958be54 100644 --- a/docs/resources/access_policy.md +++ b/docs/resources/access_policy.md @@ -54,6 +54,44 @@ resource "cloudflare_access_policy" "test_policy" { ip = [var.office_ip] } } + +# Access policy for an infrastructure application +resource "cloudflare_access_policy" "infra-app-example-allow" { + application_id = cloudflare_zero_trust_access_application.infra-app-example.id + account_id = "0da42c8d2132a9ddaf714f9e7c920711" + name = "infra-app-example-allow" + decision = "allow" + precedence = 1 + + include { + email = ["devuser@gmail.com"] + } + + connection_rules { + ssh { + usernames = ["ec2-user"] + } + } +} + +# Infrastructure application configuration for infra-app-example-allow +resource "cloudflare_zero_trust_access_application" "infra-app-example" { + account_id = "0da42c8d2132a9ddaf714f9e7c920711" + name = "infra-app" + type = "infrastructure" + + target_criteria { + port = 22 + protocol = "SSH" + target_attributes { + name = "hostname" + values = ["tfgo-tests-useast", "tfgo-tests-uswest"] + } + } + + # specify existing access policies by id + policies = [] +} ``` ## Schema @@ -70,6 +108,7 @@ resource "cloudflare_access_policy" "test_policy" { - `application_id` (String) The ID of the application the policy is associated with. Required when using `precedence`. **Modifying this attribute will force creation of a new resource.** - `approval_group` (Block List) (see [below for nested schema](#nestedblock--approval_group)) - `approval_required` (Boolean) +- `connection_rules` (Block List, Max: 1) The rules that define how users may connect to the targets secured by your application. (see [below for nested schema](#nestedblock--connection_rules)) - `exclude` (Block List) A series of access conditions, see [Access Groups](https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs/resources/access_group#conditions). (see [below for nested schema](#nestedblock--exclude)) - `isolation_required` (Boolean) Require this application to be served in an isolated browser for users matching this policy. - `precedence` (Number) The unique precedence for policies on a single application. Required when using `application_id`. @@ -192,6 +231,22 @@ Optional: - `email_list_uuid` (String) + +### Nested Schema for `connection_rules` + +Required: + +- `ssh` (Block List, Min: 1, Max: 1) The SSH-specific rules that define how users may connect to the targets secured by your application. (see [below for nested schema](#nestedblock--connection_rules--ssh)) + + +### Nested Schema for `connection_rules.ssh` + +Required: + +- `usernames` (List of String) Contains the Unix usernames that may be used when connecting over SSH. + + + ### Nested Schema for `exclude` diff --git a/docs/resources/infrastructure_access_target.md b/docs/resources/infrastructure_access_target.md index 3c51b6b291..916bc42b73 100644 --- a/docs/resources/infrastructure_access_target.md +++ b/docs/resources/infrastructure_access_target.md @@ -9,7 +9,35 @@ description: |- The [Infrastructure Access Target](https://developers.cloudflare.com/cloudflare-one/insights/risk-score/) resource allows you to configure Cloudflare Risk Behaviors for an account. - +## Example Usage + +```terraform +resource "cloudflare_infrastructure_access_target" "example" { + account_id = "f037e56e89293a057740de681ac9abbe" + hostname = "example-target" + ip = { + ipv4 = { + ip_addr = "210.26.29.230" + virtual_network_id = "238dccd1-149b-463d-8228-560ab83a54fd" + } + ipv6 = { + ip_addr = "24c0:64e8:f0b4:8dbf:7104:72b0:ef8f:f5e0" + virtual_network_id = "238dccd1-149b-463d-8228-560ab83a54fd" + } + } +} + +resource "cloudflare_infrastructure_access_target" "ipv4_only_example" { + account_id = "f037e56e89293a057740de681ac9abbe" + hostname = "example-ipv4-only" + ip = { + ipv4 = { + ip_addr = "210.26.29.230" + virtual_network_id = "238dccd1-149b-463d-8228-560ab83a54fd" + } + } +} +``` ## Schema @@ -50,4 +78,10 @@ Required: - `ip_addr` (String) The IP address of the target. - `virtual_network_id` (String) The private virtual network identifier for the target. +## Import + +Import is supported using the following syntax: +```shell +$ terraform import cloudflare_infrastructure_access_target.example +``` diff --git a/docs/resources/zero_trust_access_application.md b/docs/resources/zero_trust_access_application.md index e2db34220d..09dd8d28b2 100644 --- a/docs/resources/zero_trust_access_application.md +++ b/docs/resources/zero_trust_access_application.md @@ -90,7 +90,8 @@ resource "cloudflare_zero_trust_access_application" "staging_app" { - `skip_app_launcher_login_page` (Boolean) Option to skip the App Launcher landing page. Defaults to `false`. - `skip_interstitial` (Boolean) Option to skip the authorization interstitial when using the CLI. Defaults to `false`. - `tags` (Set of String) The itags associated with the application. -- `type` (String) The application type. Available values: `app_launcher`, `bookmark`, `biso`, `dash_sso`, `saas`, `self_hosted`, `ssh`, `vnc`, `warp`. Defaults to `self_hosted`. +- `target_criteria` (Block List) A list of mappings to apply to SCIM resources before provisioning them in this application. These can transform or filter the resources to be provisioned. (see [below for nested schema](#nestedblock--target_criteria)) +- `type` (String) The application type. Available values: `app_launcher`, `bookmark`, `biso`, `dash_sso`, `saas`, `self_hosted`, `ssh`, `vnc`, `warp`, `infrastructure`. Defaults to `self_hosted`. - `zone_id` (String) The zone identifier to target for the resource. Conflicts with `account_id`. ### Read-Only @@ -294,6 +295,26 @@ Optional: - `delete` (Boolean) Whether or not this mapping applies to DELETE operations. - `update` (Boolean) Whether or not this mapping applies to update (PATCH/PUT) operations. + + + + +### Nested Schema for `target_criteria` + +Required: + +- `port` (Number) The port that the targets use for the chosen communication protocol. A port cannot be assigned to multiple protocols. +- `protocol` (String) The communication protocol your application secures. +- `target_attributes` (Block List, Min: 1) Contains a map of target attribute keys to target attribute values. (see [below for nested schema](#nestedblock--target_criteria--target_attributes)) + + +### Nested Schema for `target_criteria.target_attributes` + +Required: + +- `name` (String) The key of the attribute. +- `values` (List of String) The values of the attribute. + ## Import Import is supported using the following syntax: diff --git a/docs/resources/zero_trust_access_policy.md b/docs/resources/zero_trust_access_policy.md index a250658754..debcb49eda 100644 --- a/docs/resources/zero_trust_access_policy.md +++ b/docs/resources/zero_trust_access_policy.md @@ -70,6 +70,7 @@ resource "cloudflare_zero_trust_access_policy" "test_policy" { - `application_id` (String) The ID of the application the policy is associated with. Required when using `precedence`. **Modifying this attribute will force creation of a new resource.** - `approval_group` (Block List) (see [below for nested schema](#nestedblock--approval_group)) - `approval_required` (Boolean) +- `connection_rules` (Block List, Max: 1) The rules that define how users may connect to the targets secured by your application. (see [below for nested schema](#nestedblock--connection_rules)) - `exclude` (Block List) A series of access conditions, see [Access Groups](https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs/resources/access_group#conditions). (see [below for nested schema](#nestedblock--exclude)) - `isolation_required` (Boolean) Require this application to be served in an isolated browser for users matching this policy. - `precedence` (Number) The unique precedence for policies on a single application. Required when using `application_id`. @@ -192,6 +193,22 @@ Optional: - `email_list_uuid` (String) + +### Nested Schema for `connection_rules` + +Required: + +- `ssh` (Block List, Min: 1, Max: 1) The SSH-specific rules that define how users may connect to the targets secured by your application. (see [below for nested schema](#nestedblock--connection_rules--ssh)) + + +### Nested Schema for `connection_rules.ssh` + +Required: + +- `usernames` (List of String) Contains the Unix usernames that may be used when connecting over SSH. + + + ### Nested Schema for `exclude` diff --git a/examples/data-sources/cloudflare_infrastructure_access_target/data-source.tf b/examples/data-sources/cloudflare_infrastructure_access_target/data-source.tf new file mode 100644 index 0000000000..a9a5b1e3d5 --- /dev/null +++ b/examples/data-sources/cloudflare_infrastructure_access_target/data-source.tf @@ -0,0 +1,11 @@ +data "cloudflare_infrastructure_access_targets" "example" { + account_id = "f037e56e89293a057740de681ac9abbe" + # Query parameters + hostname_contains = "example" + ipv4 = "210.26.29.230" +} + +# output the list of targets the data source contains +output "targets" { + value = data.cloudflare_infrastructure_access_targets.example.targets +} diff --git a/examples/resources/cloudflare_access_application/resource.tf b/examples/resources/cloudflare_access_application/resource.tf index 5c536b2494..80eff6e3e0 100644 --- a/examples/resources/cloudflare_access_application/resource.tf +++ b/examples/resources/cloudflare_access_application/resource.tf @@ -29,3 +29,22 @@ resource "cloudflare_access_application" "staging_app" { max_age = 10 } } + +# Infrastructure application configuration +resource "cloudflare_zero_trust_access_application" "infra-app-example" { + account_id = "0da42c8d2132a9ddaf714f9e7c920711" + name = "infra-app" + type = "infrastructure" + + target_criteria { + port = 22 + protocol = "SSH" + target_attributes { + name = "hostname" + values = ["tfgo-tests-useast", "tfgo-tests-uswest"] + } + } + + # specify existing access policies by id + policies = [] +} diff --git a/examples/resources/cloudflare_access_policy/resource.tf b/examples/resources/cloudflare_access_policy/resource.tf index 24a8c70bae..2ec5d95add 100644 --- a/examples/resources/cloudflare_access_policy/resource.tf +++ b/examples/resources/cloudflare_access_policy/resource.tf @@ -28,3 +28,41 @@ resource "cloudflare_access_policy" "test_policy" { ip = [var.office_ip] } } + +# Access policy for an infrastructure application +resource "cloudflare_access_policy" "infra-app-example-allow" { + application_id = cloudflare_zero_trust_access_application.infra-app-example.id + account_id = "0da42c8d2132a9ddaf714f9e7c920711" + name = "infra-app-example-allow" + decision = "allow" + precedence = 1 + + include { + email = ["devuser@gmail.com"] + } + + connection_rules { + ssh { + usernames = ["ec2-user"] + } + } +} + +# Infrastructure application configuration for infra-app-example-allow +resource "cloudflare_zero_trust_access_application" "infra-app-example" { + account_id = "0da42c8d2132a9ddaf714f9e7c920711" + name = "infra-app" + type = "infrastructure" + + target_criteria { + port = 22 + protocol = "SSH" + target_attributes { + name = "hostname" + values = ["tfgo-tests-useast", "tfgo-tests-uswest"] + } + } + + # specify existing access policies by id + policies = [] +} diff --git a/examples/resources/cloudflare_infrastructure_access_target/import.sh b/examples/resources/cloudflare_infrastructure_access_target/import.sh new file mode 100644 index 0000000000..7676dca7af --- /dev/null +++ b/examples/resources/cloudflare_infrastructure_access_target/import.sh @@ -0,0 +1 @@ +$ terraform import cloudflare_infrastructure_access_target.example diff --git a/examples/resources/cloudflare_infrastructure_access_target/resource.tf b/examples/resources/cloudflare_infrastructure_access_target/resource.tf new file mode 100644 index 0000000000..fe533cf51c --- /dev/null +++ b/examples/resources/cloudflare_infrastructure_access_target/resource.tf @@ -0,0 +1,25 @@ +resource "cloudflare_infrastructure_access_target" "example" { + account_id = "f037e56e89293a057740de681ac9abbe" + hostname = "example-target" + ip = { + ipv4 = { + ip_addr = "210.26.29.230" + virtual_network_id = "238dccd1-149b-463d-8228-560ab83a54fd" + } + ipv6 = { + ip_addr = "24c0:64e8:f0b4:8dbf:7104:72b0:ef8f:f5e0" + virtual_network_id = "238dccd1-149b-463d-8228-560ab83a54fd" + } + } +} + +resource "cloudflare_infrastructure_access_target" "ipv4_only_example" { + account_id = "f037e56e89293a057740de681ac9abbe" + hostname = "example-ipv4-only" + ip = { + ipv4 = { + ip_addr = "210.26.29.230" + virtual_network_id = "238dccd1-149b-463d-8228-560ab83a54fd" + } + } +} diff --git a/internal/sdkv2provider/resource_cloudflare_access_application.go b/internal/sdkv2provider/resource_cloudflare_access_application.go index 4eb7fb0611..22c22d8eb1 100644 --- a/internal/sdkv2provider/resource_cloudflare_access_application.go +++ b/internal/sdkv2provider/resource_cloudflare_access_application.go @@ -103,6 +103,14 @@ func resourceCloudflareAccessApplicationCreate(ctx context.Context, d *schema.Re newAccessApplication.SaasApplication = convertSaasSchemaToStruct(d) } + if _, ok := d.GetOk("target_criteria"); ok { + target_contexts, err := convertTargetContextsToStruct(d) + if err != nil { + return diag.FromErr(err) + } + newAccessApplication.TargetContexts = target_contexts + } + if value, ok := d.GetOk("tags"); ok { newAccessApplication.Tags = expandInterfaceToStringList(value.(*schema.Set).List()) } @@ -244,6 +252,11 @@ func resourceCloudflareAccessApplicationRead(ctx context.Context, d *schema.Reso return diag.FromErr(fmt.Errorf("error setting Access Application SaaS app configuration: %w", saasConfigErr)) } + targetContexts := convertTargetContextsToSchema(accessApplication.TargetContexts) + if targetContextsErr := d.Set("target_criteria", targetContexts); targetContextsErr != nil { + return diag.FromErr(fmt.Errorf("error setting Access Application Infrastructure app configuration: %w", targetContextsErr)) + } + if _, ok := d.GetOk("self_hosted_domains"); ok { d.Set("self_hosted_domains", accessApplication.SelfHostedDomains) } @@ -328,6 +341,14 @@ func resourceCloudflareAccessApplicationUpdate(ctx context.Context, d *schema.Re updatedAccessApplication.SaasApplication = saasConfig } + if _, ok := d.GetOk("target_criteria"); ok { + target_contexts, err := convertTargetContextsToStruct(d) + if err != nil { + return diag.FromErr(err) + } + updatedAccessApplication.TargetContexts = target_contexts + } + if value, ok := d.GetOk("tags"); ok { updatedAccessApplication.Tags = expandInterfaceToStringList(value.(*schema.Set).List()) } diff --git a/internal/sdkv2provider/resource_cloudflare_access_application_test.go b/internal/sdkv2provider/resource_cloudflare_access_application_test.go index 9af3d79834..946ab45d29 100644 --- a/internal/sdkv2provider/resource_cloudflare_access_application_test.go +++ b/internal/sdkv2provider/resource_cloudflare_access_application_test.go @@ -919,6 +919,34 @@ func TestAccCloudflareAccessApplication_WithAppLauncherVisible(t *testing.T) { }) } +func TestAccCloudflareAccessApplication_WithTargetContexts(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: testAccCloudflareAccessApplicationWithTargetContexts(rnd, domain, cloudflare.AccountIdentifier(accountID)), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(name, consts.AccountIDSchemaKey, accountID), + resource.TestCheckResourceAttr(name, "name", rnd), + resource.TestCheckResourceAttr(name, "type", "infrastructure"), + resource.TestCheckResourceAttr(name, "target_criteria.0.port", "22"), + resource.TestCheckResourceAttr(name, "target_criteria.0.protocol", "SSH"), + resource.TestCheckResourceAttr(name, "target_criteria.0.target_attributes.0.name", "hostname"), + resource.TestCheckResourceAttr(name, "target_criteria.0.target_attributes.0.values.0", "tfgo-acc-test"), + ), + }, + }, + }) +} + func TestAccCloudflareAccessApplication_WithSelfHostedDomains(t *testing.T) { rnd := generateRandomResourceName() name := fmt.Sprintf("cloudflare_zero_trust_access_application.%s", rnd) @@ -1396,6 +1424,24 @@ func testAccessApplicationWithAppLauncherCustomizationFields(rnd, accountID stri `, rnd, accountID) } +func testAccCloudflareAccessApplicationWithTargetContexts(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 = "infrastructure" + target_criteria { + port = 22 + protocol = "SSH" + target_attributes { + name = "hostname" + values = ["tfgo-acc-test"] + } + } +} +`, 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/resource_cloudflare_access_policy.go b/internal/sdkv2provider/resource_cloudflare_access_policy.go index 6c00dd6c6e..9ecb7c4dd6 100644 --- a/internal/sdkv2provider/resource_cloudflare_access_policy.go +++ b/internal/sdkv2provider/resource_cloudflare_access_policy.go @@ -64,6 +64,46 @@ func apiAccessPolicyApprovalGroupToSchema(approvalGroup cloudflare.AccessApprova return data } +func schemaAccessPolicyConnectionRulesToAPI(connectionRules map[string]interface{}) (*cloudflare.AccessInfrastructureConnectionRules, error) { + usernames := []string{} + if sshVal, ok := connectionRules["ssh"].([]interface{}); ok && len(sshVal) > 0 { + if sshMap, ok := sshVal[0].(map[string]interface{}); ok { + str_return := []string{} + if usernamesMap, ok := sshMap["usernames"].([]interface{}); ok { + for _, username := range usernamesMap { + str_return = append(str_return, username.(string)) + } + } + usernames = str_return + } + } + + return &cloudflare.AccessInfrastructureConnectionRules{ + SSH: &cloudflare.AccessInfrastructureConnectionRulesSSH{ + Usernames: usernames, + }, + }, nil +} + +func apiAccessPolicyConnectionRulesToSchema(connectionRules *cloudflare.AccessInfrastructureConnectionRules) []interface{} { + if connectionRules == nil { + return []interface{}{} + } + + var connectionRulesSchema []interface{} + var usernameList []map[string]interface{} + + usernameMap := map[string]interface{}{ + "usernames": connectionRules.SSH.Usernames, + } + usernameList = append(usernameList, usernameMap) + connectionRulesSchema = append(connectionRulesSchema, map[string]interface{}{ + "ssh": usernameList, + }) + + return connectionRulesSchema +} + func schemaAccessPolicyApprovalGroupToAPI(data map[string]interface{}) cloudflare.AccessApprovalGroup { var approvalGroup cloudflare.AccessApprovalGroup @@ -87,6 +127,10 @@ func apiCloudflareAccessPolicyToResource(ctx context.Context, d *schema.Resource d.Set("name", accessPolicy.Name) d.Set("decision", accessPolicy.Decision) + if err := d.Set("connection_rules", apiAccessPolicyConnectionRulesToSchema(accessPolicy.InfrastructureConnectionRules)); err != nil { + return diag.FromErr(fmt.Errorf("failed to set connection_rules attribute: %w", err)) + } + if err := d.Set("require", TransformAccessGroupForSchema(ctx, accessPolicy.Require)); err != nil { return diag.FromErr(fmt.Errorf("failed to set require attribute: %w", err)) } @@ -166,6 +210,15 @@ func resourceCloudflareAccessPolicyCreate(ctx context.Context, d *schema.Resourc return diag.FromErr(fmt.Errorf("application_id is required for non-account level Access Policies")) } + connectionRulesSchema, ok := d.Get("connection_rules").([]interface{}) + if ok && len(connectionRulesSchema) > 0 { + connectionRules, err := schemaAccessPolicyConnectionRulesToAPI(connectionRulesSchema[0].(map[string]interface{})) + if err != nil { + return diag.FromErr(err) + } + newAccessPolicy.InfrastructureConnectionRules = connectionRules + } + exclude := d.Get("exclude").([]interface{}) for _, value := range exclude { if value != nil { @@ -230,6 +283,15 @@ func resourceCloudflareAccessPolicyUpdate(ctx context.Context, d *schema.Resourc SessionDuration: cloudflare.StringPtr(d.Get("session_duration").(string)), } + connectionRulesSchema, ok := d.Get("connection_rules").([]interface{}) + if ok && len(connectionRulesSchema) > 0 { + connectionRules, err := schemaAccessPolicyConnectionRulesToAPI(connectionRulesSchema[0].(map[string]interface{})) + if err != nil { + return diag.FromErr(err) + } + updateReq.InfrastructureConnectionRules = connectionRules + } + exclude := d.Get("exclude").([]interface{}) for _, value := range exclude { if value != nil { diff --git a/internal/sdkv2provider/resource_cloudflare_access_policy_test.go b/internal/sdkv2provider/resource_cloudflare_access_policy_test.go index 64e93092b1..de4a45a4eb 100644 --- a/internal/sdkv2provider/resource_cloudflare_access_policy_test.go +++ b/internal/sdkv2provider/resource_cloudflare_access_policy_test.go @@ -972,6 +972,68 @@ func testAccessPolicyExternalEvalautionConfig(resourceID, zone, accountID string `, resourceID, zone, accountID) } +func TestAccCloudflareAccessPolicy_ConnectionRules(t *testing.T) { + rnd := generateRandomResourceName() + name := "cloudflare_access_policy." + rnd + zone := os.Getenv("CLOUDFLARE_DOMAIN") + accountID := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccPreCheckAccount(t) + }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: testAccessPolicyConnectionRulesConfig(rnd, zone, accountID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(name, "name", rnd), + resource.TestCheckResourceAttr(name, consts.AccountIDSchemaKey, accountID), + resource.TestCheckResourceAttr(name, "connection_rules.0.ssh.0.usernames.0", "tfgo-acc-test"), + resource.TestCheckResourceAttr(name, "include.0.email.0", "devuser@cloudflare.com"), + ), + }, + }, + }) +} + +func testAccessPolicyConnectionRulesConfig(resourceID, zone, accountID string) string { + return fmt.Sprintf(` + resource "cloudflare_zero_trust_access_application" "%[1]s" { + name = "%[1]s" + type = "infrastructure" + account_id = "%[3]s" + domain = "%[1]s.%[2]s" + target_criteria { + port = 22 + protocol = "SSH" + target_attributes { + name = "hostname" + values = ["tfgo-acc-test"] + } + } + } + + resource "cloudflare_access_policy" "%[1]s" { + application_id = cloudflare_zero_trust_access_application.%[1]s.id + name = "%[1]s" + account_id = "%[3]s" + decision = "allow" + precedence = "1" + connection_rules { + ssh { + usernames = ["tfgo-acc-test"] + } + } + include { + email = ["devuser@cloudflare.com"] + } + } + + `, resourceID, zone, accountID) +} + func TestAccCloudflareAccessPolicy_IsolationRequired(t *testing.T) { rnd := generateRandomResourceName() name := "cloudflare_access_policy." + rnd diff --git a/internal/sdkv2provider/schema_cloudflare_access_application.go b/internal/sdkv2provider/schema_cloudflare_access_application.go index db61b9e02d..f89487b0b2 100644 --- a/internal/sdkv2provider/schema_cloudflare_access_application.go +++ b/internal/sdkv2provider/schema_cloudflare_access_application.go @@ -44,6 +44,15 @@ func resourceCloudflareAccessApplicationSchema() map[string]*schema.Schema { Optional: true, Computed: true, Description: "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.", + 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 + }, }, "self_hosted_domains": { Type: schema.TypeSet, @@ -57,8 +66,8 @@ func resourceCloudflareAccessApplicationSchema() map[string]*schema.Schema { Type: schema.TypeString, Optional: true, Default: "self_hosted", - ValidateFunc: validation.StringInSlice([]string{"app_launcher", "bookmark", "biso", "dash_sso", "saas", "self_hosted", "ssh", "vnc", "warp"}, false), - Description: fmt.Sprintf("The application type. %s", renderAvailableDocumentationValuesStringSlice([]string{"app_launcher", "bookmark", "biso", "dash_sso", "saas", "self_hosted", "ssh", "vnc", "warp"})), + ValidateFunc: validation.StringInSlice([]string{"app_launcher", "bookmark", "biso", "dash_sso", "saas", "self_hosted", "ssh", "vnc", "warp", "infrastructure"}, false), + Description: fmt.Sprintf("The application type. %s", renderAvailableDocumentationValuesStringSlice([]string{"app_launcher", "bookmark", "biso", "dash_sso", "saas", "self_hosted", "ssh", "vnc", "warp", "infrastructure"})), }, "policies": { Type: schema.TypeList, @@ -76,9 +85,8 @@ func resourceCloudflareAccessApplicationSchema() map[string]*schema.Schema { Default: "24h", DiffSuppressFunc: func(k, oldValue, newValue string, d *schema.ResourceData) bool { appType := d.Get("type").(string) - // Suppress the diff if it's a bookmark app type. Bookmarks don't have a session duration - // field which always creates a diff because of the default '24h' value. - if appType == "bookmark" { + // Suppress the diff if it's an app type that doesn't need a `session_duration` value. + if appType == "bookmark" || appType == "infrastructure" { return true } @@ -405,6 +413,47 @@ func resourceCloudflareAccessApplicationSchema() map[string]*schema.Schema { }, }, }, + "target_criteria": { + Type: schema.TypeList, + Optional: true, + Description: "A list of mappings to apply to SCIM resources before provisioning them in this application. These can transform or filter the resources to be provisioned.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "port": { + Type: schema.TypeInt, + Required: true, + Description: "The port that the targets use for the chosen communication protocol. A port cannot be assigned to multiple protocols.", + }, + "protocol": { + Type: schema.TypeString, + Required: true, + Description: "The communication protocol your application secures.", + }, + "target_attributes": { + Type: schema.TypeList, + Required: true, + Description: "Contains a map of target attribute keys to target attribute values.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + Description: "The key of the attribute.", + }, + "values": { + Type: schema.TypeList, + Required: true, + Description: "The values of the attribute.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + }, + }, + }, + }, + }, "auto_redirect_to_identity": { Type: schema.TypeBool, Optional: true, @@ -467,6 +516,16 @@ func resourceCloudflareAccessApplicationSchema() map[string]*schema.Schema { Optional: true, Default: true, Description: "Option to show/hide applications in App Launcher.", + 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 `app_launcher_visible` + // value. + if appType == "infrastructure" { + return true + } + + return oldValue == newValue + }, }, "service_auth_401_redirect": { Type: schema.TypeBool, @@ -933,6 +992,52 @@ func convertSaasSchemaToStruct(d *schema.ResourceData) *cloudflare.SaasApplicati return &SaasConfig } +func convertTargetContextsToStruct(d *schema.ResourceData) (*[]cloudflare.AccessInfrastructureTargetContext, error) { + TargetContexts := []cloudflare.AccessInfrastructureTargetContext{} + if value, ok := d.GetOk("target_criteria"); ok { + targetCriteria := value.([]interface{}) + targetContext := cloudflare.AccessInfrastructureTargetContext{} + for _, item := range targetCriteria { + itemMap := item.(map[string]interface{}) + + if port, ok := itemMap["port"].(int); ok { + targetContext.Port = port + } + if protocol, ok := itemMap["protocol"].(string); ok { + switch protocol { + case "SSH": + targetContext.Protocol = cloudflare.AccessInfrastructureSSH + case "RDP": + targetContext.Protocol = cloudflare.AccessInfrastructureRDP + default: + return &[]cloudflare.AccessInfrastructureTargetContext{}, fmt.Errorf("failed to parse protocol: value must be one of SSH or RDP") + } + } + + str_return := make(map[string][]string) + if sshVal, ok := itemMap["target_attributes"].([]interface{}); ok && len(sshVal) > 0 { + for _, attrItem := range sshVal { + if sshMap, ok := attrItem.(map[string]interface{}); ok { + attributes := make(map[string][]string) + key := sshMap["name"].(string) + if usernames, ok := sshMap["values"].([]interface{}); ok { + for _, username := range usernames { + attributes[key] = append(attributes[key], username.(string)) + } + } + str_return = attributes + } + } + targetContext.TargetAttributes = str_return + } + + TargetContexts = append(TargetContexts, targetContext) + } + } + + return &TargetContexts, nil +} + func convertLandingPageDesignSchemaToStruct(d *schema.ResourceData) *cloudflare.AccessLandingPageDesign { LandingPageDesign := cloudflare.AccessLandingPageDesign{} if _, ok := d.GetOk("landing_page_design"); ok { @@ -1234,6 +1339,34 @@ func convertSaasStructToSchema(d *schema.ResourceData, app *cloudflare.SaasAppli } } +func convertTargetContextsToSchema(targetContexts *[]cloudflare.AccessInfrastructureTargetContext) []interface{} { + if targetContexts == nil { + return []interface{}{} + } + var targetContextsSchema []interface{} + + for _, targetContext := range *targetContexts { + //targetAttributesList := []map[string][]string{} + var attributesReturned []map[string]interface{} + + for key, values := range targetContext.TargetAttributes { + attributeMap := map[string]interface{}{ + "name": key, + "values": values, + } + + attributesReturned = append(attributesReturned, attributeMap) + } + + targetContextsSchema = append(targetContextsSchema, map[string]interface{}{ + "port": targetContext.Port, + "protocol": targetContext.Protocol, + "target_attributes": attributesReturned, + }) + } + return targetContextsSchema +} + func convertScimConfigStructToSchema(scimConfig *cloudflare.AccessApplicationSCIMConfig) []interface{} { if scimConfig == nil { return []interface{}{} diff --git a/internal/sdkv2provider/schema_cloudflare_access_policy.go b/internal/sdkv2provider/schema_cloudflare_access_policy.go index 3a99d9279b..787d009719 100644 --- a/internal/sdkv2provider/schema_cloudflare_access_policy.go +++ b/internal/sdkv2provider/schema_cloudflare_access_policy.go @@ -103,6 +103,34 @@ func resourceCloudflareAccessPolicySchema() map[string]*schema.Schema { }, Description: "How often a user will be forced to re-authorise. Must be in the format `48h` or `2h45m`", }, + "connection_rules": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Description: "The rules that define how users may connect to the targets secured by your application.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "ssh": { + Type: schema.TypeList, + Required: true, + MaxItems: 1, + Description: "The SSH-specific rules that define how users may connect to the targets secured by your application.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "usernames": { + Type: schema.TypeList, + Required: true, + Description: "Contains the Unix usernames that may be used when connecting over SSH.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + }, + }, + }, + }, + }, } }