Public/Test-IntuneGroupRemoval.ps1
|
function Test-IntuneGroupRemoval { [CmdletBinding()] param( [Parameter()] [string]$UserPrincipalNames, [Parameter()] [string]$DeviceNames, [Parameter()] [string]$SimulateRemoveTargetGroup, [Parameter()] [string]$GroupNames, [Parameter()] [switch]$ExportToCSV, [Parameter()] [string]$ExportPath, [Parameter()] [string]$ScopeTagFilter ) Write-Host "Group Membership Removal Impact Analysis selected" -ForegroundColor Green # Get User Principal Name and/or Device Name. At least one must be supplied. $simUpnInput = $UserPrincipalNames $simDeviceInput = $DeviceNames if (-not $simUpnInput -and -not $simDeviceInput) { Write-Host "Enter a User Principal Name, a Device name, or both (leave one blank to skip)." -ForegroundColor Cyan Write-Host " User Principal Name: " -NoNewline -ForegroundColor Cyan $simUpnInput = Read-Host Write-Host " Device Name: " -NoNewline -ForegroundColor Cyan $simDeviceInput = Read-Host } if ([string]::IsNullOrWhiteSpace($simUpnInput) -and [string]::IsNullOrWhiteSpace($simDeviceInput)) { Write-Host "No User or Device provided. Please supply at least one." -ForegroundColor Red return } $simUpn = $null if (-not [string]::IsNullOrWhiteSpace($simUpnInput)) { $simUpn = ($simUpnInput -split ',')[0].Trim() if ($simUpn -notmatch '^[^@\s]+@[^@\s]+\.[^@\s]+$') { Write-Host "Invalid UPN format: '$simUpn'. Expected: user@domain.com" -ForegroundColor Red return } } $simDeviceName = $null if (-not [string]::IsNullOrWhiteSpace($simDeviceInput)) { $simDeviceName = ($simDeviceInput -split ',')[0].Trim() } # Get Target Group - SimulateRemoveTargetGroup takes precedence over GroupNames if ($SimulateRemoveTargetGroup) { $simGroupInput = $SimulateRemoveTargetGroup } elseif ($GroupNames) { $simGroupInput = $GroupNames } else { Write-Host "Please enter the Target Group name or Object ID: " -ForegroundColor Cyan Write-Host "Example: 'Marketing Team' or '12345678-1234-1234-1234-123456789012'" -ForegroundColor Gray $simGroupInput = Read-Host } if ([string]::IsNullOrWhiteSpace($simGroupInput)) { Write-Host "No group provided. Please try again." -ForegroundColor Red return } $simGroupInput = ($simGroupInput -split ',')[0].Trim() # Resolve user (optional) $simUserInfo = $null if ($simUpn) { Write-Host "Looking up user: $simUpn" -ForegroundColor Yellow $simUserInfo = Get-UserInfo -UserPrincipalName $simUpn if (-not $simUserInfo.Success) { Write-Host "User not found: $simUpn" -ForegroundColor Red return } } # Resolve device (optional) $simDeviceInfo = $null if ($simDeviceName) { Write-Host "Looking up device: $simDeviceName" -ForegroundColor Yellow $simDeviceInfo = Get-DeviceInfo -DeviceName $simDeviceName if (-not $simDeviceInfo.Success) { Write-Host "Device not found: $simDeviceName" -ForegroundColor Red return } if ($simDeviceInfo.MultipleFound) { Write-Host "Multiple devices match name '$simDeviceName'. Use a more specific name." -ForegroundColor Red foreach ($d in $simDeviceInfo.AllDevices) { Write-Host " - $($d.displayName) (ID: $($d.id), OS: $($d.operatingSystem))" -ForegroundColor Yellow } return } } # Determine simulation perspective $hasUserPersp = [bool]$simUserInfo $hasDevicePersp = [bool]$simDeviceInfo $includeReasons = @() if ($hasUserPersp) { $includeReasons += "All Users" } if ($hasDevicePersp) { $includeReasons += "All Devices" } # Resolve target group Write-Host "Looking up group: $simGroupInput" -ForegroundColor Yellow $simTargetGroupId = $null $simTargetGroupName = $null if ($simGroupInput -match '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$') { $simGroupInfo = Get-GroupInfo -GroupId $simGroupInput if (-not $simGroupInfo.Success) { Write-Host "No group found with ID: $simGroupInput" -ForegroundColor Red return } $simTargetGroupId = $simGroupInfo.Id $simTargetGroupName = $simGroupInfo.DisplayName } else { $escapedSimGroupName = $simGroupInput -replace "'", "''" $simGroupUri = "$script:GraphEndpoint/v1.0/groups?`$filter=displayName eq '$escapedSimGroupName'" $simGroupResponse = Invoke-MgGraphRequest -Uri $simGroupUri -Method Get if ($simGroupResponse.value.Count -eq 0) { Write-Host "No group found with name: $simGroupInput" -ForegroundColor Red return } elseif ($simGroupResponse.value.Count -gt 1) { Write-Host "Multiple groups found with name: $simGroupInput. Please use the Object ID instead:" -ForegroundColor Red foreach ($g in $simGroupResponse.value) { Write-Host " - $($g.displayName) (ID: $($g.id))" -ForegroundColor Yellow } return } $simTargetGroupId = $simGroupResponse.value[0].id $simTargetGroupName = $simGroupResponse.value[0].displayName } Write-Host "Target group: $simTargetGroupName (ID: $simTargetGroupId)" -ForegroundColor Green # Get current group memberships (union of user and device, depending on what was supplied) $simCurrentGroupIds = @() try { if ($hasUserPersp) { $simUserGroups = Get-GroupMemberships -ObjectId $simUserInfo.Id -ObjectType "User" $simCurrentGroupIds += @($simUserGroups | Where-Object { $_.id } | ForEach-Object { $_.id }) } if ($hasDevicePersp) { $simDeviceGroups = Get-GroupMemberships -ObjectId $simDeviceInfo.Id -ObjectType "Device" $simCurrentGroupIds += @($simDeviceGroups | Where-Object { $_.id } | ForEach-Object { $_.id }) } $simCurrentGroupIds = @($simCurrentGroupIds | Select-Object -Unique) } catch { Write-Host "Error fetching group memberships: $($_.Exception.Message)" -ForegroundColor Red return } # Build subject label for messages $subjectLabel = if ($hasUserPersp -and $hasDevicePersp) { "User '$simUpn' + Device '$($simDeviceInfo.DisplayName)'" } elseif ($hasUserPersp) { "User '$simUpn'" } else { "Device '$($simDeviceInfo.DisplayName)'" } # Check if subject is a member of the target group (required for removal simulation) $isMember = $simCurrentGroupIds -contains $simTargetGroupId if (-not $isMember) { Write-Host "`n$subjectLabel is NOT a member of '$simTargetGroupName'. Nothing to simulate." -ForegroundColor Red return } # Get target group's parent groups (transitive) $simTargetParentGroups = Get-TransitiveGroupMembership -GroupId $simTargetGroupId $simTargetAllGroupIds = @($simTargetGroupId) if ($simTargetParentGroups) { $simTargetAllGroupIds += $simTargetParentGroups.id } # Build simulated group set (current MINUS target and target's parents) $simSimulatedGroupIds = @($simCurrentGroupIds | Where-Object { $simTargetAllGroupIds -notcontains $_ }) Write-Host "Analyzing removal impact..." -ForegroundColor Yellow # UserContext supplies this cmdlet's legacy display names and the unfiltered Settings # Catalog fetch; reorder to the legacy 18-step walk and fetch the Autopilot/ESP # categories that UserContext keeps bucket-only. $categoryIndex = @{} foreach ($category in (Get-IntuneCategoryDefinition -Audience 'UserContext')) { $categoryIndex[$category.Id] = $category } $categories = foreach ($id in @( 'DeviceConfigurations', 'SettingsCatalog', 'CompliancePolicies', 'AppProtectionPolicies', 'AppConfigurationPolicies', 'Applications', 'PlatformScripts', 'HealthScripts', 'ESAntivirus', 'ESDiskEncryption', 'ESFirewall', 'ESEndpointDetection', 'ESAttackSurface', 'ESAccountProtection', 'DeploymentProfiles', 'ESPProfiles', 'CloudPCProvisioningPolicies', 'CloudPCUserSettings')) { $categoryIndex[$id].BucketOnly = $false $categoryIndex[$id] } # Conflict rows keep the legacy category labels; where those differ from the registry # export label the override below wins. $conflictLabels = @{ SettingsCatalog = 'Settings Catalog' PlatformScripts = 'Platform Script' HealthScripts = 'Proactive Remediation Script' CloudPCProvisioningPolicies = 'Cloud PC Provisioning' CloudPCUserSettings = 'Cloud PC User Setting' } $conflictPolicies = [System.Collections.ArrayList]::new() # Caller-owned cache: each entity set is fetched from Graph once (the legacy walk # re-fetched configurationPolicies and intents for every Endpoint Security family) $entityCache = @{} $appProgress = @{ Current = 0; Total = $null; FilteredTotal = $null } $processEntity = { param($ctx) $entity = $ctx.Entity if ($ctx.Category.Kind -eq 'MobileApps') { if ($null -eq $appProgress.Total) { $allCachedApps = @($entityCache[$ctx.Category.EntityType]) $appProgress.Total = $allCachedApps.Count $appProgress.FilteredTotal = @($allCachedApps | Where-Object { -not ($_.isFeatured -or $_.isBuiltIn) }).Count } $appProgress.Current++ Write-Host "`rFetching Application $($appProgress.Current) of $($appProgress.Total)" -NoNewline # Legacy app walk: track inclusion/exclusion against the current and the # simulated group sets. The first matching inclusion wins the filter suffix, # the last matching inclusion wins the intent. $currentExcluded = $false; $currentIncluded = $false $simExcluded = $false; $simIncluded = $false $currentAppIntent = $null $currentWinningTarget = $null foreach ($assignment in $ctx.RawAssignments) { $targetType = $assignment.target.'@odata.type' $targetGroupId = $assignment.target.groupId if ($targetType -eq '#microsoft.graph.exclusionGroupAssignmentTarget') { if ($simCurrentGroupIds -contains $targetGroupId) { $currentExcluded = $true } if ($simSimulatedGroupIds -contains $targetGroupId) { $simExcluded = $true } } elseif ($targetType -in @('#microsoft.graph.allLicensedUsersAssignmentTarget', '#microsoft.graph.allDevicesAssignmentTarget')) { $allTargetReason = if ($targetType -eq '#microsoft.graph.allLicensedUsersAssignmentTarget') { "All Users" } else { "All Devices" } if ($includeReasons -contains $allTargetReason) { $currentIncluded = $true $simIncluded = $true $currentAppIntent = $assignment.intent if (-not $currentWinningTarget) { $currentWinningTarget = $assignment.target } } } elseif ($targetType -eq '#microsoft.graph.groupAssignmentTarget') { if ($simCurrentGroupIds -contains $targetGroupId) { $currentIncluded = $true $currentAppIntent = $assignment.intent if (-not $currentWinningTarget) { $currentWinningTarget = $assignment.target } } if ($simSimulatedGroupIds -contains $targetGroupId) { $simIncluded = $true } } } $currentHasApp = $currentIncluded -and -not $currentExcluded $simHasApp = $simIncluded -and -not $simExcluded if ($currentHasApp -and -not $simHasApp) { $filterSuffix = '' if ($currentWinningTarget) { $filterSuffix = Format-AssignmentFilter -FilterId $currentWinningTarget.deviceAndAppManagementAssignmentFilterId -FilterType $currentWinningTarget.deviceAndAppManagementAssignmentFilterType } $appWithReason = $entity.PSObject.Copy() $appWithReason | Add-Member -NotePropertyName 'AssignmentReason' -NotePropertyValue "Group Assignment$filterSuffix" -Force $appWithReason | Add-Member -NotePropertyName 'AssignmentIntent' -NotePropertyValue $currentAppIntent -Force switch ($currentAppIntent) { "required" { $ctx.Buckets['AppsRequired'].Add($appWithReason) } "available" { $ctx.Buckets['AppsAvailable'].Add($appWithReason) } "uninstall" { $ctx.Buckets['AppsUninstall'].Add($appWithReason) } } } elseif ($currentExcluded -and $simExcluded) { # Check if target group specifically includes this app while the subject is excluded foreach ($assignment in $ctx.RawAssignments) { if ($assignment.target.'@odata.type' -eq '#microsoft.graph.groupAssignmentTarget' -and $simTargetAllGroupIds -contains $assignment.target.groupId -and $simCurrentGroupIds -notcontains $assignment.target.groupId) { $appName = if ($entity.displayName) { $entity.displayName } else { $entity.name } [void]$conflictPolicies.Add([PSCustomObject]@{ Category = "Application ($($assignment.intent))"; PolicyName = $appName; PolicyId = $entity.id; ConflictType = "Currently included; removal would expose exclusion" }) break } } } if ($appProgress.Current -eq $appProgress.FilteredTotal) { # Legacy final overwrite before the next category header Write-Host "`rFetching Application $($appProgress.Total) of $($appProgress.Total)" } return } $assignments = $ctx.Assignments if ($ctx.Category.Kind -eq 'AppProtection') { # The legacy App Protection walk never mapped All Devices targets and only # evaluated policies that had at least one relevant assignment $assignments = @($assignments | Where-Object { $_.Reason -ne 'All Devices' }) if ($assignments.Count -eq 0) { return } } elseif ($ctx.Category.Kind -eq 'EndpointSecurity' -and $null -ne $ctx.RawAssignments) { # Intent-phase detail rows historically carried no filter info, so the resolved # status strings get no filter suffix (unlike the config-policy phase) $assignments = @($assignments | ForEach-Object { [PSCustomObject]@{ Reason = $_.Reason; GroupId = $_.GroupId } }) } $delta = Resolve-SimulatedAssignmentDelta -Assignments $assignments -CurrentGroupIds $simCurrentGroupIds -SimulatedGroupIds $simSimulatedGroupIds -TargetGroupIds $simTargetAllGroupIds -IncludeReasons $includeReasons if ($delta.IsLostPolicy) { $entity | Add-Member -NotePropertyName 'AssignmentReason' -NotePropertyValue $delta.CurrentStatus -Force $ctx.Buckets[$ctx.Category.BucketKeys[0]].Add($entity) } elseif ($delta.IsConflict) { $conflictCategory = if ($conflictLabels.ContainsKey($ctx.Category.Id)) { $conflictLabels[$ctx.Category.Id] } else { $ctx.Category.ExportCategory } # Legacy App Protection conflict rows read displayName without a name fallback $conflictName = if ($ctx.Category.Kind -eq 'AppProtection') { $entity.displayName } elseif ($entity.displayName) { $entity.displayName } else { $entity.name } [void]$conflictPolicies.Add([PSCustomObject]@{ Category = $conflictCategory; PolicyName = $conflictName; PolicyId = $entity.id; ConflictType = "Currently included; removal would expose exclusion" }) } } $scanResult = Invoke-IntuneCategoryScan -Categories $categories -ProcessEntity $processEntity -ShowProgress -EntityCache $entityCache $deltaPolicies = $scanResult.Buckets # Apply scope tag filter if specified if ($ScopeTagFilter) { foreach ($key in @($deltaPolicies.Keys)) { $deltaPolicies[$key] = @(Filter-ByScopeTag -Items $deltaPolicies[$key] -FilterTag $ScopeTagFilter -ScopeTagLookup $script:ScopeTagLookup) } } # ===== DISPLAY RESULTS ===== Write-Host "" Write-Host (Get-Separator -Character "=") -ForegroundColor Yellow Write-Host " SIMULATION RESULTS - GROUP MEMBERSHIP REMOVAL IMPACT" -ForegroundColor Yellow Write-Host " (no changes were made)" -ForegroundColor DarkGray Write-Host (Get-Separator -Character "=") -ForegroundColor Yellow if ($hasUserPersp) { Write-Host " User: $simUpn" -ForegroundColor White } if ($hasDevicePersp) { Write-Host " Device: $($simDeviceInfo.DisplayName) (ID: $($simDeviceInfo.Id))" -ForegroundColor White } Write-Host " Target Group: $simTargetGroupName (ID: $simTargetGroupId)" -ForegroundColor White Write-Host (Get-Separator -Character "=") -ForegroundColor Yellow # Per-bucket display and export labels in the legacy order $categoryTable = @( @{ Key = 'DeviceConfigs'; Display = 'Device Configurations'; Export = 'Device Configuration' } @{ Key = 'SettingsCatalog'; Display = 'Settings Catalog Policies'; Export = 'Settings Catalog Policy' } @{ Key = 'CompliancePolicies'; Display = 'Compliance Policies'; Export = 'Compliance Policy' } @{ Key = 'AppProtectionPolicies'; Display = 'App Protection Policies'; Export = 'App Protection Policy' } @{ Key = 'AppConfigurationPolicies'; Display = 'App Configuration Policies'; Export = 'App Configuration Policy' } @{ Key = 'AppsRequired'; Display = 'Required Apps'; Export = 'Required App' } @{ Key = 'AppsAvailable'; Display = 'Available Apps'; Export = 'Available App' } @{ Key = 'AppsUninstall'; Display = 'Uninstall Apps'; Export = 'Uninstall App' } @{ Key = 'PlatformScripts'; Display = 'Platform Scripts'; Export = 'Platform Script' } @{ Key = 'HealthScripts'; Display = 'Proactive Remediation Scripts'; Export = 'Proactive Remediation Script' } @{ Key = 'AntivirusProfiles'; Display = 'Endpoint Security - Antivirus'; Export = 'Endpoint Security - Antivirus' } @{ Key = 'DiskEncryptionProfiles'; Display = 'Endpoint Security - Disk Encryption'; Export = 'Endpoint Security - Disk Encryption' } @{ Key = 'FirewallProfiles'; Display = 'Endpoint Security - Firewall'; Export = 'Endpoint Security - Firewall' } @{ Key = 'EndpointDetectionProfiles'; Display = 'Endpoint Security - EDR'; Export = 'Endpoint Security - EDR' } @{ Key = 'AttackSurfaceProfiles'; Display = 'Endpoint Security - ASR'; Export = 'Endpoint Security - ASR' } @{ Key = 'AccountProtectionProfiles'; Display = 'Endpoint Security - Account Protection'; Export = 'Endpoint Security - Account Protection' } @{ Key = 'DeploymentProfiles'; Display = 'Autopilot Deployment Profiles'; Export = 'Autopilot Deployment Profile' } @{ Key = 'ESPProfiles'; Display = 'Enrollment Status Page Profiles'; Export = 'Enrollment Status Page Profile' } @{ Key = 'CloudPCProvisioningPolicies'; Display = 'Windows 365 Cloud PC Provisioning'; Export = 'Cloud PC Provisioning Policy' } @{ Key = 'CloudPCUserSettings'; Display = 'Windows 365 Cloud PC User Settings'; Export = 'Cloud PC User Setting' } ) # Legacy column truncation (cut at max, keep max-3 characters plus "...") $truncate = { param($text, $max) if ($text.Length -gt $max) { $text.Substring(0, $max - 3) + "..." } else { $text } } $totalLostPolicies = 0 foreach ($cat in $categoryTable) { $items = $deltaPolicies[$cat.Key] if ($items.Count -gt 0) { $totalLostPolicies += $items.Count Write-Host "`n------- LOST: $($cat.Display) ($($items.Count)) -------" -ForegroundColor Red $headerFormat = "{0,-50} {1,-40} {2,-30}" -f "Policy Name", "Policy ID", "Assignment Reason" $separator = Get-Separator Write-Host $separator Write-Host $headerFormat -ForegroundColor Yellow Write-Host $separator foreach ($item in $items) { $itemName = if (-not [string]::IsNullOrWhiteSpace($item.displayName)) { $item.displayName } else { $item.name } if (-not $itemName) { $itemName = "Unnamed" } $itemName = & $truncate $itemName 47 $itemId = if ($item.id) { $item.id } else { "Unknown" } $itemId = & $truncate $itemId 37 $reason = if ($item.AssignmentReason) { $item.AssignmentReason } else { "Unknown" } $reason = & $truncate $reason 27 Write-Host ("{0,-50} {1,-40} {2,-30}" -f $itemName, $itemId, $reason) -ForegroundColor White } Write-Host $separator } } # Display conflicts if ($conflictPolicies.Count -gt 0) { Write-Host "`n------- CONFLICTS (Exclusion Overrides) -------" -ForegroundColor Red Write-Host "Note: In Intune, exclusions take priority over inclusions." -ForegroundColor Yellow $headerFormat = "{0,-50} {1,-35} {2,-35}" -f "Policy Name", "Category", "Conflict" $separator = Get-Separator Write-Host $separator Write-Host $headerFormat -ForegroundColor Yellow Write-Host $separator foreach ($conflict in $conflictPolicies) { $cName = & $truncate $conflict.PolicyName 47 $cCat = & $truncate $conflict.Category 32 $cType = & $truncate $conflict.ConflictType 32 Write-Host ("{0,-50} {1,-35} {2,-35}" -f $cName, $cCat, $cType) -ForegroundColor Red } Write-Host $separator } # Summary Write-Host "`n=== Impact Summary ===" -ForegroundColor Cyan Write-Host "Removing $subjectLabel from '$simTargetGroupName' would result in:" -ForegroundColor White $categoryCount = ($categoryTable | Where-Object { $deltaPolicies[$_.Key].Count -gt 0 }).Count $conflictCount = $conflictPolicies.Count if ($totalLostPolicies -eq 0 -and $conflictCount -eq 0) { Write-Host " No lost policy assignments and no conflicts." -ForegroundColor Yellow } else { $parts = @() if ($totalLostPolicies -gt 0) { $parts += "$totalLostPolicies lost $(if ($totalLostPolicies -eq 1) { 'policy' } else { 'policies' }) across $categoryCount $(if ($categoryCount -eq 1) { 'category' } else { 'categories' })" } if ($conflictCount -gt 0) { $parts += "$conflictCount $(if ($conflictCount -eq 1) { 'conflict' } else { 'conflicts' })" } Write-Host " Impact: $($parts -join ', ')" -ForegroundColor $(if ($conflictCount -gt 0) { "Red" } else { "Yellow" }) } # Export $exportData = [System.Collections.ArrayList]::new() $null = $exportData.Add([PSCustomObject]@{ Category = "Simulation Info" Item = "$subjectLabel -> Remove from Group: $simTargetGroupName (ID: $simTargetGroupId)" ScopeTags = "" AssignmentReason = "Removal Impact Analysis" }) foreach ($cat in $categoryTable) { Add-ExportData -ExportData $exportData -Category "LOST: $($cat.Export)" -Items $deltaPolicies[$cat.Key] -AssignmentReason { param($item) $item.AssignmentReason } } foreach ($conflict in $conflictPolicies) { $null = $exportData.Add([PSCustomObject]@{ Category = "CONFLICT: $($conflict.Category)" Item = "$($conflict.PolicyName) (ID: $($conflict.PolicyId))" ScopeTags = "" AssignmentReason = $conflict.ConflictType }) } Export-ResultsIfRequested -ExportData $exportData -DefaultFileName "IntuneGroupRemovalImpact.csv" -ForceExport:$ExportToCSV -CustomExportPath $ExportPath -ExportToCSV:$ExportToCSV -ParameterMode:$parameterMode } |