Files
docs/Scripts/Powershell/General Purpose/Inactive User Profile Data Cleanup.md
Nicole Rappe 5b6b97aeac
All checks were successful
GitOps Automatic Deployment / GitOps Automatic Deployment (push) Successful in 8s
Update Scripts/Powershell/General Purpose/Inactive User Profile Data Cleanup.md
2025-09-10 20:26:25 -06:00

14 KiB
Raw Blame History

Purpose

This script is designed to iterate over every computer device within an Active Directory Domain. It then reaches out to those devices over the network and iterates upon every local user profile on those devices, and using CIM, determines which profiles have not been logged into in X number of days. If executed in a non-dry-run nature, it will then delete those profiles (this does not delete local or domain users, it just cleans up their local profile data on the workstation).

!!! note "Windows Servers not Targeted" For safety, this script is designed to not target servers. There is no telling the potential turmoil of clearing profiles in server environments, and to avoid that risk all-together, we just avoid them entirely.

!!! example "Commandline Arguments" You can execute the script with the following arguments to change the behavior of the script: .\UserProfileDataPruner.ps1

- `-DryRun` : Do not Delete Data, just report on what (*would*) be deleted.
- `-InactiveDays 90` : Adjust the threshold of the pruning cutoff.  (*Default 90 Days*)
- `-PilotTestingDevices` : Optional comma-separated list of devices to target (*instead of all workstations in Active Directory*)

Script

You can find the full script below, save it as UserProfileDataPruner.ps1:

<# 
UserProfileDataPruner.ps1
Prune stale local user profile data on Windows workstations.

- Deletes only on-disk profile data via Win32_UserProfile.Delete()
- Never deletes user accounts
- Skips servers (ProductType != 1)
- Parameters:
    -DryRun                -> shows [DRY-RUN]: lines; no deletions
    -InactiveDays [int]    -> default 90
    -PilotTestingDevices   -> optional comma-separated list or string[]; if omitted, auto-discovers all enabled Windows workstations in AD

Output:
  [INFO]: Total Hosts Queried: <N>
  [INFO]: Skipped host(s) due to WinRM/Connectivity/TimeDifference/SPN issues: <N>
  [DRY-RUN]/[INFO]: Deleting X profile(s) on "<HOST>" (user1,user2,...)   # per host
  <ASCII summary table at the bottom>
#>

[CmdletBinding()]
param(
  [switch]$DryRun,
  [int]$InactiveDays = 90,
  [string[]]$PilotTestingDevices
)

begin {
  function Write-Info([string]$msg){ Write-Host "[INFO]: $msg" }
  function Write-Dry ([string]$msg){ Write-Host "[DRY-RUN]: $msg" }

  function Convert-LastUse {
    param([object]$raw)
    if ($null -eq $raw) { return $null }
    if ($raw -is [datetime]) {
      $dt = [datetime]$raw
      if ($dt.Kind -eq [System.DateTimeKind]::Utc) { return $dt } else { return $dt.ToUniversalTime() }
    }
    if ($raw -is [int64] -or $raw -is [uint64] -or $raw -is [int] -or ($raw -is [string] -and $raw -match '^\d+$')) {
      try { return [DateTime]::FromFileTimeUtc([int64]$raw) } catch { return $null }
    }
    if ($raw -is [string] -and $raw -match '^\d{14}\.\d{6}[-+]\d{3}$') {
      try { $d = [System.Management.ManagementDateTimeConverter]::ToDateTime($raw); return $d.ToUniversalTime() }
      catch { return $null }
    }
    if ($raw -is [string]) {
      $tmp = $null
      if ([DateTime]::TryParse($raw, [ref]$tmp)) { return $tmp.ToUniversalTime() }
    }
    return $null
  }

  function Try-TranslateSid($sid) {
    try {
      (New-Object System.Security.Principal.SecurityIdentifier($sid)).Translate([System.Security.Principal.NTAccount]).Value
    } catch { $null }
  }

  function Test-HostOnline {
    param([string]$Computer)
    try { Test-WSMan -ComputerName $Computer -ErrorAction Stop | Out-Null; return $true }
    catch { return $false }
  }

  function Show-AsciiTable {
    param([hashtable]$Data)
    # Keep order if [ordered] was used
    $keys   = @($Data.Keys)
    $values = $keys | ForEach-Object { [string]$Data[$_] }
    $wKey   = [Math]::Max(4, ($keys   | ForEach-Object { $_.ToString().Length }  | Measure-Object -Maximum).Maximum)
    $wVal   = [Math]::Max(5, ($values | ForEach-Object { $_.ToString().Length }  | Measure-Object -Maximum).Maximum)
    $sep    = '+' + ('-'*($wKey+2)) + '+' + ('-'*($wVal+2)) + '+'
    Write-Host $sep
    foreach ($k in $keys) {
      $v = [string]$Data[$k]
      Write-Host ('| {0} | {1} |' -f $k.PadRight($wKey), $v.PadRight($wVal))
    }
    Write-Host $sep
  }

  # Normalize any comma-separated single string into an array
  if ($PilotTestingDevices -and $PilotTestingDevices.Count -eq 1 -and $PilotTestingDevices[0] -match ',') {
    $PilotTestingDevices = $PilotTestingDevices[0].Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ }
  }

  # Targets: use provided list, else discover workstations from AD
  $Targets = @()
  if ($PilotTestingDevices -and ($PilotTestingDevices | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })) {
    $Targets = $PilotTestingDevices | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Select-Object -Unique
  } else {
    try { Import-Module ActiveDirectory -ErrorAction Stop }
    catch { throw "ActiveDirectory module not found. Install RSAT or specify -PilotTestingDevices." }

    # Discover enabled Windows workstations (exclude servers)
    $ad = Get-ADComputer -Filter * -Properties OperatingSystem, DNSHostName, Enabled
    $Targets = $ad |
      Where-Object {
        $_.Enabled -and $_.DNSHostName -and
        ($_.OperatingSystem -like 'Windows*') -and
        ($_.OperatingSystem -notmatch 'Server')
      } |
      Select-Object -ExpandProperty DNSHostName
  }

  if (-not $Targets -or $Targets.Count -eq 0) { throw "No eligible Windows workstations to query." }

  $CutoffUtc = [DateTime]::UtcNow.AddDays(-$InactiveDays)
  $Throttle  = 25

  # ---------- Remote blocks ----------

  $RemoteEnumerateProfiles = {
    param([datetime]$CutoffUtc)

    function Convert-LastUse {
      param([object]$raw)
      if ($null -eq $raw) { return $null }
      if ($raw -is [datetime]) {
        $dt = [datetime]$raw
        if ($dt.Kind -eq [System.DateTimeKind]::Utc) { return $dt } else { return $dt.ToUniversalTime() }
      }
      if ($raw -is [int64] -or $raw -is [uint64] -or $raw -is [int] -or ($raw -is [string] -and $raw -match '^\d+$')) {
        try { return [DateTime]::FromFileTimeUtc([int64]$raw) } catch { return $null }
      }
      if ($raw -is [string] -and $raw -match '^\d{14}\.\d{6}[-+]\d{3}$') {
        try { $d = [System.Management.ManagementDateTimeConverter]::ToDateTime($raw); return $d.ToUniversalTime() }
        catch { return $null }
      }
      if ($raw -is [string]) {
        $tmp = $null
        if ([DateTime]::TryParse($raw, [ref]$tmp)) { return $tmp.ToUniversalTime() }
      }
      return $null
    }

    function Try-TranslateSid($sid) {
      try { (New-Object System.Security.Principal.SecurityIdentifier($sid)).Translate([System.Security.Principal.NTAccount]).Value }
      catch { $null }
    }

    # Skip non-workstations
    try {
      $hasCIM = [bool](Get-Command -Name Get-CimInstance -ErrorAction SilentlyContinue)
      $os = if ($hasCIM) { Get-CimInstance Win32_OperatingSystem -ErrorAction Stop }
            else          { Get-WmiObject  Win32_OperatingSystem -ErrorAction Stop }
      if ($os.ProductType -ne 1) { return } # not a workstation
    } catch { return }

    try {
      $hasCIM = [bool](Get-Command -Name Get-CimInstance -ErrorAction SilentlyContinue)
      $profiles = if ($hasCIM) {
        Get-CimInstance -ClassName Win32_UserProfile -ErrorAction Stop
      } else {
        Get-WmiObject  -Class Win32_UserProfile      -ErrorAction Stop
      }

      $profiles = $profiles | Where-Object {
        $_.Special -eq $false -and
        $_.Loaded  -eq $false -and
        $_.LocalPath -like 'C:\Users\*'
      }

      foreach ($p in $profiles) {
        $sid       = $p.SID
        $nameGuess = Split-Path $p.LocalPath -Leaf
        $acc       = Try-TranslateSid $sid
        $accName   = if ($acc -and ($acc -like '*\*')) { ($acc -split '\\',2)[1] } else { $nameGuess }
        $luUtc     = Convert-LastUse ($p.PSObject.Properties['LastUseTime'].Value)

        # Optional fast size (not always present)
        $sizeBytes = $null
        $szProp = $p.PSObject.Properties['Size']
        if ($szProp -and $szProp.Value -ne $null) { try { $sizeBytes = [int64]$szProp.Value } catch { } }

        $stale = ($null -eq $luUtc) -or ($luUtc -lt $CutoffUtc)

        [PSCustomObject]@{
          Computer    = $env:COMPUTERNAME
          SID         = $sid
          AccountName = $accName
          AccountFQN  = $acc
          LocalPath   = $p.LocalPath
          LastUseUtc  = $luUtc
          Eligible    = $stale
          SizeBytes   = $sizeBytes
        }
      }
    } catch {
      Write-Error ($_.Exception.Message)
    }
  }

  $RemoteDeleteProfiles = {
    param([string[]]$SIDs)
    $results = @()
    $hasCIM = [bool](Get-Command -Name Get-CimInstance -ErrorAction SilentlyContinue)
    foreach ($sid in $SIDs) {
      try {
        if ($hasCIM) {
          $obj = Get-CimInstance -ClassName Win32_UserProfile -Filter ("SID='{0}'" -f $sid) -ErrorAction Stop
          if (-not $obj -or $obj.Loaded -or $obj.Special) { $results += [pscustomobject]@{SID=$sid;Deleted=$false;Code='SKIP';Message='Not found or loaded/special'}; continue }
          $rv = Invoke-CimMethod -InputObject $obj -MethodName Delete -ErrorAction Stop
          if ($rv.ReturnValue -eq 0) { $results += [pscustomobject]@{SID=$sid;Deleted=$true; Code=0; Message='OK'} }
          else                       { $results += [pscustomobject]@{SID=$sid;Deleted=$false;Code=$rv.ReturnValue;Message='Delete returned non-zero'} }
        } else {
          $obj = Get-WmiObject -Class Win32_UserProfile -Filter ("SID='{0}'" -f $sid) -ErrorAction Stop
          if (-not $obj -or $obj.Loaded -or $obj.Special) { $results += [pscustomobject]@{SID=$sid;Deleted=$false;Code='SKIP';Message='Not found or loaded/special'}; continue }
          $rv = $obj.Delete()
          if ($rv.ReturnValue -eq 0) { $results += [pscustomobject]@{SID=$sid;Deleted=$true; Code=0; Message='OK'} }
          else                       { $results += [pscustomobject]@{SID=$sid;Deleted=$false;Code=$rv.ReturnValue;Message='Delete returned non-zero'} }
        }
      } catch {
        $results += [pscustomobject]@{SID=$sid;Deleted=$false;Code='EXC';Message=$_.Exception.Message}
      }
    }
    return $results
  }

  $SkippedHosts = New-Object System.Collections.Generic.HashSet[string]  # names we couldnt query or errored on
}

process {
  # Reachability (WSMan) filter
  $TotalHostsQueried = $Targets.Count
  $reachable = @()
  foreach ($c in $Targets) {
    if (Test-HostOnline $c) { $reachable += $c } else { $null = $SkippedHosts.Add([string]$c) }
  }

  if (-not $reachable) {
    Write-Info ("Total Hosts Queried: {0}" -f $TotalHostsQueried)
    Write-Info ("Skipped host(s) due to WinRM/Connectivity/TimeDifference/SPN issues: {0}" -f $SkippedHosts.Count)
    throw "No reachable hosts via WinRM."
  }

  # Inventory
  $remoteErrors = @()
  $inv = Invoke-Command -ComputerName $reachable -ThrottleLimit 25 `
          -ScriptBlock $RemoteEnumerateProfiles -ArgumentList $CutoffUtc `
          -ErrorAction Continue -ErrorVariable +remoteErrors

  foreach ($e in $remoteErrors) {
    if ($e.PSComputerName) { $null = $SkippedHosts.Add([string]$e.PSComputerName) }
  }

  $rows = $inv | Where-Object { $_ -and $_.SID -and $_.LocalPath -like 'C:\Users\*' }
  $eligibleRows = $rows | Where-Object { $_.Eligible -eq $true }

  # Plan per host
  $plan = @{}
  foreach ($r in $eligibleRows) {
    if (-not $plan.ContainsKey($r.Computer)) {
      $plan[$r.Computer] = [PSCustomObject]@{
        SIDs  = New-Object System.Collections.Generic.List[string]
        Names = New-Object System.Collections.Generic.List[string]
        Size  = [int64]0
      }
    }
    $plan[$r.Computer].SIDs.Add($r.SID)
    $plan[$r.Computer].Names.Add($r.AccountName)
    if ($r.SizeBytes -ne $null) { $plan[$r.Computer].Size += [int64]$r.SizeBytes }
  }

  # Host-level counters first
  Write-Info ("Total Hosts Queried: {0}" -f $TotalHostsQueried)
  Write-Info ("Skipped host(s) due to WinRM/Connectivity/TimeDifference/SPN issues: {0}" -f $SkippedHosts.Count)

  # Per-host summary lines
  $hostKeys = $plan.Keys | Sort-Object
  foreach ($h in $hostKeys) {
    $sids  = $plan[$h].SIDs  | Sort-Object -Unique
    $names = $plan[$h].Names | Where-Object { $_ } | Sort-Object -Unique
    $list  = '(' + ($names -join ',') + ')'
    if ($DryRun) { Write-Dry ("Deleting {0} profile(s) on ""{1}"" {2}" -f $sids.Count, $h, $list) }
    else         { Write-Info("Deleting {0} profile(s) on ""{1}"" {2}" -f $sids.Count, $h, $list) }
  }

  # Execute deletes when not DryRun
  if (-not $DryRun -and $hostKeys.Count -gt 0) {
    foreach ($h in $hostKeys) {
      $sids = $plan[$h].SIDs | Sort-Object -Unique
      try {
        $null = Invoke-Command -ComputerName $h -ThrottleLimit 1 `
                -ScriptBlock $RemoteDeleteProfiles -ArgumentList (,$sids) `
                -ErrorAction Stop
      } catch {
        Write-Info ("Host ""{0}"": delete attempt failed: {1}" -f $h, $_.Exception.Message)
      }
    }
  }

  # ---------- Bottom summary table ----------
  $analyzed      = $rows.Count
  $evaluated     = $rows.Count
  $eligibleCount = $eligibleRows.Count
  $fleetBytes    = ($eligibleRows | Where-Object { $_.SizeBytes -ne $null } | Measure-Object -Property SizeBytes -Sum).Sum
  $fleetGB       = if ($fleetBytes -and $fleetBytes -gt 0) { "{0} GB" -f ([Math]::Round($fleetBytes / 1GB, 2)) } else { "N/A" }

  $summary = [ordered]@{
    "Local User Profiles Analyzed"                    = "$analyzed"
    "User Profiles Evaluated"                         = "$evaluated"
    ("User Profiles Not Logged in for {0}+ Days" -f $InactiveDays) = "$eligibleCount"
    "Estimated Data To Remove (if executed)"          = "$fleetGB"
  }

  Show-AsciiTable -Data $summary
}

end { }