@@ -34,6 +34,74 @@ function Invoke-ExecCompareIntunePolicy {
3434 throw ' Both sourceA and sourceB are required'
3535 }
3636
37+ # Load a stored Intune template. When a tenant is supplied the template is put through the
38+ # same preparation the IntuneTemplate standard uses - nesting repair, reusable settings sync
39+ # and text replacement - so a comparison made here matches what drift reports for that tenant.
40+ function Get-ComparisonTemplate {
41+ param (
42+ [Parameter (Mandatory = $true )]
43+ [string ]$TemplateGuid ,
44+ [string ]$TenantFilter ,
45+ [string ]$Label
46+ )
47+
48+ $Table = Get-CippTable - tablename ' templates'
49+ $TemplateEntity = Get-CIPPAzDataTableEntity @Table - Filter " PartitionKey eq 'IntuneTemplate' and RowKey eq '$TemplateGuid '"
50+
51+ if (-not $TemplateEntity ) {
52+ throw " $Label : Template with GUID '$TemplateGuid ' not found"
53+ }
54+
55+ $JSONData = $TemplateEntity.JSON | ConvertFrom-Json - Depth 100
56+ $JSONData = Repair-CIPPIntuneTemplateNesting - Template $JSONData - Table $Table
57+ $RawJSON = $JSONData.RAWJson
58+
59+ if ($TenantFilter ) {
60+ try {
61+ $ReusableSync = Sync-CIPPReusablePolicySettings - TemplateInfo $JSONData - Tenant $TenantFilter - ErrorAction Stop
62+ if ($ReusableSync.RawJSON ) {
63+ $RawJSON = $ReusableSync.RawJSON
64+ }
65+ } catch {
66+ Write-Warning " $Label : Failed to sync reusable policy settings - $ ( $_.Exception.Message ) "
67+ }
68+ $RawJSON = Get-CIPPTextReplacement - Text $RawJSON - TenantFilter $TenantFilter - EscapeForJson
69+ }
70+
71+ return @ {
72+ Object = $RawJSON | ConvertFrom-Json - Depth 100
73+ TemplateType = Get-CIPPIntuneTemplateType - Type $JSONData.Type - RawJson $RawJSON
74+ DisplayName = $JSONData.Displayname
75+ }
76+ }
77+
78+ # A standard can be configured to replace a similarly named policy on deployment rather than
79+ # create a new one. That setting lives on the standards template, keyed by the Intune
80+ # template GUID, and is stored as a string by the settings form.
81+ function Get-StandardFuzzyDistance {
82+ param (
83+ [string ]$StandardsTemplateId ,
84+ [string ]$TemplateGuid
85+ )
86+
87+ if (-not $StandardsTemplateId ) { return 0 }
88+
89+ try {
90+ $Table = Get-CippTable - tablename ' templates'
91+ $Entity = Get-CIPPAzDataTableEntity @Table - Filter " PartitionKey eq 'StandardsTemplateV2' and RowKey eq '$StandardsTemplateId '"
92+ if (-not $Entity ) { return 0 }
93+
94+ $Standards = ($Entity.JSON | ConvertFrom-Json - Depth 100 ).standards.IntuneTemplate
95+ $Match = @ ($Standards ) | Where-Object { $_.TemplateList.value -eq $TemplateGuid } | Select-Object - First 1
96+
97+ if ([string ]::IsNullOrWhiteSpace($Match.levenshteinDistance )) { return 0 }
98+ return [int ]$Match.levenshteinDistance
99+ } catch {
100+ Write-Warning " Could not read the fuzzy match distance from standards template '$StandardsTemplateId ': $ ( $_.Exception.Message ) "
101+ return 0
102+ }
103+ }
104+
37105 # Resolve a source descriptor to its policy object and metadata
38106 function Resolve-PolicySource {
39107 param (
@@ -46,23 +114,95 @@ function Invoke-ExecCompareIntunePolicy {
46114 if (-not $Source.templateGuid ) {
47115 throw " $Label : templateGuid is required for template sources"
48116 }
49- $Table = Get-CippTable - tablename ' templates'
50- $Filter = " PartitionKey eq 'IntuneTemplate' and RowKey eq '$ ( $Source.templateGuid ) '"
51- $TemplateEntity = Get-CIPPAzDataTableEntity @Table - Filter $Filter
52117
53- if (-not $TemplateEntity ) {
54- throw " $Label : Template with GUID '$ ( $Source.templateGuid ) ' not found"
118+ # tenantFilter is optional here - supplying it resolves the template the way the
119+ # standard would for that tenant instead of comparing the stored template verbatim.
120+ $Template = Get-ComparisonTemplate - TemplateGuid $Source.templateGuid - TenantFilter $Source.tenantFilter - Label $Label
121+ $LabelSuffix = if ($Source.tenantFilter ) { " Template, resolved for $ ( $Source.tenantFilter ) " } else { ' Template' }
122+
123+ return @ {
124+ Object = $Template.Object
125+ TemplateType = $Template.TemplateType
126+ Label = " $ ( $Template.DisplayName ) ($LabelSuffix )"
127+ RawData = $Template.Object
128+ }
129+
130+ } elseif ($Source.type -eq ' tenantPolicyByTemplate' ) {
131+ # Used by the drift and standards pages, which know the template a standard points at
132+ # but not which policy in the tenant it landed on. Matches the standard's own lookup:
133+ # by the template's display name and type.
134+ if (-not $Source.templateGuid -or -not $Source.tenantFilter ) {
135+ throw " $Label : templateGuid and tenantFilter are required for tenantPolicyByTemplate sources"
136+ }
137+
138+ $Template = Get-ComparisonTemplate - TemplateGuid $Source.templateGuid - Label $Label
139+
140+ if (-not $Template.TemplateType ) {
141+ throw " $Label : Template '$ ( $Template.DisplayName ) ' has no policy type and none could be inferred. Re-import the template to fix this."
55142 }
56143
57- $JSONData = $TemplateEntity.JSON | ConvertFrom-Json - Depth 100
58- $PolicyObj = $JSONData.RAWJson | ConvertFrom-Json - Depth 100
59- $TemplateType = $JSONData.Type
144+ $Policy = Get-CIPPIntunePolicy - TemplateType $Template.TemplateType - DisplayName $Template.DisplayName - tenantFilter $Source.tenantFilter - Headers $Headers - APINAME $APIName
145+ $MatchType = ' exact'
146+ $MatchedName = $Template.DisplayName
147+
148+ # Without this the comparison would report the policy as missing while remediation
149+ # would happily overwrite an existing, similarly named one. Mirrors the candidate
150+ # selection Set-CIPPIntunePolicy performs at deployment time.
151+ $MaxDistance = Get-StandardFuzzyDistance - StandardsTemplateId $Source.standardsTemplateId - TemplateGuid $Source.templateGuid
152+
153+ if (-not $Policy -and $MaxDistance -gt 0 ) {
154+ $AllPolicies = @ (Get-CIPPIntunePolicy - TemplateType $Template.TemplateType - tenantFilter $Source.tenantFilter - Headers $Headers - APINAME $APIName )
155+
156+ $FuzzyParams = @ {
157+ DisplayName = $Template.DisplayName
158+ ExistingPolicies = $AllPolicies
159+ MaxDistance = $MaxDistance
160+ }
161+ # Same sub-type guards the deployment applies, so a similarly named policy of a
162+ # different type is not offered as the match.
163+ if ($Template.TemplateType -eq ' Catalog' ) {
164+ $FuzzyParams.NameProperty = ' name'
165+ if ($Template.Object.templateReference.templateId ) {
166+ $FuzzyParams.TemplateId = $Template.Object.templateReference.templateId
167+ }
168+ } elseif ($Template.Object .' @odata.type' ) {
169+ $FuzzyParams.ODataType = $Template.Object .' @odata.type'
170+ }
171+
172+ $FuzzyResult = Find-CIPPFuzzyPolicyMatch @FuzzyParams
173+ if ($FuzzyResult ) {
174+ $MatchType = $FuzzyResult.MatchType
175+ $MatchedName = $FuzzyResult.OriginalName
176+ # Re-read by id so nested settings come back expanded the way the
177+ # by-name lookup returns them.
178+ $Policy = Get-CIPPIntunePolicy - TemplateType $Template.TemplateType - PolicyId $FuzzyResult.Policy.id - tenantFilter $Source.tenantFilter - Headers $Headers - APINAME $APIName
179+ }
180+ }
181+
182+ if (-not $Policy ) {
183+ # Not an error. A template that has just been added to a drift or standards
184+ # template legitimately has no policy in the tenant yet, and the caller can still
185+ # show the baseline on its own, which is more useful than a failed comparison.
186+ return @ {
187+ Object = $null
188+ Missing = $true
189+ MissingName = $Template.DisplayName
190+ FuzzyDistance = $MaxDistance
191+ TemplateType = $Template.TemplateType
192+ Label = " $ ( $Template.DisplayName ) (not deployed to $ ( $Source.tenantFilter ) )"
193+ RawData = $null
194+ }
195+ }
196+
197+ $PolicyObj = $Policy.cippconfiguration | ConvertFrom-Json - Depth 100
60198
61199 return @ {
62200 Object = $PolicyObj
63- TemplateType = $TemplateType
64- Label = " $ ( $JSONData .Displayname ) (Template )"
201+ TemplateType = $Template . TemplateType
202+ Label = " $MatchedName ( $ ( $Source .tenantFilter ) )"
65203 RawData = $PolicyObj
204+ MatchType = $MatchType
205+ MatchedName = $MatchedName
66206 }
67207
68208 } elseif ($Source.type -eq ' tenantPolicy' ) {
@@ -142,13 +282,41 @@ function Invoke-ExecCompareIntunePolicy {
142282 }
143283
144284 } else {
145- throw " $Label : Invalid source type '$ ( $Source.type ) '. Must be 'template', 'tenantPolicy', or 'communityRepo'"
285+ throw " $Label : Invalid source type '$ ( $Source.type ) '. Must be 'template', 'tenantPolicy', 'tenantPolicyByTemplate', or 'communityRepo'"
146286 }
147287 }
148288
149289 $ResolvedA = Resolve-PolicySource - Source $SourceA - Label ' Source A'
150290 $ResolvedB = Resolve-PolicySource - Source $SourceB - Label ' Source B'
151291
292+ # With one side absent there is nothing to diff. Report that as its own state and hand back
293+ # whichever side does exist, so the caller can still show it.
294+ if ($ResolvedA.Missing -or $ResolvedB.Missing ) {
295+ $MissingBody = @ {
296+ Results = @ ()
297+ sourceALabel = $ResolvedA.Label
298+ sourceBLabel = $ResolvedB.Label
299+ sourceAData = $ResolvedA.RawData
300+ sourceBData = $ResolvedB.RawData
301+ identical = $false
302+ policyMissing = $true
303+ # The name that was searched for, and which side is absent, so the caller can say
304+ # exactly what is missing rather than just that something is.
305+ missingPolicyName = $ResolvedA.Missing ? $ResolvedA.MissingName : $ResolvedB.MissingName
306+ missingSide = $ResolvedA.Missing ? ' baseline' : ' tenant'
307+ # Tells the caller whether fuzzy matching was even in play, so a "not found" can be
308+ # explained as "exact name only" rather than looking like a broader search failed.
309+ fuzzyDistance = $ResolvedA.Missing ? $ResolvedA.FuzzyDistance : $ResolvedB.FuzzyDistance
310+ }
311+
312+ Write-LogMessage - headers $Headers - API $APIName - message " Compared Intune policies: $ ( $ResolvedA.Label ) vs $ ( $ResolvedB.Label ) - one side does not exist" - Sev ' Info'
313+
314+ return ([HttpResponseContext ]@ {
315+ StatusCode = [HttpStatusCode ]::OK
316+ Body = ConvertTo-Json - Depth 100 - InputObject $MissingBody
317+ })
318+ }
319+
152320 # Determine compare type
153321 $CompareParams = @ {
154322 ReferenceObject = $ResolvedA.Object
@@ -170,6 +338,10 @@ function Invoke-ExecCompareIntunePolicy {
170338 sourceAData = $ResolvedA.RawData
171339 sourceBData = $ResolvedB.RawData
172340 identical = ($ComparisonResults.Count -eq 0 )
341+ # Non-exact means the tenant policy was found under a different name, which changes how
342+ # the result should be read - the caller says so rather than implying a name match.
343+ matchType = $ResolvedB.MatchType ?? $ResolvedA.MatchType
344+ matchedName = $ResolvedB.MatchedName ?? $ResolvedA.MatchedName
173345 }
174346
175347 Write-LogMessage - headers $Headers - API $APIName - message " Compared Intune policies: $ ( $ResolvedA.Label ) vs $ ( $ResolvedB.Label ) - $ ( $ComparisonResults.Count ) differences found" - Sev ' Info'
0 commit comments