Remediation/EntraID/ENT-STALE-DEVICES.ps1
|
# UNTESTED: this script has never been run on a real tenant. Read it, try it on a test tenant first, and use it at your own risk. <# .SYNOPSIS Disables then deletes the stale devices of the target according to the customer decisions (procedure ENT-STALE-DEVICES). .DESCRIPTION Target: decisions\ENT-STALE-DEVICES.csv, one row per device name, choices Disable, Delete or Keep. Always disable first: Delete aims at a device already disabled and is entered after the agreed grace period. Devices joined to on-premises AD (Hybrid) are not in this file: they would come back through sync, they go in a report for on-premises AD. Sources: nothing is cleaned, the objects go away with the tenant. The script produces the list to take off the devices to re-enroll. The snapshot does not carry the device identifier: it is read again by name on the tenant, and only objects still with no sign-in since the StaleDeviceDays setting are touched. A device that came back in the meantime is left in place. Guards before a deletion: the device must be disabled, an Autopilot device is refused, and its BitLocker keys are saved with DPAPI in secrets\ in the log folder. Limits: Graph can only disable Windows devices; the others are disabled in the portal. #> $c = Open-TcRemediation $Threshold = [int](Get-TcRemediationOption 'StaleDeviceDays') function Get-StaleObject { <# Objects of the tenant with this name, outside on-premises AD, still with no sign-in since the threshold. #> param([string]$Tenant, [string]$Name) $limit = (Get-TcRemediationNow).AddDays(-$Threshold) $fields = 'id,deviceId,displayName,accountEnabled,approximateLastSignInDateTime,trustType,physicalIds' $l = @(Invoke-TcGraph -Tenant $Tenant -Uri ("v1.0/devices?`$filter=displayName eq '{0}'&`$select={1}" -f [uri]::EscapeDataString($Name.Replace("'", "''")), $fields) -All) return @($l | Where-Object { (Get-TcMember $_ 'trustType') -ne 'ServerAd' -and (Get-TcMember $_ 'approximateLastSignInDateTime') -and [datetime](Get-TcMember $_ 'approximateLastSignInDateTime') -le $limit }) } function Save-BitlockerKey { <# A key stored in Entra goes away with the device: DPAPI, readable by this Windows account only. #> param([string]$Tenant, $Device) $keys = @(Invoke-TcGraph -Tenant $Tenant -Uri ("v1.0/informationProtection/bitlocker/recoveryKeys?`$filter=deviceId eq '{0}'" -f (Get-TcMember $Device 'deviceId')) -All) if (-not $keys.Count) { return 0 } $d = Join-Path $script:TcRemediationRun['Folder'] 'secrets' New-Item -ItemType Directory -Force -Path $d | Out-Null @($keys | ForEach-Object { $k = Invoke-TcGraph -Tenant $Tenant -Uri ("v1.0/informationProtection/bitlocker/recoveryKeys/{0}?`$select=key" -f (Get-TcMember $_ 'id')) @{ id = (Get-TcMember $_ 'id'); key = (ConvertTo-TcSecret -Text (Get-TcText $k 'key')) } }) | Export-Clixml -LiteralPath (Join-Path $d ("bitlocker-{0}.xml" -f (Get-TcMember $Device 'deviceId'))) return $keys.Count } $proposals = [System.Collections.Generic.List[object]]::new() $hybrids = @() foreach ($t in $c.Tenants) { Write-TcRemediationTenant $t 'Devices with no recent sign-in' $devices = @(Get-TcRemediationTable $t 'devices') if (-not $devices.Count -and -not (Test-TcCollectionComplete $t 'devices')) { Write-TcRemediationAlert "${t}: device collection incomplete"; continue } $stale = @($devices | Where-Object { "$($_['Days inactive'])" -ne '' -and [int]$_['Days inactive'] -ge $Threshold }) Write-TcRemediationNote ("{0} device(s) with no sign-in for {1} days or more, out of {2}" -f $stale.Count, $Threshold, $devices.Count) if ($t -ne 'Target') { if ($stale.Count) { Export-TcRemediationReport -Name "devices-not-to-re-enroll-$t" -Rows @($stale | ForEach-Object { [ordered]@{ Tenant = $t; Device = $_['Name']; OS = $_['OS']; 'Join type' = $_['Join type']; 'Days inactive' = $_['Days inactive'] } }) | Out-Null } continue } $serials = @{} foreach ($g in Get-TcRemediationTable $t 'managedDevices') { $serials[[string]$g['Name']] = $g['Serial number'] } $autopilot = @(Get-TcRemediationTable $t 'autopilotDevices' | ForEach-Object { $_['Serial number'] }) $hybrids = @($stale | Where-Object { $_['Join type'] -eq 'Hybrid' }) $others = @($stale | Where-Object { $_['Join type'] -ne 'Hybrid' }) Write-TcRemediationNote ("{0} joined to on-premises AD, to handle there; {1} to decide here, including {2} non-Windows" -f $hybrids.Count, $others.Count, @($others | Where-Object { $_['OS'] -ne 'Windows' }).Count) foreach ($g in $others | Group-Object { $_['Name'] }) { $x = (Get-TcMember $g 'Group') | Sort-Object { [int]$_['Days inactive'] } | Select-Object -First 1 $underAutopilot = $serials[(Get-TcMember $g 'Name')] -and $serials[(Get-TcMember $g 'Name')] -in $autopilot $proposals.Add([ordered]@{ Device = (Get-TcMember $g 'Name'); OS = $x['OS']; 'Join type' = $x['Join type']; Objects = (Get-TcCount $g); 'Days inactive' = $x['Days inactive']; Active = $(if (ConvertTo-TcBoolean $x['Enabled']) { 'Yes' } else { 'No' }) Autopilot = $(if ($underAutopilot) { 'Yes' } else { 'No' }); Proposal = $(if ($underAutopilot) { 'Keep' } else { 'Disable' }) }) } } $decisions = @(Get-TcDecision -Name 'ENT-STALE-DEVICES' -Proposal @($proposals) -Key 'Device' -Choice 'Disable', 'Delete', 'Keep') if ($hybrids.Count) { $f = Export-TcRemediationReport -Name 'stale-devices-on-premises' -Rows @($hybrids | ForEach-Object { [ordered]@{ Device = $_['Name']; OS = $_['OS']; 'Days inactive' = $_['Days inactive']; Active = $_['Enabled'] } }) Add-TcManualAction -Tenant 'Target' -Object 'Devices joined to on-premises AD' -Description ("Handle {0} stale device(s) joined to on-premises AD" -f $hybrids.Count) ` -Instruction @("List: $f", 'Disable or remove them in on-premises AD: deleted in Entra, they would come back through synchronization. Have the customer validate the list.') } $nonWindows = @($decisions | Where-Object { (Get-TcMember $_ 'Decision') -eq 'Disable' -and (Get-TcMember $_ 'OS') -ne 'Windows' }) if ($nonWindows.Count) { $f = Export-TcRemediationReport -Name 'devices-to-disable-in-portal' -Rows $nonWindows Add-TcManualAction -Tenant 'Target' -Object 'Non-Windows devices' -Description ("Disable {0} non-Windows device(s) in the portal" -f $nonWindows.Count) ` -Instruction @("List: $f", 'Microsoft Entra admin center > Entra ID > Devices: disable each device. Graph does not allow it for non-Windows devices.') } foreach ($d in $decisions | Where-Object { (Get-TcMember $_ 'Decision') -eq 'Disable' -and (Get-TcMember $_ 'OS') -eq 'Windows' }) { Add-TcBlockAction -Tenant 'Target' -Service Graph -Object (Get-TcMember $d 'Device') -Description ("Disable the stale device {0}" -f (Get-TcMember $d 'Device')) -Data @{ Name = (Get-TcMember $d 'Device') } ` -Test { param($a) -not @(Get-StaleObject $a.tenant $a.data.Name | Where-Object { (Get-TcMember $_ 'accountEnabled') }).Count } ` -Block { param($a) $a.undo = [System.Collections.Generic.List[object]]::new() foreach ($x in Get-StaleObject $a.tenant $a.data.Name | Where-Object { (Get-TcMember $_ 'accountEnabled') }) { Invoke-TcGraph -Tenant $a.tenant -Method PATCH -Uri ("v1.0/devices/{0}" -f (Get-TcMember $x 'id')) -Body @{ accountEnabled = $false } | Out-Null $a.undo.Add(@{ type = 'Graph'; tenant = $a.tenant; method = 'PATCH'; uri = ("v1.0/devices/{0}" -f (Get-TcMember $x 'id')); body = @{ accountEnabled = $true } }) } } } foreach ($d in $decisions | Where-Object { (Get-TcMember $_ 'Decision') -eq 'Delete' }) { if ((Get-TcMember $d 'Autopilot') -eq 'Yes') { Write-TcRemediationAlert ("{0}: Autopilot device, to clean up in Intune, deletion ignored" -f (Get-TcMember $d 'Device')); continue } Add-TcBlockAction -Tenant 'Target' -Service Graph -Object (Get-TcMember $d 'Device') -Description ("Delete the disabled device {0}" -f (Get-TcMember $d 'Device')) -Data @{ Name = (Get-TcMember $d 'Device') } ` -Reason 'A deleted device cannot be restored: it must enroll again. BitLocker keys are backed up first.' ` -Test { param($a) -not @(Get-StaleObject $a.tenant $a.data.Name).Count } ` -Block { param($a) foreach ($x in Get-StaleObject $a.tenant $a.data.Name) { if ((Get-TcMember $x 'accountEnabled')) { throw "$((Get-TcMember $x 'displayName')) is still active: decide Disable first, then wait for the grace period." } if (@((Get-TcMember $x 'physicalIds')) -match '^\[ZTDID\]') { throw "$((Get-TcMember $x 'displayName')) is an Autopilot object: to clean up in Intune." } Save-BitlockerKey -Tenant $a.tenant -Device $x | Out-Null Invoke-TcGraph -Tenant $a.tenant -Method DELETE -Uri ("v1.0/devices/{0}" -f (Get-TcMember $x 'id')) } } } Invoke-TcRemediationPlan -Verify { param($t) if ($t -ne 'Target') { return } $limit = (Get-TcRemediationNow).AddDays(-$Threshold) $n = @(Invoke-TcGraph -Tenant $t -Uri "v1.0/devices?`$select=id,approximateLastSignInDateTime" -All | Where-Object { (Get-TcMember $_ 'approximateLastSignInDateTime') -and [datetime](Get-TcMember $_ 'approximateLastSignInDateTime') -le $limit }).Count New-TcRemediationResult Info ("{0} device(s) with no sign-in for {1} days or more" -f $n, $Threshold) } Close-TcRemediation |