commit 5a652dbbe25c28cd9a40ce7e95c6e64d6c871379 Author: Petr Štěpán Date: Fri Jul 17 21:57:40 2026 +0200 Add script to remove inactive Entra ID and Intune managed devices diff --git a/Remove-InactiveEntraDevice.ps1 b/Remove-InactiveEntraDevice.ps1 new file mode 100644 index 0000000..3aab4af --- /dev/null +++ b/Remove-InactiveEntraDevice.ps1 @@ -0,0 +1,582 @@ +<# +.SYNOPSIS + Removes Entra ID devices and Intune managed devices that have not connected in + longer than a given number of days. + +.DESCRIPTION + Connects to Microsoft Graph and evaluates staleness in two passes: + + 1. Entra ID devices, cross-referenced with their matching Intune managed + device (if any), matched via the device's Azure AD device ID. "Last + activity" is the more recent of the Intune managed device's + LastSyncDateTime (last MDM check-in) and the Entra ID device's + ApproximateLastSignInDateTime (last sign-in with the device identity). A + device is considered stale only when BOTH available signals are older than + the -InactiveDays threshold, to minimize the risk of removing a device that + is active under one signal but temporarily quiet under the other. When + stale, the script removes the Intune managed device object (if any) and + then the Entra ID device object itself. + + 2. Intune managed devices that have NO matching Entra ID device object at all + (e.g. the Entra ID device was already deleted manually, or sync between + Intune and Entra ID is broken). These are invisible to pass 1, since it is + driven from the Entra ID device list. For these, staleness is judged purely + by LastSyncDateTime, and only the Intune managed device object is removed + (there is no Entra ID device object to remove). + + Devices with no usable activity signal at all cannot be judged reliably and are + reported as 'Unknown' rather than removed. Every row in the output has a 'Source' + column ('EntraID' or 'Intune') so it's clear which pass found it and what was + actually evaluated/removed. When a removal attempt fails, the 'ErrorDetail' column + holds the underlying error message(s) so the cause is visible directly in the + report, without needing to correlate back to the -Warning output. The 'TrustType' + column (Entra ID rows only - 'AzureAd' cloud joined, 'ServerAd' hybrid joined, + 'Workplace' registered/BYOD) helps explain permission-related removal failures, + since deleting a Workplace-registered device can require different rights than + deleting a joined one. The 'AutopilotRegistered' column is purely informational - + it flags devices that still have an active Windows Autopilot device identity, in + case that turns out to be relevant to a removal failure, but it does not change + whether removal is attempted. This lookup is best-effort: the Autopilot/Intune + enrollment backend can return transient errors (e.g. HTTP 500) independently of + the rest of the script; if it fails, the column shows 'Unknown' for every device + instead of aborting the whole run. + + The output only lists devices that were removed, attempted-but-failed, skipped + (e.g. under -WhatIf), or flagged as 'Unknown' for manual review - healthy, still + active devices are deliberately left out of the report to keep it focused on what + needs attention. Use -Verbose to see how many active devices were excluded. + + This script reads Windows Autopilot device identities only to populate the + 'AutopilotRegistered' column above - it never modifies or removes an Autopilot + registration. The hardware hash entry is always left in place, so a device can + always be re-provisioned via Autopilot later, even after its Entra ID / Intune + objects have been cleaned up (when that succeeds). + + Hybrid Azure AD joined devices (TrustType 'ServerAd') are never removed - their + Entra ID device object is synced from on-premises Active Directory, and Entra ID + rejects deleting it directly, so removal must happen by deleting/disabling the + on-prem computer object and letting Entra Connect sync the deletion. A hybrid + device is only mentioned (via -Verbose, not in the output) when it would + otherwise have been flagged stale and removed - active hybrid devices are not + called out at all, same as any other active device. + + Because these deletions are effectively irreversible, run with -WhatIf first to + preview exactly what would be removed - each candidate device is still reported + individually. For a real run, instead of prompting once per device/operation, the + script asks for a single upfront confirmation ("This will permanently remove ... + Continue?") before touching anything; declining aborts the entire run with nothing + removed. Pass -Confirm:$false to skip even that single prompt (e.g. for unattended/ + scheduled execution). + + Requires an account with sufficient Entra ID / Intune role assignments (e.g. Cloud + Device Administrator + Intune Administrator, or Global Administrator) and the + following Microsoft Graph delegated scopes: + Device.Read.All + Directory.ReadWrite.All + DeviceManagementManagedDevices.ReadWrite.All + DeviceManagementServiceConfig.ReadWrite.All (read-only use: listing Autopilot + device identities for the 'AutopilotRegistered' column; no Graph permission + model exists for read-only access to this resource, so ReadWrite.All is the + minimum available scope even though this script never writes to it) + +.PARAMETER InactiveDays + Number of days of inactivity after which a device is considered stale. Defaults to + 90. + +.PARAMETER TenantId + Optional tenant ID or verified domain name to sign in to. If omitted, Connect-MgGraph + uses the interactive account picker / default tenant. + +.EXAMPLE + .\Remove-InactiveEntraDevice.ps1 -WhatIf + Signs in, lists every Entra ID and Intune-only device inactive for more than 90 + days, and shows what would be removed without changing anything. + +.EXAMPLE + .\Remove-InactiveEntraDevice.ps1 -InactiveDays 180 -TenantId 'contoso.onmicrosoft.com' + Signs in to the specified tenant and, after a single confirmation prompt, + removes every device that has not connected in more than 180 days. + +.EXAMPLE + .\Remove-InactiveEntraDevice.ps1 -Confirm:$false + Signs in and removes every inactive device without even the single upfront + confirmation prompt - use only for unattended/scheduled execution, ideally after + a prior -WhatIf run has already been reviewed. + +.NOTES + Author: Petr Stepan + Created: 2026-07-17 + Version: 1.0.0 + Changelog: + 1.0.0 - Initial version +#> + +#Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Identity.DirectoryManagement, Microsoft.Graph.DeviceManagement, Microsoft.Graph.DeviceManagement.Enrollment + +[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] +param( + [Parameter(Mandatory = $false, HelpMessage = 'Number of days of inactivity after which a device is considered stale.')] + [ValidateRange(1, 3650)] + [int] + $InactiveDays = 180, + + [Parameter(Mandatory = $false, HelpMessage = 'Tenant ID or verified domain name to connect to.')] + [ValidateNotNullOrEmpty()] + [string] + $TenantId +) + +# --- Safety preamble ------------------------------------------------------- +# Fail fast on uninitialized variables and typos in property/member names. +Set-StrictMode -Version Latest +# Treat all non-terminating errors as terminating so failures can't slip by silently. +$ErrorActionPreference = 'Stop' + +# Graph scopes needed: read devices, delete devices, manage Intune managed-device +# records, and read (never write) Windows Autopilot device identities to populate the +# 'AutopilotRegistered' reporting column - this script never removes an Autopilot +# registration. +$script:RequiredGraphScopes = @( + 'Device.Read.All' + 'Directory.ReadWrite.All' + 'DeviceManagementManagedDevices.ReadWrite.All' + 'DeviceManagementServiceConfig.ReadWrite.All' +) + +# --- Functions --------------------------------------------------------- + +function Connect-TenantGraph { + <# + .SYNOPSIS + Ensures an authenticated Microsoft Graph session with the required scopes. + .DESCRIPTION + Read-only from the script's perspective (it only inspects/creates an auth + session), so it does not need ShouldProcess. Reuses an existing connection if + it already grants every required scope, to avoid re-prompting unnecessarily. + .PARAMETER TenantId + Optional tenant ID or verified domain name to connect to. + .EXAMPLE + Connect-TenantGraph -TenantId 'contoso.onmicrosoft.com' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string] + $TenantId + ) + + $context = Get-MgContext + $hasAllScopes = $context -and (-not (Compare-Object -ReferenceObject $script:RequiredGraphScopes -DifferenceObject $context.Scopes | Where-Object { $_.SideIndicator -eq '<=' })) + + if (-not $hasAllScopes) { + $connectParams = @{ + Scopes = $script:RequiredGraphScopes + NoWelcome = $true + } + if ($TenantId) { + $connectParams['TenantId'] = $TenantId + } + Connect-MgGraph @connectParams + } + else { + Write-Verbose 'Reusing existing Microsoft Graph connection with sufficient scopes.' + } +} + +function Get-EntraDeviceInventory { + <# + .SYNOPSIS + Returns every Entra ID device object with the properties needed to judge + staleness (sign-in recency) and join type (TrustType). + .DESCRIPTION + Read-only; does not modify anything, so it does not need ShouldProcess. + .EXAMPLE + Get-EntraDeviceInventory + #> + [CmdletBinding()] + param() + + try { + Get-MgDevice -All -Property 'Id,DeviceId,DisplayName,AccountEnabled,ApproximateLastSignInDateTime,OperatingSystem,TrustType' -ErrorAction Stop + } + catch { + Write-Error "Failed to retrieve devices from Entra ID: $_" + throw + } +} + +function Get-IntuneManagedDeviceInventory { + <# + .SYNOPSIS + Returns every Intune managed device with the properties needed to judge + staleness (sync recency) and to match it to an Entra ID device object. + .DESCRIPTION + Read-only; does not modify anything, so it does not need ShouldProcess. + .EXAMPLE + Get-IntuneManagedDeviceInventory + #> + [CmdletBinding()] + param() + + try { + Get-MgDeviceManagementManagedDevice -All -Property 'Id,AzureADDeviceId,DeviceName,LastSyncDateTime' -ErrorAction Stop + } + catch { + Write-Error "Failed to retrieve Intune managed devices: $_" + throw + } +} + +function Get-AutopilotRegisteredDeviceIdSet { + <# + .SYNOPSIS + Returns the set of Azure AD device IDs that currently have an active Windows + Autopilot device identity. + .DESCRIPTION + Read-only; does not modify anything, so it does not need ShouldProcess. This + script never removes an Autopilot registration - the result is used purely to + populate the 'AutopilotRegistered' reporting column, since Entra ID/Intune + commonly reject deleting a device object while its Autopilot registration is + still in place. + .EXAMPLE + Get-AutopilotRegisteredDeviceIdSet + #> + [CmdletBinding()] + param() + + try { + # Deliberately no -Property/$select here - the windowsAutopilotDeviceIdentities + # backend has been observed to return HTTP 500 when a $select is applied, even + # though the same call without it succeeds. Fetching full objects costs a + # little extra payload but avoids that failure mode entirely. + $autopilotIdentities = Get-MgDeviceManagementWindowsAutopilotDeviceIdentity -All -ErrorAction Stop + $set = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($identity in $autopilotIdentities) { + if ($identity.AzureActiveDirectoryDeviceId) { + $set.Add($identity.AzureActiveDirectoryDeviceId) | Out-Null + } + } + # -NoEnumerate is required here: PowerShell unwraps any IEnumerable return + # value onto the pipeline element-by-element by default, so a plain + # "return $set" would emit the HashSet's individual strings (or nothing at + # all if empty) instead of the HashSet object itself. + Write-Output -NoEnumerate $set + } + catch { + Write-Error "Failed to retrieve Windows Autopilot device identities: $_" + throw + } +} + +function Remove-StaleTenantDevice { + <# + .SYNOPSIS + Removes a stale device's Intune managed device object (if any) and, when it + has one, its Entra ID device object. Does not touch Autopilot. + .DESCRIPTION + Each deletion is gated independently behind ShouldProcess so -WhatIf reports + exactly what would be removed, and a failure in one step does not prevent the + other from being attempted. DeviceObjectId is optional: pass it for a device + that has an Entra ID device object to remove; omit it for an Intune-only + managed device that has no matching Entra ID object at all. + .PARAMETER DisplayName + Display name of the device, used only for prompts/logging. + .PARAMETER DeviceObjectId + The Entra ID device object's Id (not its deviceId property). Omit for an + Intune-only device with no Entra ID device object. + .PARAMETER ManagedDevices + The matching Intune managed device object(s) for this device, if any. + .EXAMPLE + Remove-StaleTenantDevice -DisplayName 'LAPTOP-01' -DeviceObjectId $objId -ManagedDevices $managedDevices + .EXAMPLE + Remove-StaleTenantDevice -DisplayName 'LAPTOP-02' -ManagedDevices $managedDevices + #> + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] + $DisplayName, + + [Parameter(Mandatory = $false)] + [string] + $DeviceObjectId, + + [Parameter(Mandatory = $false)] + [object[]] + $ManagedDevices = @() + ) + + $errors = [System.Collections.Generic.List[string]]::new() + # 'NotFound' only when there was genuinely no matching managed device; if one + # exists but ShouldProcess declines (WhatIf/Confirm), that's 'Skipped', not + # 'NotFound' - it WAS found, it just wasn't removed. + $intuneDeviceStatus = if ($ManagedDevices.Count -gt 0) { 'Skipped' } else { 'NotFound' } + $entraDeviceStatus = if ($DeviceObjectId) { 'Skipped' } else { 'N/A' } + + # 1. Remove the matching Intune managed device object(s), if any exist. + if ($ManagedDevices.Count -gt 0 -and $PSCmdlet.ShouldProcess($DisplayName, 'Remove Intune managed device object')) { + try { + foreach ($managedDevice in $ManagedDevices) { + Remove-MgDeviceManagementManagedDevice -ManagedDeviceId $managedDevice.Id -ErrorAction Stop + } + $intuneDeviceStatus = 'Removed' + } + catch { + $intuneDeviceStatus = 'Failed' + $errors.Add("Intune managed device removal failed: $_") + Write-Warning "Failed to remove Intune managed device for '$DisplayName': $_" + } + } + + # 2. Remove the Entra ID device object itself, if this device has one. + if ($DeviceObjectId -and $PSCmdlet.ShouldProcess($DisplayName, 'Remove Entra ID device object')) { + try { + Remove-MgDevice -DeviceId $DeviceObjectId -ErrorAction Stop + $entraDeviceStatus = 'Removed' + } + catch { + $entraDeviceStatus = 'Failed' + $errors.Add("Entra ID device removal failed: $_") + Write-Warning "Failed to remove Entra ID device object for '$DisplayName': $_" + } + } + + return [PSCustomObject]@{ + IntuneDeviceStatus = $intuneDeviceStatus + EntraDeviceStatus = $entraDeviceStatus + Errors = $errors + } +} + +# --- Main -------------------------------------------------------------- +$results = [System.Collections.Generic.List[PSCustomObject]]::new() +$hasFailures = $false +$cutoffDate = (Get-Date).ToUniversalTime().AddDays(-$InactiveDays) + +try { + Write-Verbose 'Connecting to Microsoft Graph.' + Connect-TenantGraph -TenantId $TenantId + + # One upfront confirmation for the whole run, instead of a separate interactive + # prompt per device/operation. Skipped entirely under -WhatIf (nothing to confirm + # for a preview) and honored if the caller explicitly passed -Confirm:$false. + # Every individual removal below then runs with -Confirm:$false, since the + # decision to proceed has already been made here. + if (-not $WhatIfPreference) { + $confirmExplicitlySuppressed = $PSBoundParameters.ContainsKey('Confirm') -and -not $PSBoundParameters['Confirm'] + if (-not $confirmExplicitlySuppressed -and -not $PSCmdlet.ShouldContinue( + "This will permanently remove Entra ID and/or Intune device objects inactive for more than $InactiveDays day(s). This cannot be undone. Continue?", + 'Confirm device removal')) { + Write-Warning 'Aborted by user - no devices were removed.' + exit 0 + } + } + + Write-Verbose 'Retrieving device inventory from Entra ID.' + $devices = @(Get-EntraDeviceInventory) + Write-Verbose "Found $($devices.Count) device(s) in Entra ID." + + Write-Verbose 'Retrieving Intune managed device inventory.' + $managedDevices = @(Get-IntuneManagedDeviceInventory) + Write-Verbose "Found $($managedDevices.Count) managed device(s) in Intune." + + # This lookup is purely informational (populates the 'AutopilotRegistered' column) + # and must never abort the whole run - the Autopilot/Intune enrollment backend is + # a separate, occasionally flaky service from core Graph device/Intune endpoints. + # A failure here just means we can't tell either way, not that nothing else works. + $autopilotDeviceIdSet = $null + try { + Write-Verbose 'Retrieving Windows Autopilot device identities (read-only, for reporting only).' + $autopilotDeviceIdSet = Get-AutopilotRegisteredDeviceIdSet + Write-Verbose "Found $($autopilotDeviceIdSet.Count) device(s) registered in Windows Autopilot." + } + catch { + Write-Warning "Could not retrieve Windows Autopilot device identities - the 'AutopilotRegistered' column will show 'Unknown' for every device this run. Error: $_" + } + + $managedDeviceMap = @{} + foreach ($managedDevice in $managedDevices) { + if ($managedDevice.AzureADDeviceId) { + if (-not $managedDeviceMap.ContainsKey($managedDevice.AzureADDeviceId)) { + $managedDeviceMap[$managedDevice.AzureADDeviceId] = [System.Collections.Generic.List[object]]::new() + } + $managedDeviceMap[$managedDevice.AzureADDeviceId].Add($managedDevice) + } + } + + # Track every Azure AD device ID seen in Entra ID, so pass 2 can tell which + # Intune managed devices have no matching Entra ID device object at all. + $entraDeviceIdSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + + # --- Pass 1: Entra ID devices --------------------------------------- + foreach ($device in $devices) { + if ($device.DeviceId) { + # Register the ID even for devices we're about to skip (e.g. hybrid + # joined), so pass 2 doesn't mistake their Intune managed device for an + # orphan with no matching Entra ID device object. + $entraDeviceIdSet.Add($device.DeviceId) | Out-Null + } + + $matchedManagedDevices = @() + if ($device.DeviceId -and $managedDeviceMap.ContainsKey($device.DeviceId)) { + $matchedManagedDevices = $managedDeviceMap[$device.DeviceId] + } + + # "Last activity" is the more recent of the Intune check-in and the Entra + # sign-in - a device only counts as stale when BOTH signals agree it's old. + $lastSyncDateTime = ($matchedManagedDevices | ForEach-Object { $_.LastSyncDateTime } | Sort-Object -Descending | Select-Object -First 1) + $lastSignInDateTime = $device.ApproximateLastSignInDateTime + # Wrap the whole pipeline in @() - if Where-Object filters out every date, + # PowerShell would otherwise collapse the result to $null instead of an empty + # array, and $null.Count throws under Set-StrictMode. + $candidateDates = @(@($lastSyncDateTime, $lastSignInDateTime) | Where-Object { $_ }) + + $resultRow = [PSCustomObject]@{ + Source = 'EntraID' + DisplayName = $device.DisplayName + ObjectId = $device.Id + AzureAdDeviceId = $device.DeviceId + TrustType = $device.TrustType + # Informational only - does not affect whether removal is attempted. + AutopilotRegistered = if (-not $autopilotDeviceIdSet) { 'Unknown' } elseif ($device.DeviceId -and $autopilotDeviceIdSet.Contains($device.DeviceId)) { 'Yes' } else { 'No' } + ManagedDeviceId = if ($matchedManagedDevices.Count -gt 0) { ($matchedManagedDevices.Id -join ', ') } else { $null } + LastSyncDateTime = $lastSyncDateTime + LastSignInDateTime = $lastSignInDateTime + IntuneDeviceStatus = 'N/A' + EntraDeviceStatus = 'N/A' + OverallStatus = 'Unknown' + ErrorDetail = $null + } + + if ($candidateDates.Count -eq 0) { + # No usable signal at all - do not guess; flag for manual review instead. + # Hybrid devices are the exception: with no signal we can't tell it would + # even be stale, so there's nothing actionable to report - stay silent, + # same as a hybrid device that turns out to be active. + if ($device.TrustType -ne 'ServerAd') { + $results.Add($resultRow) + } + continue + } + + $lastActivityDate = $candidateDates | Sort-Object -Descending | Select-Object -First 1 + + if ($lastActivityDate -ge $cutoffDate) { + $resultRow.OverallStatus = 'Active' + $results.Add($resultRow) + continue + } + + # The device IS stale at this point - only now does hybrid join type matter. + if ($device.TrustType -eq 'ServerAd') { + # Hybrid Azure AD joined devices are synced from on-premises Active + # Directory; Entra ID rejects deleting their device object directly, so + # attempting it would just fail. Skip them - removal has to happen by + # deleting/disabling the computer object in on-prem AD and letting Entra + # Connect sync propagate it. Only mention devices that would actually have + # been removed, not every hybrid device regardless of staleness. + Write-Verbose "Skipping hybrid Azure AD joined device '$($device.DisplayName)' (stale since $lastActivityDate) - it must be removed via on-premises Active Directory, not directly from Entra ID." + continue + } + + Write-Verbose "Removing stale Entra ID device '$($device.DisplayName)' (last activity: $lastActivityDate)." + # Forward -WhatIf explicitly rather than relying on $WhatIfPreference to + # cascade implicitly into this sibling function - keeps the dry-run + # behavior obvious and independent of scope-inheritance quirks. -Confirm:$false + # because the single upfront confirmation above already covers this whole run. + $removalResult = Remove-StaleTenantDevice -DisplayName $device.DisplayName -DeviceObjectId $device.Id ` + -ManagedDevices $matchedManagedDevices -WhatIf:$WhatIfPreference -Confirm:$false + + $resultRow.IntuneDeviceStatus = $removalResult.IntuneDeviceStatus + $resultRow.EntraDeviceStatus = $removalResult.EntraDeviceStatus + $resultRow.OverallStatus = if ($removalResult.Errors.Count -gt 0) { 'Failed' } elseif ($removalResult.EntraDeviceStatus -eq 'Removed') { 'Removed' } else { 'Skipped' } + if ($removalResult.Errors.Count -gt 0) { + $resultRow.ErrorDetail = $removalResult.Errors -join '; ' + } + + if ($resultRow.OverallStatus -eq 'Failed') { + $hasFailures = $true + } + + $results.Add($resultRow) + } + + # --- Pass 2: Intune managed devices with no matching Entra ID device object. + # Pass 1 is driven from the Entra ID device list, so an orphaned Intune record + # (Entra ID device already gone, or sync never created/lost the link) would + # otherwise never be evaluated at all. + $orphanManagedDevices = @($managedDevices | Where-Object { + -not $_.AzureADDeviceId -or -not $entraDeviceIdSet.Contains($_.AzureADDeviceId) + }) + Write-Verbose "Found $($orphanManagedDevices.Count) Intune managed device(s) with no matching Entra ID device object." + + foreach ($managedDevice in $orphanManagedDevices) { + $displayName = if ($managedDevice.DeviceName) { $managedDevice.DeviceName } else { $managedDevice.Id } + + $resultRow = [PSCustomObject]@{ + Source = 'Intune' + DisplayName = $displayName + ObjectId = $null + AzureAdDeviceId = $managedDevice.AzureADDeviceId + TrustType = 'N/A' + AutopilotRegistered = if (-not $autopilotDeviceIdSet) { 'Unknown' } elseif ($managedDevice.AzureADDeviceId -and $autopilotDeviceIdSet.Contains($managedDevice.AzureADDeviceId)) { 'Yes' } else { 'No' } + ManagedDeviceId = $managedDevice.Id + LastSyncDateTime = $managedDevice.LastSyncDateTime + LastSignInDateTime = $null + IntuneDeviceStatus = 'N/A' + EntraDeviceStatus = 'N/A' + OverallStatus = 'Unknown' + ErrorDetail = $null + } + + if (-not $managedDevice.LastSyncDateTime) { + # No usable signal at all - do not guess; flag for manual review instead. + $results.Add($resultRow) + continue + } + + if ($managedDevice.LastSyncDateTime -ge $cutoffDate) { + $resultRow.OverallStatus = 'Active' + $results.Add($resultRow) + continue + } + + Write-Verbose "Removing stale Intune-only device '$displayName' (last sync: $($managedDevice.LastSyncDateTime))." + $removalResult = Remove-StaleTenantDevice -DisplayName $displayName -ManagedDevices @($managedDevice) -WhatIf:$WhatIfPreference -Confirm:$false + + $resultRow.IntuneDeviceStatus = $removalResult.IntuneDeviceStatus + $resultRow.EntraDeviceStatus = $removalResult.EntraDeviceStatus + $resultRow.OverallStatus = if ($removalResult.Errors.Count -gt 0) { 'Failed' } elseif ($removalResult.IntuneDeviceStatus -eq 'Removed') { 'Removed' } else { 'Skipped' } + if ($removalResult.Errors.Count -gt 0) { + $resultRow.ErrorDetail = $removalResult.Errors -join '; ' + } + + if ($resultRow.OverallStatus -eq 'Failed') { + $hasFailures = $true + } + + $results.Add($resultRow) + } + + $activeCount = @($results | Where-Object { $_.OverallStatus -eq 'Active' }).Count + Write-Verbose "$activeCount device(s) are still active and excluded from the output below." + + # Only surface devices the script acted on (or would act on) or flagged for manual + # review - healthy 'Active' devices are deliberately omitted to keep the report + # focused on what actually needs attention. + $results | Where-Object { $_.OverallStatus -ne 'Active' } +} +catch { + Write-Error "Script failed: $_" + exit 1 +} +finally { + # No persistent sessions to release - Connect-MgGraph tokens are cached by the SDK. +} + +if ($hasFailures) { + exit 1 +} + +exit 0