In this article, I describe how to clean up user profiles on a computer, when using the GUI is not an option or not desirable.
Introduction
As part of my day job, I manage servers in a controlled environment, which users use via RDP. As a consequence, there is a large amount of user profiles created on each RDP server. Additionally, these servers have a relatively small C disk so the user profiles have an impact on the system. Because of compliance and documentation reasons, moving the user profiles to a different drive is not an option.
The good news is that these user profiles do not contain critical data. So pruning them occasionally does not pose a problem. However, there are quite a lot of them, on quite a lot of servers, so manually doing that is not an option.
This is the perfect situation where scripting is a good idea.
Premises
The script we need has the following requirements:
- Certain user profiles shall not be deleted, based on group membership. In this example that means profiles belonging to administrator accounts.
- Logged in user profiles shall not be deleted, nor profiles of user accounts marked as 'special' by the operating system such as service accounts.
- Only profiles for users that belong to a specific OU will deleted. This is a way to guarantee that the profiles belong to actual users, not service accounts or local accounts.
WMI vs. CIM
Before getting into the nitty gritty, we need to look at the methods we can use to interact with Windows. While we could get rid of profiles by removing the profile folder, this is not a clean operation because it doesn't clean up the registry. So we need to interact with Windows itself and ask Windows to delete the profile for us, similar to what we would do manually via the control panel.
In the old days, WMI would have been the obvious choice. However, WMI is being deprecated. Rather than go into the details myself here, I link to this excellent article which explains the differences and why CIM is the future and WMI the past.
For our purposes, we use CIM because there is a very convenient way to retrieve a list of user profiles via Get-CimObject
, and then remove selected user profiles by passing the CIM objects to Remove-CimInstance
.
Building the Script
The script is built from various sections.
Setting Up the Prerequisites
In my environment, the exclusion list contains the admin groups as an example. Based on your own needs, you can add specific application groups for accounts that have a need for persistent user profiles.
At some point during the script, we need to evaluate whether the list of groups that the user belongs to, does not contain a group from the exclusion list. A script guru may be able to implement this with a single clear line, but for my own sanity, I put that check in a function to keep the rest of the script comprehensible.
Import-Module ActiveDirectory
function MemberOfExcludedGroup
{
param ($memberof, $exclusionlist)
$retval = $false
$exclusionlist | foreach {if($memberof -match $_ ) {$retval = $true}}
$retval
}
$exclusionlist = @("Domain Admins", "Administrators")
The MemberOfExcludedGroup
function returns $true
if the user is a member of one of the exclusion groups, and $false
if it isn't.
Getting the List of Users in Scope
Finding out which users may be in scope of the cleanup process is the first step. We do that with the following code segment. That code segment in English would say 'get all users in the specified OU which are not member of one of the exclusion groups, and get their SID
.'
$userOUpath = (Get-ADOrganizationalUnit -Filter 'Name -like "DeltaV Users"').DistinguishedName
$users = Get-ADUser -Filter * -SearchBase $userOUpath -properties memberof, SID |
where {! (MemberOfExcludedGroup $_.MemberOf $exclusionlist)} |
foreach { $_.SID.Value }
At first, it may seem odd that we specify the users in scope through their SID
. There is a very good reason for that.
In Step 2, we need to match the user profiles to the users from which they are. In Windows, these are linked through their SID
. Sadly, because of how the Local Security Authority (LSA) performs SID
lookup, getting the name for a SID
is extremely slow; so slow that the script just freezes for a long time, trying to retrieve the names and domain for a SID
. We're talking about a half hour or more for a couple hundred users.
gwmi win32_userprofile |
foreach {
($act = gwmi win32_useraccount -Filter "sid = '$($_.sid)'"); $act.Name;$act.Domain }
However, there is a silver lining here: we don't care about the name.
Rather than translate the SID
to a name and then check if the name is a domain users and the SID
Domain is the user Domain, we just get all SID
s for the domain users in scope, and check if the profile SID
is in that list. This lookup is blazingly fast.
Getting the List of Profiles
Now that we have the list of users that are in scope, we match it against the profiles that are in scope.
Get-CimInstance win32_userprofile |
where { $users.Contains($_.SID) -and
!$_.Special -and
!$_.Loaded } |
foreach {"Deleting profile $($_.LocalPath)"; Remove-CimInstance $_}
We get the list of user profiles through WMI and the win32_userprofile
class. From that list, we keep only the ones that belong to a user in scope, if said profile is not marked as special or loaded (the user is currently logged in).
The remaining userprofiles
are then removed via another WMI function: Remove-CimInstance
.
Putting Everything Together
Putting all those things together, the resulting script is surprisingly simple.
Import-Module ActiveDirectory
function MemberOfExcludedGroup
{
param ($memberof, $exclusionlist)
$retval = $false
$exclusionlist | foreach {if($memberof -match $_ ) {$retval = $true}}
return $retval
}
$exclusionlist = @("Domain Admins", "Administrators")
$userOUpath = (Get-ADOrganizationalUnit -Filter 'Name -like "DeltaV Users"').DistinguishedName
$users = Get-ADUser -Filter * -SearchBase $userOUpath -properties memberof, SID |
where {! (MemberOfExcludedGroup $_.MemberOf $exclusionlist)} |
foreach { $_.SID.Value }
Get-CimInstance win32_userprofile |
where { $users.Contains($_.SID) -and
!$_.Special -and
!$_.Loaded } |
foreach {"Deleting profile $($_.LocalPath)"; Remove-CimInstance $_}
Points of Interest
I'm not a script guy. I am a software developer. This means that I am used to variables having concrete types, or functions always having a control path that ends up with a return value and things like that. In scripting, this is a lot less strict. I have no doubt that my background influences the way I approach scripting.
If you're a scripting expert and see something that's wrong / inefficient or whatever, I'd be happy to hear it.
This script is licensed under the MIT license so have fun with it.
History
- 9th March, 2023: First version