Weekend Scripter: Use PowerShell for Conditional User Profile Removal

Weekend Scripter: Use PowerShell for Conditional User Profile Removal

  • Comments 27
  • Likes

Summary: Guest blogger, Bob Stevens, talks about how to use Windows PowerShell to perform conditional user profile removal.

Microsoft Scripting Guy, Ed Wilson, is here. Today we are lucky to have guest blogger, Bob Stevens, return for another exciting and useful blog post. Be sure you check out Bob's other Hey, Scripting Guy! Blog posts. Bob is a member of the Twin Cities PowerShell User Group. Here is his contact information:

Blog: Help! I’m Stuck in My PowerShell!
Twitter: @B_stevens6
LinkedIn: Robert Stevens

Note   This is the second post in a two part series about using Windows PowerShell to work with user profiles. You should read yesterday’s post, Use PowerShell to Generate a Recent Profile Report, prior to reading today’s.

And now, here’s Bob…

In my last post I discussed how to use Windows PowerShell to return a list of profiles that have not been accessed in the last 30 days by using the metadata of the ntuser.dat file:

Image of command output

In response to input from Ed Wilson, I decided to modify this script heavily to not only return the opposite (profiles that have not been accessed in the past 30 days), but also to provide the user the option of removing such profiles. Although this seems rather straight forward, a number of issues do exist. Primary among them is the built-in profiles, such as Administrator. As you may well know, removing the local Administrator profile can cause some issues, especially if the Group Policy Objects (GPOs) for your organization alter this profile.

Before we address that, we need to alter the script from my previous blog post. In that post, we used the ntuser.dat file to define the last time the profile was used. This is not going to be necessary now because we are looking for the last time the profile has been used at all, not the last time the user’s registry hive was loaded. Because of this, we can confidantly reduce the complexity of the first part of our script by removing the wildcard character.

Get-Childitem -force "C:\Documents and Settings"

Here you see the Get-Childitem cmdlet followed by the -force switch. (This is followed by the profile path for the version 5 Windows systems, whichI will address later in this post). This returns the content of our defined directory, including hidden and system objects, C:\Documents and Settings or C:\Users.

Now we need to pipe the output from that command to a conditional statement that is very similar to the previous script. The only alterations we are going to make are with the inequality statement and the value. These we change from less than (-le) to greater than (-ge), and we change the number of days from 31 to 30.

Get-ChildItem -force "C:\Documents and Settings" | Where {

((Get-Date)-$_.lastwritetime).days -ge 30

}

You can stop here if you simply want the list of profiles that were not accessed in the last 30 days. If you want to move on, we need to alter that first line to pipe it to an array. An array is similar to a variable; however, with an array you are defining multiple values within a single variable. This is done by encapsulating the values in parentheses and preceding the left parentheses with an ampersand (@).

$over30dayprofiles = @(Get-ChildItem -force "C:\Documents and Settings" | Where {

((Get-Date)-$_.lastwritetime).days -ge 30

} )

We need to use a pair of IF statements to avoid having to change the script on a case-by-case basis. The structure of these commands are explained in detail in my previous post, Use PowerShell to Generate a Recent Profile Report, so feel free to backtrack if you want to furthur understand them.

IF ((Get-WmiObject Win32_OperatingSystem).version –like “5*”) {}

IF ((Get-WmiObject Win32_OperatingSystem).version –like “6*”) {}

Inside the braces that follow the first IF statement’s condition, we need to nest our variable declaration $over30dayprofiles.

IF ((Get-WmiObject Win32_OperatingSystem).version –like “5*”) {

$over30dayprofiles = @(Get-ChildItem -force "C:\Documents and Settings" | Where {

((Get-Date)-$_.lastwritetime).days -ge 30

} )

}

We now nest the same variable in the second IF statement’s declaration with one minor variation. We replace “Documents and Settings” with “Users.”

IF ((Get-WmiObject Win32_OperatingSystem).version –like “6*”) {

$over30dayprofiles = @(Get-ChildItem -force "C:\Users" | Where {

((Get-Date)-$_.lastwritetime).days -ge 30

} )

}

Roll them together to have a complete picture of our array declaration:

IF ((Get-WmiObject Win32_OperatingSystem).version –like “5*”) {

$over30dayprofiles = @(Get-ChildItem -force "C:\Documents and Settings" | Where {

((Get-Date)-$_.lastwritetime).days -ge 30

} )

}

IF ((Get-WmiObject Win32_OperatingSystem).version –like “6*”) {

$over30dayprofiles = @(Get-ChildItem -force "C:\Users" | Where {

((Get-Date)-$_.lastwritetime).days -ge 30

} )

}

Combined, our pair of IF statements declare our variable, $over30dayprofiles, as all items with a last write time of greater than 30 days. To manipulate this data into the correct format, we use the Set-Content cmdlet. Set-content will replace the context of a text file, and it natively does so without all of the metadata, which is exactly what we need. (If you want to append data to the text file, use Add-Content.) With that, we have the following command:

Set-Content ".\over30dayprofiles.txt" $over30dayprofiles

Here is the result:

Image of command output

Notice how Administrator, LocalService and NetworkService are present. We need those to remain, so we are going to actually remove them, and set the content to another file. To do this, we first need to get the content of this file with the Get-Content cmdlet:

Get-Content ".\over30dayprofiles.txt"

Now we use the pipe operator (|) to funnel the content of over30dayprofiles.txt  into the ForEach-Object cmdlet.

Get-Content ".\over30dayprofiles.txt" | ForEach-Object

ForEach-Object is simply stating, “For each object, do this.” The “do this” must be encapsulated in braces because it is followed by actual script.

Get-Content ".\over30dayprofiles.txt" | ForEach-Object{}

Inside the braces, we replace Administrator, LocalService, and NetworkService (with any other profiles you want to exempt) with  a null value by using two single quotation marks with nothing between them (‘’). This is done with the -replace operator.

Note   Remember that you need to have the placeholder, $_, on the first line because it is necessary for the operation of ForEach-Object. The placeholder, $_, simply tells Windows PowerShell to address each individual object within the data provided.

$_ -replace 'Administrator', '' `
  -replace 'LocalService', '' `
  -replace 'NetworkService', '' `

It is important to clarify the structure of the -replace operator. -Replace must be followed by the item to be replaced, which is encapsulated in single quotation marks() and followed by a comma (,). The comma is followed by what is being replaced, and it is also encapsulated in single quotation marks.

Note   Because we are replacing these items with nothing, the single quotation marks look like double quotation marks. Be careful to make this distinction because this code requires single quotation marks.

Finally, we use the accent (`) character to signify “next line.” As you can see, only the first line requires the placeholder.

So far, this set of script looks like this:

Get-Content ".\over30dayprofiles.txt" | ForEach-Object {

$_ -replace 'Administrator', '' `
  -replace 'LocalService', '' `
  -replace 'NetworkService', '' `

  }

Now that we have removed those important profiles, we need to remove any empty lines (also known as whitespace) that may be in our file. To do this, we do not look for actual white space because that is tantamount to looking for nothing. Rather, we look for lines that have an alpha character in them. This is accomplished by piping the results of the previous code to the Select-String cmdlet and a regular expression. We start with the pipe operator and the Select String cmdlet:

| Select-String

Select-String requires two things to operate properly: the path and the pattern. Thankfully, the path (location of the data) is taken care of by the pipe operator. We add the -pattern definition followed by the Word regular expression (\w). The Word regular expression defines an alphabetical character. The following script states, “Select any alpha character.”

Select-String -pattern "\w"

Now we pipe the results of that to yet another Foreach-Object. This time we are looking for the lines that the previous command returned. Attach .line to the $_ in all ForEach-Object commands and encapsulate that in braces:

| ForEach-Object { $_.line }

Now that we have all the commands necessary to format our data, we are going to roll them together:

Get-Content ".\over30dayprofiles.txt" | ForEach-Object {

$_ -replace 'Administrator', '' `
   -replace 'LocalService', '' `
   -replace 'NetworkService', '' `

} | Select-String -Pattern "\w" | ForEach-Object { $_.line }

Now, we redefine the variable $over30dayprofiles. We do this by encapsulating the entire previous script in paretheses, and preceding that with the “at” sign (@) to create an array:

$over30dayprofiles = @(Get-Content ".\over30dayprofiles.txt" | Foreach-Object {

$_ -replace 'Administrator', '' `
   -replace 'LocalService', '' `
   -replace 'NetworkService', '' `

} | Select-String -Pattern "\w" | ForEach-Object { $_.line })

If you want to check your work, type $over30dayprofiles. It should return a list of profiles that have not been accessed within the past 30 days, with the exception of our defined profiles.  

Now that we have our input, we can proceed with our script. To sequentially delete profiles, we need to set up a Do-Until loop. This kind of loop simply states, “Do X, until Y is True.” For our loop we need to define two variables. The first is the counter, $i, and the second is the Until condition (Y).

Note   A counter is a value in programming that is used to faciliate an end to a programming loop. Without it, the program would constantly “Do” something until the computer is shut off or the process is ended somehow.

I always start by defining $i as 0:

$i = 0

The Count variable is merely the number of profiles in our $over30dayprofiles array. To define the number of items in an array, we append the count array property, .count, to $over30dayprofiles, and we use that to define the $count variable:

$count = $over30dayprofiles.count

We also need to make sure that our path actually contains profiles by changing our logical location on the hard drive to C:\Documents and Settings. We do this with the Set-Location cmdlet:

Set-Location ‘C:\Documents and Settings’

Now we start our Do-Until loop. As explained earlier, there are two parts to this loop: the Do section and the Until section. Because Do is followed by an action (and functional code, not simply values), it is followed by braces. Until is followed by a condition, so we use parentheses instead of braces:

Do{}

Until()

To remove the subsequent directories, we are going to use the Remove-Item cmdlet. When we remove these profiles, it is important  that the profile and all child folders and items (regardless of special properties such as Read-only, Hidden, or System) are removed. We do this by using the -force and the -recurse switches. The -force switch ignores special properties, and the -recurse switch removes all of the child directories and their content.

Remove-Item -force -recurse

By itself, all this command does is indiscriminately delete the following item and all of its content, regardless of properties. What it does not say is actually what to delete. That is where we use the $over30dayprofiles array. As displayed earlier in this post, this array holds the names of the profiles that have not been accessed within the past 30 days, minus those that we specified.

Remove-Item -force -recurse $over30dayprofiles

We want to do this sequentially, so we are using the counter that we defined earlier to identify the element in the array that we want to remove. Remember that with an array, you are defining multiple values within a single variable. The values in the array are called elements, and each element is numbered (beginning with 0) to differentiate one value from the next.

Specifing an element within an array is done by appending brackets to the end of an array variable, and putting the element number within those brackets. You can use a variable in place of an actual number. This is where we are using our counter, which conveniently starts at 0 and increments by 1 every time the Do statement loops.

Remove-Item -force -recurse $over30dayprofiles[$i]

We now add the counter. Simply appending two addition symbols (++) to the $i variable will increment it by one:

Remove-Item -force -recurse $over30dayprofiles[$i]
$i++

Now that we have completed our statement, we need to nest it in the braces following Do:

Do{

Remove-Item -force -recurse $over30dayprofiles[$i]
$i++

}

Until()

Without defining the Until condition, this loop will continue until it starts generating errors (when it runs out of elements in the array to perform the Do action against). The following error message tells you where the issue is: “Missing While or Until in Do loop.” Always read your error messages.

Image of message

Right now our Until statement looks like this:

Until()

Our condition is going to say Do the above Until the counter is greater than the number of items in $over30dayprofiles ($count).” Here is where Until, our counter, and $count come together. First we express the counter:

Until($i)

Then we use the greater than (-ge) inequality:

Until($i -ge)

And finally, we insert the $count directly after the greater than operator for the complete conditon that reads, “Until our counter is greater than the count.”

Until($i -ge $count)

Combined, the script looks like this:

IF ((Get-WmiObject Win32_OperatingSystem).version –like “5*”) {

$over30dayprofiles = @(Get-ChildItem -force "C:\Documents and Settings" | Where {

((Get-Date)-$_.lastwritetime).days -ge 30

} )

}

IF ((Get-WmiObject Win32_OperatingSystem).version –like “6*”) {

$over30dayprofiles = @(Get-ChildItem -force "C:\Users" | Where {

((Get-Date)-$_.lastwritetime).days -ge 30

} )

Set-Content ".\over30dayprofiles.txt" $over30dayprofiles

$over30dayprofiles = @(Get-Content ".\over30dayprofiles.txt" | Foreach-Object {

     $_ -replace 'Administrator', '' `
       -replace 'LocalService', '' `
       -replace 'NetworkService', ''

} | Select-String -Pattern "\w" | ForEach-Object { $_.line })

$count = $over30dayprofiles.count

$i = 0

Set-Location 'C:\Documents and Settings'

Do {

  Remove-Item -force -recurse $over30dayprofiles[$i]

  $i++

}

Until($i -ge $count)

The beauty of technology is its fluid nature. This script, much like the script in Use PowerShell to Generate a Recent Profile Report will need to be altered as time goes by, new versions of operating systems are released, and old versions lose support. I stand by the following statement: “There is always another better way to do things.” That is especially true with Windows PowerShell. Thank you for reading and, as always, please post your comments in the following Leave a Comment text box!

~Bob

Awesome job, Bob. These are great posts. Thank you for taking time to share with the Windows PowerShell community. Join me tomorrow when I will have a guest blog written by Brian Wilhite. Have a look at his previous Hey, Scripting Guy! Blog posts.

I invite you to follow me on Twitter and Facebook. If you have any questions, send email to me at scripter@microsoft.com, or post your questions on the Official Scripting Guys Forum. See you tomorrow. Until then, peace.

Ed Wilson, Microsoft Scripting Guy

Your comment has been posted.   Close
Thank you, your comment requires moderation so it may take a while to appear.   Close
Leave a Comment
  • Where are you removing the profiles from the registry?  We have found in our environment that in Windows 7 if you remove the profile folder without removing it from the registry, it logs the user in with a temporary profile.

  • Now you also need to remove the profiles from the registry, and, if the profiles are local, from the local user account database.

  • I agree with ceasley; it has broken logic for Windows 7\8. If you don't remove registry entries for the users then will not be able to logon with a maintainable profile.

    Also remove-item even with -force is very fragile and often breaks if you use to remove profile folders. This is usually caused by +260 character limit been exceeded - often from web passed files Flash etc. are common.

    I personally prefer to use invoke-command to call cmd's rd function. Even this often doesn't remove the folder at the first attempt so it is best to use test-path to check; then run the removal code again to finally delete the folder. Strangely I have never found it it takes more that 2 calls to remove the folder this way.

    Pete

  • There are two reliable ways to remove profiles remotely

    Win32_UserProfile.Delete()  (Vista and later)

    AND

    DELPROF (pre-Vista)

    These both use the internal API to remove the profiles.  The removal is done in the system context and makes all safety checks before attempting to remove the profile.  If a profile is hung in the registry manual methods will corrupt the profile and make it impossible to remove without a restart.  

    Win32_UserProfile can also change the owner of a profile.  This is useful if you want to rename a folder that has become decorated due to a collision or hung registry hive.

    There is really no need to clean the registry on a properly working system.  The registry entry is only a hint.  If the registry entry points nowhere the profile will be regenerated or a temporary profile will be generated which is what you will see when a profile has become corrupted due to a failed attempt to do a manual deletion or due to bad security settings on a roaming profile folder share root folder.

  • Everyone,

    Wow!  What a response!  This is what I love about tech communities.  It seems that while my script works, It has a disconnect in windows 7/8 as far as the registry is concerned.  Kudos to the catch!  User profiles are listed in:

    HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\WindowsNT\CurrentVersion\ProfileList

    This key is present in both Version 5 and Version 6 of windows, but as you have stated, it does cause an issue in version 6 of windows if you remove the actual profile folder without removing the key you will run into the temporary profile issue that has been brought up by ceasley, Ian, and Pete Gomersall.  

    The best solution That has been presented to me is by JRV, that is using the

    Win32_UserProfile.Delete()  (Vista and later)

    AND

    DELPROF (pre-Vista)

    I will always stand by my statement “There is always a better way” and you have proven me right.  As soon as I update the script with the suggestions, I will update it here.

    Thank You,

    Bob Stevens

  • Unfortunately there is no such method - Win32_UserProfile.Delete(). The only method for this class is ChangeOwner?

  • "Pete Gomersall 10 Jun 2013 8:17 AM

    Unfortunately there is no such method - Win32_UserProfile.Delete(). The only method for this class is ChangeOwner?"

    While this does not exist for Windows XP, I have not been able to verify for Windows vista and up.  As soon as I do I will post a comprehensive update to the script.  If you have any suggestions, I would be happy to discuss them!

    Thank You,

    Bob Stevens

  • @Bob -

    Profile management comes up frequently for some reason.  Here are some things learned over a number of years fixing damage done by Cowboy Admins.

    It is perfectly OK to delete a profile on all versions of Windows if the profile is a roaming profile.  It will be regenerated on next login from the roamed copy.  Deleting the registry key will have no effect on this behavior.

    Local profiles may behave differently on Vista and later and the key existence can affect this. It doesn't always and I have not yet tracked down why.

    The bigger issue for me is orphaned profiles.  If a profile becomes locked due to a hanging registry hive a second logon can cause a new profile to be generated.  This can start a cascade of issues.  Currently I just manually delete profiles and keys.  If the profile is roamed I validate the master profile with CProfile.

    I strongly recommend using redirected folders for ALL re-directable folders.  This can prevent a huge amount of profile issues and will help you when it is time to migrate to a new platform.  Usually this improves performance and reliability.

    On Windows 7 and later we have been using  mostly local profiles with all folders redirected.  On a Terminal Server I roam the profile and set GP to delete the local copy.  This reduces some demand on resources and assures a clean profile as long as we check the event log for failures.

    Many of these things have helped to avoid profile maintenance on larger systems.

  • @Bob

    Delete exists but is poorly documented.  Somewhere is a KB recommending that it is the replacement for DELPROF.  I have used it since Vista.

    We can see the 'Delete' method with PowerShell easily.

    PS C:\scripts> $profile=gwmi win32_userprofile -filter 'SID="S-1-5-21-714214563-99999999-2839987549-1001"'

    PS C:\scripts> $profile.Delete

    OverloadDefinitions

    -------------------

    void Delete()

    void Delete(System.Management.DeleteOptions options)

    void Delete(System.Management.ManagementOperationObserver watcher)

    void Delete(System.Management.ManagementOperationObserver watcher, System.Management.DeleteOptions options)

    Note the 'DeleteOptions'

    PS C:\scripts> $opt=New-Object System.Management.DeleteOptions

    PS C:\scripts> $opt

    Context                                                     Timeout

    -------                                                     -------

    {}                                                          10675199.02:48:05.4775807

    You do not need to use this unless you want to timeout the operation.  A delete can take some time if the profile is large.

  • @JRV you are correct. The UserProfileProvider in Vista (and above) is updated to support instance delete. Here is a KB article that talks about this. support.microsoft.com/.../930955

  • @Bob

    Hah! You found it before I did.

    Now we just have to figure out why Microsoft refuses to updated the WMI documentation...???!!???

  • @bob @jrv,

    Well that discounts the lengthy regedit script I was working on for this (Headache and a half).  Now to figure out how to implement it within the above script effectively.

    I suspect the best place to put it is:

    Do {

     Remove-Item -force -recurse $over30dayprofiles[$i]

                      (Begin If statement for version 6 HERE<---------------------------------------------

                     (Put Win32_UserProfile associated code HERE) <-----------------------------------

                       (End If statement for version 6 HERE<----------------------------------------------

    $i++

    }

  • @Bob Stevens- sometimes good things come with big headaches.  Sorry about that.

  • @jrv - I stand up and salute you; obviously we can't believe everything Microsoft says. Hat's off to you for pursuing the problem.

    All have checked out the method works doing tests on a range of profiles - however it doesn't always. In some case the users profile permissions become corrupted and the script removes the registry settings and all the files it can then errors. I believe if you run the script as SYSTEM it would have greater chance of success, however I can't find a profile to test on with broken permissions to verify against.

  • @Pete - the code runs in the highest context.  If permissions are corrupted it is either disk corruption, A rogue program or a bad source profile if roamed.  There is no standard fix for this.  You need to fix each one manually.  I have a set of scripts that can be run but in many cases I have to do it manually and do a 'Safe Boot' to succeed.