11package zero_trust_access_application
22
33import (
4- "fmt "
4+ "sort "
55 "strconv"
6+ "strings"
67
78 "github.com/hashicorp/hcl/v2"
89 "github.com/hashicorp/hcl/v2/hclsyntax"
910 "github.com/hashicorp/hcl/v2/hclwrite"
1011 "github.com/tidwall/gjson"
1112 "github.com/tidwall/sjson"
13+ "github.com/zclconf/go-cty/cty"
1214
1315 "github.com/cloudflare/tf-migrate/internal"
1416 "github.com/cloudflare/tf-migrate/internal/transform"
@@ -59,8 +61,20 @@ func (m *V4ToV5Migrator) TransformConfig(ctx *transform.Context, block *hclwrite
5961 // V4 has type default = "self_hosted", default to this value if type is not specified in V4 config
6062 tfhcl .EnsureAttribute (body , "type" , "self_hosted" )
6163
64+ // V5 changed the default for http_only_cookie_attribute from false to true
65+ // Explicitly set to false to maintain v4 behavior when not specified
66+ // Only applicable for types: self_hosted, ssh, vnc, rdp, mcp_portal
67+ appType := tfhcl .ExtractStringFromAttribute (body .GetAttribute ("type" ))
68+ if appType == "self_hosted" || appType == "ssh" || appType == "vnc" || appType == "rdp" || appType == "mcp_portal" {
69+ tfhcl .EnsureAttribute (body , "http_only_cookie_attribute" , "false" )
70+ }
71+
6272 tfhcl .RemoveAttributes (body , "domain_type" )
6373
74+ // Remove attributes with default/empty values that v4 provider removes from state
75+ // This prevents drift when migrating to v5
76+ removeDefaultValueAttributes (body )
77+
6478 tfhcl .ConvertBlocksToAttribute (body , "cors_headers" , "cors_headers" , nil )
6579 tfhcl .ConvertBlocksToAttributeList (body , "destinations" , nil )
6680 tfhcl .ConvertBlocksToAttributeList (body , "footer_links" , nil )
@@ -82,6 +96,9 @@ func (m *V4ToV5Migrator) TransformConfig(ctx *transform.Context, block *hclwrite
8296 tfhcl .RemoveFunctionWrapper (body , "custom_pages" , "toset" )
8397 tfhcl .RemoveFunctionWrapper (body , "self_hosted_domains" , "toset" )
8498
99+ // Sort self_hosted_domains to match provider ordering and avoid drift
100+ sortStringArrayAttribute (body , "self_hosted_domains" )
101+
85102 m .transformSaasAppBlock (body )
86103 m .transformScimConfigBlock (body )
87104 m .transformTargetCriteriaBlocks (body )
@@ -92,6 +109,137 @@ func (m *V4ToV5Migrator) TransformConfig(ctx *transform.Context, block *hclwrite
92109 }, nil
93110}
94111
112+ // removeDefaultValueAttributes removes attributes that have default/empty values.
113+ // v4 provider removes these from state, so we should remove them from config to avoid drift.
114+ func removeDefaultValueAttributes (body * hclwrite.Body ) {
115+ // Boolean attributes that should be removed if false
116+ boolAttrs := []string {
117+ "auto_redirect_to_identity" ,
118+ "enable_binding_cookie" ,
119+ "options_preflight_bypass" ,
120+ "service_auth_401_redirect" ,
121+ "skip_interstitial" ,
122+ }
123+
124+ for _ , attrName := range boolAttrs {
125+ if attr := body .GetAttribute (attrName ); attr != nil {
126+ if val , ok := tfhcl .ExtractBoolFromAttribute (attr ); ok && ! val {
127+ // Remove if value is explicitly false
128+ tfhcl .RemoveAttributes (body , attrName )
129+ }
130+ }
131+ }
132+
133+ // Array attributes that should be removed if empty
134+ arrayAttrs := []string {"allowed_idps" , "tags" }
135+ for _ , attrName := range arrayAttrs {
136+ if attr := body .GetAttribute (attrName ); attr != nil {
137+ tokens := attr .Expr ().BuildTokens (nil )
138+ // Check if it's an empty array []
139+ tokenStr := string (tokens .Bytes ())
140+ if strings .TrimSpace (tokenStr ) == "[]" {
141+ tfhcl .RemoveAttributes (body , attrName )
142+ }
143+ }
144+ }
145+ }
146+
147+ // sortStringArrayAttribute sorts a string array attribute alphabetically.
148+ // This is needed when the provider returns arrays in a consistent (sorted) order
149+ // different from the user-specified order, causing drift.
150+ func sortStringArrayAttribute (body * hclwrite.Body , attrName string ) {
151+ attr := body .GetAttribute (attrName )
152+ if attr == nil {
153+ return
154+ }
155+
156+ // Parse the expression to extract string values
157+ expr := attr .Expr ()
158+
159+ // Try to parse as tuple (array)
160+ tokens := expr .BuildTokens (nil )
161+ tokenBytes := tokens .Bytes ()
162+
163+ // Parse the HCL expression
164+ parsed , diags := hclsyntax .ParseExpression (tokenBytes , "" , hcl.Pos {Line : 1 , Column : 1 })
165+ if diags .HasErrors () {
166+ return
167+ }
168+
169+ // Check if it's a tuple (array)
170+ tuple , ok := parsed .(* hclsyntax.TupleConsExpr )
171+ if ! ok {
172+ return
173+ }
174+
175+ // Extract string values
176+ var strings []string
177+ for _ , elem := range tuple .Exprs {
178+ if template , ok := elem .(* hclsyntax.TemplateExpr ); ok {
179+ // Handle string literals
180+ if len (template .Parts ) == 1 {
181+ if lit , ok := template .Parts [0 ].(* hclsyntax.LiteralValueExpr ); ok {
182+ if lit .Val .Type () == cty .String {
183+ strings = append (strings , lit .Val .AsString ())
184+ }
185+ }
186+ }
187+ }
188+ }
189+
190+ // If we couldn't extract all strings, don't modify
191+ if len (strings ) != len (tuple .Exprs ) {
192+ return
193+ }
194+
195+ // Sort the strings
196+ // Special handling for OIDC scopes: use canonical OIDC scope ordering
197+ // The provider orders scopes according to the OIDC spec: openid, profile, email, then others alphabetically
198+ if attrName == "scopes" && len (strings ) > 0 {
199+ // Define canonical OIDC scope order
200+ scopeOrder := map [string ]int {
201+ "openid" : 1 ,
202+ "profile" : 2 ,
203+ "email" : 3 ,
204+ "address" : 4 ,
205+ "phone" : 5 ,
206+ "offline_access" : 6 ,
207+ }
208+
209+ sort .SliceStable (strings , func (i , j int ) bool {
210+ orderI , hasI := scopeOrder [strings [i ]]
211+ orderJ , hasJ := scopeOrder [strings [j ]]
212+
213+ // Both have defined order - use it
214+ if hasI && hasJ {
215+ return orderI < orderJ
216+ }
217+ // Only i has order - it comes first
218+ if hasI {
219+ return true
220+ }
221+ // Only j has order - it comes first
222+ if hasJ {
223+ return false
224+ }
225+ // Neither has order - sort alphabetically
226+ return strings [i ] < strings [j ]
227+ })
228+ } else {
229+ // Not scopes, sort normally
230+ sort .Strings (strings )
231+ }
232+
233+ // Build new array tokens with sorted values
234+ var sortedTokens []hclwrite.Tokens
235+ for _ , s := range strings {
236+ sortedTokens = append (sortedTokens , hclwrite .TokensForValue (cty .StringVal (s )))
237+ }
238+
239+ // Set the attribute with sorted values
240+ body .SetAttributeRaw (attrName , hclwrite .TokensForTuple (sortedTokens ))
241+ }
242+
95243func (m * V4ToV5Migrator ) transformSaasAppBlock (body * hclwrite.Body ) {
96244 saasAppBlocks := tfhcl .FindBlocksByType (body , "saas_app" )
97245 if len (saasAppBlocks ) == 0 {
@@ -137,6 +285,9 @@ func (m *V4ToV5Migrator) transformSaasAppBlock(body *hclwrite.Body) {
137285
138286 tfhcl .ConvertSingleBlockToAttribute (saasAppBody , "hybrid_and_implicit_options" , "hybrid_and_implicit_options" )
139287 tfhcl .ConvertSingleBlockToAttribute (saasAppBody , "refresh_token_options" , "refresh_token_options" )
288+
289+ // Sort scopes array to match provider ordering and avoid drift
290+ sortStringArrayAttribute (saasAppBody , "scopes" )
140291 }
141292
142293 tfhcl .ConvertSingleBlockToAttribute (body , "saas_app" , "saas_app" )
@@ -342,20 +493,6 @@ func (m *V4ToV5Migrator) transformSingleInstance(result string, instance gjson.R
342493 return result
343494 }
344495
345- // Debug: Log CFGFiles status
346- if ctx != nil {
347- fmt .Printf ("DEBUG transformSingleInstance: resource=%s, CFGFiles count=%d\n " , resourceName , len (ctx .CFGFiles ))
348- if len (ctx .CFGFiles ) > 0 {
349- fmt .Printf ("DEBUG transformSingleInstance: CFGFiles keys: " )
350- for k := range ctx .CFGFiles {
351- fmt .Printf ("%s, " , k )
352- }
353- fmt .Printf ("\n " )
354- }
355- } else {
356- fmt .Printf ("DEBUG transformSingleInstance: ctx is nil for resource=%s\n " , resourceName )
357- }
358-
359496 // If ctx is nil, create an empty context so transformations can still run
360497 if ctx == nil {
361498 ctx = & transform.Context {
@@ -637,6 +774,24 @@ func (m *V4ToV5Migrator) transformSaasApp(result string, attrs gjson.Result, att
637774 customClaims .ForEach (func (idx , item gjson.Result ) bool {
638775 itemPath := attrPath + ".saas_app.custom_claim." + idx .String ()
639776 result = state .TransformFieldArrayToObject (result , itemPath , item , "source" , state.ArrayToObjectOptions {})
777+
778+ // Remove empty name_by_idp maps from custom_claims (OIDC)
779+ // Empty maps should be null/absent, not {}
780+ transformedItem := gjson .Parse (result ).Get (itemPath )
781+ nameByIdp := transformedItem .Get ("source.name_by_idp" )
782+ if nameByIdp .Exists () && nameByIdp .IsObject () {
783+ // Check if it's an empty object
784+ isEmpty := true
785+ nameByIdp .ForEach (func (key , value gjson.Result ) bool {
786+ isEmpty = false
787+ return false // Stop iteration
788+ })
789+ if isEmpty {
790+ // Remove the empty name_by_idp field
791+ result , _ = sjson .Delete (result , itemPath + ".source.name_by_idp" )
792+ }
793+ }
794+
640795 return true
641796 })
642797 // Rename custom_claim to custom_claims (plural)
0 commit comments