Public/system/Remove-UserProfile.ps1
|
#Requires -Version 5.1 function Remove-UserProfile { <# .SYNOPSIS Removes stale user profiles that have not been used within a specified number of days .DESCRIPTION Enumerates Win32_UserProfile instances via CIM and removes those whose LastUseTime exceeds the configured threshold. System and service profiles are always excluded. Supports -WhatIf and -Confirm for safe operation. Profile folder size is calculated before deletion unless -SkipSizeCalculation is specified. Returns a result object for each profile processed. .PARAMETER ComputerName One or more computer names to target. Defaults to the local computer. Accepts pipeline input by value and by property name. .PARAMETER OlderThanDays Remove profiles not used within this many days. Valid range 1-3650. Defaults to 90. .PARAMETER ExcludeUser One or more usernames to exclude from removal. Supports wildcards. System profiles are always excluded regardless of this parameter. .PARAMETER SkipSizeCalculation Skips profile folder size calculation for faster execution. Sets ProfileSizeMB to -1 in the output. .PARAMETER Credential Optional PSCredential for authenticating to remote computers. .PARAMETER Force Suppresses the confirmation prompt. .EXAMPLE Remove-UserProfile -WhatIf Shows which profiles older than 90 days would be removed on the local machine. .EXAMPLE Remove-UserProfile -ComputerName 'SRV01' -OlderThanDays 180 -Confirm:$false Removes profiles unused for 180+ days on SRV01 without confirmation prompts. .EXAMPLE 'SRV01', 'SRV02' | Remove-UserProfile -ExcludeUser 'admin', 'svc_*' -WhatIf Shows which profiles would be removed on two servers, excluding admin and any account starting with svc_. .OUTPUTS PSWinOps.UserProfileRemoval Returns objects with ComputerName, UserName, LocalPath, SID, Source, LastUseTime, ProfileSizeMB, DaysInactive, Status, ErrorMessage, and Timestamp. The Source property indicates how the profile was detected: - Registry+Disk : entry in Win32_UserProfile AND folder on disk (normal) - RegistryOnly : entry in Win32_UserProfile but folder missing (ghost) - DiskOnly : folder under C:\Users with no Win32_UserProfile entry (orphan) .NOTES Author: Franck SALLET Version: 1.0.0 Last Modified: 2026-04-11 Requires: PowerShell 5.1+ / Windows only Requires: Administrator privileges (profile deletion) .LINK https://github.com/k9fr4n/PSWinOps .LINK https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-userprofile #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] [OutputType('PSWinOps.UserProfileRemoval')] param( [Parameter(Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [ValidateNotNullOrEmpty()] [Alias('CN', 'Name', 'DNSHostName')] [string[]]$ComputerName = $env:COMPUTERNAME, [Parameter(Mandatory = $false)] [ValidateRange(1, 3650)] [int]$OlderThanDays = 90, [Parameter(Mandatory = $false)] [SupportsWildcards()] [string[]]$ExcludeUser, [Parameter(Mandatory = $false)] [switch]$SkipSizeCalculation, [Parameter(Mandatory = $false)] [PSCredential]$Credential, [Parameter(Mandatory = $false)] [switch]$Force ) begin { Write-Verbose -Message "[$($MyInvocation.MyCommand)] Starting — threshold: $OlderThanDays days" if ($Force.IsPresent -and -not $PSBoundParameters.ContainsKey('Confirm')) { $ConfirmPreference = 'None' } # System SIDs that must never be removed $systemSids = @( 'S-1-5-18', # SYSTEM 'S-1-5-19', # LOCAL SERVICE 'S-1-5-20' # NETWORK SERVICE ) # Path segments that indicate system/default profiles $systemPathSegments = @( 'Default', 'Public', 'systemprofile', 'LocalService', 'NetworkService', 'Default User', 'All Users' ) $enumerateBlock = { param( [bool]$CalcSize ) $usersRoot = Join-Path -Path $env:SystemDrive -ChildPath 'Users' # Disk-side folders that mirror system / built-in profiles $skipFolders = @( 'Public', 'Default', 'Default User', 'All Users', 'systemprofile', 'LocalService', 'NetworkService', 'WDAGUtilityAccount', 'desktop.ini' ) $getSize = { param($path, [bool]$calc) if (-not $calc) { return [double]-1 } if (-not $path -or -not (Test-Path -LiteralPath $path)) { return [double]-1 } $sumBytes = (Get-ChildItem -LiteralPath $path -Recurse -File -Force -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum [math]::Round(($sumBytes / 1MB), 2) } $results = [System.Collections.Generic.List[hashtable]]::new() $seenPaths = @{} # 1) Registry side — Win32_UserProfile $profiles = @(Get-CimInstance -ClassName 'Win32_UserProfile' -ErrorAction Stop) foreach ($prof in $profiles) { if ($prof.Special -eq $true) { continue } $path = $prof.LocalPath $onDisk = $path -and (Test-Path -LiteralPath $path) $source = if ($onDisk) { 'Registry+Disk' } else { 'RegistryOnly' } if ($path) { $seenPaths[$path.ToLowerInvariant()] = $true } $results.Add(@{ Source = $source SID = $prof.SID LocalPath = $path LastUseTime = $prof.LastUseTime Loaded = $prof.Loaded SizeMB = & $getSize $path $CalcSize }) } # 2) Disk side — C:\Users folders without a Win32_UserProfile entry if (Test-Path -LiteralPath $usersRoot) { $diskFolders = @(Get-ChildItem -LiteralPath $usersRoot -Directory -Force -ErrorAction SilentlyContinue) foreach ($dir in $diskFolders) { $full = $dir.FullName $name = $dir.Name # Filter out built-in / system folders $skip = $false foreach ($pat in $skipFolders) { if ($name -like $pat) { $skip = $true; break } } if ($skip) { continue } if ($seenPaths.ContainsKey($full.ToLowerInvariant())) { continue } $results.Add(@{ Source = 'DiskOnly' SID = $null LocalPath = $full LastUseTime = $dir.LastWriteTime # no reliable LastUseTime — fallback Loaded = $false SizeMB = & $getSize $full $CalcSize }) } } @($results) } $removeBlock = { param( [string]$ProfileSid, [string]$LocalPath, [string]$Source ) switch ($Source) { 'DiskOnly' { # Orphan folder — no registry entry, just nuke the directory if ($LocalPath -and (Test-Path -LiteralPath $LocalPath)) { Remove-Item -LiteralPath $LocalPath -Recurse -Force -ErrorAction Stop } } default { # Registry+Disk and RegistryOnly: drop the CIM instance. # For RegistryOnly the on-disk Remove-Item is a no-op inside the # provider, so this purges the orphan registry entry cleanly. $prof = Get-CimInstance -ClassName 'Win32_UserProfile' -Filter "SID='$ProfileSid'" -ErrorAction Stop if ($prof) { Remove-CimInstance -InputObject $prof -ErrorAction Stop } } } } } process { foreach ($machine in $ComputerName) { Write-Verbose -Message "[$($MyInvocation.MyCommand)] Processing '$machine'" try { # ---- Enumerate eligible profiles ---- $invokeParams = @{ ComputerName = $machine ScriptBlock = $enumerateBlock ArgumentList = @((-not $SkipSizeCalculation.IsPresent)) } if ($PSBoundParameters.ContainsKey('Credential')) { $invokeParams['Credential'] = $Credential } $rawProfiles = @(Invoke-RemoteOrLocal @invokeParams) } catch { Write-Error -Message "[$($MyInvocation.MyCommand)] Failed to enumerate profiles on '${machine}': $_" continue } $cutoff = (Get-Date).AddDays(-$OlderThanDays) foreach ($prof in $rawProfiles) { # Skip if raw result is null or not a hashtable if ($null -eq $prof -or $prof -isnot [hashtable]) { continue } $localPath = $prof.LocalPath $sid = $prof.SID $source = $prof.Source $lastUse = $prof.LastUseTime $loaded = $prof.Loaded $sizeMB = $prof.SizeMB # Extract username from LocalPath (last segment) $userName = if ($localPath) { Split-Path -Path $localPath -Leaf } else { $sid } # ---- Exclusion: system SIDs (only meaningful when SID is set) ---- if ($sid -and $sid -in $systemSids) { continue } # ---- Exclusion: system path segments ---- $isSystemPath = $false foreach ($segment in $systemPathSegments) { if ($userName -eq $segment) { $isSystemPath = $true break } } if ($isSystemPath) { continue } # ---- Exclusion: user-specified patterns ---- if ($ExcludeUser) { $isExcluded = $false foreach ($pattern in $ExcludeUser) { if ($userName -like $pattern) { $isExcluded = $true break } } if ($isExcluded) { continue } } # ---- Filter: age threshold ---- $daysInactive = if ($null -ne $lastUse -and $lastUse -ne [datetime]::MinValue) { [int](((Get-Date) - $lastUse).TotalDays) } else { [int]::MaxValue # Never used — eligible } if ($null -ne $lastUse -and $lastUse -ne [datetime]::MinValue -and $lastUse -ge $cutoff) { continue # Profile is recent — skip } # ---- Skip loaded profiles ---- if ($loaded) { [PSCustomObject]@{ PSTypeName = 'PSWinOps.UserProfileRemoval' ComputerName = $machine UserName = $userName LocalPath = $localPath SID = $sid Source = $source LastUseTime = $lastUse ProfileSizeMB = $sizeMB DaysInactive = $daysInactive Status = 'Skipped' ErrorMessage = 'Profile is currently loaded' Timestamp = Get-Date -Format 'o' } continue } # ---- ShouldProcess ---- $lastUseDisplay = if ($null -ne $lastUse -and $lastUse -ne [datetime]::MinValue) { $lastUse.ToString('yyyy-MM-dd') } else { 'Never' } $sizeDisplay = if ($sizeMB -ge 0) { '{0:N1} MB' -f $sizeMB } else { 'unknown' } $actionVerb = switch ($source) { 'DiskOnly' { 'Remove orphan user folder' } 'RegistryOnly' { 'Remove ghost profile registry entry' } default { 'Remove user profile' } } $target = "User '$userName' [$source] ($localPath, last used $lastUseDisplay, $sizeDisplay)" if (-not $PSCmdlet.ShouldProcess($target, $actionVerb)) { [PSCustomObject]@{ PSTypeName = 'PSWinOps.UserProfileRemoval' ComputerName = $machine UserName = $userName LocalPath = $localPath SID = $sid Source = $source LastUseTime = $lastUse ProfileSizeMB = $sizeMB DaysInactive = $daysInactive Status = 'WhatIf' ErrorMessage = $null Timestamp = Get-Date -Format 'o' } continue } # ---- Delete profile ---- try { $removeParams = @{ ComputerName = $machine ScriptBlock = $removeBlock ArgumentList = @($sid, $localPath, $source) } if ($PSBoundParameters.ContainsKey('Credential')) { $removeParams['Credential'] = $Credential } Invoke-RemoteOrLocal @removeParams [PSCustomObject]@{ PSTypeName = 'PSWinOps.UserProfileRemoval' ComputerName = $machine UserName = $userName LocalPath = $localPath SID = $sid Source = $source LastUseTime = $lastUse ProfileSizeMB = $sizeMB DaysInactive = $daysInactive Status = 'Removed' ErrorMessage = $null Timestamp = Get-Date -Format 'o' } } catch { [PSCustomObject]@{ PSTypeName = 'PSWinOps.UserProfileRemoval' ComputerName = $machine UserName = $userName LocalPath = $localPath SID = $sid Source = $source LastUseTime = $lastUse ProfileSizeMB = $sizeMB DaysInactive = $daysInactive Status = 'Failed' ErrorMessage = $_.Exception.Message Timestamp = Get-Date -Format 'o' } } } } } end { Write-Verbose -Message "[$($MyInvocation.MyCommand)] Completed" } } |