513 lines
22 KiB
PowerShell
513 lines
22 KiB
PowerShell
<#
|
|
.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, finds stale Entra ID and Intune-managed devices,
|
|
and removes the matching device records after checking their last sign-in and
|
|
last sync activity. It also reports devices that cannot be deleted, devices with
|
|
no reliable activity signal, and whether an Autopilot registration still exists.
|
|
|
|
.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
|