From 25606589f7e88ac27e07d84b2ae37856332e9367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 21 Sep 2026 15:23:34 +0200 Subject: [PATCH 1/4] Prepare staged Entra and Intune device cleanup --- Build/Build-Module.ps1 | 2 +- CHANGELOG.MD | 10 ++- CleanupMonster.psd1 | 2 +- Examples/CleanupEntraDevices.md | 30 ++++++++ Examples/CleanupEntraDevices.ps1 | 55 +++++++++++++++ Private/Get-CloudDevicesToProcess.ps1 | 6 ++ Private/Get-InitialCloudDevices.ps1 | 8 ++- Private/Request-CloudDevicesDelete.ps1 | 9 ++- Public/Invoke-CloudDevicesCleanup.ps1 | 8 +++ Tests/CloudDeviceInventory.Tests.ps1 | 73 +++++++++++++++++++ Tests/Invoke-CloudDevicesCleanup.Tests.ps1 | 15 ++++ Tests/Request-CloudDeviceActions.Tests.ps1 | 82 ++++++++++++++++++++++ 12 files changed, 295 insertions(+), 5 deletions(-) create mode 100644 Examples/CleanupEntraDevices.md create mode 100644 Examples/CleanupEntraDevices.ps1 diff --git a/Build/Build-Module.ps1 b/Build/Build-Module.ps1 index f804e67..9075331 100644 --- a/Build/Build-Module.ps1 +++ b/Build/Build-Module.ps1 @@ -15,7 +15,7 @@ Import-Module PSPublishModule -Force -ErrorAction Stop Build-Module -ModuleName 'CleanupMonster' { # Usual defaults as per standard module $Manifest = [ordered] @{ - ModuleVersion = '3.1.14' + ModuleVersion = '3.1.15' CompatiblePSEditions = @('Desktop', 'Core') GUID = 'cd1f9987-6242-452c-a7db-6337d4a6b639' Author = 'Przemyslaw Klys' diff --git a/CHANGELOG.MD b/CHANGELOG.MD index bf2259a..d087a0a 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,4 +1,12 @@ -### 3.1.13 - 2026.09.17 +### 3.1.15 + +- Stop Entra deletion when Intune record removal fails during cloud-device cleanup. +- Apply Autopilot identity deletion only to Windows or unknown-OS records. +- Allow optional Intune recency protection for staged Entra disable and deletion while retaining Entra-only candidates. +- Keep ambiguous Windows Autopilot associations out of final deletion when GraphEssentials reports multiple matches. +- Add a staged Entra and Intune cleanup example for Windows, Android, iOS, iPadOS, and macOS. + +### 3.1.13 - 2026.09.17 - Added guarded MSA and gMSA cleanup with reporting, action limits, selection criteria, and `WhatIf` support. - Added staged cloud-device cleanup for Entra ID, Intune, and Autopilot records, including pending-state tracking, orphan and broken-link handling, and duplicate-device protection. diff --git a/CleanupMonster.psd1 b/CleanupMonster.psd1 index a169a9e..9a135fb 100644 --- a/CleanupMonster.psd1 +++ b/CleanupMonster.psd1 @@ -8,7 +8,7 @@ Description = 'This module provides an easy way to cleanup Active Directory and cloud devices from dead/old objects based on various criteria. It can also disable, move, retire or delete objects. It can utilize Azure AD, Intune and Jamf to get additional information about objects before deleting them.' FunctionsToExport = @('Invoke-ADComputersCleanup', 'Invoke-ADServiceAccountsCleanup', 'Invoke-ADSIDHistoryCleanup', 'Invoke-CloudDevicesCleanup') GUID = 'cd1f9987-6242-452c-a7db-6337d4a6b639' - ModuleVersion = '3.1.14' + ModuleVersion = '3.1.15' PowerShellVersion = '5.1' PrivateData = @{ PSData = @{ diff --git a/Examples/CleanupEntraDevices.md b/Examples/CleanupEntraDevices.md new file mode 100644 index 0000000..e598ebe --- /dev/null +++ b/Examples/CleanupEntraDevices.md @@ -0,0 +1,30 @@ +# Entra and Intune device cleanup + +Use [CleanupEntraDevices.ps1](CleanupEntraDevices.ps1) as a separate daily job beside the existing AD/Jamf computer cleanup. It follows the same pattern: connect to Microsoft Graph, set one splat, and call CleanupMonster. The operating-system scope includes Windows, Android, iOS, iPadOS, and macOS. The existing AD/Jamf job does not need to change. + +## Reuse the existing Graph app + +Keep the existing job's `Connect-MgGraph -ClientSecretCredential` block in the private scheduled script. Set `$TenantId` in the example to that job's tenant GUID. Do not copy its encrypted secret into this repository. The example checks the connected tenant before reading inventory or taking action. There is no need for a new app registration or Azure subscription resource if the existing app can be used. + +Check the existing app's Microsoft Graph **application permissions** and tenant admin consent: + +| Permission | Needed for | +| --- | --- | +| `Device.ReadWrite.All` | Entra device disable and delete | +| `DeviceManagementManagedDevices.ReadWrite.All` | Intune managed-device inventory and final record removal | +| `DeviceManagementServiceConfig.ReadWrite.All` | Autopilot identity inventory and final removal | + +Release GraphEssentials **0.0.63** first, then CleanupMonster **3.1.15**. Install both on the scheduled host before running this example. The example requires these versions because they stop ambiguous Autopilot matches, stop Entra deletion after an Intune deletion failure, and protect recently synced Intune devices. Earlier releases do not contain these changes. + +## Run it like the old job + +1. Keep the existing Graph connection and log/transcript setup. Create the example's report, log, and script folders if they do not exist. Use its **new cloud datastore path**, never the AD job's `CleanupComputers_ListProcessed.xml`. +2. The old job used inventory minimums of `20,000` Entra and `19,000` Intune. The cloud job counts selected join types, so these numbers may not fit. Check its reported counts and adjust the two limits to stable, current cloud baselines before actions. Check the operating-system values in the report, especially for Macs and iPads, and adjust the patterns if this tenant uses different labels. Add approved cloud exclusions and protected Autopilot group tags to the splat. +3. Leave `ReportOnly = $true` for the first 30 daily runs and review the reports. The supplied category counts add up to **37,600**, while the headline says **38,600**; reconcile this before enabling actions. Unknown activity and hybrid joined devices remain outside action scope. +4. To preview actions, set `ReportOnly = $false`, `WhatIfDisable = $true`, and `WhatIfDelete = $true`. Then start disabling with `WhatIfDisable = $false` while leaving `WhatIfDelete = $true`. Enable deletion only after reviewing recovery, duplicates, Autopilot associations, and the first disabled cohort. + +The policy disables Entra joined or registered devices on the selected platforms after more than 90 days of known Entra inactivity and registration age. It deletes only after more than 180 days of both, and after a successful CleanupMonster disable has remained in the new datastore for more than 90 days. The old already-disabled backlog is not automatically promoted. Keep the datastore across runs; do not run overlapping instances. + +This cloud cmdlet uses Entra and Intune inventory. In this example, **both** Entra activity and, when an Intune record exists, Intune last sync must be older than 90 days for disable and 180 days for delete. A matching Intune record with unknown last sync is excluded. Entra-only records still use the Entra threshold. The cmdlet does not query Jamf or delete Jamf records. A recent Jamf check-in will not block cloud actions for a Mac. The old AD cleanup job continues to use Jamf for its AD computer decisions. The Autopilot identity option applies only to Windows or records whose operating system is unknown. + +If an Intune device cleanup rule is set to 90 days, remember that Microsoft describes it as **hiding** stale devices from the portal and reports. It does not replace this Entra cleanup policy or prove an Intune object was deleted. See [Intune device cleanup rules](https://learn.microsoft.com/en-us/intune/governance/configure-cleanup-rules) and [Microsoft Entra stale-device guidance](https://learn.microsoft.com/en-us/entra/identity/devices/manage-stale-devices). diff --git a/Examples/CleanupEntraDevices.ps1 b/Examples/CleanupEntraDevices.ps1 new file mode 100644 index 0000000..d72c980 --- /dev/null +++ b/Examples/CleanupEntraDevices.ps1 @@ -0,0 +1,55 @@ +# Run the existing scheduled task's Connect-MgGraph block before this script. +# Keep its client secret outside the repository. No Jamf connection is needed. + +$TenantId = '' # Use the tenant ID from the existing computer cleanup job. +$Today = Get-Date -Format 'yyyy-MM-dd_HH_mm_ss' + +Import-Module GraphEssentials -MinimumVersion '0.0.63' -ErrorAction Stop +Import-Module CleanupMonster -MinimumVersion '3.1.15' -ErrorAction Stop + +if ([string] (Get-MgContext).TenantId -ne $TenantId) { + throw "Connect-MgGraph to tenant $TenantId before running cloud cleanup." +} + +$invokeCloudDevicesCleanupSplat = @{ + # Starting values from the old job; cloud-scoped counts may differ. + SafetyEntraLimit = 20000 + SafetyIntuneLimit = 19000 + + Disable = $true + DisableLimit = 10 + DisableLastSeenEntraMoreThan = 90 + DisableRegisteredMoreThan = 90 + DisableListProcessedMoreThan = $null + DisableIncludeEntraOnly = $true + + Delete = $true + DeleteLimit = 5 + DeleteLastSeenEntraMoreThan = 180 + DeleteRegisteredMoreThan = 180 + DeleteListProcessedMoreThan = 90 + DeleteIncludeEntraOnly = $true + DeleteRemoveIntuneRecord = $true + DeleteAutopilotIdentity = $true + + IncludeJoinType = @('AzureAD joined', 'AzureAD registered') + IncludeOperatingSystem = @('Windows*', 'Android*', 'iOS*', 'iPadOS*', 'macOS*', 'Mac OS*') + IncludeCompanyOwned = $true + ProtectRecentIntuneActivity = $true + PreserveDuplicateDeviceNames = $true + Exclusions = @() # Add approved cloud device names or IDs. + ExcludeAutopilotGroupTag = @() # Add protected group tags if needed. + + # Use a new, persistent datastore for this cloud policy. Keep it across runs. + DataStorePath = 'E:\Support\Scripts\CleanupCloudDevices_ListProcessed.xml' + ReportPath = "E:\Support\Reporting\Custom\CleanupCloudDevices_$Today.html" + LogPath = "E:\Support\Logs\CleanupComputersCloud\CleanupCloudDevices_$Today.log" + + ReportOnly = $true + WhatIfDisable = $false + WhatIfDelete = $false + ShowHTML = $false +} + +$Output = Invoke-CloudDevicesCleanup @invokeCloudDevicesCleanupSplat +$Output diff --git a/Private/Get-CloudDevicesToProcess.ps1 b/Private/Get-CloudDevicesToProcess.ps1 index 06af7b2..4dec32c 100644 --- a/Private/Get-CloudDevicesToProcess.ps1 +++ b/Private/Get-CloudDevicesToProcess.ps1 @@ -290,6 +290,12 @@ function Get-CloudDevicesToProcess { } } + if ($null -ne $ActionIf.IntuneStaleWhenPresentMoreThan -and $device.HasIntuneRecord) { + if ($null -eq $device.IntuneLastSeenDays -or $device.IntuneLastSeenDays -le $ActionIf.IntuneStaleWhenPresentMoreThan) { + continue + } + } + if ($null -ne $ActionIf.RegisteredMoreThan) { if ($null -eq $device.RegisteredDays -or $device.RegisteredDays -le $ActionIf.RegisteredMoreThan) { continue diff --git a/Private/Get-InitialCloudDevices.ps1 b/Private/Get-InitialCloudDevices.ps1 index 298c1d2..dd7ab10 100644 --- a/Private/Get-InitialCloudDevices.ps1 +++ b/Private/Get-InitialCloudDevices.ps1 @@ -211,7 +211,11 @@ function Get-InitialCloudDevices { $entraRegisteredDays = & $getAgeDays $entraDevice.FirstSeen $intuneRegisteredDays = if ($intuneDevice) { & $getAgeDays $intuneDevice.FirstSeen } else { $null } $autopilotInventoryLoaded = & $getFirstNonNullPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotInventoryLoaded' + $autopilotMatchAmbiguous = ($intuneDevice -and $intuneDevice.AutopilotMatchAmbiguous -eq $true) -or ($entraDevice.AutopilotMatchAmbiguous -eq $true) $autopilotOnboarded = & $getFirstNonNullPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotOnboarded' + if ($autopilotMatchAmbiguous) { + $autopilotOnboarded = $true + } $autopilotLastContacted = & $getFirstPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotLastContacted' $autopilotLastContactedDays = & $getFirstNonNullPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotLastContactedDays' if ($null -eq $autopilotLastContactedDays -and $autopilotLastContacted) { @@ -265,8 +269,9 @@ function Get-InitialCloudDevices { ComplianceState = if ($intuneDevice) { $intuneDevice.ComplianceState } else { $null } ManagementAgent = if ($intuneDevice) { $intuneDevice.ManagementAgent } else { $null } AutopilotInventoryLoaded = $autopilotInventoryLoaded + AutopilotMatchAmbiguous = $autopilotMatchAmbiguous AutopilotOnboarded = $autopilotOnboarded - AutopilotDeviceId = & $getFirstPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotDeviceId' + AutopilotDeviceId = if ($autopilotMatchAmbiguous) { $null } else { & $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' @@ -341,6 +346,7 @@ function Get-InitialCloudDevices { ComplianceState = $intuneDevice.ComplianceState ManagementAgent = $intuneDevice.ManagementAgent AutopilotInventoryLoaded = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotInventoryLoaded' + AutopilotMatchAmbiguous = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotMatchAmbiguous' AutopilotOnboarded = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotOnboarded' AutopilotDeviceId = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotDeviceId' AutopilotManagedDeviceId = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotManagedDeviceId' diff --git a/Private/Request-CloudDevicesDelete.ps1 b/Private/Request-CloudDevicesDelete.ps1 index 36cf9db..4631375 100644 --- a/Private/Request-CloudDevicesDelete.ps1 +++ b/Private/Request-CloudDevicesDelete.ps1 @@ -39,7 +39,12 @@ function Request-CloudDevicesDelete { if ($DeleteAutopilotIdentity) { $operatingSystem = [string] $device.OperatingSystem $autopilotMayApply = [string]::IsNullOrWhiteSpace($operatingSystem) -or $operatingSystem -eq 'Unknown' -or $operatingSystem -like 'Windows*' - if ($device.AutopilotOnboarded -eq $true) { + if ($autopilotMayApply -and $device.AutopilotMatchAmbiguous -eq $true) { + $subActionExecuted = $true + $subActionSuccess = $false + $continueRecordDelete = $false + $subActionMessages.Add('Autopilot: Multiple identities match this device; record delete was skipped.') + } elseif ($autopilotMayApply -and $device.AutopilotOnboarded -eq $true) { $subActionExecuted = $true if ([string]::IsNullOrWhiteSpace([string] $device.AutopilotDeviceId)) { $subActionSuccess = $false @@ -73,6 +78,8 @@ function Request-CloudDevicesDelete { } if (-not $removeIntuneResult.Success -and -not ($WhatIf -or $WhatIfDelete)) { $subActionSuccess = $false + $continueRecordDelete = $false + $subActionMessages.Add('Entra: Record delete was skipped because Intune removal failed.') } } diff --git a/Public/Invoke-CloudDevicesCleanup.ps1 b/Public/Invoke-CloudDevicesCleanup.ps1 index 85509e3..cbe4770 100644 --- a/Public/Invoke-CloudDevicesCleanup.ps1 +++ b/Public/Invoke-CloudDevicesCleanup.ps1 @@ -169,6 +169,11 @@ 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 ProtectRecentIntuneActivity + For disable and delete stages with an Entra last-seen threshold, also require any matching + Intune record to have a known last sync older than that stage's threshold. Entra-only + records remain eligible using the Entra threshold. + .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. @@ -363,6 +368,7 @@ function Invoke-CloudDevicesCleanup { [switch] $IncludeUnknownOperatingSystem, [switch] $IncludeUnknownOperatingSystemVersion, [switch] $IncludeUnknownActivity, + [switch] $ProtectRecentIntuneActivity, [ValidateSet('Any', 'Healthy', 'Broken', 'NotClaimed', 'IntuneOnly')] [string] $IntuneLinkState = 'Any', [ValidateSet('Any', 'Onboarded', 'NotOnboarded')] @@ -496,6 +502,7 @@ function Invoke-CloudDevicesCleanup { $disableOnlyIf = [ordered] @{ LastSeenEntraMoreThan = $DisableLastSeenEntraMoreThan LastSeenIntuneMoreThan = $DisableLastSeenIntuneMoreThan + IntuneStaleWhenPresentMoreThan = if ($ProtectRecentIntuneActivity) { $DisableLastSeenEntraMoreThan } else { $null } RegisteredMoreThan = $DisableRegisteredMoreThan ListProcessedMoreThan = $DisableListProcessedMoreThan IncludeUnknownActivity = $IncludeUnknownActivity.IsPresent @@ -521,6 +528,7 @@ function Invoke-CloudDevicesCleanup { $deleteOnlyIf = [ordered] @{ LastSeenEntraMoreThan = $DeleteLastSeenEntraMoreThan LastSeenIntuneMoreThan = $DeleteLastSeenIntuneMoreThan + IntuneStaleWhenPresentMoreThan = if ($ProtectRecentIntuneActivity) { $DeleteLastSeenEntraMoreThan } else { $null } RegisteredMoreThan = $DeleteRegisteredMoreThan ListProcessedMoreThan = $DeleteListProcessedMoreThan IncludeUnknownActivity = $IncludeUnknownActivity.IsPresent diff --git a/Tests/CloudDeviceInventory.Tests.ps1 b/Tests/CloudDeviceInventory.Tests.ps1 index 6ee50e4..2b84c62 100644 --- a/Tests/CloudDeviceInventory.Tests.ps1 +++ b/Tests/CloudDeviceInventory.Tests.ps1 @@ -19,6 +19,31 @@ BeforeAll { } Describe 'Cloud device inventory and selection helpers' { + It 'keeps an ambiguous Autopilot match when another inventory source has an identity ID' { + Mock Get-MyDevice { + @([PSCustomObject] @{ + Name = 'Windows-Ambiguous'; EntraDeviceObjectId = 'entra-ambiguous'; DeviceId = 'device-ambiguous' + Enabled = $false; OperatingSystem = 'Windows'; TrustType = 'AzureAD joined' + LastSeen = (Get-Date).AddDays(-200); FirstSeen = (Get-Date).AddDays(-300) + AutopilotInventoryLoaded = $true; AutopilotOnboarded = $true; AutopilotDeviceId = 'autopilot-unique' + }) + } + Mock Get-MyDeviceIntune { + @([PSCustomObject] @{ + Name = 'Windows-Ambiguous'; ManagedDeviceId = 'managed-ambiguous'; AzureAdDeviceId = 'device-ambiguous' + OperatingSystem = 'Windows'; LastSeen = (Get-Date).AddDays(-200); FirstSeen = (Get-Date).AddDays(-300) + AutopilotInventoryLoaded = $true; AutopilotMatchAmbiguous = $true; AutopilotOnboarded = $true + }) + } + + $devices = @(Get-InitialCloudDevices -IncludeJoinType 'AzureAD joined' -IncludeOperatingSystem @('Windows*') -ExcludeOperatingSystem @() -Exclusions @() -IncludeAutopilotInventory) + + $devices | Should -HaveCount 1 + $devices[0].AutopilotMatchAmbiguous | Should -BeTrue + $devices[0].AutopilotOnboarded | Should -BeTrue + $devices[0].AutopilotDeviceId | Should -Be $null + } + It 'includes Intune orphan records in inventory output' { Mock Get-MyDevice { @( @@ -1935,4 +1960,52 @@ Describe 'Cloud device inventory and selection helpers' { $candidates[0].ProcessedDeviceKey | Should -Be 'entra:entra-8' $candidates[0].MatchedProcessedDeviceKey | Should -Be 'intune:managed-8' } + + It 'blocks recent or unknown Intune activity while retaining Entra-only candidates' { + $devices = @( + foreach ($record in @( + @{ Name = 'Mac-Recent'; HasIntune = $true; IntuneDays = 5 } + @{ Name = 'Android-Unknown'; HasIntune = $true; IntuneDays = $null } + @{ Name = 'iPhone-Old'; HasIntune = $true; IntuneDays = 200 } + @{ Name = 'Windows-EntraOnly'; HasIntune = $false; IntuneDays = $null } + )) { + [PSCustomObject] @{ + Name = $record.Name + EntraDeviceObjectId = $record.Name + DeviceId = $record.Name + ManagedDeviceId = if ($record.HasIntune) { $record.Name } else { $null } + HasEntraRecord = $true + HasIntuneRecord = $record.HasIntune + RecordState = if ($record.HasIntune) { 'Matched' } else { 'EntraOnly' } + ManagedDeviceOwnerType = 'company' + EntraLastSeenDays = 300 + IntuneLastSeenDays = $record.IntuneDays + RegisteredDays = 400 + Enabled = $true + } + } + ) + $actionIf = [ordered] @{ + LastSeenEntraMoreThan = 90 + IntuneStaleWhenPresentMoreThan = 90 + ListProcessedMoreThan = $null + IncludeEntraOnly = $true + ExcludeCompanyOwned = $false + } + + $disableCandidates = @(Get-CloudDevicesToProcess -Type Disable -Devices $devices -ActionIf $actionIf -ProcessedDevices ([ordered] @{})) + + $disableCandidates.Count | Should -Be 2 + $disableCandidates.Name | Should -Contain 'iPhone-Old' + $disableCandidates.Name | Should -Contain 'Windows-EntraOnly' + + foreach ($device in $devices) { $device.Enabled = $false } + $actionIf.LastSeenEntraMoreThan = 180 + $actionIf.IntuneStaleWhenPresentMoreThan = 180 + $deleteCandidates = @(Get-CloudDevicesToProcess -Type Delete -Devices $devices -ActionIf $actionIf -ProcessedDevices ([ordered] @{})) + + $deleteCandidates.Count | Should -Be 2 + $deleteCandidates.Name | Should -Contain 'iPhone-Old' + $deleteCandidates.Name | Should -Contain 'Windows-EntraOnly' + } } diff --git a/Tests/Invoke-CloudDevicesCleanup.Tests.ps1 b/Tests/Invoke-CloudDevicesCleanup.Tests.ps1 index f68b0b9..c17c0c9 100644 --- a/Tests/Invoke-CloudDevicesCleanup.Tests.ps1 +++ b/Tests/Invoke-CloudDevicesCleanup.Tests.ps1 @@ -21,6 +21,21 @@ BeforeAll { } Describe 'Invoke-CloudDevicesCleanup' { + It 'uses each Entra age as the optional Intune recency threshold' { + $script:intuneThresholds = @{} + Mock Get-InitialCloudDevices { @() } + Mock Get-CloudDevicesToProcess { + param($Type, $Devices, $ActionIf, $ProcessedDevices) + $script:intuneThresholds[$Type] = $ActionIf.IntuneStaleWhenPresentMoreThan + @() + } + + Invoke-CloudDevicesCleanup -Disable -Delete -DisableLastSeenEntraMoreThan 90 -DeleteLastSeenEntraMoreThan 180 -ProtectRecentIntuneActivity -Suppress | Out-Null + + $script:intuneThresholds['Disable'] | Should -Be 90 + $script:intuneThresholds['Delete'] | Should -Be 180 + } + It 'retires stale cloud devices when retire mode is enabled' { Mock Get-InitialCloudDevices { @( diff --git a/Tests/Request-CloudDeviceActions.Tests.ps1 b/Tests/Request-CloudDeviceActions.Tests.ps1 index 6b9e9e8..d3aa3d6 100644 --- a/Tests/Request-CloudDeviceActions.Tests.ps1 +++ b/Tests/Request-CloudDeviceActions.Tests.ps1 @@ -357,6 +357,42 @@ Describe 'Request-CloudDevicesDelete' { $processedDevices.Contains('entra:entra-ios') | Should -BeFalse } + It 'does not remove an Autopilot identity associated with a macOS record' { + Mock Remove-MyDeviceIntuneRecord { [PSCustomObject] @{ Success = $true; Message = 'Removed Intune record' } } + Mock Remove-MyDevice { [PSCustomObject] @{ Success = $true; Message = 'Removed Entra record' } } + + $processedDevices = [ordered] @{ + 'entra:entra-mac' = [PSCustomObject] @{ + Action = 'Disable' + ActionStatus = 'True' + ActionDate = (Get-Date).AddDays(-95) + } + } + $devices = @( + [PSCustomObject] @{ + Name = 'Mac-Old' + EntraDeviceObjectId = 'entra-mac' + ManagedDeviceId = 'managed-mac' + OperatingSystem = 'macOS' + AutopilotInventoryLoaded = $true + AutopilotOnboarded = $true + AutopilotDeviceId = 'autopilot-windows-device' + HasEntraRecord = $true + HasIntuneRecord = $true + RecordState = 'Matched' + ProcessedDeviceKey = 'entra:entra-mac' + ProcessedDeviceKeys = @('entra:entra-mac', 'intune:managed-mac') + } + ) + + $results = @(Request-CloudDevicesDelete -Devices $devices -ProcessedDevices $processedDevices -Today (Get-Date) -DeleteAutopilotIdentity) + + $results[0].ActionStatus | Should -Be 'True' + Assert-MockCalled Remove-MyAutopilotDevice -Times 0 -Exactly + Assert-MockCalled Remove-MyDeviceIntuneRecord -Times 1 -Exactly + Assert-MockCalled Remove-MyDevice -Times 1 -Exactly + } + It 'does not delete records when an onboarded Autopilot identity cannot be removed' { Mock Remove-MyAutopilotDevice { [PSCustomObject] @{ Success = $false; Message = 'Autopilot removal failed' } } @@ -433,6 +469,52 @@ Describe 'Request-CloudDevicesDelete' { $results[0].ActionNotes | Should -Match 'Intune: Intune removal failed' Assert-MockCalled Remove-MyAutopilotDevice -Times 1 -Exactly Assert-MockCalled Remove-MyDeviceIntuneRecord -Times 1 -Exactly + Assert-MockCalled Remove-MyDevice -Times 0 -Exactly + $processedDevices.Contains('entra:entra-ap-partial') | Should -BeTrue + } + + It 'does not delete Entra when Intune record removal fails without Autopilot' { + Mock Remove-MyDeviceIntuneRecord { [PSCustomObject] @{ Success = $false; Message = 'Intune removal failed' } } + $processedDevices = [ordered] @{ + 'entra:entra-intune-fail' = [PSCustomObject] @{ Action = 'Disable'; ActionStatus = 'True'; ActionDate = (Get-Date).AddDays(-95) } + } + $devices = @([PSCustomObject] @{ + Name = 'Windows-Intune-Fail'; EntraDeviceObjectId = 'entra-intune-fail'; ManagedDeviceId = 'managed-intune-fail' + HasEntraRecord = $true; HasIntuneRecord = $true; RecordState = 'Matched' + ProcessedDeviceKey = 'entra:entra-intune-fail'; ProcessedDeviceKeys = @('entra:entra-intune-fail', 'intune:managed-intune-fail') + }) + + $results = @(Request-CloudDevicesDelete -Devices $devices -ProcessedDevices $processedDevices -Today (Get-Date)) + + $results | Should -HaveCount 1 + $results[0].ActionStatus | Should -Be 'False' + $results[0].ActionNotes | Should -Match 'Entra: Record delete was skipped because Intune removal failed' + Assert-MockCalled Remove-MyDeviceIntuneRecord -Times 1 -Exactly + Assert-MockCalled Remove-MyDevice -Times 0 -Exactly + $processedDevices.Contains('entra:entra-intune-fail') | Should -BeTrue + } + + It 'does not delete any record when an Autopilot association is ambiguous' { + $processedDevices = [ordered] @{ + 'entra:entra-ambiguous' = [PSCustomObject] @{ Action = 'Disable'; ActionStatus = 'True'; ActionDate = (Get-Date).AddDays(-95) } + } + $devices = @([PSCustomObject] @{ + Name = 'Windows-Ambiguous'; EntraDeviceObjectId = 'entra-ambiguous'; ManagedDeviceId = 'managed-ambiguous' + HasEntraRecord = $true; HasIntuneRecord = $true; RecordState = 'Matched' + AutopilotInventoryLoaded = $true; AutopilotMatchAmbiguous = $true; AutopilotOnboarded = $true + AutopilotDeviceId = $null; ProcessedDeviceKey = 'entra:entra-ambiguous' + ProcessedDeviceKeys = @('entra:entra-ambiguous', 'intune:managed-ambiguous') + }) + + $results = @(Request-CloudDevicesDelete -Devices $devices -ProcessedDevices $processedDevices -Today (Get-Date) -DeleteAutopilotIdentity) + + $results | Should -HaveCount 1 + $results[0].ActionStatus | Should -Be 'False' + $results[0].ActionNotes | Should -Match 'Multiple identities match this device' + Assert-MockCalled Remove-MyAutopilotDevice -Times 0 -Exactly + Assert-MockCalled Remove-MyDeviceIntuneRecord -Times 0 -Exactly + Assert-MockCalled Remove-MyDevice -Times 0 -Exactly + $processedDevices.Contains('entra:entra-ambiguous') | Should -BeTrue } It 'does not remove Entra objects for Intune-only delete candidates' { From ccec1d42c89e14163ba9e1696eaff453066bbee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 22 Sep 2026 18:34:31 +0200 Subject: [PATCH 2/4] Require safe Graph inventory version for cloud cleanup --- CHANGELOG.MD | 1 + Private/Assert-CloudDeviceCleanupSettings.ps1 | 12 +++++---- ...ssert-CloudDeviceCleanupSettings.Tests.ps1 | 25 +++++++++++++++---- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.MD b/CHANGELOG.MD index d087a0a..8e3d371 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -5,6 +5,7 @@ - Allow optional Intune recency protection for staged Entra disable and deletion while retaining Entra-only candidates. - Keep ambiguous Windows Autopilot associations out of final deletion when GraphEssentials reports multiple matches. - Add a staged Entra and Intune cleanup example for Windows, Android, iOS, iPadOS, and macOS. +- Require GraphEssentials 0.0.63 for cloud cleanup so Autopilot match ambiguity is available. ### 3.1.13 - 2026.09.17 diff --git a/Private/Assert-CloudDeviceCleanupSettings.ps1 b/Private/Assert-CloudDeviceCleanupSettings.ps1 index fde505b..d378d9e 100644 --- a/Private/Assert-CloudDeviceCleanupSettings.ps1 +++ b/Private/Assert-CloudDeviceCleanupSettings.ps1 @@ -8,7 +8,7 @@ function Assert-CloudDeviceCleanupSettings { return $false } - $minimumVersion = [version] '0.0.57' + $minimumVersion = [version] '0.0.63' 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 @@ -39,10 +39,12 @@ function Assert-CloudDeviceCleanupSettings { return $false } - $inventoryCommand = $resolvedCommands['Get-MyDeviceIntune'] - if ($inventoryCommand.ModuleName -ne 'GraphEssentials' -or -not $inventoryCommand.Module -or $inventoryCommand.Module.Version -lt $minimumVersion) { - Write-Color -Text '[e] ', "'Get-MyDeviceIntune' is not provided by the required GraphEssentials version '$minimumVersion' in the current session. Import the updated module and try again. Terminating." -Color Yellow, Red - return $false + foreach ($inventoryCommandName in @('Get-MyDevice', 'Get-MyDeviceIntune')) { + $inventoryCommand = $resolvedCommands[$inventoryCommandName] + if ($inventoryCommand.ModuleName -ne 'GraphEssentials' -or -not $inventoryCommand.Module -or $inventoryCommand.Module.Version -lt $minimumVersion) { + Write-Color -Text '[e] ', "'$inventoryCommandName' is not provided by the required GraphEssentials version '$minimumVersion' in the current session. Import the updated module and try again. Terminating." -Color Yellow, Red + return $false + } } $true diff --git a/Tests/Assert-CloudDeviceCleanupSettings.Tests.ps1 b/Tests/Assert-CloudDeviceCleanupSettings.Tests.ps1 index 6d80820..55f17c9 100644 --- a/Tests/Assert-CloudDeviceCleanupSettings.Tests.ps1 +++ b/Tests/Assert-CloudDeviceCleanupSettings.Tests.ps1 @@ -16,7 +16,7 @@ Describe 'Assert-CloudDeviceCleanupSettings' { Mock Get-Module { [PSCustomObject] @{ Name = 'GraphEssentials' - Version = [version] '0.0.57' + Version = [version] '0.0.63' } } Mock Get-Command { @@ -25,13 +25,13 @@ Describe 'Assert-CloudDeviceCleanupSettings' { Name = $Name ModuleName = 'GraphEssentials' Module = [PSCustomObject] @{ - Version = [version] '0.0.57' + Version = [version] '0.0.63' } } } } - It 'accepts the GraphEssentials release that provides lifecycle projection' { + It 'accepts the GraphEssentials release that provides unambiguous Autopilot matching' { Assert-CloudDeviceCleanupSettings | Should -BeTrue } @@ -39,7 +39,7 @@ Describe 'Assert-CloudDeviceCleanupSettings' { Mock Get-Module { [PSCustomObject] @{ Name = 'GraphEssentials' - Version = [version] '0.0.56' + Version = [version] '0.0.62' } } @@ -53,7 +53,22 @@ Describe 'Assert-CloudDeviceCleanupSettings' { Name = $Name ModuleName = 'GraphEssentials' Module = [PSCustomObject] @{ - Version = [version] '0.0.56' + Version = [version] '0.0.62' + } + } + } + + Assert-CloudDeviceCleanupSettings | Should -BeFalse + } + + It 'rejects an older Entra inventory command while Intune inventory is current' { + Mock Get-Command { + param($Name) + [PSCustomObject] @{ + Name = $Name + ModuleName = 'GraphEssentials' + Module = [PSCustomObject] @{ + Version = if ($Name -eq 'Get-MyDevice') { [version] '0.0.62' } else { [version] '0.0.63' } } } } From 3f7ba8157502ff29fd339079acf740f89e205ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 22 Sep 2026 18:40:40 +0200 Subject: [PATCH 3/4] Block ambiguous Intune links and non-Windows Autopilot removal --- CHANGELOG.MD | 1 + Private/Get-CloudDevicesToProcess.ps1 | 9 ++++- Private/Get-InitialCloudDevices.ps1 | 11 +++++- Private/Request-CloudDevicesDelete.ps1 | 8 +++- ...st-CloudDevicesRemoveAutopilotIdentity.ps1 | 6 ++- Tests/CloudDeviceInventory.Tests.ps1 | 39 +++++++++++++++++++ Tests/Request-CloudDeviceActions.Tests.ps1 | 29 ++++++++++++++ 7 files changed, 98 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 8e3d371..4b6988f 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -6,6 +6,7 @@ - Keep ambiguous Windows Autopilot associations out of final deletion when GraphEssentials reports multiple matches. - Add a staged Entra and Intune cleanup example for Windows, Android, iOS, iPadOS, and macOS. - Require GraphEssentials 0.0.63 for cloud cleanup so Autopilot match ambiguity is available. +- Skip destructive cloud actions when multiple Intune records link to one Entra device, and limit standalone Autopilot removal to Windows. ### 3.1.13 - 2026.09.17 diff --git a/Private/Get-CloudDevicesToProcess.ps1 b/Private/Get-CloudDevicesToProcess.ps1 index 4dec32c..3aa1ee6 100644 --- a/Private/Get-CloudDevicesToProcess.ps1 +++ b/Private/Get-CloudDevicesToProcess.ps1 @@ -210,6 +210,10 @@ function Get-CloudDevicesToProcess { continue } + if ($Type -in @('Disable', 'Delete', 'RemoveAutopilotIdentity') -and $device.IntuneMatchAmbiguous) { + continue + } + if ($ActionIf.IntuneLinkState -and $ActionIf.IntuneLinkState -ne 'Any' -and $device.IntuneLinkState -ne $ActionIf.IntuneLinkState) { continue } @@ -248,7 +252,10 @@ function Get-CloudDevicesToProcess { continue } } elseif ($Type -eq 'RemoveAutopilotIdentity') { - if ($device.AutopilotOnboarded -ne $true -or [string]::IsNullOrWhiteSpace([string] $device.AutopilotDeviceId)) { + if ([string] $device.OperatingSystem -notlike 'Windows*' -or + $device.AutopilotMatchAmbiguous -eq $true -or + $device.AutopilotOnboarded -ne $true -or + [string]::IsNullOrWhiteSpace([string] $device.AutopilotDeviceId)) { continue } } diff --git a/Private/Get-InitialCloudDevices.ps1 b/Private/Get-InitialCloudDevices.ps1 index dd7ab10..b364d2a 100644 --- a/Private/Get-InitialCloudDevices.ps1 +++ b/Private/Get-InitialCloudDevices.ps1 @@ -170,10 +170,15 @@ function Get-InitialCloudDevices { Write-Color -Text '[i] ', 'Cloud devices found in Intune: ', $intuneDevices.Count -Color Yellow, Cyan, Green $intuneByAzureDeviceId = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase) + $ambiguousIntuneAzureDeviceIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $matchedIntuneManagedDeviceIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($intuneDevice in $intuneDevices) { - if ($intuneDevice.AzureAdDeviceId -and -not $intuneByAzureDeviceId.ContainsKey($intuneDevice.AzureAdDeviceId)) { - $intuneByAzureDeviceId[$intuneDevice.AzureAdDeviceId] = $intuneDevice + if ($intuneDevice.AzureAdDeviceId) { + if ($intuneByAzureDeviceId.ContainsKey($intuneDevice.AzureAdDeviceId)) { + $null = $ambiguousIntuneAzureDeviceIds.Add($intuneDevice.AzureAdDeviceId) + } else { + $intuneByAzureDeviceId[$intuneDevice.AzureAdDeviceId] = $intuneDevice + } } } @@ -237,6 +242,7 @@ function Get-InitialCloudDevices { ManagedDeviceId = if ($intuneDevice) { $intuneDevice.ManagedDeviceId } else { $null } HasEntraRecord = $true HasIntuneRecord = [bool] $intuneDevice + IntuneMatchAmbiguous = [bool] ($entraDevice.DeviceId -and $ambiguousIntuneAzureDeviceIds.Contains($entraDevice.DeviceId)) RecordState = if ($intuneDevice) { 'Matched' } else { 'EntraOnly' } RecordSource = if ($intuneDevice) { 'Microsoft Entra ID + Intune' } else { 'Microsoft Entra ID only' } IntuneLinkState = $intuneLinkState @@ -314,6 +320,7 @@ function Get-InitialCloudDevices { ManagedDeviceId = $intuneDevice.ManagedDeviceId HasEntraRecord = [bool] $intuneDevice.EntraDeviceObjectId HasIntuneRecord = $true + IntuneMatchAmbiguous = [bool] ($intuneDevice.AzureAdDeviceId -and $ambiguousIntuneAzureDeviceIds.Contains($intuneDevice.AzureAdDeviceId)) RecordState = 'IntuneOnly' RecordSource = 'Intune only' IntuneLinkState = 'IntuneOnly' diff --git a/Private/Request-CloudDevicesDelete.ps1 b/Private/Request-CloudDevicesDelete.ps1 index 4631375..fa3f270 100644 --- a/Private/Request-CloudDevicesDelete.ps1 +++ b/Private/Request-CloudDevicesDelete.ps1 @@ -36,7 +36,13 @@ function Request-CloudDevicesDelete { $autopilotIdentityRemoved = $false if (-not $ReportOnly) { - if ($DeleteAutopilotIdentity) { + if ($device.IntuneMatchAmbiguous -eq $true) { + $subActionExecuted = $true + $subActionSuccess = $false + $continueRecordDelete = $false + $subActionMessages.Add('Intune: Multiple records link to this Entra device; record delete was skipped.') + } + if ($continueRecordDelete -and $DeleteAutopilotIdentity) { $operatingSystem = [string] $device.OperatingSystem $autopilotMayApply = [string]::IsNullOrWhiteSpace($operatingSystem) -or $operatingSystem -eq 'Unknown' -or $operatingSystem -like 'Windows*' if ($autopilotMayApply -and $device.AutopilotMatchAmbiguous -eq $true) { diff --git a/Private/Request-CloudDevicesRemoveAutopilotIdentity.ps1 b/Private/Request-CloudDevicesRemoveAutopilotIdentity.ps1 index 3bd1961..102e731 100644 --- a/Private/Request-CloudDevicesRemoveAutopilotIdentity.ps1 +++ b/Private/Request-CloudDevicesRemoveAutopilotIdentity.ps1 @@ -25,7 +25,11 @@ function Request-CloudDevicesRemoveAutopilotIdentity { $actionStatus = 'False' $actionNotes = $null - if ($ReportOnly) { + if ([string] $device.OperatingSystem -notlike 'Windows*') { + $actionNotes = 'Autopilot: Identity removal is only supported for Windows devices.' + } elseif ($device.IntuneMatchAmbiguous -eq $true -or $device.AutopilotMatchAmbiguous -eq $true) { + $actionNotes = 'Autopilot: Device association is ambiguous; identity removal was skipped.' + } elseif ($ReportOnly) { $actionStatus = 'ReportOnly' $actionNotes = 'Autopilot identity removal previewed by report-only mode.' } elseif ([string]::IsNullOrWhiteSpace([string] $device.AutopilotDeviceId)) { diff --git a/Tests/CloudDeviceInventory.Tests.ps1 b/Tests/CloudDeviceInventory.Tests.ps1 index 2b84c62..7df07c4 100644 --- a/Tests/CloudDeviceInventory.Tests.ps1 +++ b/Tests/CloudDeviceInventory.Tests.ps1 @@ -19,6 +19,41 @@ BeforeAll { } Describe 'Cloud device inventory and selection helpers' { + It 'blocks Entra actions when two Intune records link to the same device' { + Mock Get-MyDevice { + [PSCustomObject] @{ + Name = 'iPhone-01'; EntraDeviceObjectId = 'entra-1'; DeviceId = 'device-1' + Enabled = $true; OperatingSystem = 'iOS'; TrustType = 'AzureAD registered' + LastSeenDays = 200; FirstSeen = (Get-Date).AddDays(-300) + } + } + Mock Get-MyDeviceIntune { + [PSCustomObject] @{ Name = 'iPhone-01'; ManagedDeviceId = 'managed-old'; AzureAdDeviceId = 'device-1'; OperatingSystem = 'iOS'; DeviceRegistrationState = 'registered'; LastSeenDays = 200 } + [PSCustomObject] @{ Name = 'iPhone-01'; ManagedDeviceId = 'managed-new'; AzureAdDeviceId = 'device-1'; OperatingSystem = 'iOS'; DeviceRegistrationState = 'registered'; LastSeenDays = 1 } + } + + $devices = @(Get-InitialCloudDevices -IncludeJoinType 'AzureAD registered' -IncludeOperatingSystem @('iOS*') -ExcludeOperatingSystem @() -Exclusions @()) + $devices | Should -HaveCount 2 + @($devices | Where-Object { $_.IntuneMatchAmbiguous }).Count | Should -Be 2 + + $actionIf = [ordered] @{ LastSeenEntraMoreThan = 90; IntuneStaleWhenPresentMoreThan = 90; IncludeEntraOnly = $true } + @(Get-CloudDevicesToProcess -Type Disable -Devices $devices -ActionIf $actionIf -ProcessedDevices ([ordered] @{})) | Should -BeNullOrEmpty + ($devices | Where-Object { $_.RecordState -eq 'Matched' }).Enabled = $false + @(Get-CloudDevicesToProcess -Type Delete -Devices $devices -ActionIf $actionIf -ProcessedDevices ([ordered] @{})) | Should -BeNullOrEmpty + } + + It 'excludes non-Windows devices from standalone Autopilot removal' { + $devices = @( + [PSCustomObject] @{ Name = 'Mac-01'; ManagedDeviceId = 'managed-mac'; OperatingSystem = 'macOS'; AutopilotOnboarded = $true; AutopilotDeviceId = 'autopilot-1' } + [PSCustomObject] @{ Name = 'Windows-01'; ManagedDeviceId = 'managed-win'; OperatingSystem = 'Windows'; AutopilotOnboarded = $true; AutopilotDeviceId = 'autopilot-2' } + ) + + $candidates = @(Get-CloudDevicesToProcess -Type RemoveAutopilotIdentity -Devices $devices -ActionIf ([ordered] @{}) -ProcessedDevices ([ordered] @{})) + + $candidates | Should -HaveCount 1 + $candidates[0].Name | Should -Be 'Windows-01' + } + It 'keeps an ambiguous Autopilot match when another inventory source has an identity ID' { Mock Get-MyDevice { @([PSCustomObject] @{ @@ -959,6 +994,7 @@ Describe 'Cloud device inventory and selection helpers' { $devices = @( [PSCustomObject] @{ Name = 'Windows-Autopilot-Orphan' + OperatingSystem = 'Windows' EntraDeviceObjectId = 'entra-ap-orphan' DeviceId = 'device-ap-orphan' HasEntraRecord = $true @@ -976,6 +1012,7 @@ Describe 'Cloud device inventory and selection helpers' { } [PSCustomObject] @{ Name = 'Windows-Autopilot-Managed' + OperatingSystem = 'Windows' EntraDeviceObjectId = 'entra-ap-managed' DeviceId = 'device-ap-managed' HasEntraRecord = $true @@ -1014,6 +1051,7 @@ Describe 'Cloud device inventory and selection helpers' { $devices = @( [PSCustomObject] @{ Name = 'Windows-Autopilot-MissingEntra' + OperatingSystem = 'Windows' EntraDeviceObjectId = 'entra-missing-entra' DeviceId = 'device-missing-entra' HasEntraRecord = $true @@ -1031,6 +1069,7 @@ Describe 'Cloud device inventory and selection helpers' { } [PSCustomObject] @{ Name = 'Windows-Autopilot-AssociatedEntra' + OperatingSystem = 'Windows' EntraDeviceObjectId = 'entra-associated-entra' DeviceId = 'device-associated-entra' HasEntraRecord = $true diff --git a/Tests/Request-CloudDeviceActions.Tests.ps1 b/Tests/Request-CloudDeviceActions.Tests.ps1 index d3aa3d6..543e904 100644 --- a/Tests/Request-CloudDeviceActions.Tests.ps1 +++ b/Tests/Request-CloudDeviceActions.Tests.ps1 @@ -517,6 +517,22 @@ Describe 'Request-CloudDevicesDelete' { $processedDevices.Contains('entra:entra-ambiguous') | Should -BeTrue } + It 'does not delete records when multiple Intune records link to one Entra device' { + $processedDevices = [ordered] @{} + $devices = @([PSCustomObject] @{ + Name = 'iPhone-DuplicateLink'; EntraDeviceObjectId = 'entra-duplicate'; ManagedDeviceId = 'managed-old' + HasEntraRecord = $true; HasIntuneRecord = $true; RecordState = 'Matched'; IntuneMatchAmbiguous = $true + }) + + $results = @(Request-CloudDevicesDelete -Devices $devices -ProcessedDevices $processedDevices -Today (Get-Date)) + + $results | Should -HaveCount 1 + $results[0].ActionStatus | Should -Be 'False' + $results[0].ActionNotes | Should -Match 'Multiple records link' + Assert-MockCalled Remove-MyDeviceIntuneRecord -Times 0 -Exactly + Assert-MockCalled Remove-MyDevice -Times 0 -Exactly + } + It 'does not remove Entra objects for Intune-only delete candidates' { Mock Remove-MyDeviceIntuneRecord { [PSCustomObject] @{ Success = $true; Message = 'Removed Intune record' } } @@ -588,6 +604,7 @@ Describe 'Request-CloudDevicesRemoveAutopilotIdentity' { $devices = @( [PSCustomObject] @{ Name = 'Windows-Autopilot-Orphan' + OperatingSystem = 'Windows' AutopilotDeviceId = 'autopilot-orphan' ProcessedDeviceKeys = @('autopilot:autopilot-orphan') } @@ -604,12 +621,24 @@ Describe 'Request-CloudDevicesRemoveAutopilotIdentity' { Assert-MockCalled Remove-MyDeviceIntuneRecord -Times 0 -Exactly } + It 'does not remove an Autopilot identity for a non-Windows device passed directly' { + $devices = @([PSCustomObject] @{ Name = 'Mac-01'; OperatingSystem = 'macOS'; AutopilotDeviceId = 'autopilot-1' }) + + $results = @(Request-CloudDevicesRemoveAutopilotIdentity -Devices $devices -Today (Get-Date)) + + $results | Should -HaveCount 1 + $results[0].ActionStatus | Should -Be 'False' + $results[0].ActionNotes | Should -Match 'only supported for Windows' + Assert-MockCalled Remove-MyAutopilotDevice -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' + OperatingSystem = 'Windows' AutopilotDeviceId = 'autopilot-preview' ProcessedDeviceKeys = @('autopilot:autopilot-preview') } From 77b831188fa07710e93e75bcd395c0774df7354a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Tue, 22 Sep 2026 19:01:48 +0200 Subject: [PATCH 4/4] Ignore empty Entra IDs in Intune link matching --- Private/Get-InitialCloudDevices.ps1 | 13 ++++++++----- Tests/CloudDeviceInventory.Tests.ps1 | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/Private/Get-InitialCloudDevices.ps1 b/Private/Get-InitialCloudDevices.ps1 index b364d2a..27006bb 100644 --- a/Private/Get-InitialCloudDevices.ps1 +++ b/Private/Get-InitialCloudDevices.ps1 @@ -173,11 +173,12 @@ function Get-InitialCloudDevices { $ambiguousIntuneAzureDeviceIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $matchedIntuneManagedDeviceIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($intuneDevice in $intuneDevices) { - if ($intuneDevice.AzureAdDeviceId) { - if ($intuneByAzureDeviceId.ContainsKey($intuneDevice.AzureAdDeviceId)) { - $null = $ambiguousIntuneAzureDeviceIds.Add($intuneDevice.AzureAdDeviceId) + $azureAdDeviceId = [string] $intuneDevice.AzureAdDeviceId + if (-not [string]::IsNullOrWhiteSpace($azureAdDeviceId) -and $azureAdDeviceId -ne [guid]::Empty.ToString()) { + if ($intuneByAzureDeviceId.ContainsKey($azureAdDeviceId)) { + $null = $ambiguousIntuneAzureDeviceIds.Add($azureAdDeviceId) } else { - $intuneByAzureDeviceId[$intuneDevice.AzureAdDeviceId] = $intuneDevice + $intuneByAzureDeviceId[$azureAdDeviceId] = $intuneDevice } } } @@ -301,6 +302,8 @@ function Get-InitialCloudDevices { } $operatingSystem = $intuneDevice.OperatingSystem + $azureAdDeviceId = [string] $intuneDevice.AzureAdDeviceId + $hasUsableAzureAdDeviceId = -not [string]::IsNullOrWhiteSpace($azureAdDeviceId) -and $azureAdDeviceId -ne [guid]::Empty.ToString() $deviceInScope = Test-CloudDeviceInventoryScope -OperatingSystem $operatingSystem -OperatingSystemVersion $intuneDevice.OperatingSystemVersion -IncludeOperatingSystem $IncludeOperatingSystem -ExcludeOperatingSystem $ExcludeOperatingSystem -IncludeOperatingSystemVersion $IncludeOperatingSystemVersion -ExcludeOperatingSystemVersion $ExcludeOperatingSystemVersion -IncludeUnknownOperatingSystem:$IncludeUnknownOperatingSystem -IncludeUnknownOperatingSystemVersion:$IncludeUnknownOperatingSystemVersion -Exclusions $Exclusions -Name $intuneDevice.Name -DeviceId $intuneDevice.AzureAdDeviceId -EntraDeviceObjectId $intuneDevice.EntraDeviceObjectId -ManagedDeviceId $intuneDevice.ManagedDeviceId if (-not $deviceInScope) { continue @@ -320,7 +323,7 @@ function Get-InitialCloudDevices { ManagedDeviceId = $intuneDevice.ManagedDeviceId HasEntraRecord = [bool] $intuneDevice.EntraDeviceObjectId HasIntuneRecord = $true - IntuneMatchAmbiguous = [bool] ($intuneDevice.AzureAdDeviceId -and $ambiguousIntuneAzureDeviceIds.Contains($intuneDevice.AzureAdDeviceId)) + IntuneMatchAmbiguous = [bool] ($hasUsableAzureAdDeviceId -and $ambiguousIntuneAzureDeviceIds.Contains($azureAdDeviceId)) RecordState = 'IntuneOnly' RecordSource = 'Intune only' IntuneLinkState = 'IntuneOnly' diff --git a/Tests/CloudDeviceInventory.Tests.ps1 b/Tests/CloudDeviceInventory.Tests.ps1 index 7df07c4..2d2ca67 100644 --- a/Tests/CloudDeviceInventory.Tests.ps1 +++ b/Tests/CloudDeviceInventory.Tests.ps1 @@ -19,6 +19,22 @@ BeforeAll { } Describe 'Cloud device inventory and selection helpers' { + It 'keeps empty-guid Intune orphans independent for deletion' { + Mock Get-MyDevice { @() } + Mock Get-MyDeviceIntune { + [PSCustomObject] @{ Name = 'Android-Orphan-01'; ManagedDeviceId = 'managed-orphan-1'; AzureAdDeviceId = [guid]::Empty; OperatingSystem = 'Android'; DeviceRegistrationState = 'registered'; LastSeenDays = 200 } + [PSCustomObject] @{ Name = 'Android-Orphan-02'; ManagedDeviceId = 'managed-orphan-2'; AzureAdDeviceId = [guid]::Empty.ToString(); OperatingSystem = 'Android'; DeviceRegistrationState = 'registered'; LastSeenDays = 220 } + } + + $devices = @(Get-InitialCloudDevices -IncludeJoinType 'AzureAD registered' -IncludeOperatingSystem @('Android*') -ExcludeOperatingSystem @() -Exclusions @()) + + $devices | Should -HaveCount 2 + @($devices | Where-Object { $_.IntuneMatchAmbiguous }).Count | Should -Be 0 + @($devices | Where-Object { $_.RecordState -eq 'IntuneOnly' }).Count | Should -Be 2 + $actionIf = [ordered] @{ IncludeIntuneOnly = $true; LastSeenIntuneMoreThan = 180 } + @(Get-CloudDevicesToProcess -Type Delete -Devices $devices -ActionIf $actionIf -ProcessedDevices ([ordered] @{})) | Should -HaveCount 2 + } + It 'blocks Entra actions when two Intune records link to the same device' { Mock Get-MyDevice { [PSCustomObject] @{