Skip to content

Commit 1e7a9c6

Browse files
GreenStagestainless-app[bot]
authored andcommitted
Fix RDP & Infrastructure types of zero_trust_access_application
1 parent b696832 commit 1e7a9c6

File tree

6 files changed

+220
-23
lines changed

6 files changed

+220
-23
lines changed

internal/services/zero_trust_access_application/normalizations.go

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,34 @@ func normalizeReadZeroTrustApplicationOidcAppData(data *ZeroTrustAccessApplicati
8383
normalizeFalseAndNullBool(&data.AllowPKCEWithoutClientSecret, stateData.AllowPKCEWithoutClientSecret)
8484
}
8585

86+
func normalizeZeroTrustApplicationPolicyConnectionRulesAPIData(_ context.Context, data, stateData *ZeroTrustAccessApplicationPoliciesConnectionRulesModel) {
87+
if data.SSH != nil && stateData.SSH != nil {
88+
normalizeFalseAndNullBool(&data.SSH.AllowEmailAlias, stateData.SSH.AllowEmailAlias)
89+
}
90+
}
91+
92+
func normalizeZeroTrustApplicationPolicyAPIData(ctx context.Context, data, stateData *ZeroTrustAccessApplicationPoliciesModel) {
93+
// Preserve null values from the Terraform state, even if the API response returns actual values.
94+
// This is important because the API may populate these fields when it expands the attached reusable policy
95+
// from its given ID.
96+
//
97+
// However, we intentionally avoid storing the full expanded policy inside the application resource's
98+
// nested block, as its source of truth is the reusable policy resource itself.
99+
// Only the policy ID should be persisted in state for reusable policies.
100+
// For legacy policies, the ID should be ignored as they are not a standalone resource, but rather
101+
// live as a nested object owned by the application.
102+
persistNullFromState(&data.ID, stateData.ID)
103+
persistNullFromState(&data.Decision, stateData.Decision)
104+
persistNullFromState(&data.Name, stateData.Name)
105+
persistNullFromState(&data.Include, stateData.Include)
106+
persistNullFromState(&data.Require, stateData.Require)
107+
persistNullFromState(&data.Exclude, stateData.Exclude)
108+
109+
if data.ConnectionRules != nil && stateData.ConnectionRules != nil {
110+
normalizeZeroTrustApplicationPolicyConnectionRulesAPIData(ctx, data.ConnectionRules, stateData.ConnectionRules)
111+
}
112+
}
113+
86114
// Normalizing function to ensure consistency between the state and the meaning of the API response.
87115
// Alters the API response before applying it to the state by laxing equalities between null & zero-value
88116
// for some attributes, and nullifies fields that terraform should not be saving in the state.
@@ -129,21 +157,7 @@ func normalizeReadZeroTrustApplicationAPIData(ctx context.Context, data, stateDa
129157

130158
if data.Policies != nil && stateData.Policies != nil {
131159
for i := range *data.Policies {
132-
// Preserve null values from the Terraform state, even if the API response returns actual values.
133-
// This is important because the API may populate these fields when it expands the attached reusable policy
134-
// from its given ID.
135-
//
136-
// However, we intentionally avoid storing the full expanded policy inside the application resource's
137-
// nested block, as its source of truth is the reusable policy resource itself.
138-
// Only the policy ID should be persisted in state for reusable policies.
139-
// For legacy policies, the ID should be ignored as they are not a standalone resource, but rather
140-
// live as a nested object owned by the application.
141-
persistNullFromState(&(*data.Policies)[i].ID, (*stateData.Policies)[i].ID)
142-
persistNullFromState(&(*data.Policies)[i].Decision, (*stateData.Policies)[i].Decision)
143-
persistNullFromState(&(*data.Policies)[i].Name, (*stateData.Policies)[i].Name)
144-
persistNullFromState(&(*data.Policies)[i].Include, (*stateData.Policies)[i].Include)
145-
persistNullFromState(&(*data.Policies)[i].Require, (*stateData.Policies)[i].Require)
146-
persistNullFromState(&(*data.Policies)[i].Exclude, (*stateData.Policies)[i].Exclude)
160+
normalizeZeroTrustApplicationPolicyAPIData(ctx, &(*data.Policies)[i], &(*stateData.Policies)[i])
147161
}
148162
}
149163

internal/services/zero_trust_access_application/plan_modifiers.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ import (
1313
)
1414

1515
var (
16-
selfHostedAppTypes = []string{"self_hosted", "ssh", "vnc", "rdp"}
17-
saasAppTypes = []string{"saas", "dash_sso"}
18-
appLauncherVisibleAppTypes = []string{"self_hosted", "ssh", "vnc", "rdp", "saas", "bookmark", "infrastructure"}
19-
targetCompatibleAppTypes = []string{"rdp", "infrastructure"}
20-
durationRegex = regexp.MustCompile(`^(?:0|[-+]?(\d+(?:\.\d*)?|\.\d+)(?:ns|us|µs|ms|s|m|h)(?:(\d+(?:\.\d*)?|\.\d+)(?:ns|us|µs|ms|s|m|h))*)$`)
16+
selfHostedAppTypes = []string{"self_hosted", "ssh", "vnc", "rdp"}
17+
saasAppTypes = []string{"saas", "dash_sso"}
18+
appLauncherVisibleAppTypes = []string{"self_hosted", "ssh", "vnc", "rdp", "saas", "bookmark"}
19+
targetCompatibleAppTypes = []string{"rdp", "infrastructure"}
20+
sessionDurationCompatibleAppTypes = []string{"saas", "dash_sso", "self_hosted", "ssh", "vnc", "rdp", "app_launcher"}
21+
durationRegex = regexp.MustCompile(`^(?:0|[-+]?(\d+(?:\.\d*)?|\.\d+)(?:ns|us|µs|ms|s|m|h)(?:(\d+(?:\.\d*)?|\.\d+)(?:ns|us|µs|ms|s|m|h))*)$`)
2122
)
2223

2324
// Sets a specific default value for a computed attribute specific to a set of app types, in case the attribute is unknown.
@@ -124,6 +125,7 @@ func modifyPlan(ctx context.Context, req resource.ModifyPlanRequest, res *resour
124125
setDefaultAccordingToAppTypes(selfHostedAppTypes, appType, &planApp.HTTPOnlyCookieAttribute, types.BoolValue(true), types.BoolNull())
125126
setDefaultAccordingToAppTypes(appLauncherVisibleAppTypes, appType, &planApp.AppLauncherVisible, types.BoolValue(true), types.BoolNull())
126127
setDefaultAccordingToAppType("app_launcher", appType, &planApp.SkipAppLauncherLoginPage, types.BoolValue(false), types.BoolNull())
128+
setDefaultAccordingToAppTypes(sessionDurationCompatibleAppTypes, appType, &planApp.SessionDuration, types.StringValue("24h"), types.StringNull())
127129

128130
if appType == "saas" {
129131
res.Diagnostics.Append(modifySaasAppNestedObjectPlan(ctx, planApp)...)

internal/services/zero_trust_access_application/resource_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1163,6 +1163,82 @@ func TestAccCloudflareAccessApplication_WithAppLauncherCustomization(t *testing.
11631163
})
11641164
}
11651165

1166+
func TestAccCloudflareAccessApplication_Infrastructure(t *testing.T) {
1167+
rnd := utils.GenerateRandomResourceName()
1168+
name := fmt.Sprintf("cloudflare_zero_trust_access_application.%s", rnd)
1169+
accountID := os.Getenv("CLOUDFLARE_ACCOUNT_ID")
1170+
resource.Test(t, resource.TestCase{
1171+
PreCheck: func() {
1172+
acctest.TestAccPreCheck(t)
1173+
acctest.TestAccPreCheck_AccountID(t)
1174+
},
1175+
ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories,
1176+
CheckDestroy: testAccCheckCloudflareAccessApplicationDestroy,
1177+
Steps: []resource.TestStep{
1178+
{
1179+
Config: testAccCloudflareAccessApplicationInfrastructure(rnd, accountID),
1180+
Check: resource.ComposeTestCheckFunc(
1181+
resource.TestCheckResourceAttr(name, consts.AccountIDSchemaKey, accountID),
1182+
resource.TestCheckNoResourceAttr(name, "session_duration"),
1183+
resource.TestCheckResourceAttr(name, "type", "infrastructure"),
1184+
resource.TestCheckResourceAttr(name, "target_criteria.#", "1"),
1185+
resource.TestCheckResourceAttr(name, "target_criteria.0.port", "22"),
1186+
resource.TestCheckResourceAttr(name, "target_criteria.0.protocol", "SSH"),
1187+
resource.TestCheckResourceAttr(name, "target_criteria.0.target_attributes.hostname.#", "1"),
1188+
resource.TestCheckResourceAttr(name, "target_criteria.0.target_attributes.hostname.0", rnd),
1189+
resource.TestCheckResourceAttr(name, "policies.#", "1"),
1190+
resource.TestCheckResourceAttr(name, "policies.0.connection_rules.ssh.usernames.#", "1"),
1191+
resource.TestCheckResourceAttr(name, "policies.0.connection_rules.ssh.usernames.0", "root"),
1192+
),
1193+
},
1194+
{
1195+
// Ensures no diff on last plan
1196+
Config: testAccCloudflareAccessApplicationInfrastructure(rnd, accountID),
1197+
PlanOnly: true,
1198+
},
1199+
},
1200+
})
1201+
}
1202+
1203+
func TestAccCloudflareAccessApplication_RDP(t *testing.T) {
1204+
rnd := utils.GenerateRandomResourceName()
1205+
name := fmt.Sprintf("cloudflare_zero_trust_access_application.%s", rnd)
1206+
appDomain := fmt.Sprintf("%[1]s.%[2]s", rnd, domain)
1207+
accountID := os.Getenv("CLOUDFLARE_ACCOUNT_ID")
1208+
resource.Test(t, resource.TestCase{
1209+
PreCheck: func() {
1210+
acctest.TestAccPreCheck(t)
1211+
acctest.TestAccPreCheck_AccountID(t)
1212+
},
1213+
ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories,
1214+
CheckDestroy: testAccCheckCloudflareAccessApplicationDestroy,
1215+
Steps: []resource.TestStep{
1216+
{
1217+
Config: testAccCloudflareAccessApplicationRDP(rnd, accountID, domain),
1218+
Check: resource.ComposeTestCheckFunc(
1219+
resource.TestCheckResourceAttr(name, consts.AccountIDSchemaKey, accountID),
1220+
resource.TestCheckResourceAttr(name, "session_duration", "24h"),
1221+
resource.TestCheckResourceAttr(name, "type", "rdp"),
1222+
resource.TestCheckResourceAttr(name, "domain", appDomain),
1223+
resource.TestCheckResourceAttr(name, "destinations.#", "1"),
1224+
resource.TestCheckResourceAttr(name, "destinations.0.uri", appDomain),
1225+
resource.TestCheckResourceAttr(name, "target_criteria.#", "1"),
1226+
resource.TestCheckResourceAttr(name, "target_criteria.0.port", "3389"),
1227+
resource.TestCheckResourceAttr(name, "target_criteria.0.protocol", "RDP"),
1228+
resource.TestCheckResourceAttr(name, "target_criteria.0.target_attributes.hostname.#", "1"),
1229+
resource.TestCheckResourceAttr(name, "target_criteria.0.target_attributes.hostname.0", rnd),
1230+
resource.TestCheckResourceAttr(name, "policies.#", "1"),
1231+
),
1232+
},
1233+
{
1234+
// Ensures no diff on last plan
1235+
Config: testAccCloudflareAccessApplicationRDP(rnd, accountID, domain),
1236+
PlanOnly: true,
1237+
},
1238+
},
1239+
})
1240+
}
1241+
11661242
func testAccCloudflareAccessApplicationConfigBasic(rnd string, domain string, identifier *cloudflare.ResourceContainer) string {
11671243
return acctest.LoadTestCase("accessapplicationconfigbasic.tf", rnd, domain, identifier.Type, identifier.Identifier)
11681244
}
@@ -1171,6 +1247,14 @@ func testAccCloudflareAccessApplicationConfigWithCORS(rnd, zoneID, domain string
11711247
return acctest.LoadTestCase("accessapplicationconfigwithcors.tf", rnd, zoneID, domain)
11721248
}
11731249

1250+
func testAccCloudflareAccessApplicationInfrastructure(rnd, accID string) string {
1251+
return acctest.LoadTestCase("accessapplicationconfiginfrastructure.tf", rnd, accID)
1252+
}
1253+
1254+
func testAccCloudflareAccessApplicationRDP(rnd, accID, domain string) string {
1255+
return acctest.LoadTestCase("accessapplicationconfigrdp.tf", rnd, accID, domain)
1256+
}
1257+
11741258
func testAccCloudflareAccessApplicationConfigWithSAMLSaas(rnd, accountID string) string {
11751259
return acctest.LoadTestCase("accessapplicationconfigwithsamlsaas.tf", rnd, accountID)
11761260
}

internal/services/zero_trust_access_application/schema.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ func ResourceSchema(ctx context.Context) schema.Schema {
398398
Description: "The communication protocol your application secures.\nAvailable values: \"SSH\".",
399399
Required: true,
400400
Validators: []validator.String{
401-
stringvalidator.OneOfCaseInsensitive("SSH"),
401+
stringvalidator.OneOfCaseInsensitive("SSH", "RDP"),
402402
},
403403
},
404404
"target_attributes": schema.MapAttribute{
@@ -449,9 +449,9 @@ func ResourceSchema(ctx context.Context) schema.Schema {
449449
Description: "The amount of time that tokens issued for this application will be valid. Must be in the format `300ms` or `2h45m`. Valid time units are: ns, us (or µs), ms, s, m, h. Note: unsupported for infrastructure type applications.",
450450
Computed: true,
451451
Optional: true,
452-
Default: stringdefault.StaticString("24h"),
453452
Validators: []validator.String{
454453
stringvalidator.RegexMatches(durationRegex, `"session_duration" only supports "ns", "us" (or "µs"), "ms", "s", "m", or "h" as valid units`),
454+
customvalidator.RequiresOtherStringAttributeToBeOneOf(path.MatchRoot("type"), sessionDurationCompatibleAppTypes...),
455455
},
456456
},
457457
"skip_app_launcher_login_page": schema.BoolAttribute{
@@ -469,7 +469,6 @@ func ResourceSchema(ctx context.Context) schema.Schema {
469469
DeprecationMessage: "This attribute is deprecated.",
470470
CustomType: customfield.NewListType[types.String](ctx),
471471
ElementType: types.StringType,
472-
473472
Validators: []validator.List{
474473
customvalidator.RequiresOtherStringAttributeToBeOneOf(path.MatchRoot("type"), selfHostedAppTypes...),
475474
listvalidator.ConflictsWith(path.Expressions{
@@ -882,6 +881,7 @@ func ResourceSchema(ctx context.Context) schema.Schema {
882881
Optional: true,
883882
Validators: []validator.Object{
884883
objectvalidator.AlsoRequires(path.MatchRelative().AtParent().AtName("include")),
884+
customvalidator.RequiredWhenOtherStringIsOneOf(path.MatchRoot("type"), "infrastructure"),
885885
},
886886
Attributes: map[string]schema.Attribute{
887887
"ssh": schema.SingleNestedAttribute{
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
resource "cloudflare_zero_trust_tunnel_cloudflared_virtual_network" "%[1]s" {
2+
account_id = "%[2]s"
3+
name = "%[1]s"
4+
comment = "%[1]s"
5+
is_default_network = "false"
6+
}
7+
8+
resource "cloudflare_zero_trust_access_infrastructure_target" "%[1]s" {
9+
account_id = "%[2]s"
10+
hostname = "%[1]s"
11+
ip = {
12+
ipv4 = {
13+
ip_addr = "127.0.0.1"
14+
virtual_network_id = cloudflare_zero_trust_tunnel_cloudflared_virtual_network.%[1]s.id
15+
}
16+
}
17+
}
18+
19+
resource "cloudflare_zero_trust_access_application" "%[1]s" {
20+
account_id = "%[2]s"
21+
name = "%[1]s"
22+
type = "infrastructure"
23+
target_criteria = [
24+
{
25+
port = 22
26+
protocol = "SSH"
27+
target_attributes = {
28+
"hostname" = ["%[1]s"]
29+
}
30+
}
31+
]
32+
policies = [
33+
{
34+
name = "%[1]s-policy-1"
35+
decision = "allow"
36+
include = [
37+
{
38+
email = { email = "[email protected]" }
39+
}
40+
]
41+
connection_rules = {
42+
ssh = {
43+
usernames = ["root"]
44+
}
45+
}
46+
}
47+
]
48+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
resource "cloudflare_zero_trust_tunnel_cloudflared_virtual_network" "%[1]s" {
2+
account_id = "%[2]s"
3+
name = "%[1]s"
4+
comment = "%[1]s"
5+
is_default_network = "false"
6+
}
7+
8+
resource "cloudflare_zero_trust_access_infrastructure_target" "%[1]s" {
9+
account_id = "%[2]s"
10+
hostname = "%[1]s"
11+
ip = {
12+
ipv4 = {
13+
ip_addr = "127.0.0.1"
14+
virtual_network_id = cloudflare_zero_trust_tunnel_cloudflared_virtual_network.%[1]s.id
15+
}
16+
}
17+
}
18+
19+
resource "cloudflare_zero_trust_access_application" "%[1]s" {
20+
account_id = "%[2]s"
21+
name = "%[1]s"
22+
type = "rdp"
23+
destinations = [
24+
{
25+
type = "public"
26+
uri = "%[1]s.%[3]s"
27+
}
28+
]
29+
target_criteria = [
30+
{
31+
port = 3389
32+
protocol = "RDP"
33+
target_attributes = {
34+
"hostname" = ["%[1]s"]
35+
}
36+
}
37+
]
38+
policies = [
39+
{
40+
name = "%[1]s-policy-1"
41+
decision = "allow"
42+
include = [
43+
{
44+
email = { email = "[email protected]" }
45+
}
46+
]
47+
}
48+
]
49+
}

0 commit comments

Comments
 (0)