Skip to content

Commit 4ce1bcd

Browse files
feat(mem): add baseline compare for intune policies
Add a reusable policy diff table and a new compare dialog used from Drift and Applied Standards to compare Intune template standards against live tenant policy state. Extend ExecCompareIntunePolicy with template-prepared resolution, tenantPolicyByTemplate lookup, fuzzy-name matching aligned to standard settings, and explicit missing-policy responses. Also extract Intune template type inference into a shared CIPPCore helper and reuse it in the IntuneTemplate standard. Synced from CyberDrain/CIPP@2c9020b
1 parent 6c40e9b commit 4ce1bcd

3 files changed

Lines changed: 230 additions & 23 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
function Get-CIPPIntuneTemplateType {
2+
<#
3+
.SYNOPSIS
4+
Resolves the policy type of a stored Intune template.
5+
6+
.DESCRIPTION
7+
Returns the template's recorded Type when it has one. Templates imported before Type was
8+
stored have none, so the type is inferred from the shape of the raw policy payload instead.
9+
Returns $null when the type is neither recorded nor inferable, which means the template
10+
needs re-importing.
11+
12+
.PARAMETER Type
13+
The template's recorded Type, if any.
14+
15+
.PARAMETER RawJson
16+
The raw policy JSON to infer from when Type is empty.
17+
18+
.EXAMPLE
19+
Get-CIPPIntuneTemplateType -Type $Template.Type -RawJson $Template.RAWJson
20+
#>
21+
[CmdletBinding()]
22+
[OutputType([string])]
23+
param(
24+
[string]$Type,
25+
$RawJson
26+
)
27+
28+
if ($Type) {
29+
return $Type
30+
}
31+
32+
try {
33+
$ParsedRaw = $RawJson | ConvertFrom-Json -ErrorAction SilentlyContinue
34+
$ODataType = $ParsedRaw.'@odata.type'
35+
36+
if ($null -ne $ParsedRaw.settings -and $null -ne $ParsedRaw.technologies) { return 'Catalog' }
37+
if ($null -ne $ParsedRaw.scheduledActionsForRule -or $ODataType -match 'CompliancePolicy') { return 'deviceCompliancePolicies' }
38+
if ($ODataType -match 'windowsDriverUpdateProfile') { return 'windowsDriverUpdateProfiles' }
39+
if ($ODataType -match 'ManagedApp|managedAppProtection') { return 'AppProtection' }
40+
if ($ODataType -match 'deviceConfiguration|#microsoft\.graph\.\w+Configuration$') { return 'Device' }
41+
} catch {
42+
return $null
43+
}
44+
45+
return $null
46+
}

Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1

Lines changed: 183 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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'

Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -78,18 +78,7 @@ function Invoke-CIPPStandardIntuneTemplate {
7878

7979
# Fallback: infer type from RAWJson content when stored template has no Type
8080
if (-not $TemplateType) {
81-
try {
82-
$parsedRaw = $rawJsonFromTemplate | ConvertFrom-Json -ErrorAction SilentlyContinue
83-
$odataType = $parsedRaw.'@odata.type'
84-
$TemplateType = if ($null -ne $parsedRaw.settings -and $null -ne $parsedRaw.technologies) { 'Catalog' }
85-
elseif ($null -ne $parsedRaw.scheduledActionsForRule -or $odataType -match 'CompliancePolicy') { 'deviceCompliancePolicies' }
86-
elseif ($odataType -match 'windowsDriverUpdateProfile') { 'windowsDriverUpdateProfiles' }
87-
elseif ($odataType -match 'ManagedApp|managedAppProtection') { 'AppProtection' }
88-
elseif ($odataType -match 'deviceConfiguration|#microsoft\.graph\.\w+Configuration$') { 'Device' }
89-
else { $null }
90-
} catch {
91-
$TemplateType = $null
92-
}
81+
$TemplateType = Get-CIPPIntuneTemplateType -Type $TemplateType -RawJson $rawJsonFromTemplate
9382
if ($TemplateType) {
9483
Write-Information "[IntuneTemplate][$Tenant] Inferred template type '$TemplateType' from content for '$displayname'"
9584
} else {

0 commit comments

Comments
 (0)