Public/Compare-IntuneGroupAssignment.ps1
|
function Compare-IntuneGroupAssignment { [CmdletBinding()] param( [Parameter()] [string]$CompareGroupNames, [Parameter()] [switch]$IncludeNestedGroups, [Parameter()] [switch]$ExportToCSV, [Parameter()] [string]$ExportPath ) Write-Host "Compare Group Assignments chosen" -ForegroundColor Green # Get Group names to compare from parameter or prompt if ($CompareGroupNames) { $groupInput = $CompareGroupNames } else { # Prompt for Group names or IDs Write-Host "Please enter Group names or Object IDs to compare, separated by commas (,): " -ForegroundColor Cyan Write-Host "Example: 'Marketing Team, 12345678-1234-1234-1234-123456789012'" -ForegroundColor Gray $groupInput = Read-Host } $groupInputs = $groupInput -split ',' | ForEach-Object { $_.Trim() } if ($groupInputs.Count -lt 2) { Write-Host "Please provide at least two groups to compare." -ForegroundColor Red return } # Determine if nested group checking should be enabled $checkNestedGroupsCompare = $false if ($IncludeNestedGroups) { $checkNestedGroupsCompare = $true } elseif (-not $CompareGroupNames) { $nestedPromptCompare = Read-Host "Include assignments inherited from parent groups? (y/n)" if ($nestedPromptCompare -match '^[Yy]') { $checkNestedGroupsCompare = $true } } # Before caching starts, initialize the group assignments hashtable $groupAssignments = [ordered]@{} # Shell Scripts stay outside the shared engine: their assignments live under # /groupAssignments (flat targetGroupId shape) instead of /assignments, which # the engine's assignment fetch does not model. $scanCategories = @(Get-IntuneCategoryDefinition -Audience 'Compare' | Where-Object { $_.Id -ne 'ShellScripts' }) # Shared across groups so each entity set is fetched from Graph once per run $entityCache = @{} # Compare historically checks Endpoint Security via deviceManagement/intents only. # Skip the engine's configurationPolicies phase (those entities carry no top-level # templateId) so results and fetch counts match the legacy intents-only scope. $entityPreFilter = { param($entity, $category) if ($category.Kind -eq 'EndpointSecurity' -and -not $entity.templateId) { return $false } return $true } $processEntity = { param($ctx) $entity = $ctx.Entity $category = $ctx.Category if ($category.Kind -eq 'MobileApps') { # Legacy per-assignment loop: every assignment targeting one of the checked # group ids yields its own entry, tagged [EXCLUDED]/[INHERITED]/filter and # bucketed by that assignment's intent (exclusion-only apps stay visible). foreach ($assignment in $ctx.RawAssignments) { if ($allGroupIds -notcontains $assignment.target.groupId) { continue } $exclusionSuffix = if ($assignment.target.'@odata.type' -eq '#microsoft.graph.exclusionGroupAssignmentTarget') { " [EXCLUDED]" } else { "" } $inheritedSuffix = if ($assignment.target.groupId -ne $groupId) { " [INHERITED]" } else { "" } $filterSuffix = Format-AssignmentFilter -FilterId $assignment.target.deviceAndAppManagementAssignmentFilterId -FilterType $assignment.target.deviceAndAppManagementAssignmentFilterType $combinedSuffix = "$exclusionSuffix$inheritedSuffix$filterSuffix" switch ($assignment.intent) { "required" { $ctx.Buckets['RequiredApps'].Add("$($entity.displayName)$combinedSuffix") } "available" { $ctx.Buckets['AvailableApps'].Add("$($entity.displayName)$combinedSuffix") } "uninstall" { $ctx.Buckets['UninstallApps'].Add("$($entity.displayName)$combinedSuffix") } } } return } if ($category.Kind -eq 'EndpointSecurity' -or $category.Id -eq 'HealthScripts') { # Backstop for the prefilter: never take ES results from the configurationPolicies phase if ($category.Kind -eq 'EndpointSecurity' -and $null -eq $ctx.RawAssignments) { return } # Legacy matching: inclusion targets only, [INHERITED] as the only tag $inclusions = @($ctx.Assignments | Where-Object { $_.Reason -eq 'Direct Assignment' }) if ($inclusions.Count -eq 0) { return } $suffix = if (@($inclusions | Where-Object { $_.GroupId -ne $groupId }).Count -gt 0) { " [INHERITED]" } else { "" } $ctx.Buckets[$category.BucketKeys[0]].Add("$($entity.displayName)$suffix") return } if ($category.Id -eq 'PlatformScripts') { # Legacy matching: any group target (inclusion or exclusion) counts, # [INHERITED] as the only tag, "(PowerShell)" marker in the name if (@($ctx.Assignments).Count -eq 0) { return } $suffix = if (@($ctx.Assignments | Where-Object { $_.GroupId -ne $groupId }).Count -gt 0) { " [INHERITED]" } else { "" } $ctx.Buckets['PlatformScripts'].Add("$($entity.displayName) (PowerShell)$suffix") return } # Device Configurations / Settings Catalog / Compliance Policies: one entry per # policy with [EXCLUDED], [INHERITED] and filter suffixes, in that order if (@($ctx.Assignments).Count -eq 0) { return } $suffix = "" if (@($ctx.Assignments | Where-Object { $_.Reason -eq 'Direct Exclusion' }).Count -gt 0) { $suffix += " [EXCLUDED]" } if (@($ctx.Assignments | Where-Object { $_.GroupId -ne $groupId }).Count -gt 0) { $suffix += " [INHERITED]" } $filterMatch = $ctx.Assignments | Where-Object { $_.FilterId } | Select-Object -First 1 if ($filterMatch) { $suffix += (Format-AssignmentFilter -FilterId $filterMatch.FilterId -FilterType $filterMatch.FilterType) } $entryName = if ($category.Id -eq 'SettingsCatalog') { $entity.name } else { $entity.displayName } $ctx.Buckets[$category.BucketKeys[0]].Add("$entryName$suffix") } # Process each group input $resolvedGroups = @{} foreach ($groupInput in $groupInputs) { Write-Host "`nProcessing input: $groupInput" -ForegroundColor Yellow # Initialize variables $groupId = $null $groupName = $null # Check if input is a GUID if ($groupInput -match '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$') { try { # Get group info from Graph API $groupUri = "$script:GraphEndpoint/v1.0/groups/$groupInput" $groupResponse = Invoke-MgGraphRequest -Uri $groupUri -Method Get $groupId = $groupResponse.id $groupName = $groupResponse.displayName $resolvedGroups[$groupId] = $groupName Write-Host "Found group by ID: $groupName" -ForegroundColor Green } catch { Write-Host "No group found with ID: $groupInput" -ForegroundColor Red continue } } else { # Try to find group by display name (single quotes escaped for the OData filter) $escapedGroupName = $groupInput -replace "'", "''" $groupUri = "$script:GraphEndpoint/v1.0/groups?`$filter=displayName eq '$escapedGroupName'" $groupResponse = Invoke-MgGraphRequest -Uri $groupUri -Method Get if ($groupResponse.value.Count -eq 0) { Write-Host "No group found with name: $groupInput" -ForegroundColor Red continue } elseif ($groupResponse.value.Count -gt 1) { Write-Host "Multiple groups found with name: $groupInput. Please use the Object ID instead:" -ForegroundColor Red foreach ($group in $groupResponse.value) { Write-Host " - $($group.displayName) (ID: $($group.id))" -ForegroundColor Yellow } continue } $groupId = $groupResponse.value[0].id $groupName = $groupResponse.value[0].displayName $resolvedGroups[$groupId] = $groupName Write-Host "Found group by name: $groupName (ID: $groupId)" -ForegroundColor Green } # Build effective group IDs for nested group support (direct + transitive parents) $allGroupIds = @($groupId) if ($checkNestedGroupsCompare) { $parentGroups = Get-TransitiveGroupMembership -GroupId $groupId if ($parentGroups.Count -gt 0) { foreach ($pg in $parentGroups) { $allGroupIds += $pg.id } Write-Host " Found $($parentGroups.Count) parent group(s)" -ForegroundColor Green } } $scanResult = Invoke-IntuneCategoryScan -Categories $scanCategories -ProcessEntity $processEntity -AssignmentGroupIds $allGroupIds -EntityPreFilter $entityPreFilter -ShowProgress -EntityCache $entityCache $groupAssignments[$groupName] = $scanResult.Buckets # Process Shell Scripts (macOS) via the legacy /groupAssignments endpoint Write-Host "Fetching Shell Scripts..." -ForegroundColor Yellow if (-not $entityCache.ContainsKey('deviceShellScripts')) { $entityCache['deviceShellScripts'] = @(Get-IntuneEntities -EntityType 'deviceShellScripts') } foreach ($shellScript in $entityCache['deviceShellScripts']) { $assignmentsUri = "$script:GraphEndpoint/beta/deviceManagement/deviceShellScripts('$($shellScript.id)')/groupAssignments" $shellAssignments = [System.Collections.Generic.List[object]]::new() do { $assignmentResponse = Invoke-MgGraphRequest -Uri $assignmentsUri -Method Get if ($assignmentResponse -and $null -ne $assignmentResponse.value) { $shellAssignments.AddRange(@($assignmentResponse.value)) } $assignmentsUri = $assignmentResponse.'@odata.nextLink' } while (![string]::IsNullOrEmpty($assignmentsUri)) $hasAssignment = @($shellAssignments | Where-Object { $allGroupIds -contains $_.targetGroupId }) if ($hasAssignment.Count -gt 0) { $isInherited = @($hasAssignment | Where-Object { $_.targetGroupId -ne $groupId }) $suffix = if ($isInherited.Count -gt 0) { " [INHERITED]" } else { "" } $groupAssignments[$groupName]['PlatformScripts'].Add("$($shellScript.displayName) (Shell)$suffix") } } } # Comparison Results section Write-Host "`nComparison Results:" -ForegroundColor Cyan Write-Host "Comparing assignments between groups:" -ForegroundColor White foreach ($groupName in $groupAssignments.Keys) { Write-Host " * $groupName" -ForegroundColor White } Write-Host "" # Update categories to include "Proactive Remediation Scripts" $categories = [ordered]@{ "Device Configurations" = "DeviceConfigs" "Settings Catalog" = "SettingsCatalog" "Compliance Policies" = "CompliancePolicies" "Required Apps" = "RequiredApps" "Available Apps" = "AvailableApps" "Uninstall Apps" = "UninstallApps" "Platform Scripts" = "PlatformScripts" "Proactive Remediation Scripts" = "HealthScripts" "Endpoint Security - Antivirus" = "AntivirusProfiles" "Endpoint Security - Disk Encryption" = "DiskEncryptionProfiles" "Endpoint Security - Firewall" = "FirewallProfiles" "Endpoint Security - EDR" = "EndpointDetectionProfiles" "Endpoint Security - ASR" = "AttackSurfaceProfiles" "Endpoint Security - Account Protection" = "AccountProtectionProfiles" } # Collect all unique base policy names (strip tag suffixes for deduplication) $uniqueBasePolicies = [System.Collections.ArrayList]@() foreach ($groupName in $groupAssignments.Keys) { foreach ($categoryKey in $categories.Values) { foreach ($policy in $groupAssignments[$groupName][$categoryKey]) { $baseName = $policy -replace ' \[(EXCLUDED|INHERITED)\]', '' $baseName = $baseName.Trim() if ($uniqueBasePolicies -notcontains $baseName) { $null = $uniqueBasePolicies.Add($baseName) } } } } Write-Host "Found $($uniqueBasePolicies.Count) unique policies/apps/scripts across all groups`n" -ForegroundColor Yellow $groupNames = @($groupAssignments.Keys) # Display comparison for each category in table format foreach ($category in $categories.Keys) { $categoryKey = $categories[$category] # Collect base policy names that belong to this category $categoryPolicies = [System.Collections.ArrayList]@() foreach ($baseName in $uniqueBasePolicies) { $isInCategory = $false foreach ($g in $groupNames) { $matchFound = $groupAssignments[$g][$categoryKey] | Where-Object { ($_ -replace ' \[(EXCLUDED|INHERITED)\]', '').Trim() -eq $baseName } if ($matchFound) { $isInCategory = $true break } } if ($isInCategory) { $null = $categoryPolicies.Add($baseName) } } Write-Host "=== $category ===" -ForegroundColor Cyan if ($categoryPolicies.Count -eq 0) { Write-Host "No assignments found in this category" -ForegroundColor Gray Write-Host "" continue } # Calculate column widths $maxPolicyLen = ($categoryPolicies | ForEach-Object { $_.Length } | Measure-Object -Maximum).Maximum $maxPolicyLen = [Math]::Max($maxPolicyLen, 6) # min width for "Policy" header $maxPolicyLen = [Math]::Min($maxPolicyLen, 50) # cap at 50 chars $groupColWidths = @{} foreach ($g in $groupNames) { $groupColWidths[$g] = [Math]::Max($g.Length, 10) } # Header row $header = ("Policy".PadRight($maxPolicyLen + 2)) foreach ($g in $groupNames) { $header += ($g.PadRight($groupColWidths[$g] + 2)) } Write-Host $header -ForegroundColor White # Separator row $sep = ("-" * ($maxPolicyLen + 2)) foreach ($g in $groupNames) { $sep += ("-" * ($groupColWidths[$g] + 2)) } Write-Host $sep -ForegroundColor Gray # Data rows foreach ($baseName in $categoryPolicies) { $displayName = if ($baseName.Length -gt 50) { $baseName.Substring(0, 47) + "..." } else { $baseName } $row = $displayName.PadRight($maxPolicyLen + 2) foreach ($g in $groupNames) { $assignments = $groupAssignments[$g][$categoryKey] # Find all matching entries for this base name $matchingEntries = $assignments | Where-Object { ($_ -replace ' \[(EXCLUDED|INHERITED)\]', '').Trim() -eq $baseName } $cell = "" if ($matchingEntries) { $hasExcluded = $matchingEntries | Where-Object { $_ -match '\[EXCLUDED\]' } $hasInherited = $matchingEntries | Where-Object { $_ -match '\[INHERITED\]' } if ($hasExcluded -and $hasInherited) { $cell = "IE" } elseif ($hasExcluded) { $cell = "E" } elseif ($hasInherited) { $cell = "I" } else { $cell = "X" } } $row += $cell.PadRight($groupColWidths[$g] + 2) } Write-Host $row -ForegroundColor Yellow } Write-Host "" } # Legend Write-Host "Legend: X = Included, E = Excluded, I = Inherited, IE = Inherited+Excluded" -ForegroundColor Gray Write-Host "" # Summary section Write-Host "=== Summary ===" -ForegroundColor Cyan foreach ($groupName in $groupAssignments.Keys) { $totalAssignments = 0 foreach ($categoryKey in $categories.Values) { $totalAssignments += $groupAssignments[$groupName][$categoryKey].Count } Write-Host "$groupName has $totalAssignments total assignments" -ForegroundColor Yellow } Write-Host "" # Create comparison results with one column per group $comparisonResults = [System.Collections.ArrayList]@() foreach ($category in $categories.Keys) { $categoryKey = $categories[$category] foreach ($baseName in $uniqueBasePolicies) { # Check if this policy belongs to this category $isInCategory = $false foreach ($g in $groupNames) { $matchFound = $groupAssignments[$g][$categoryKey] | Where-Object { ($_ -replace ' \[(EXCLUDED|INHERITED)\]', '').Trim() -eq $baseName } if ($matchFound) { $isInCategory = $true break } } if (-not $isInCategory) { continue } $props = [ordered]@{ Category = $category PolicyName = $baseName } foreach ($g in $groupNames) { $matchingEntries = $groupAssignments[$g][$categoryKey] | Where-Object { ($_ -replace ' \[(EXCLUDED|INHERITED)\]', '').Trim() -eq $baseName } $val = "" if ($matchingEntries) { $hasExcluded = $matchingEntries | Where-Object { $_ -match '\[EXCLUDED\]' } $hasInherited = $matchingEntries | Where-Object { $_ -match '\[INHERITED\]' } if ($hasExcluded -and $hasInherited) { $val = "Inherited+Excluded" } elseif ($hasExcluded) { $val = "Excluded" } elseif ($hasInherited) { $val = "Inherited" } else { $val = "Included" } } $props[$g] = $val } [void]$comparisonResults.Add([PSCustomObject]$props) } } # Export results if requested if ($ExportToCSV) { $csvExportPath = if ($ExportPath) { $ExportPath } else { $null } if ($csvExportPath) { $comparisonResults | Export-Csv -Path $csvExportPath -NoTypeInformation Write-Host "Results exported to $csvExportPath" -ForegroundColor Green } } elseif (-not $CompareGroupNames) { $export = Read-Host "Would you like to export the comparison results to CSV? (y/n)" if ($export -match '^[Yy]') { $csvExportPath = Show-SaveFileDialog -DefaultFileName "IntuneGroupAssignmentComparison.csv" if ($csvExportPath) { $comparisonResults | Export-Csv -Path $csvExportPath -NoTypeInformation Write-Host "Results exported to $csvExportPath" -ForegroundColor Green } } } } |