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, LastUseTime, ProfileSizeMB, DaysInactive, Status, ErrorMessage, and Timestamp. .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 ) $profiles = @(Get-CimInstance -ClassName 'Win32_UserProfile' -ErrorAction Stop) $results = [System.Collections.Generic.List[hashtable]]::new() foreach ($prof in $profiles) { # Skip system / special profiles if ($prof.Special -eq $true) { continue } $results.Add(@{ SID = $prof.SID LocalPath = $prof.LocalPath LastUseTime = $prof.LastUseTime Loaded = $prof.Loaded SizeMB = if ($CalcSize -and $prof.LocalPath -and (Test-Path -LiteralPath $prof.LocalPath)) { $sizeBytes = (Get-ChildItem -LiteralPath $prof.LocalPath -Recurse -File -Force -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum [math]::Round(($sizeBytes / 1MB), 2) } else { [double]-1 } }) } @($results) } $removeBlock = { param( [string]$ProfileSid ) $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 $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 ---- if ($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 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' } $target = "User '$userName' ($localPath, last used $lastUseDisplay, $sizeDisplay)" if (-not $PSCmdlet.ShouldProcess($target, 'Remove user profile')) { [PSCustomObject]@{ PSTypeName = 'PSWinOps.UserProfileRemoval' ComputerName = $machine UserName = $userName LocalPath = $localPath SID = $sid 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) } if ($PSBoundParameters.ContainsKey('Credential')) { $removeParams['Credential'] = $Credential } Invoke-RemoteOrLocal @removeParams [PSCustomObject]@{ PSTypeName = 'PSWinOps.UserProfileRemoval' ComputerName = $machine UserName = $userName LocalPath = $localPath SID = $sid 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 LastUseTime = $lastUse ProfileSizeMB = $sizeMB DaysInactive = $daysInactive Status = 'Failed' ErrorMessage = $_.Exception.Message Timestamp = Get-Date -Format 'o' } } } } } end { Write-Verbose -Message "[$($MyInvocation.MyCommand)] Completed" } } |