Add script to remove inactive Entra ID and Intune managed devices
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user