Add script to remove inactive Entra ID and Intune managed devices

This commit is contained in:
Petr Štěpán
2026-07-17 21:57:40 +02:00
commit 5a652dbbe2
+582
View File
@@ -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