diff --git a/Private/Assert-CloudDeviceCleanupSettings.ps1 b/Private/Assert-CloudDeviceCleanupSettings.ps1 index 5cc4bd3..3bc8aff 100644 --- a/Private/Assert-CloudDeviceCleanupSettings.ps1 +++ b/Private/Assert-CloudDeviceCleanupSettings.ps1 @@ -8,7 +8,7 @@ function Assert-CloudDeviceCleanupSettings { return $false } - $minimumVersion = [version] '0.0.58' + $minimumVersion = [version] '0.0.59' if ($moduleAvailable.Version -lt $minimumVersion) { Write-Color -Text '[e] ', "'GraphEssentials' module is outdated for cloud-device cleanup. Please update to minimum version '$minimumVersion'. Terminating." -Color Yellow, Red return $false diff --git a/Private/Get-CloudDeviceSelectionReason.ps1 b/Private/Get-CloudDeviceSelectionReason.ps1 index cebe942..1b390a8 100644 --- a/Private/Get-CloudDeviceSelectionReason.ps1 +++ b/Private/Get-CloudDeviceSelectionReason.ps1 @@ -5,7 +5,7 @@ function Get-CloudDeviceSelectionReason { [PSObject] $Device, [Parameter(Mandatory)] - [ValidateSet('Retire', 'Disable', 'Delete')] + [ValidateSet('Retire', 'Disable', 'Delete', 'RemoveAutopilotIdentity')] [string] $Type, [Parameter(Mandatory)] @@ -18,6 +18,14 @@ function Get-CloudDeviceSelectionReason { $reasons.Add("RecordState=$($Device.RecordState)") + if ($Device.PSObject.Properties['IntuneLinkState'] -and $Device.IntuneLinkState) { + $reasons.Add("IntuneLinkState=$($Device.IntuneLinkState)") + } + + if ($ActionIf.IntuneLinkState -and $ActionIf.IntuneLinkState -ne 'Any') { + $reasons.Add("RequiredIntuneLinkState=$($ActionIf.IntuneLinkState)") + } + if ($null -ne $ActionIf.LastSeenEntraMoreThan -and $null -ne $Device.EntraLastSeenDays) { $reasons.Add("EntraLastSeenDays=$($Device.EntraLastSeenDays) > $($ActionIf.LastSeenEntraMoreThan)") } elseif ($null -ne $ActionIf.LastSeenEntraMoreThan -and $ActionIf.IncludeUnknownActivity -and $null -eq $Device.EntraLastSeenDays) { @@ -42,6 +50,20 @@ function Get-CloudDeviceSelectionReason { $reasons.Add("AutopilotState=$($ActionIf.AutopilotState)") } + if ($null -ne $ActionIf.AutopilotLastContactMoreThan -and $null -ne $Device.AutopilotLastContactedDays) { + $reasons.Add("AutopilotLastContactedDays=$($Device.AutopilotLastContactedDays) > $($ActionIf.AutopilotLastContactMoreThan)") + } elseif ($null -ne $ActionIf.AutopilotLastContactMoreThan -and $ActionIf.IncludeUnknownActivity -and $null -eq $Device.AutopilotLastContactedDays) { + $reasons.Add("AutopilotLastContactedDays=Unknown allowed") + } + + if ($ActionIf.AutopilotIntuneAssociationState -and $ActionIf.AutopilotIntuneAssociationState -ne 'Any') { + $reasons.Add("AutopilotIntuneAssociationState=$($ActionIf.AutopilotIntuneAssociationState)") + } + + if ($ActionIf.AutopilotEntraAssociationState -and $ActionIf.AutopilotEntraAssociationState -ne 'Any') { + $reasons.Add("AutopilotEntraAssociationState=$($ActionIf.AutopilotEntraAssociationState)") + } + if ($ActionIf.OwnerState -and $ActionIf.OwnerState -ne 'Any') { $reasons.Add("OwnerState=$($ActionIf.OwnerState)") } @@ -100,5 +122,9 @@ function Get-CloudDeviceSelectionReason { } } + if ($Type -eq 'RemoveAutopilotIdentity') { + $reasons.Add('Remove Windows Autopilot device identity only') + } + $reasons -join '; ' } diff --git a/Private/Get-CloudDevicesToProcess.ps1 b/Private/Get-CloudDevicesToProcess.ps1 index f779813..1b346d3 100644 --- a/Private/Get-CloudDevicesToProcess.ps1 +++ b/Private/Get-CloudDevicesToProcess.ps1 @@ -2,7 +2,7 @@ function Get-CloudDevicesToProcess { [CmdletBinding()] param( [Parameter(Mandatory)] - [ValidateSet('Retire', 'Disable', 'Delete')] + [ValidateSet('Retire', 'Disable', 'Delete', 'RemoveAutopilotIdentity')] [string] $Type, [Parameter(Mandatory)] @@ -115,6 +115,56 @@ function Get-CloudDevicesToProcess { 'Unknown' } + function Test-CloudDeviceAutopilotIntuneAssociationState { + param( + [Parameter(Mandatory)] [object] $Device, + [string] $State + ) + + if (-not $State -or $State -eq 'Any') { + return $true + } + + $hasAssociation = -not [string]::IsNullOrWhiteSpace([string] $Device.AutopilotManagedDeviceId) + if ($State -eq 'Missing') { + return -not $hasAssociation + } + if ($State -eq 'Present') { + return $hasAssociation + } + + $true + } + + function Test-CloudDeviceAutopilotEntraAssociationState { + param( + [Parameter(Mandatory)] [object] $Device, + [string] $State + ) + + if (-not $State -or $State -eq 'Any') { + return $true + } + + $associationValue = [string] $Device.AutopilotResourceName + $hasAssociation = -not [string]::IsNullOrWhiteSpace([string] $Device.AutopilotAzureAdDeviceId) + $serialNumber = [string] $Device.AutopilotSerialNumber + if ($State -eq 'Missing') { + return -not $hasAssociation + } + if ($State -eq 'Present') { + return $hasAssociation + } + if ($State -eq 'EqualsSerialNumber') { + return -not [string]::IsNullOrWhiteSpace($associationValue) -and -not [string]::IsNullOrWhiteSpace($serialNumber) -and $associationValue -eq $serialNumber + } + if ($State -eq 'NotEqualsSerialNumber') { + return -not [string]::IsNullOrWhiteSpace($associationValue) -and -not [string]::IsNullOrWhiteSpace($serialNumber) -and $associationValue -ne $serialNumber + } + + $true + } + Write-Color -Text '[i] ', "Applying following rules to $Type action:" -Color Yellow, Cyan, Green foreach ($key in $ActionIf.Keys) { if ($null -eq $ActionIf[$key] -or ($ActionIf[$key] -is [System.Array] -and $ActionIf[$key].Count -eq 0)) { @@ -142,6 +192,10 @@ function Get-CloudDevicesToProcess { continue } + if ($ActionIf.IntuneLinkState -and $ActionIf.IntuneLinkState -ne 'Any' -and $device.IntuneLinkState -ne $ActionIf.IntuneLinkState) { + continue + } + if ($Type -eq 'Retire') { if (-not $device.HasIntuneRecord) { continue @@ -175,6 +229,10 @@ function Get-CloudDevicesToProcess { if ($device.RecordState -eq 'IntuneOnly' -and -not $ActionIf.IncludeIntuneOnly) { continue } + } elseif ($Type -eq 'RemoveAutopilotIdentity') { + if ($device.AutopilotOnboarded -ne $true -or [string]::IsNullOrWhiteSpace([string] $device.AutopilotDeviceId)) { + continue + } } if ($null -ne $ActionIf.ListProcessedMoreThan) { @@ -220,6 +278,24 @@ function Get-CloudDevicesToProcess { } } + if ($null -ne $ActionIf.AutopilotLastContactMoreThan) { + if ($null -eq $device.AutopilotLastContactedDays) { + if (-not $ActionIf.IncludeUnknownActivity) { + continue + } + } elseif ($device.AutopilotLastContactedDays -le $ActionIf.AutopilotLastContactMoreThan) { + continue + } + } + + if (-not (Test-CloudDeviceAutopilotIntuneAssociationState -Device $device -State $ActionIf.AutopilotIntuneAssociationState)) { + continue + } + + if (-not (Test-CloudDeviceAutopilotEntraAssociationState -Device $device -State $ActionIf.AutopilotEntraAssociationState)) { + continue + } + if ($ActionIf.EnabledState -and $ActionIf.EnabledState -ne 'Any') { if ($ActionIf.EnabledState -eq 'Enabled' -and $device.Enabled -ne $true) { continue diff --git a/Private/Get-InitialCloudDevices.ps1 b/Private/Get-InitialCloudDevices.ps1 index e2d6ef9..e100df6 100644 --- a/Private/Get-InitialCloudDevices.ps1 +++ b/Private/Get-InitialCloudDevices.ps1 @@ -59,6 +59,42 @@ function Get-InitialCloudDevices { $null } + $testIntuneManagementClaim = { + param([AllowNull()] $Device) + + if (-not $Device) { + return $false + } + + $intuneMdmAppId = '0000000a-0000-0000-c000-000000000000' + $mdmAppId = Get-CloudDevicePropertyValue -InputObject $Device -Name @('MdmAppId', 'mdmAppId') + $isManagedValue = Get-CloudDevicePropertyValue -InputObject $Device -Name 'IsManaged' + $isManaged = if ($isManagedValue -is [bool]) { + $isManagedValue + } else { + [string] $isManagedValue -match '^(?i:true|1)$' + } + if (-not [string]::IsNullOrWhiteSpace([string] $mdmAppId) -and [string] $mdmAppId -ine $intuneMdmAppId) { + return $false + } + if ($isManaged -and [string] $mdmAppId -ieq $intuneMdmAppId) { + return $true + } + + $managementValues = @( + Get-CloudDevicePropertyValue -InputObject $Device -Name 'ManagementType' + Get-CloudDevicePropertyValue -InputObject $Device -Name 'ManagementAgent' + ) | Where-Object { -not [string]::IsNullOrWhiteSpace([string] $_) } + + foreach ($managementValue in $managementValues) { + if ([string] $managementValue -like '*mdm*' -or [string] $managementValue -like '*intune*') { + return $true + } + } + + $false + } + [Array] $entraJoinType = @($IncludeJoinType | Where-Object { $_ -ne 'Not available' }) [Array] $entraDevices = @() if ($entraJoinType.Count -gt 0) { @@ -160,6 +196,19 @@ function Get-InitialCloudDevices { $intuneRegisteredDays = if ($intuneDevice) { & $getAgeDays $intuneDevice.FirstSeen } else { $null } $autopilotInventoryLoaded = & $getFirstNonNullPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotInventoryLoaded' $autopilotOnboarded = & $getFirstNonNullPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotOnboarded' + $autopilotLastContacted = & $getFirstPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotLastContacted' + $autopilotLastContactedDays = & $getFirstNonNullPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotLastContactedDays' + if ($null -eq $autopilotLastContactedDays -and $autopilotLastContacted) { + $autopilotLastContactedDays = & $getAgeDays $autopilotLastContacted + } + $claimsIntuneManagement = & $testIntuneManagementClaim $entraDevice + $intuneLinkState = if ($intuneDevice) { + 'Healthy' + } elseif ($claimsIntuneManagement) { + 'Broken' + } else { + 'NotClaimed' + } $outputDevices.Add([PSCustomObject] [ordered] @{ Name = $entraDevice.Name @@ -170,6 +219,8 @@ function Get-InitialCloudDevices { HasIntuneRecord = [bool] $intuneDevice RecordState = if ($intuneDevice) { 'Matched' } else { 'EntraOnly' } RecordSource = if ($intuneDevice) { 'Microsoft Entra ID + Intune' } else { 'Microsoft Entra ID only' } + IntuneLinkState = $intuneLinkState + ClaimsIntuneManagement = $claimsIntuneManagement Enabled = $entraDevice.Enabled OperatingSystem = if ($intuneDevice -and $intuneDevice.OperatingSystem) { $intuneDevice.OperatingSystem } else { $entraDevice.OperatingSystem } OperatingSystemVersion = $operatingSystemVersion @@ -200,10 +251,14 @@ function Get-InitialCloudDevices { AutopilotInventoryLoaded = $autopilotInventoryLoaded AutopilotOnboarded = $autopilotOnboarded AutopilotDeviceId = & $getFirstPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotDeviceId' + AutopilotManagedDeviceId = & $getFirstPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotManagedDeviceId' + AutopilotAzureAdDeviceId = & $getFirstPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotAzureAdDeviceId' + AutopilotResourceName = & $getFirstPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotResourceName' AutopilotGroupTag = & $getFirstPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotGroupTag' AutopilotSerialNumber = & $getFirstPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotSerialNumber' AutopilotEnrollmentState = & $getFirstPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotEnrollmentState' - AutopilotLastContacted = & $getFirstPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotLastContacted' + AutopilotLastContacted = $autopilotLastContacted + AutopilotLastContactedDays = $autopilotLastContactedDays AutopilotUserPrincipalName = & $getFirstPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotUserPrincipalName' SelectionReason = $null }) @@ -225,6 +280,11 @@ function Get-InitialCloudDevices { } $intuneRegisteredDays = & $getAgeDays $intuneDevice.FirstSeen + $autopilotLastContacted = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotLastContacted' + $autopilotLastContactedDays = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotLastContactedDays' + if ($null -eq $autopilotLastContactedDays -and $autopilotLastContacted) { + $autopilotLastContactedDays = & $getAgeDays $autopilotLastContacted + } $outputDevices.Add([PSCustomObject] [ordered] @{ Name = $intuneDevice.Name @@ -235,6 +295,8 @@ function Get-InitialCloudDevices { HasIntuneRecord = $true RecordState = 'IntuneOnly' RecordSource = 'Intune only' + IntuneLinkState = 'IntuneOnly' + ClaimsIntuneManagement = $true Enabled = $null OperatingSystem = $intuneDevice.OperatingSystem OperatingSystemVersion = $intuneDevice.OperatingSystemVersion @@ -265,10 +327,14 @@ function Get-InitialCloudDevices { AutopilotInventoryLoaded = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotInventoryLoaded' AutopilotOnboarded = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotOnboarded' AutopilotDeviceId = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotDeviceId' + AutopilotManagedDeviceId = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotManagedDeviceId' + AutopilotAzureAdDeviceId = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotAzureAdDeviceId' + AutopilotResourceName = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotResourceName' AutopilotGroupTag = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotGroupTag' AutopilotSerialNumber = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotSerialNumber' AutopilotEnrollmentState = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotEnrollmentState' - AutopilotLastContacted = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotLastContacted' + AutopilotLastContacted = $autopilotLastContacted + AutopilotLastContactedDays = $autopilotLastContactedDays AutopilotUserPrincipalName = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotUserPrincipalName' SelectionReason = $null }) diff --git a/Private/Merge-CloudDeviceReportInventory.ps1 b/Private/Merge-CloudDeviceReportInventory.ps1 new file mode 100644 index 0000000..debc7cc --- /dev/null +++ b/Private/Merge-CloudDeviceReportInventory.ps1 @@ -0,0 +1,52 @@ +function Merge-CloudDeviceReportInventory { + [CmdletBinding()] + param( + [Array] $PrimaryDevices, + [Array] $AdditionalDevices + ) + + function Get-CloudDeviceReportInventoryKey { + param( + [Parameter(Mandatory)] + [object] $Device + ) + + @( + if ($Device.PSObject.Properties['ManagedDeviceId'] -and $Device.ManagedDeviceId) { "intune:$($Device.ManagedDeviceId)" } + if ($Device.PSObject.Properties['EntraDeviceObjectId'] -and $Device.EntraDeviceObjectId) { "entra:$($Device.EntraDeviceObjectId)" } + if ($Device.PSObject.Properties['DeviceId'] -and $Device.DeviceId) { "device:$($Device.DeviceId)" } + if ($Device.PSObject.Properties['AutopilotDeviceId'] -and $Device.AutopilotDeviceId) { "autopilot:$($Device.AutopilotDeviceId)" } + ) + } + + $reportDevices = @($PrimaryDevices) + if (-not $AdditionalDevices) { + return $reportDevices + } + + $reportDeviceKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($reportDevice in $reportDevices) { + foreach ($key in @(Get-CloudDeviceReportInventoryKey -Device $reportDevice)) { + $null = $reportDeviceKeys.Add($key) + } + } + + foreach ($additionalDevice in @($AdditionalDevices)) { + $additionalDeviceKeys = @(Get-CloudDeviceReportInventoryKey -Device $additionalDevice) + $knownReportDevice = $false + foreach ($key in $additionalDeviceKeys) { + if ($reportDeviceKeys.Contains($key)) { + $knownReportDevice = $true + break + } + } + if (-not $knownReportDevice) { + $reportDevices += $additionalDevice + foreach ($key in $additionalDeviceKeys) { + $null = $reportDeviceKeys.Add($key) + } + } + } + + $reportDevices +} diff --git a/Private/New-EmailBodyCloudDevices.ps1 b/Private/New-EmailBodyCloudDevices.ps1 index 08b31c0..c40be68 100644 --- a/Private/New-EmailBodyCloudDevices.ps1 +++ b/Private/New-EmailBodyCloudDevices.ps1 @@ -9,6 +9,7 @@ function New-EmailBodyCloudDevices { [Array] $retiredDevices = $CurrentRun | Where-Object { $_.Action -eq 'Retire' } [Array] $disabledDevices = $CurrentRun | Where-Object { $_.Action -eq 'Disable' } [Array] $deletedDevices = $CurrentRun | Where-Object { $_.Action -eq 'Delete' } + [Array] $autopilotIdentitiesRemoved = $CurrentRun | Where-Object { $_.Action -eq 'RemoveAutopilotIdentity' } $emailBody = EmailBody -EmailBody { EmailText -Text 'Hello,' @@ -20,6 +21,7 @@ function New-EmailBodyCloudDevices { EmailListItem -Text 'Objects retired: ', $retiredDevices.Count -Color None, Orange -FontWeight normal, bold EmailListItem -Text 'Objects disabled: ', $disabledDevices.Count -Color None, Orange -FontWeight normal, bold EmailListItem -Text 'Objects deleted: ', $deletedDevices.Count -Color None, Salmon -FontWeight normal, bold + EmailListItem -Text 'Autopilot identities removed: ', $autopilotIdentitiesRemoved.Count -Color None, Salmon -FontWeight normal, bold } EmailTable -DataTable $CurrentRun -HideFooter EmailText -LineBreak diff --git a/Private/New-HTMLProcessedCloudDevices.ps1 b/Private/New-HTMLProcessedCloudDevices.ps1 index fa6ea84..0f3d84b 100644 --- a/Private/New-HTMLProcessedCloudDevices.ps1 +++ b/Private/New-HTMLProcessedCloudDevices.ps1 @@ -16,6 +16,8 @@ function New-HTMLProcessedCloudDevices { [Parameter(Mandatory)] [System.Collections.IDictionary] $DeleteOnlyIf, + [System.Collections.IDictionary] $RemoveAutopilotIdentityOnlyIf, + [Parameter(Mandatory)] [string] $FilePath, @@ -47,12 +49,14 @@ function New-HTMLProcessedCloudDevices { New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Retire' -BackgroundColor EnergyYellow New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Disable' -BackgroundColor Yellow New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Delete' -BackgroundColor PinkLace + New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'RemoveAutopilotIdentity' -BackgroundColor LightCyan New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'True' -BackgroundColor LightGreen New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'False' -BackgroundColor Salmon New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'WhatIf' -BackgroundColor LightBlue New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'ReportOnly' -BackgroundColor Lavender New-HTMLTableCondition -Name 'RecordState' -ComparisonType string -Value 'EntraOnly' -BackgroundColor AliceBlue New-HTMLTableCondition -Name 'RecordState' -ComparisonType string -Value 'IntuneOnly' -BackgroundColor Cornsilk + New-HTMLTableCondition -Name 'IntuneLinkState' -ComparisonType string -Value 'Broken' -BackgroundColor MistyRose } -WarningAction SilentlyContinue } @@ -61,8 +65,10 @@ function New-HTMLProcessedCloudDevices { New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Retire' -BackgroundColor EnergyYellow New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Disable' -BackgroundColor Yellow New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Delete' -BackgroundColor PinkLace + New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'RemoveAutopilotIdentity' -BackgroundColor LightCyan New-HTMLTableCondition -Name 'RecordState' -ComparisonType string -Value 'EntraOnly' -BackgroundColor AliceBlue New-HTMLTableCondition -Name 'RecordState' -ComparisonType string -Value 'IntuneOnly' -BackgroundColor Cornsilk + New-HTMLTableCondition -Name 'IntuneLinkState' -ComparisonType string -Value 'Broken' -BackgroundColor MistyRose } -WarningAction SilentlyContinue } @@ -70,6 +76,7 @@ function New-HTMLProcessedCloudDevices { New-HTMLTable -DataTable $Export.PendingActions.Values -Filtering -ScrollX { New-HTMLTableCondition -Name 'RecordState' -ComparisonType string -Value 'EntraOnly' -BackgroundColor AliceBlue New-HTMLTableCondition -Name 'RecordState' -ComparisonType string -Value 'IntuneOnly' -BackgroundColor Cornsilk + New-HTMLTableCondition -Name 'IntuneLinkState' -ComparisonType string -Value 'Broken' -BackgroundColor MistyRose } -WarningAction SilentlyContinue } @@ -81,6 +88,7 @@ function New-HTMLProcessedCloudDevices { New-HTMLPanel { New-HTMLToast -TextHeader 'Matched' -Text "Matched records: $(@($Devices | Where-Object { $_.RecordState -eq 'Matched' }).Count)" -BarColorLeft SeaGreen -IconSolid info-circle -IconColor SeaGreen } -Invisible New-HTMLPanel { New-HTMLToast -TextHeader 'Entra only' -Text "Entra-only records: $(@($Devices | Where-Object { $_.RecordState -eq 'EntraOnly' }).Count)" -BarColorLeft CornflowerBlue -IconSolid info-circle -IconColor CornflowerBlue } -Invisible New-HTMLPanel { New-HTMLToast -TextHeader 'Intune only' -Text "Intune-only records: $(@($Devices | Where-Object { $_.RecordState -eq 'IntuneOnly' }).Count)" -BarColorLeft OrangePeel -IconSolid info-circle -IconColor OrangePeel } -Invisible + New-HTMLPanel { New-HTMLToast -TextHeader 'Broken Intune links' -Text "Broken Intune links: $(@($Devices | Where-Object { $_.IntuneLinkState -eq 'Broken' }).Count)" -BarColorLeft Salmon -IconSolid unlink -IconColor Salmon } -Invisible } -Invisible New-HTMLSection -HeaderText 'Rules' { @@ -96,12 +104,19 @@ function New-HTMLProcessedCloudDevices { New-HTMLText -Text 'Delete rules' -FontWeight bold New-HTMLTable -DataTable @([PSCustomObject] $DeleteOnlyIf) } + if ($RemoveAutopilotIdentityOnlyIf) { + New-HTMLPanel { + New-HTMLText -Text 'Remove Autopilot identity rules' -FontWeight bold + New-HTMLTable -DataTable @([PSCustomObject] $RemoveAutopilotIdentityOnlyIf) + } + } } New-HTMLTable -DataTable $Devices -Filtering -ScrollX { New-HTMLTableCondition -Name 'RecordState' -ComparisonType string -Value 'Matched' -BackgroundColor Honeydew New-HTMLTableCondition -Name 'RecordState' -ComparisonType string -Value 'EntraOnly' -BackgroundColor AliceBlue New-HTMLTableCondition -Name 'RecordState' -ComparisonType string -Value 'IntuneOnly' -BackgroundColor Cornsilk + New-HTMLTableCondition -Name 'IntuneLinkState' -ComparisonType string -Value 'Broken' -BackgroundColor MistyRose } -WarningAction SilentlyContinue } diff --git a/Private/Request-CloudDevicesDelete.ps1 b/Private/Request-CloudDevicesDelete.ps1 index 173642f..36cf9db 100644 --- a/Private/Request-CloudDevicesDelete.ps1 +++ b/Private/Request-CloudDevicesDelete.ps1 @@ -33,6 +33,7 @@ function Request-CloudDevicesDelete { $subActionSuccess = $true $subActionExecuted = $false $continueRecordDelete = $true + $autopilotIdentityRemoved = $false if (-not $ReportOnly) { if ($DeleteAutopilotIdentity) { @@ -52,6 +53,8 @@ function Request-CloudDevicesDelete { if (-not $removeAutopilotResult.Success -and -not ($WhatIf -or $WhatIfDelete)) { $subActionSuccess = $false $continueRecordDelete = $false + } elseif ($removeAutopilotResult.Success -and -not ($WhatIf -or $WhatIfDelete)) { + $autopilotIdentityRemoved = $true } } } elseif ($autopilotMayApply -and $device.AutopilotInventoryLoaded -ne $true -and $device.AutopilotOnboarded -ne $false) { @@ -107,6 +110,7 @@ function Request-CloudDevicesDelete { Add-Member -InputObject $result -MemberType NoteProperty -Name 'ActionStatus' -Value $actionStatus -Force Add-Member -InputObject $result -MemberType NoteProperty -Name 'Action' -Value 'Delete' -Force Add-Member -InputObject $result -MemberType NoteProperty -Name 'ActionNotes' -Value ($subActionMessages -join '; ') -Force + Add-Member -InputObject $result -MemberType NoteProperty -Name 'AutopilotIdentityRemoved' -Value $autopilotIdentityRemoved -Force Add-Member -InputObject $result -MemberType NoteProperty -Name 'ProcessedDeviceKeys' -Value $device.ProcessedDeviceKeys -Force $results.Add($result) $attemptedCount++ diff --git a/Private/Request-CloudDevicesRemoveAutopilotIdentity.ps1 b/Private/Request-CloudDevicesRemoveAutopilotIdentity.ps1 new file mode 100644 index 0000000..3bd1961 --- /dev/null +++ b/Private/Request-CloudDevicesRemoveAutopilotIdentity.ps1 @@ -0,0 +1,58 @@ +function Request-CloudDevicesRemoveAutopilotIdentity { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [Array] $Devices, + + [Parameter(Mandatory)] + [datetime] $Today, + + [int] $RemoveAutopilotIdentityLimit = 0, + + [switch] $ReportOnly, + [switch] $WhatIfRemoveAutopilotIdentity, + [switch] $GlobalWhatIf + ) + + $results = [System.Collections.Generic.List[object]]::new() + $attemptedCount = 0 + + foreach ($device in $Devices) { + if ($RemoveAutopilotIdentityLimit -gt 0 -and $attemptedCount -ge $RemoveAutopilotIdentityLimit) { + break + } + + $actionStatus = 'False' + $actionNotes = $null + + if ($ReportOnly) { + $actionStatus = 'ReportOnly' + $actionNotes = 'Autopilot identity removal previewed by report-only mode.' + } elseif ([string]::IsNullOrWhiteSpace([string] $device.AutopilotDeviceId)) { + $actionNotes = 'Autopilot: Device is onboarded but AutopilotDeviceId is missing.' + } else { + $previewRemoval = $GlobalWhatIf -or $WhatIfRemoveAutopilotIdentity -or $WhatIfPreference + $removeAutopilotResult = Remove-MyAutopilotDevice -InputObject $device -Confirm:$false -WhatIf:$previewRemoval + if ($removeAutopilotResult.Message) { + $actionNotes = "Autopilot: $($removeAutopilotResult.Message)" + } + + if ($previewRemoval) { + $actionStatus = 'WhatIf' + } elseif ($removeAutopilotResult.Success) { + $actionStatus = 'True' + } + } + + $result = $device | Select-Object * + Add-Member -InputObject $result -MemberType NoteProperty -Name 'ActionDate' -Value $Today -Force + Add-Member -InputObject $result -MemberType NoteProperty -Name 'ActionStatus' -Value $actionStatus -Force + Add-Member -InputObject $result -MemberType NoteProperty -Name 'Action' -Value 'RemoveAutopilotIdentity' -Force + Add-Member -InputObject $result -MemberType NoteProperty -Name 'ActionNotes' -Value $actionNotes -Force + Add-Member -InputObject $result -MemberType NoteProperty -Name 'ProcessedDeviceKeys' -Value $device.ProcessedDeviceKeys -Force + $results.Add($result) + $attemptedCount++ + } + + @($results) +} diff --git a/Public/Invoke-CloudDevicesCleanup.ps1 b/Public/Invoke-CloudDevicesCleanup.ps1 index 68dcfc0..8dd36ea 100644 --- a/Public/Invoke-CloudDevicesCleanup.ps1 +++ b/Public/Invoke-CloudDevicesCleanup.ps1 @@ -13,6 +13,7 @@ function Invoke-CloudDevicesCleanup { - Retire: retires stale Intune managed devices. - Disable: disables stale Microsoft Entra devices after matching criteria or pending-list age. - Delete: removes stale Microsoft Entra devices and, by default, eligible Intune records. + - RemoveAutopilotIdentity: removes stale Windows Autopilot identities without deleting Entra or Intune records. The cmdlet keeps a datastore with PendingActions and History so staged actions can be reviewed over multiple runs. ReportOnly and WhatIf/action-specific @@ -115,6 +116,25 @@ function Invoke-CloudDevicesCleanup { When enabled, Autopilot inventory must load successfully. If an onboarded device cannot have its Autopilot identity removed, the Intune and Entra record delete sub-actions are skipped. + .PARAMETER RemoveAutopilotIdentity + Removes selected Windows Autopilot device identities without deleting matching Intune or Entra records. + Defaults to requiring a missing associated Intune managed-device id and a last-contact age greater than 90 days. + + .PARAMETER RemoveAutopilotIdentityLastContactMoreThan + Removes Autopilot identities only when the Autopilot LastContacted age is greater than this number of days. + Defaults to 90 days. Blank Autopilot last-contact values are excluded unless IncludeUnknownActivity is set. + + .PARAMETER RemoveAutopilotIdentityIntuneAssociationState + Filters Autopilot identity removal by the Autopilot associated Intune managed-device id. + Defaults to Missing. + + .PARAMETER RemoveAutopilotIdentityEntraAssociationState + Filters Autopilot identity removal by the Autopilot associated Microsoft Entra device value. + EqualsSerialNumber and NotEqualsSerialNumber compare the Autopilot resource/display name with the Autopilot serial number. + + .PARAMETER RemoveAutopilotIdentityLimit + Maximum number of Autopilot identities to remove in one run. 0 means unlimited. Default is 10. + .PARAMETER IncludeOperatingSystem Operating-system patterns to include when building cloud-device inventory. Defaults to iOS and Android patterns. Wildcards are supported. @@ -143,6 +163,10 @@ function Invoke-CloudDevicesCleanup { Allows blank Entra and Intune activity timestamps to satisfy configured LastSeen*MoreThan filters. By default, unknown activity is treated as unsafe and excluded from destructive action selection. + .PARAMETER IntuneLinkState + Filters action candidates by the relationship between Microsoft Entra MDM metadata and Intune managed-device inventory. + Broken means the Entra device claims Intune/MDM management but no matching Intune managed-device record exists. + .PARAMETER AutopilotState Filters action candidates by Windows Autopilot inventory state: Any, Onboarded, or NotOnboarded. NotOnboarded only matches when Autopilot inventory was loaded successfully. @@ -209,6 +233,9 @@ function Invoke-CloudDevicesCleanup { .PARAMETER WhatIfDelete Previews delete actions only. Preview results are shown in the current report but are not stored as pending actions or history. + .PARAMETER WhatIfRemoveAutopilotIdentity + Previews standalone Autopilot identity removal only. Preview results are shown in the current report but are not stored in history. + .PARAMETER LogPath Path to a log file. When omitted, file logging is not enabled. @@ -262,6 +289,16 @@ function Invoke-CloudDevicesCleanup { Previews cleanup of stale Intune-only orphan records without deleting anything or updating the pending-action datastore. + .EXAMPLE + Invoke-CloudDevicesCleanup -RemoveAutopilotIdentity -IncludeJoinType 'AzureAD joined','AzureAD registered' -IncludeOperatingSystem '*' -IncludeUnknownOperatingSystem -RemoveAutopilotIdentityIntuneAssociationState Missing -RemoveAutopilotIdentityLastContactMoreThan 90 -WhatIfRemoveAutopilotIdentity -ShowHTML + + Previews removing stale Windows Autopilot identities whose associated Intune managed-device id is missing. + + .EXAMPLE + Invoke-CloudDevicesCleanup -Disable -DisableIncludeEntraOnly -DisableListProcessedMoreThan $null -IntuneLinkState Broken -DisableLastSeenEntraMoreThan 90 -IncludeJoinType 'AzureAD joined','AzureAD registered' -IncludeOperatingSystem '*' -IncludeUnknownOperatingSystem -WhatIfDisable -ShowHTML + + Previews disabling Entra devices older than 90 days that claim Intune/MDM management but have no matching Intune managed-device record. + .EXAMPLE $cloudCleanup = Invoke-CloudDevicesCleanup -Disable -DisableLastSeenEntraMoreThan 180 -IncludeOperatingSystem 'Android*' -ExcludeOperatingSystem '*Dedicated*' -Confirm $cloudCleanup.CurrentRun | Format-Table Name, Action, ActionStatus, ActionDate @@ -299,6 +336,14 @@ function Invoke-CloudDevicesCleanup { [bool] $DeleteRemoveIntuneRecord = $true, [switch] $DeleteAutopilotIdentity, + [switch] $RemoveAutopilotIdentity, + [nullable[int]] $RemoveAutopilotIdentityLastContactMoreThan = 90, + [ValidateSet('Any', 'Missing', 'Present')] + [string] $RemoveAutopilotIdentityIntuneAssociationState = 'Missing', + [ValidateSet('Any', 'Missing', 'Present', 'EqualsSerialNumber', 'NotEqualsSerialNumber')] + [string] $RemoveAutopilotIdentityEntraAssociationState = 'Any', + [int] $RemoveAutopilotIdentityLimit = 10, + [ValidateSet('Hybrid AzureAD', 'AzureAD joined', 'AzureAD registered', 'Not available')] [string[]] $IncludeJoinType = @('AzureAD registered'), [Array] $IncludeOperatingSystem = @('iOS*', 'Android*'), @@ -308,6 +353,8 @@ function Invoke-CloudDevicesCleanup { [switch] $IncludeUnknownOperatingSystem, [switch] $IncludeUnknownOperatingSystemVersion, [switch] $IncludeUnknownActivity, + [ValidateSet('Any', 'Healthy', 'Broken', 'NotClaimed', 'IntuneOnly')] + [string] $IntuneLinkState = 'Any', [ValidateSet('Any', 'Onboarded', 'NotOnboarded')] [string] $AutopilotState = 'Any', [ValidateSet('Any', 'WithOwner', 'WithoutOwner')] @@ -335,6 +382,7 @@ function Invoke-CloudDevicesCleanup { [switch] $WhatIfDisable, [switch] $WhatIfStageDelete, [switch] $WhatIfDelete, + [switch] $WhatIfRemoveAutopilotIdentity, [string] $LogPath, [int] $LogMaximum = 5, [switch] $LogShowTime, @@ -360,6 +408,9 @@ function Invoke-CloudDevicesCleanup { if (-not $PSBoundParameters.ContainsKey('WhatIfDelete')) { $WhatIfDelete = $true } + if (-not $PSBoundParameters.ContainsKey('WhatIfRemoveAutopilotIdentity')) { + $WhatIfRemoveAutopilotIdentity = $true + } } Set-LoggingCapabilities -LogPath $LogPath -LogMaximum $LogMaximum -ShowTime:$LogShowTime -TimeFormat $LogTimeFormat -ScriptPath $MyInvocation.ScriptName @@ -386,11 +437,25 @@ function Invoke-CloudDevicesCleanup { $processStageDisabledForDelete = $false } - if (-not $Retire -and -not $Disable -and -not $processStageDisabledForDelete -and -not $Delete) { - Write-Color -Text '[i] ', 'No action can be taken. You need to enable Retire, Disable, StageDisabledForDelete or Delete.' -Color Yellow, Red + if (-not $Retire -and -not $Disable -and -not $processStageDisabledForDelete -and -not $Delete -and -not $RemoveAutopilotIdentity) { + Write-Color -Text '[i] ', 'No action can be taken. You need to enable Retire, Disable, StageDisabledForDelete, Delete or RemoveAutopilotIdentity.' -Color Yellow, Red return } + $otherCloudActionsEnabled = $Retire -or $Disable -or $processStageDisabledForDelete -or $Delete + $autopilotRemovalIncludeOperatingSystem = @($IncludeOperatingSystem) + if ($RemoveAutopilotIdentity -and -not $PSBoundParameters.ContainsKey('IncludeOperatingSystem')) { + $autopilotRemovalIncludeOperatingSystem = @($autopilotRemovalIncludeOperatingSystem + 'Windows*') | Select-Object -Unique + } + $autopilotRemovalIncludeJoinType = @($IncludeJoinType) + if ($RemoveAutopilotIdentity -and -not $PSBoundParameters.ContainsKey('IncludeJoinType')) { + $autopilotRemovalIncludeJoinType = @($autopilotRemovalIncludeJoinType + 'AzureAD joined') | Select-Object -Unique + } + $autopilotRemovalUsesWidenedScope = $RemoveAutopilotIdentity -and (-not $PSBoundParameters.ContainsKey('IncludeOperatingSystem') -or -not $PSBoundParameters.ContainsKey('IncludeJoinType')) + $useSeparateAutopilotRemovalInventory = $otherCloudActionsEnabled -and $autopilotRemovalUsesWidenedScope + $primaryIncludeOperatingSystem = if ($RemoveAutopilotIdentity -and -not $otherCloudActionsEnabled) { $autopilotRemovalIncludeOperatingSystem } else { $IncludeOperatingSystem } + $primaryIncludeJoinType = if ($RemoveAutopilotIdentity -and -not $otherCloudActionsEnabled) { $autopilotRemovalIncludeJoinType } else { $IncludeJoinType } + $confirmActions = $PSBoundParameters.ContainsKey('Confirm') -and [bool] $PSBoundParameters['Confirm'] $retireOnlyIf = [ordered] @{ @@ -399,6 +464,7 @@ function Invoke-CloudDevicesCleanup { RegisteredMoreThan = $RetireRegisteredMoreThan IncludeUnknownActivity = $IncludeUnknownActivity.IsPresent ExcludeCompanyOwned = -not $IncludeCompanyOwned + IntuneLinkState = $IntuneLinkState IncludeIntuneOnly = $RetireIncludeIntuneOnly.IsPresent AutopilotState = $AutopilotState OwnerState = $OwnerState @@ -422,6 +488,7 @@ function Invoke-CloudDevicesCleanup { ListProcessedMoreThan = $DisableListProcessedMoreThan IncludeUnknownActivity = $IncludeUnknownActivity.IsPresent ExcludeCompanyOwned = -not $IncludeCompanyOwned + IntuneLinkState = $IntuneLinkState IncludeEntraOnly = $DisableIncludeEntraOnly.IsPresent AutopilotState = $AutopilotState OwnerState = $OwnerState @@ -445,6 +512,7 @@ function Invoke-CloudDevicesCleanup { ListProcessedMoreThan = $DeleteListProcessedMoreThan IncludeUnknownActivity = $IncludeUnknownActivity.IsPresent ExcludeCompanyOwned = -not $IncludeCompanyOwned + IntuneLinkState = $IntuneLinkState IncludeEntraOnly = $DeleteIncludeEntraOnly.IsPresent IncludeIntuneOnly = $DeleteIncludeIntuneOnly.IsPresent AutopilotState = $AutopilotState @@ -462,6 +530,28 @@ function Invoke-CloudDevicesCleanup { ExcludeAutopilotGroupTag = $ExcludeAutopilotGroupTag } + $removeAutopilotIdentityOnlyIf = [ordered] @{ + AutopilotLastContactMoreThan = $RemoveAutopilotIdentityLastContactMoreThan + IncludeUnknownActivity = $IncludeUnknownActivity.IsPresent + ExcludeCompanyOwned = -not $IncludeCompanyOwned + IntuneLinkState = $IntuneLinkState + AutopilotState = 'Onboarded' + AutopilotIntuneAssociationState = $RemoveAutopilotIdentityIntuneAssociationState + AutopilotEntraAssociationState = $RemoveAutopilotIdentityEntraAssociationState + OwnerState = $OwnerState + ManagementState = $ManagementState + ComplianceState = $ComplianceState + EnabledState = $EnabledState + IncludeManagementAgent = $IncludeManagementAgent + ExcludeManagementAgent = $ExcludeManagementAgent + IncludeEnrollmentType = $IncludeEnrollmentType + ExcludeEnrollmentType = $ExcludeEnrollmentType + IncludeDeviceRegistrationState = $IncludeDeviceRegistrationState + ExcludeDeviceRegistrationState = $ExcludeDeviceRegistrationState + IncludeAutopilotGroupTag = $IncludeAutopilotGroupTag + ExcludeAutopilotGroupTag = $ExcludeAutopilotGroupTag + } + $stageDeleteOnlyIf = [ordered] @{} foreach ($key in $deleteOnlyIf.Keys) { $stageDeleteOnlyIf[$key] = $deleteOnlyIf[$key] @@ -480,17 +570,40 @@ function Invoke-CloudDevicesCleanup { return } - $includeAutopilotInventory = $DeleteAutopilotIdentity -or $AutopilotState -ne 'Any' -or $OwnerState -ne 'Any' -or $IncludeAutopilotGroupTag.Count -gt 0 -or $ExcludeAutopilotGroupTag.Count -gt 0 - $allDevices = Get-InitialCloudDevices -SafetyEntraLimit $SafetyEntraLimit -SafetyIntuneLimit $SafetyIntuneLimit -IncludeJoinType $IncludeJoinType -IncludeOperatingSystem $IncludeOperatingSystem -ExcludeOperatingSystem $ExcludeOperatingSystem -IncludeOperatingSystemVersion $IncludeOperatingSystemVersion -ExcludeOperatingSystemVersion $ExcludeOperatingSystemVersion -IncludeUnknownOperatingSystem:$IncludeUnknownOperatingSystem -IncludeUnknownOperatingSystemVersion:$IncludeUnknownOperatingSystemVersion -Exclusions $Exclusions -IncludeAutopilotInventory:$includeAutopilotInventory + $includeAutopilotInventory = (($RemoveAutopilotIdentity -and -not $useSeparateAutopilotRemovalInventory) -or $DeleteAutopilotIdentity -or $AutopilotState -ne 'Any' -or $OwnerState -ne 'Any' -or $IncludeAutopilotGroupTag.Count -gt 0 -or $ExcludeAutopilotGroupTag.Count -gt 0) + $initialCloudDeviceParameters = @{ + SafetyEntraLimit = $SafetyEntraLimit + SafetyIntuneLimit = $SafetyIntuneLimit + IncludeJoinType = $primaryIncludeJoinType + IncludeOperatingSystem = $primaryIncludeOperatingSystem + ExcludeOperatingSystem = $ExcludeOperatingSystem + IncludeOperatingSystemVersion = $IncludeOperatingSystemVersion + ExcludeOperatingSystemVersion = $ExcludeOperatingSystemVersion + IncludeUnknownOperatingSystem = $IncludeUnknownOperatingSystem + IncludeUnknownOperatingSystemVersion = $IncludeUnknownOperatingSystemVersion + Exclusions = $Exclusions + } + if ($includeAutopilotInventory) { + $initialCloudDeviceParameters.IncludeAutopilotInventory = $true + } + $allDevices = Get-InitialCloudDevices @initialCloudDeviceParameters if ($allDevices -eq $false) { return } + $autopilotRemovalDevices = $allDevices + if ($useSeparateAutopilotRemovalInventory) { + $autopilotRemovalDevices = Get-InitialCloudDevices -SafetyEntraLimit $SafetyEntraLimit -SafetyIntuneLimit $SafetyIntuneLimit -IncludeJoinType $autopilotRemovalIncludeJoinType -IncludeOperatingSystem $autopilotRemovalIncludeOperatingSystem -ExcludeOperatingSystem $ExcludeOperatingSystem -IncludeOperatingSystemVersion $IncludeOperatingSystemVersion -ExcludeOperatingSystemVersion $ExcludeOperatingSystemVersion -IncludeUnknownOperatingSystem:$IncludeUnknownOperatingSystem -IncludeUnknownOperatingSystemVersion:$IncludeUnknownOperatingSystemVersion -Exclusions $Exclusions -IncludeAutopilotInventory + if ($autopilotRemovalDevices -eq $false) { + return + } + } $today = Get-Date $reportRetired = @() $reportDisabled = @() $reportStagedForDelete = @() $reportDeleted = @() + $reportAutopilotIdentityRemoved = @() if ($Retire) { $devicesToRetire = @(Get-CloudDevicesToProcess -Type Retire -Devices $allDevices -ActionIf $retireOnlyIf -ProcessedDevices $processedDevices) @@ -544,12 +657,37 @@ function Invoke-CloudDevicesCleanup { } } + if ($RemoveAutopilotIdentity) { + $devicesToRemoveAutopilotIdentity = @(Get-CloudDevicesToProcess -Type RemoveAutopilotIdentity -Devices $autopilotRemovalDevices -ActionIf $removeAutopilotIdentityOnlyIf -ProcessedDevices $processedDevices) + $autopilotDeviceIdsHandledByDelete = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($deletedDevice in @($reportDeleted | Where-Object { $_.AutopilotIdentityRemoved -eq $true })) { + if (-not [string]::IsNullOrWhiteSpace([string] $deletedDevice.AutopilotDeviceId)) { + $null = $autopilotDeviceIdsHandledByDelete.Add([string] $deletedDevice.AutopilotDeviceId) + } + } + if ($autopilotDeviceIdsHandledByDelete.Count -gt 0) { + $devicesToRemoveAutopilotIdentity = @($devicesToRemoveAutopilotIdentity | Where-Object { + [string]::IsNullOrWhiteSpace([string] $_.AutopilotDeviceId) -or -not $autopilotDeviceIdsHandledByDelete.Contains([string] $_.AutopilotDeviceId) + }) + } + Write-Color -Text '[i] ', 'Autopilot identities to be removed: ', $devicesToRemoveAutopilotIdentity.Count, '. Current remove limit: ', $(if ($RemoveAutopilotIdentityLimit -eq 0) { 'Unlimited' } else { $RemoveAutopilotIdentityLimit }) -Color Yellow, Cyan, Green, Cyan, Yellow + + $processRemoveAutopilotIdentity = $devicesToRemoveAutopilotIdentity.Count -gt 0 + if ($processRemoveAutopilotIdentity -and $confirmActions -and -not ($ReportOnly -or $WhatIfPreference -or $WhatIfRemoveAutopilotIdentity)) { + $processRemoveAutopilotIdentity = $PSCmdlet.ShouldProcess("$($devicesToRemoveAutopilotIdentity.Count) Windows Autopilot device identity(s)", 'Remove Autopilot identity') + } + if ($processRemoveAutopilotIdentity) { + $reportAutopilotIdentityRemoved = @(Request-CloudDevicesRemoveAutopilotIdentity -Devices $devicesToRemoveAutopilotIdentity -Today $today -RemoveAutopilotIdentityLimit $RemoveAutopilotIdentityLimit -ReportOnly:$ReportOnly -WhatIfRemoveAutopilotIdentity:$WhatIfRemoveAutopilotIdentity -GlobalWhatIf:$WhatIfPreference) + } + } + $export.PendingActions = $processedDevices $export.CurrentRun = @( if ($reportRetired.Count -gt 0) { $reportRetired } if ($reportDisabled.Count -gt 0) { $reportDisabled } if ($reportStagedForDelete.Count -gt 0) { $reportStagedForDelete } if ($reportDeleted.Count -gt 0) { $reportDeleted } + if ($reportAutopilotIdentityRemoved.Count -gt 0) { $reportAutopilotIdentityRemoved } ) $persistedRun = @() if ($reportRetired.Count -gt 0) { @@ -564,6 +702,9 @@ function Invoke-CloudDevicesCleanup { if ($reportDeleted.Count -gt 0) { $persistedRun += @($reportDeleted | Where-Object { $_.ActionStatus -notin 'WhatIf', 'ReportOnly' }) } + if ($reportAutopilotIdentityRemoved.Count -gt 0) { + $persistedRun += @($reportAutopilotIdentityRemoved | Where-Object { $_.ActionStatus -notin 'WhatIf', 'ReportOnly' }) + } $export.History = @( if ($export.History) { $export.History } if ($persistedRun.Count -gt 0) { $persistedRun } @@ -577,7 +718,12 @@ function Invoke-CloudDevicesCleanup { } } - New-HTMLProcessedCloudDevices -Export $export -Devices $allDevices -RetireOnlyIf $retireOnlyIf -DisableOnlyIf $disableOnlyIf -DeleteOnlyIf $deleteOnlyIf -FilePath $ReportPath -Online:$Online -ShowHTML:$ShowHTML -LogFile $LogPath + $reportDevices = @($allDevices) + if ($useSeparateAutopilotRemovalInventory) { + $reportDevices = @(Merge-CloudDeviceReportInventory -PrimaryDevices $allDevices -AdditionalDevices $autopilotRemovalDevices) + } + + New-HTMLProcessedCloudDevices -Export $export -Devices $reportDevices -RetireOnlyIf $retireOnlyIf -DisableOnlyIf $disableOnlyIf -DeleteOnlyIf $deleteOnlyIf -RemoveAutopilotIdentityOnlyIf $removeAutopilotIdentityOnlyIf -FilePath $ReportPath -Online:$Online -ShowHTML:$ShowHTML -LogFile $LogPath Write-Color -Text '[i] ', 'Finished process of cleaning up stale cloud devices' -Color Green diff --git a/README.MD b/README.MD index b9bd434..b0fcaef 100644 --- a/README.MD +++ b/README.MD @@ -735,6 +735,50 @@ $Output.CurrentRun | Format-Table Name, RecordState, Action, ActionStatus, Actio Entra-backed delete requires the current inventory to say `Enabled` is exactly `$false`. If a pending device previously had activity timestamps but the current inventory loses them, CleanupMonster will not promote it to the next destructive stage. +#### Remove stale Autopilot identities only + +```powershell +$Output = Invoke-CloudDevicesCleanup ` + -RemoveAutopilotIdentity ` + -RemoveAutopilotIdentityLastContactMoreThan 90 ` + -RemoveAutopilotIdentityIntuneAssociationState Missing ` + -RemoveAutopilotIdentityEntraAssociationState EqualsSerialNumber ` + -RemoveAutopilotIdentityLimit 5 ` + -IncludeJoinType 'AzureAD joined', 'AzureAD registered' ` + -IncludeOperatingSystem '*' ` + -IncludeUnknownOperatingSystem ` + -SafetyEntraLimit 1000 ` + -SafetyIntuneLimit 1000 ` + -WhatIfRemoveAutopilotIdentity ` + -ShowHTML + +$Output.CurrentRun | Format-Table Name, AutopilotSerialNumber, AutopilotManagedDeviceId, AutopilotResourceName, AutopilotLastContactedDays, ActionStatus +``` + +This stage removes only the Windows Autopilot identity. It does not delete the matching Intune managed-device record or Microsoft Entra device object. By default it requires the Autopilot associated Intune managed-device id to be missing and the Autopilot last-contact age to be older than 90 days. + +#### Detect broken Intune links + +```powershell +$Output = Invoke-CloudDevicesCleanup ` + -Disable ` + -DisableIncludeEntraOnly ` + -DisableListProcessedMoreThan $null ` + -DisableLastSeenEntraMoreThan 90 ` + -IntuneLinkState Broken ` + -IncludeJoinType 'AzureAD joined', 'AzureAD registered' ` + -IncludeOperatingSystem '*' ` + -IncludeUnknownOperatingSystem ` + -SafetyEntraLimit 1000 ` + -SafetyIntuneLimit 1000 ` + -WhatIfDisable ` + -ShowHTML + +$Output.CurrentRun | Format-Table Name, Enabled, IntuneLinkState, ClaimsIntuneManagement, EntraLastSeenDays, ActionStatus, SelectionReason +``` + +`IntuneLinkState = Broken` means the Microsoft Entra device claims Intune/MDM management, but current Intune managed-device inventory has no matching record. Use this to make cleanup reports read like the real operator intent instead of relying on portal 404s. + #### Full staged cloud profile ```powershell diff --git a/Tests/CloudDeviceInventory.Tests.ps1 b/Tests/CloudDeviceInventory.Tests.ps1 index 58e1065..786cb1b 100644 --- a/Tests/CloudDeviceInventory.Tests.ps1 +++ b/Tests/CloudDeviceInventory.Tests.ps1 @@ -89,6 +89,91 @@ Describe 'Cloud device inventory and selection helpers' { $devices.Count | Should -Be 2 @($devices | Where-Object { $_.RecordState -eq 'IntuneOnly' }).Count | Should -Be 1 ($devices | Where-Object { $_.Name -eq 'Android-Orphan' }).ManagedDeviceId | Should -Be 'managed-2' + ($devices | Where-Object { $_.Name -eq 'iPhone-01' }).IntuneLinkState | Should -Be 'Healthy' + ($devices | Where-Object { $_.Name -eq 'Android-Orphan' }).IntuneLinkState | Should -Be 'IntuneOnly' + } + + It 'marks Entra devices that claim Intune but have no managed-device match as broken links' { + Mock Get-MyDevice { + @( + [PSCustomObject] @{ + Name = 'Windows-BrokenIntuneLink' + EntraDeviceObjectId = 'entra-broken' + DeviceId = 'device-broken' + Enabled = $true + OperatingSystem = 'Windows' + TrustType = 'AzureAD joined' + LastSeenDays = 120 + FirstSeen = (Get-Date).AddDays(-300) + IsManaged = $true + ManagementType = 'mdm' + } + [PSCustomObject] @{ + Name = 'Windows-NotClaimed' + EntraDeviceObjectId = 'entra-not-claimed' + DeviceId = 'device-not-claimed' + Enabled = $true + OperatingSystem = 'Windows' + TrustType = 'AzureAD joined' + LastSeenDays = 120 + FirstSeen = (Get-Date).AddDays(-300) + IsManaged = $false + ManagementType = 'none' + } + [PSCustomObject] @{ + Name = 'Windows-OtherMdm' + EntraDeviceObjectId = 'entra-other-mdm' + DeviceId = 'device-other-mdm' + Enabled = $true + OperatingSystem = 'Windows' + TrustType = 'AzureAD joined' + LastSeenDays = 120 + FirstSeen = (Get-Date).AddDays(-300) + IsManaged = $true + ManagementType = 'jamf' + } + [PSCustomObject] @{ + Name = 'Windows-IntuneMdmAppId' + EntraDeviceObjectId = 'entra-intune-mdm-app' + DeviceId = 'device-intune-mdm-app' + Enabled = $true + OperatingSystem = 'Windows' + TrustType = 'AzureAD joined' + LastSeenDays = 120 + FirstSeen = (Get-Date).AddDays(-300) + IsManaged = $true + MdmAppId = '0000000a-0000-0000-c000-000000000000' + } + [PSCustomObject] @{ + Name = 'Windows-NonIntuneMdmAppId' + EntraDeviceObjectId = 'entra-non-intune-mdm-app' + DeviceId = 'device-non-intune-mdm-app' + Enabled = $true + OperatingSystem = 'Windows' + TrustType = 'AzureAD joined' + LastSeenDays = 120 + FirstSeen = (Get-Date).AddDays(-300) + IsManaged = $true + ManagementType = 'mdm' + MdmAppId = '11111111-1111-1111-1111-111111111111' + } + ) + } + Mock Get-MyDeviceIntune { @() } + + $devices = @(Get-InitialCloudDevices -IncludeJoinType 'AzureAD joined' -IncludeOperatingSystem @('Windows*') -ExcludeOperatingSystem @() -Exclusions @()) + + $devices | Should -HaveCount 5 + ($devices | Where-Object { $_.Name -eq 'Windows-BrokenIntuneLink' }).IntuneLinkState | Should -Be 'Broken' + ($devices | Where-Object { $_.Name -eq 'Windows-BrokenIntuneLink' }).ClaimsIntuneManagement | Should -BeTrue + ($devices | Where-Object { $_.Name -eq 'Windows-IntuneMdmAppId' }).IntuneLinkState | Should -Be 'Broken' + ($devices | Where-Object { $_.Name -eq 'Windows-IntuneMdmAppId' }).ClaimsIntuneManagement | Should -BeTrue + ($devices | Where-Object { $_.Name -eq 'Windows-NonIntuneMdmAppId' }).IntuneLinkState | Should -Be 'NotClaimed' + ($devices | Where-Object { $_.Name -eq 'Windows-NonIntuneMdmAppId' }).ClaimsIntuneManagement | Should -BeFalse + ($devices | Where-Object { $_.Name -eq 'Windows-NotClaimed' }).IntuneLinkState | Should -Be 'NotClaimed' + ($devices | Where-Object { $_.Name -eq 'Windows-NotClaimed' }).ClaimsIntuneManagement | Should -BeFalse + ($devices | Where-Object { $_.Name -eq 'Windows-OtherMdm' }).IntuneLinkState | Should -Be 'NotClaimed' + ($devices | Where-Object { $_.Name -eq 'Windows-OtherMdm' }).ClaimsIntuneManagement | Should -BeFalse } It 'continues with Intune inventory when Entra inventory is empty' { @@ -139,7 +224,12 @@ Describe 'Cloud device inventory and selection helpers' { AutopilotInventoryLoaded = $true AutopilotOnboarded = $true AutopilotDeviceId = 'entra-autopilot-id' + AutopilotManagedDeviceId = $null + AutopilotAzureAdDeviceId = 'device-ap' + AutopilotResourceName = 'Windows-Autopilot-Matched' AutopilotGroupTag = 'DoNotDelete-Ring' + AutopilotSerialNumber = 'SERIAL-AP' + AutopilotLastContactedDays = 120 AutopilotUserPrincipalName = 'assigned.user@contoso.com' } ) @@ -172,10 +262,46 @@ Describe 'Cloud device inventory and selection helpers' { $devices[0].AutopilotInventoryLoaded | Should -BeTrue $devices[0].AutopilotOnboarded | Should -BeTrue $devices[0].AutopilotDeviceId | Should -Be 'entra-autopilot-id' + $devices[0].AutopilotManagedDeviceId | Should -BeNullOrEmpty + $devices[0].AutopilotAzureAdDeviceId | Should -Be 'device-ap' + $devices[0].AutopilotResourceName | Should -Be 'Windows-Autopilot-Matched' $devices[0].AutopilotGroupTag | Should -Be 'DoNotDelete-Ring' + $devices[0].AutopilotSerialNumber | Should -Be 'SERIAL-AP' + $devices[0].AutopilotLastContactedDays | Should -Be 120 $devices[0].AutopilotUserPrincipalName | Should -Be 'assigned.user@contoso.com' } + It 'computes Autopilot last-contact age when inventory only supplies the timestamp' { + $autopilotLastContacted = (Get-Date).AddDays(-120).AddHours(-1) + Mock Get-MyDevice { + @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-Timestamp' + EntraDeviceObjectId = 'entra-ap-timestamp' + DeviceId = 'device-ap-timestamp' + Enabled = $false + OperatingSystem = 'Windows' + TrustType = 'AzureAD joined' + LastSeenDays = 250 + FirstSeen = (Get-Date).AddDays(-500) + AutopilotInventoryLoaded = $true + AutopilotOnboarded = $true + AutopilotDeviceId = 'autopilot-timestamp' + AutopilotAzureAdDeviceId = 'device-ap-timestamp' + AutopilotSerialNumber = 'SERIAL-TIMESTAMP' + AutopilotLastContacted = $autopilotLastContacted + } + ) + } + Mock Get-MyDeviceIntune { @() } + + $devices = @(Get-InitialCloudDevices -IncludeJoinType 'AzureAD joined' -IncludeOperatingSystem @('Windows*') -ExcludeOperatingSystem @() -Exclusions @()) + + $devices | Should -HaveCount 1 + $devices[0].AutopilotLastContacted | Should -Be $autopilotLastContacted + $devices[0].AutopilotLastContactedDays | Should -Be 120 + } + It 'preserves explicit false Intune Autopilot state over Entra fallback' { Mock Get-MyDevice { @( @@ -653,6 +779,177 @@ Describe 'Cloud device inventory and selection helpers' { $candidates[0].Name | Should -Be 'Windows-NotAutopilot' } + It 'selects stale Autopilot identities with missing Intune association for standalone removal' { + $devices = @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-Orphan' + EntraDeviceObjectId = 'entra-ap-orphan' + DeviceId = 'device-ap-orphan' + HasEntraRecord = $true + HasIntuneRecord = $false + RecordState = 'EntraOnly' + ManagedDeviceOwnerType = 'personal' + AutopilotInventoryLoaded = $true + AutopilotOnboarded = $true + AutopilotDeviceId = 'autopilot-orphan' + AutopilotManagedDeviceId = $null + AutopilotAzureAdDeviceId = 'device-ap-orphan' + AutopilotResourceName = 'SERIAL-ORPHAN' + AutopilotSerialNumber = 'SERIAL-ORPHAN' + AutopilotLastContactedDays = 120 + } + [PSCustomObject] @{ + Name = 'Windows-Autopilot-Managed' + EntraDeviceObjectId = 'entra-ap-managed' + DeviceId = 'device-ap-managed' + HasEntraRecord = $true + HasIntuneRecord = $true + RecordState = 'Matched' + ManagedDeviceOwnerType = 'personal' + AutopilotInventoryLoaded = $true + AutopilotOnboarded = $true + AutopilotDeviceId = 'autopilot-managed' + AutopilotManagedDeviceId = 'managed-ap' + AutopilotAzureAdDeviceId = 'device-ap-managed' + AutopilotResourceName = 'SERIAL-MANAGED' + AutopilotSerialNumber = 'SERIAL-MANAGED' + AutopilotLastContactedDays = 120 + } + ) + + $actionIf = [ordered] @{ + AutopilotLastContactMoreThan = 90 + IncludeUnknownActivity = $false + ExcludeCompanyOwned = $true + AutopilotState = 'Onboarded' + AutopilotIntuneAssociationState = 'Missing' + AutopilotEntraAssociationState = 'EqualsSerialNumber' + } + + $candidates = @(Get-CloudDevicesToProcess -Type RemoveAutopilotIdentity -Devices $devices -ActionIf $actionIf -ProcessedDevices ([ordered] @{})) + + $candidates.Count | Should -Be 1 + $candidates[0].Name | Should -Be 'Windows-Autopilot-Orphan' + $candidates[0].SelectionReason | Should -Match 'AutopilotIntuneAssociationState=Missing' + $candidates[0].SelectionReason | Should -Match 'AutopilotEntraAssociationState=EqualsSerialNumber' + } + + It 'treats Autopilot Entra association as missing when only the resource name is present' { + $devices = @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-MissingEntra' + EntraDeviceObjectId = 'entra-missing-entra' + DeviceId = 'device-missing-entra' + HasEntraRecord = $true + HasIntuneRecord = $false + RecordState = 'EntraOnly' + ManagedDeviceOwnerType = 'personal' + AutopilotInventoryLoaded = $true + AutopilotOnboarded = $true + AutopilotDeviceId = 'autopilot-missing-entra' + AutopilotManagedDeviceId = $null + AutopilotAzureAdDeviceId = $null + AutopilotResourceName = 'SERIAL-MISSING-ENTRA' + AutopilotSerialNumber = 'SERIAL-MISSING-ENTRA' + AutopilotLastContactedDays = 120 + } + [PSCustomObject] @{ + Name = 'Windows-Autopilot-AssociatedEntra' + EntraDeviceObjectId = 'entra-associated-entra' + DeviceId = 'device-associated-entra' + HasEntraRecord = $true + HasIntuneRecord = $false + RecordState = 'EntraOnly' + ManagedDeviceOwnerType = 'personal' + AutopilotInventoryLoaded = $true + AutopilotOnboarded = $true + AutopilotDeviceId = 'autopilot-associated-entra' + AutopilotManagedDeviceId = $null + AutopilotAzureAdDeviceId = 'device-associated-entra' + AutopilotResourceName = 'SERIAL-ASSOCIATED-ENTRA' + AutopilotSerialNumber = 'SERIAL-ASSOCIATED-ENTRA' + AutopilotLastContactedDays = 120 + } + ) + + $actionIf = [ordered] @{ + AutopilotLastContactMoreThan = 90 + IncludeUnknownActivity = $false + ExcludeCompanyOwned = $true + AutopilotState = 'Onboarded' + AutopilotIntuneAssociationState = 'Missing' + AutopilotEntraAssociationState = 'Missing' + } + + $candidates = @(Get-CloudDevicesToProcess -Type RemoveAutopilotIdentity -Devices $devices -ActionIf $actionIf -ProcessedDevices ([ordered] @{})) + + $candidates | Should -HaveCount 1 + $candidates[0].Name | Should -Be 'Windows-Autopilot-MissingEntra' + $candidates[0].SelectionReason | Should -Match 'AutopilotEntraAssociationState=Missing' + } + + It 'filters action candidates to broken Intune links older than the configured threshold' { + $devices = @( + [PSCustomObject] @{ + Name = 'Windows-BrokenIntuneLink' + EntraDeviceObjectId = 'entra-broken' + DeviceId = 'device-broken' + HasEntraRecord = $true + HasIntuneRecord = $false + RecordState = 'EntraOnly' + IntuneLinkState = 'Broken' + ClaimsIntuneManagement = $true + ManagedDeviceOwnerType = 'personal' + EntraLastSeenDays = 120 + Enabled = $true + } + [PSCustomObject] @{ + Name = 'Windows-NotClaimed' + EntraDeviceObjectId = 'entra-not-claimed' + DeviceId = 'device-not-claimed' + HasEntraRecord = $true + HasIntuneRecord = $false + RecordState = 'EntraOnly' + IntuneLinkState = 'NotClaimed' + ClaimsIntuneManagement = $false + ManagedDeviceOwnerType = 'personal' + EntraLastSeenDays = 120 + Enabled = $true + } + [PSCustomObject] @{ + Name = 'Windows-BrokenButFresh' + EntraDeviceObjectId = 'entra-broken-fresh' + DeviceId = 'device-broken-fresh' + HasEntraRecord = $true + HasIntuneRecord = $false + RecordState = 'EntraOnly' + IntuneLinkState = 'Broken' + ClaimsIntuneManagement = $true + ManagedDeviceOwnerType = 'personal' + EntraLastSeenDays = 10 + Enabled = $true + } + ) + + $actionIf = [ordered] @{ + LastSeenEntraMoreThan = 90 + LastSeenIntuneMoreThan = $null + RegisteredMoreThan = $null + ListProcessedMoreThan = $null + IncludeUnknownActivity = $false + ExcludeCompanyOwned = $true + IntuneLinkState = 'Broken' + IncludeEntraOnly = $true + } + + $candidates = @(Get-CloudDevicesToProcess -Type Disable -Devices $devices -ActionIf $actionIf -ProcessedDevices ([ordered] @{})) + + $candidates.Count | Should -Be 1 + $candidates[0].Name | Should -Be 'Windows-BrokenIntuneLink' + $candidates[0].SelectionReason | Should -Match 'IntuneLinkState=Broken' + $candidates[0].SelectionReason | Should -Match 'RequiredIntuneLinkState=Broken' + } + It 'filters action candidates by owner, management, compliance, and registration age' { $oldRegistration = (Get-Date).AddDays(-500) $devices = @( diff --git a/Tests/Invoke-CloudDevicesCleanup.Tests.ps1 b/Tests/Invoke-CloudDevicesCleanup.Tests.ps1 index 0fa1bf5..5427ddb 100644 --- a/Tests/Invoke-CloudDevicesCleanup.Tests.ps1 +++ b/Tests/Invoke-CloudDevicesCleanup.Tests.ps1 @@ -1,5 +1,6 @@ BeforeAll { . "$PSScriptRoot\TestHelpers.ps1" + . (Get-CleanupMonsterPath 'Private/Merge-CloudDeviceReportInventory.ps1') . (Get-CleanupMonsterPath 'Public/Invoke-CloudDevicesCleanup.ps1') function Write-Color { param([Parameter(ValueFromRemainingArguments = $true)] $Text, [object[]] $Color) } @@ -14,6 +15,7 @@ BeforeAll { function Request-CloudDevicesDisable { @() } function Request-CloudDevicesStageDelete { @() } function Request-CloudDevicesDelete { @() } + function Request-CloudDevicesRemoveAutopilotIdentity { @() } function New-HTMLProcessedCloudDevices {} function New-EmailBodyCloudDevices { param($CurrentRun) '' } } @@ -146,6 +148,29 @@ Describe 'Invoke-CloudDevicesCleanup' { $script:capturedIncludeUnknownActivity | Should -BeTrue } + It 'passes broken Intune link filtering to candidate selection' { + $script:capturedIntuneLinkState = $null + + Mock Get-InitialCloudDevices { @() } + Mock Get-CloudDevicesToProcess { + param( + $Type, + $Devices, + $ActionIf, + $ProcessedDevices + ) + + if ($Type -eq 'Disable') { + $script:capturedIntuneLinkState = $ActionIf.IntuneLinkState + } + @() + } + + Invoke-CloudDevicesCleanup -Disable -DisableIncludeEntraOnly -IntuneLinkState Broken -Suppress | Out-Null + + $script:capturedIntuneLinkState | Should -Be 'Broken' + } + It 'does not include orphan states for actions unless explicitly requested' { $script:capturedRetireIncludeIntuneOnly = $null $script:capturedDisableIncludeEntraOnly = $null @@ -284,6 +309,456 @@ Describe 'Invoke-CloudDevicesCleanup' { $script:capturedDeleteAutopilotIdentity | Should -BeTrue } + It 'loads Autopilot inventory and runs standalone Autopilot identity removal when requested' { + $script:capturedIncludeAutopilotInventory = $null + $script:capturedRemoveAutopilotType = $null + $script:capturedRemoveAutopilotRules = $null + $script:capturedRemoveAutopilotLimit = $null + $script:deleteCalled = $false + + Mock Get-InitialCloudDevices { + param( + [switch] $IncludeAutopilotInventory + ) + + $script:capturedIncludeAutopilotInventory = $IncludeAutopilotInventory.IsPresent + @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-Orphan' + EntraDeviceObjectId = 'entra-ap-orphan' + AutopilotInventoryLoaded = $true + AutopilotOnboarded = $true + AutopilotDeviceId = 'autopilot-ap-orphan' + AutopilotManagedDeviceId = $null + AutopilotLastContactedDays = 120 + HasEntraRecord = $true + HasIntuneRecord = $false + } + ) + } + Mock Get-CloudDevicesToProcess { + param( + $Type, + $Devices, + $ActionIf, + $ProcessedDevices + ) + + $script:capturedRemoveAutopilotType = $Type + $script:capturedRemoveAutopilotRules = $ActionIf + @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-Orphan' + AutopilotInventoryLoaded = $true + AutopilotOnboarded = $true + AutopilotDeviceId = 'autopilot-ap-orphan' + AutopilotManagedDeviceId = $null + AutopilotLastContactedDays = 120 + ProcessedDeviceKeys = @('autopilot:autopilot-ap-orphan') + } + ) + } + Mock Request-CloudDevicesRemoveAutopilotIdentity { + param( + $RemoveAutopilotIdentityLimit, + [switch] $WhatIfRemoveAutopilotIdentity + ) + + $script:capturedRemoveAutopilotLimit = $RemoveAutopilotIdentityLimit + $WhatIfRemoveAutopilotIdentity.IsPresent | Should -BeTrue + @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-Orphan' + Action = 'RemoveAutopilotIdentity' + ActionStatus = 'WhatIf' + } + ) + } + Mock Request-CloudDevicesDelete { + $script:deleteCalled = $true + @() + } + + Invoke-CloudDevicesCleanup -RemoveAutopilotIdentity -RemoveAutopilotIdentityLimit 3 -WhatIfRemoveAutopilotIdentity -Suppress | Out-Null + + $script:capturedIncludeAutopilotInventory | Should -BeTrue + $script:capturedRemoveAutopilotType | Should -Be 'RemoveAutopilotIdentity' + $script:capturedRemoveAutopilotRules.AutopilotState | Should -Be 'Onboarded' + $script:capturedRemoveAutopilotRules.AutopilotIntuneAssociationState | Should -Be 'Missing' + $script:capturedRemoveAutopilotRules.AutopilotLastContactMoreThan | Should -Be 90 + $script:capturedRemoveAutopilotLimit | Should -Be 3 + $script:deleteCalled | Should -BeFalse + } + + It 'adds Windows to the default inventory scope for standalone Autopilot identity removal' { + $script:capturedAutopilotOperatingSystemScope = @() + $script:capturedAutopilotJoinTypeScope = @() + + Mock Get-InitialCloudDevices { + param( + [string[]] $IncludeJoinType, + [Array] $IncludeOperatingSystem + ) + + $script:capturedAutopilotJoinTypeScope = @($IncludeJoinType) + $script:capturedAutopilotOperatingSystemScope = @($IncludeOperatingSystem) + @() + } + Mock Get-CloudDevicesToProcess { @() } + + Invoke-CloudDevicesCleanup -RemoveAutopilotIdentity -Suppress | Out-Null + + $script:capturedAutopilotOperatingSystemScope | Should -Contain 'iOS*' + $script:capturedAutopilotOperatingSystemScope | Should -Contain 'Android*' + $script:capturedAutopilotOperatingSystemScope | Should -Contain 'Windows*' + $script:capturedAutopilotJoinTypeScope | Should -Contain 'AzureAD registered' + $script:capturedAutopilotJoinTypeScope | Should -Contain 'AzureAD joined' + } + + It 'keeps widened Autopilot inventory scope separate from other actions' { + $script:capturedInventoryScopes = [System.Collections.Generic.List[object]]::new() + + Mock Get-InitialCloudDevices { + param( + [string[]] $IncludeJoinType, + [Array] $IncludeOperatingSystem, + [switch] $IncludeAutopilotInventory + ) + + $script:capturedInventoryScopes.Add([PSCustomObject] @{ + IncludeJoinType = @($IncludeJoinType) + IncludeOperatingSystem = @($IncludeOperatingSystem) + IncludeAutopilotInventory = [bool] $IncludeAutopilotInventory + }) + @() + } + Mock Get-CloudDevicesToProcess { @() } + + Invoke-CloudDevicesCleanup -Disable -RemoveAutopilotIdentity -Suppress | Out-Null + + $script:capturedInventoryScopes | Should -HaveCount 2 + $script:capturedInventoryScopes[0].IncludeOperatingSystem | Should -Contain 'iOS*' + $script:capturedInventoryScopes[0].IncludeOperatingSystem | Should -Contain 'Android*' + $script:capturedInventoryScopes[0].IncludeOperatingSystem | Should -Not -Contain 'Windows*' + $script:capturedInventoryScopes[0].IncludeJoinType | Should -Contain 'AzureAD registered' + $script:capturedInventoryScopes[0].IncludeJoinType | Should -Not -Contain 'AzureAD joined' + $script:capturedInventoryScopes[0].IncludeAutopilotInventory | Should -BeFalse + $script:capturedInventoryScopes[1].IncludeOperatingSystem | Should -Contain 'Windows*' + $script:capturedInventoryScopes[1].IncludeJoinType | Should -Contain 'AzureAD joined' + $script:capturedInventoryScopes[1].IncludeAutopilotInventory | Should -BeTrue + } + + It 'includes separate Autopilot removal inventory in report devices' { + $script:inventoryCallCount = 0 + $script:capturedReportDeviceNames = @() + + Mock Get-InitialCloudDevices { + $script:inventoryCallCount++ + if ($script:inventoryCallCount -eq 1) { + return @( + [PSCustomObject] @{ + Name = 'iPhone-Primary' + ManagedDeviceId = 'managed-primary' + HasIntuneRecord = $true + HasEntraRecord = $false + } + ) + } + + @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-Orphan' + EntraDeviceObjectId = 'entra-ap-orphan' + AutopilotInventoryLoaded = $true + AutopilotOnboarded = $true + AutopilotDeviceId = 'autopilot-ap-orphan' + AutopilotManagedDeviceId = $null + AutopilotLastContactedDays = 120 + HasEntraRecord = $true + HasIntuneRecord = $false + } + ) + } + Mock Get-CloudDevicesToProcess { @() } + Mock New-HTMLProcessedCloudDevices { + param( + $Devices + ) + + $script:capturedReportDeviceNames = @($Devices.Name) + } + + Invoke-CloudDevicesCleanup -Disable -RemoveAutopilotIdentity -ReportOnly -Suppress | Out-Null + + $script:capturedReportDeviceNames | Should -Contain 'iPhone-Primary' + $script:capturedReportDeviceNames | Should -Contain 'Windows-Autopilot-Orphan' + } + + It 'does not remove the same Autopilot identity after delete handled it in the same run' { + $script:standaloneAutopilotRemovalCalled = $false + + Mock Get-InitialCloudDevices { + @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-Delete' + EntraDeviceObjectId = 'entra-delete-ap' + AutopilotInventoryLoaded = $true + AutopilotOnboarded = $true + AutopilotDeviceId = 'autopilot-delete-ap' + AutopilotManagedDeviceId = $null + AutopilotLastContactedDays = 120 + HasEntraRecord = $true + HasIntuneRecord = $false + } + ) + } + Mock Get-CloudDevicesToProcess { + param( + $Type + ) + + if ($Type -eq 'Delete') { + return @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-Delete' + EntraDeviceObjectId = 'entra-delete-ap' + AutopilotDeviceId = 'autopilot-delete-ap' + AutopilotOnboarded = $true + ProcessedDeviceKeys = @('entra:entra-delete-ap') + } + ) + } + + @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-Delete' + EntraDeviceObjectId = 'entra-delete-ap' + AutopilotDeviceId = 'autopilot-delete-ap' + AutopilotOnboarded = $true + ProcessedDeviceKeys = @('entra:entra-delete-ap') + } + ) + } + Mock Request-CloudDevicesDelete { + @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-Delete' + AutopilotDeviceId = 'autopilot-delete-ap' + AutopilotIdentityRemoved = $true + Action = 'Delete' + ActionStatus = 'True' + } + ) + } + Mock Request-CloudDevicesRemoveAutopilotIdentity { + $script:standaloneAutopilotRemovalCalled = $true + @() + } + + Invoke-CloudDevicesCleanup -Delete -DeleteAutopilotIdentity -RemoveAutopilotIdentity -Confirm:$false -Suppress | Out-Null + + $script:standaloneAutopilotRemovalCalled | Should -BeFalse + } + + It 'does not remove the same Autopilot identity after delete removed it but later failed' { + $script:standaloneAutopilotRemovalCalled = $false + + Mock Get-InitialCloudDevices { + @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-PartialDelete' + EntraDeviceObjectId = 'entra-partial-delete-ap' + AutopilotInventoryLoaded = $true + AutopilotOnboarded = $true + AutopilotDeviceId = 'autopilot-partial-delete-ap' + AutopilotManagedDeviceId = $null + AutopilotLastContactedDays = 120 + HasEntraRecord = $true + HasIntuneRecord = $true + } + ) + } + Mock Get-CloudDevicesToProcess { + @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-PartialDelete' + EntraDeviceObjectId = 'entra-partial-delete-ap' + AutopilotDeviceId = 'autopilot-partial-delete-ap' + AutopilotOnboarded = $true + ProcessedDeviceKeys = @('entra:entra-partial-delete-ap') + } + ) + } + Mock Request-CloudDevicesDelete { + @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-PartialDelete' + AutopilotDeviceId = 'autopilot-partial-delete-ap' + AutopilotIdentityRemoved = $true + Action = 'Delete' + ActionStatus = 'False' + } + ) + } + Mock Request-CloudDevicesRemoveAutopilotIdentity { + $script:standaloneAutopilotRemovalCalled = $true + @() + } + + Invoke-CloudDevicesCleanup -Delete -DeleteAutopilotIdentity -RemoveAutopilotIdentity -Confirm:$false -Suppress | Out-Null + + $script:standaloneAutopilotRemovalCalled | Should -BeFalse + } + + It 'runs standalone Autopilot removal after a delete preview only' { + $script:standaloneAutopilotRemovalCalled = $false + + Mock Get-InitialCloudDevices { + @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-DeletePreview' + EntraDeviceObjectId = 'entra-delete-preview-ap' + AutopilotInventoryLoaded = $true + AutopilotOnboarded = $true + AutopilotDeviceId = 'autopilot-delete-preview-ap' + AutopilotManagedDeviceId = $null + AutopilotLastContactedDays = 120 + HasEntraRecord = $true + HasIntuneRecord = $false + } + ) + } + Mock Get-CloudDevicesToProcess { + @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-DeletePreview' + EntraDeviceObjectId = 'entra-delete-preview-ap' + AutopilotDeviceId = 'autopilot-delete-preview-ap' + AutopilotOnboarded = $true + ProcessedDeviceKeys = @('entra:entra-delete-preview-ap') + } + ) + } + Mock Request-CloudDevicesDelete { + @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-DeletePreview' + AutopilotDeviceId = 'autopilot-delete-preview-ap' + Action = 'Delete' + ActionStatus = 'WhatIf' + } + ) + } + Mock Request-CloudDevicesRemoveAutopilotIdentity { + $script:standaloneAutopilotRemovalCalled = $true + @() + } + + Invoke-CloudDevicesCleanup -Delete -DeleteAutopilotIdentity -RemoveAutopilotIdentity -WhatIfDelete -Confirm:$false -Suppress | Out-Null + + $script:standaloneAutopilotRemovalCalled | Should -BeTrue + } + + It 'runs standalone Autopilot removal when delete did not request Autopilot identity removal' { + $script:standaloneAutopilotRemovalCalled = $false + + Mock Get-InitialCloudDevices { + @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-DeleteOnly' + EntraDeviceObjectId = 'entra-delete-only-ap' + AutopilotInventoryLoaded = $true + AutopilotOnboarded = $true + AutopilotDeviceId = 'autopilot-delete-only-ap' + AutopilotManagedDeviceId = $null + AutopilotLastContactedDays = 120 + HasEntraRecord = $true + HasIntuneRecord = $false + } + ) + } + Mock Get-CloudDevicesToProcess { + @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-DeleteOnly' + EntraDeviceObjectId = 'entra-delete-only-ap' + AutopilotDeviceId = 'autopilot-delete-only-ap' + AutopilotOnboarded = $true + ProcessedDeviceKeys = @('entra:entra-delete-only-ap') + } + ) + } + Mock Request-CloudDevicesDelete { + @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-DeleteOnly' + AutopilotDeviceId = 'autopilot-delete-only-ap' + Action = 'Delete' + ActionStatus = 'True' + } + ) + } + Mock Request-CloudDevicesRemoveAutopilotIdentity { + $script:standaloneAutopilotRemovalCalled = $true + @() + } + + Invoke-CloudDevicesCleanup -Delete -RemoveAutopilotIdentity -Confirm:$false -Suppress | Out-Null + + $script:standaloneAutopilotRemovalCalled | Should -BeTrue + } + + It 'propagates global WhatIf to standalone Autopilot identity removal' { + $script:capturedGlobalWhatIfForAutopilotRemoval = $false + + Mock Get-InitialCloudDevices { + @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-Orphan' + AutopilotInventoryLoaded = $true + AutopilotOnboarded = $true + AutopilotDeviceId = 'autopilot-ap-orphan' + AutopilotManagedDeviceId = $null + AutopilotLastContactedDays = 120 + HasEntraRecord = $true + HasIntuneRecord = $false + } + ) + } + Mock Get-CloudDevicesToProcess { + @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-Orphan' + AutopilotInventoryLoaded = $true + AutopilotOnboarded = $true + AutopilotDeviceId = 'autopilot-ap-orphan' + AutopilotManagedDeviceId = $null + AutopilotLastContactedDays = 120 + ProcessedDeviceKeys = @('autopilot:autopilot-ap-orphan') + } + ) + } + Mock Request-CloudDevicesRemoveAutopilotIdentity { + param( + [switch] $GlobalWhatIf, + [switch] $WhatIfRemoveAutopilotIdentity + ) + + $script:capturedGlobalWhatIfForAutopilotRemoval = $GlobalWhatIf.IsPresent + @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-Orphan' + Action = 'RemoveAutopilotIdentity' + ActionStatus = 'WhatIf' + } + ) + } + + Invoke-CloudDevicesCleanup -RemoveAutopilotIdentity -WhatIf -WhatIfRemoveAutopilotIdentity:$false -Suppress | Out-Null + + $script:capturedGlobalWhatIfForAutopilotRemoval | Should -BeTrue + } + It 'stages already disabled delete candidates without running delete' { $script:stageDeleteCalled = $false $script:deleteCalled = $false diff --git a/Tests/Request-CloudDeviceActions.Tests.ps1 b/Tests/Request-CloudDeviceActions.Tests.ps1 index 0ef8f2e..6b9e9e8 100644 --- a/Tests/Request-CloudDeviceActions.Tests.ps1 +++ b/Tests/Request-CloudDeviceActions.Tests.ps1 @@ -6,6 +6,7 @@ BeforeAll { . (Get-CleanupMonsterPath 'Private/Request-CloudDevicesDisable.ps1') . (Get-CleanupMonsterPath 'Private/Request-CloudDevicesStageDelete.ps1') . (Get-CleanupMonsterPath 'Private/Request-CloudDevicesDelete.ps1') + . (Get-CleanupMonsterPath 'Private/Request-CloudDevicesRemoveAutopilotIdentity.ps1') function Invoke-MyDeviceRetire {} function Disable-MyDevice {} @@ -394,6 +395,46 @@ Describe 'Request-CloudDevicesDelete' { $processedDevices.Contains('entra:entra-ap-fail') | Should -BeTrue } + It 'marks Autopilot identity removed even when a later delete sub-action fails' { + Mock Remove-MyAutopilotDevice { [PSCustomObject] @{ Success = $true; Message = 'Removed Autopilot identity' } } + Mock Remove-MyDeviceIntuneRecord { [PSCustomObject] @{ Success = $false; Message = 'Intune removal failed' } } + Mock Remove-MyDevice { [PSCustomObject] @{ Success = $true; Message = 'Removed Entra record' } } + + $processedDevices = [ordered] @{ + 'entra:entra-ap-partial' = [PSCustomObject] @{ + Action = 'Disable' + ActionStatus = 'True' + ActionDate = (Get-Date).AddDays(-35) + } + } + + $devices = @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-Partial' + EntraDeviceObjectId = 'entra-ap-partial' + ManagedDeviceId = 'managed-ap-partial' + AutopilotInventoryLoaded = $true + AutopilotOnboarded = $true + AutopilotDeviceId = 'autopilot-ap-partial' + HasEntraRecord = $true + HasIntuneRecord = $true + RecordState = 'Matched' + ProcessedDeviceKey = 'entra:entra-ap-partial' + ProcessedDeviceKeys = @('entra:entra-ap-partial', 'intune:managed-ap-partial') + } + ) + + $results = @(Request-CloudDevicesDelete -Devices $devices -ProcessedDevices $processedDevices -Today (Get-Date) -DeleteAutopilotIdentity) + + $results | Should -HaveCount 1 + $results[0].ActionStatus | Should -Be 'False' + $results[0].AutopilotIdentityRemoved | Should -BeTrue + $results[0].ActionNotes | Should -Match 'Autopilot: Removed Autopilot identity' + $results[0].ActionNotes | Should -Match 'Intune: Intune removal failed' + Assert-MockCalled Remove-MyAutopilotDevice -Times 1 -Exactly + Assert-MockCalled Remove-MyDeviceIntuneRecord -Times 1 -Exactly + } + It 'does not remove Entra objects for Intune-only delete candidates' { Mock Remove-MyDeviceIntuneRecord { [PSCustomObject] @{ Success = $true; Message = 'Removed Intune record' } } @@ -451,3 +492,51 @@ Describe 'Request-CloudDevicesDelete' { Assert-MockCalled Remove-MyDevice -Times 1 -Exactly } } + +Describe 'Request-CloudDevicesRemoveAutopilotIdentity' { + BeforeEach { + Mock Remove-MyAutopilotDevice {} + Mock Remove-MyDevice {} + Mock Remove-MyDeviceIntuneRecord {} + } + + It 'removes only the Autopilot identity for selected devices' { + Mock Remove-MyAutopilotDevice { [PSCustomObject] @{ Success = $true; Message = 'Removed Autopilot identity' } } + + $devices = @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-Orphan' + AutopilotDeviceId = 'autopilot-orphan' + ProcessedDeviceKeys = @('autopilot:autopilot-orphan') + } + ) + + $results = @(Request-CloudDevicesRemoveAutopilotIdentity -Devices $devices -Today (Get-Date)) + + $results | Should -HaveCount 1 + $results[0].Action | Should -Be 'RemoveAutopilotIdentity' + $results[0].ActionStatus | Should -Be 'True' + $results[0].ActionNotes | Should -Match 'Removed Autopilot identity' + Assert-MockCalled Remove-MyAutopilotDevice -Times 1 -Exactly + Assert-MockCalled Remove-MyDevice -Times 0 -Exactly + Assert-MockCalled Remove-MyDeviceIntuneRecord -Times 0 -Exactly + } + + It 'previews Autopilot identity removal without treating it as a real action' { + Mock Remove-MyAutopilotDevice { [PSCustomObject] @{ Success = $false; Message = 'Operation skipped by ShouldProcess.' } } + + $devices = @( + [PSCustomObject] @{ + Name = 'Windows-Autopilot-Preview' + AutopilotDeviceId = 'autopilot-preview' + ProcessedDeviceKeys = @('autopilot:autopilot-preview') + } + ) + + $results = @(Request-CloudDevicesRemoveAutopilotIdentity -Devices $devices -Today (Get-Date) -WhatIfRemoveAutopilotIdentity) + + $results | Should -HaveCount 1 + $results[0].ActionStatus | Should -Be 'WhatIf' + Assert-MockCalled Remove-MyAutopilotDevice -Times 1 -Exactly + } +}