Conversation
Shared Windows devices accumulate C:\Users\<folder> entries for Entra ID
sign-ins whose local cached account gets removed (via password rotation,
OS reset, policy change, etc.) but whose profile folder and ProfileList
registry entry survive. Over time these "orphan" profiles consume disk
space and clutter the user list.
This patch extends ManageUsersEngine to sweep them the same way we sweep
regular inactive accounts, reusing the existing per-area/location
deletion policy.
- New StaleProfileInfo model (folder name, path, SID, creation, last
use, whether a ProfileList registry entry exists).
- UserEnumerationService.GetStaleProfiles() enumerates C:\Users and
returns entries that:
* aren't system folders (Public, Default, Default User, All Users),
* aren't in the exclusions list,
* have no matching local account,
joining each with its ProfileList entry (by LocalPath) when available,
and falling back to NTUSER.DAT's last write time when ProfileList
doesn't record a last-use timestamp.
- UserDeletionService.RemoveStaleProfile() kills any lingering procs
owned by the user, removes the ProfileList registry subtree (if a SID
is known), then deletes the profile folder. Honours the -simulate
flag.
- ManageUsersEngine wires it into the run loop alongside the orphaned-
user and hidden-users passes, and applies the same DeletionPolicy
(CreationOnly / LoginAndCreation / never-delete / force-term). Also
drops the "no users to process — exit" early return so stale profiles
still get swept on devices where nobody has logged in recently.
There was a problem hiding this comment.
Pull request overview
This PR adds detection and cleanup of stale (orphaned) Windows user profile folders—commonly left behind by Entra ID cached sign-ins—so they can be removed using the existing area/location DeletionPolicy logic.
Changes:
- Added
StaleProfileInfomodel to represent orphaned profile folder + registry metadata. - Extended
UserEnumerationServiceto enumerateC:\Usersand identify profiles without a matching local account, optionally enriching withProfileListdata. - Wired stale-profile evaluation + deletion into
ManageUsersEngine, and implemented deletion logic inUserDeletionService.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| src/ManageUsers/Services/UserEnumerationService.cs | Enumerates candidate stale profiles under C:\Users, joins with ProfileList, and derives timestamps. |
| src/ManageUsers/Services/UserDeletionService.cs | Adds deletion routine for stale profiles (registry subtree + profile directory). |
| src/ManageUsers/Services/ManageUsersEngine.cs | Integrates stale-profile pass into the main run loop and applies DeletionPolicy rules. |
| src/ManageUsers/Models/StaleProfileInfo.cs | Introduces the model used to pass stale-profile metadata through the pipeline. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Skip excluded users | ||
| if (exclusions.Contains(folderName)) continue; | ||
|
|
||
| // Skip if there's a matching local account | ||
| if (localUserNames.Contains(folderName)) continue; | ||
|
|
There was a problem hiding this comment.
Stale-profile detection currently assumes the profile directory name must exactly match a local username (localUserNames.Contains(folderName)). Windows profile folders can legitimately differ from the account name (e.g., suffixes added on name collisions), which would cause a real local account’s profile to be misclassified as stale and eligible for deletion. Consider determining staleness via ProfileList (SID/LocalPath) and verifying whether that SID corresponds to an existing local account (or comparing against local user SIDs), rather than relying on folder name equality.
| // Find matching registry entry by profile path | ||
| var profileEntry = profiles.Values.FirstOrDefault(p => | ||
| p.LocalPath.Equals(dir, StringComparison.OrdinalIgnoreCase)); | ||
|
|
There was a problem hiding this comment.
ProfileList join uses p.LocalPath.Equals(dir). ProfileImagePath values can be non-normalized (e.g., environment variables, trailing separators, different canonical forms), so this exact comparison can miss an existing ProfileList entry. That would set HasRegistryEntry=false/Sid=null and leave the registry subtree behind during deletion. Consider normalizing/expanding both paths (e.g., ExpandEnvironmentVariables + GetFullPath + trim trailing separators) and/or pre-indexing profiles by normalized LocalPath for lookup.
| try { creationDate = Directory.GetCreationTime(dir); } | ||
| catch { creationDate = DateTime.Now; } |
There was a problem hiding this comment.
When Directory.GetCreationTime(dir) fails, creationDate falls back to DateTime.Now without any warning. That can silently skew age calculations (often preventing deletion) and makes troubleshooting harder. Consider logging a warning (consistent with GetCreationDate()) and/or falling back to a more meaningful timestamp (e.g., Directory.GetLastWriteTime(dir)).
| try { creationDate = Directory.GetCreationTime(dir); } | |
| catch { creationDate = DateTime.Now; } | |
| try | |
| { | |
| creationDate = Directory.GetCreationTime(dir); | |
| } | |
| catch (Exception ex) | |
| { | |
| _log.Warning($"Could not get creation time for profile folder '{dir}': {ex.Message}. Falling back to last write time."); | |
| try | |
| { | |
| creationDate = Directory.GetLastWriteTime(dir); | |
| } | |
| catch (Exception fallbackEx) | |
| { | |
| _log.Warning($"Could not get last write time for profile folder '{dir}': {fallbackEx.Message}. Falling back to current time."); | |
| creationDate = DateTime.Now; | |
| } | |
| } |
| profileList?.DeleteSubKeyTree(profile.Sid, throwOnMissingSubKey: false); | ||
| _log.Info($"Registry entry removed for {profile.FolderName} (SID: {profile.Sid})"); |
There was a problem hiding this comment.
This logs "Registry entry removed" even if OpenSubKey returns null (the DeleteSubKeyTree call is null-conditional). Consider only logging success when profileList is non-null and the deletion call actually ran, otherwise log a warning/error so operators aren’t misled.
| profileList?.DeleteSubKeyTree(profile.Sid, throwOnMissingSubKey: false); | |
| _log.Info($"Registry entry removed for {profile.FolderName} (SID: {profile.Sid})"); | |
| if (profileList != null) | |
| { | |
| profileList.DeleteSubKeyTree(profile.Sid, throwOnMissingSubKey: false); | |
| _log.Info($"Registry entry removed for {profile.FolderName} (SID: {profile.Sid})"); | |
| } | |
| else | |
| { | |
| _log.Warning($"Could not open ProfileList registry key; registry entry was not removed for {profile.FolderName} (SID: {profile.Sid})"); | |
| } |
| /// A profile folder in C:\Users with no corresponding local user account. | ||
| /// Typically an Entra ID cached profile left behind after the user stopped logging in. | ||
| /// </summary> | ||
| public sealed class StaleProfileInfo |
There was a problem hiding this comment.
PR description calls this a "record", but the implementation is a mutable class. If value semantics/with-expressions were intended, consider switching to a record (or update the PR description if a class is intentional).
Five fixes from Copilot review of the stale profile sweep: 1. StaleProfileInfo: sealed class -> sealed record. Caller treats instances as value-ish (no mutation, no reference identity) and PR description called it a "record", so make it one. 2. GetStaleProfiles: switch from folder-name matching to SID-based local-user matching. Previously, a profile folder whose name didn't exactly match a local user name (e.g. collision suffix "jsmith.ECU" for account "jsmith") could be misclassified as stale and eligible for deletion. Now we resolve every local account to a SID, walk ProfileList, and collect the LocalPath of any entry whose SID is a real local account. Paths in that set are skipped regardless of folder-name shape. The old folder-name check is preserved as a fallback for brand-new accounts that don't have a ProfileList entry yet. 3. ProfileList join: normalize both sides before comparing. ProfileImagePath values can contain unexpanded env vars, trailing separators, or non-canonical forms, which made p.LocalPath.Equals(dir) miss legitimate matches. Added a NormalizeProfilePath helper (ExpandEnvironmentVariables + GetFullPath + trim trailing separators) and pre-index ProfileList entries by the normalized path for O(1) lookup during the scan. 4. GetCreationTime failure: log + try GetLastWriteTime before DateTime.Now. The previous silent fallback to DateTime.Now could prevent deletion (an infinitely "young" profile never crosses the policy threshold) or hide an underlying FS / ACL problem. Now logs a warning, tries LastWriteTime, and only as a last resort uses DateTime.Now with a second warning. 5. UserDeletionService.RemoveStaleProfile: only log "Registry entry removed" after OpenSubKey actually succeeded. The previous code used profileList?.DeleteSubKeyTree(...) + an unconditional success log, so a null-return from OpenSubKey would silently skip deletion while still logging success. Split into an explicit if/else with a warning branch so operators see when the key couldn't be opened at all.
Summary
Shared Windows devices accumulate
C:\Users\<folder>entries for Entra IDsign-ins whose local cached account gets removed (via password rotation,
OS reset, policy change, etc.) but whose profile folder and ProfileList
registry entry survive. Over time these "orphan" profiles consume disk
space and clutter the user list.
This PR extends
ManageUsersEngineto sweep them the same way we sweepregular inactive accounts, reusing the existing per-area/location
DeletionPolicyso there's no second knob to tune.Changes
Models/StaleProfileInfo.cs— new record carrying folder name,profile path, SID (when known), creation date, last-use time, and
whether a
ProfileListregistry entry exists.UserEnumerationService.GetStaleProfiles(exclusions)enumeratesC:\Usersand returns entries that:Public,Default,Default User,All Users),Each entry is joined with its
HKLM\...\ProfileListentry (byLocalPath) when available, and falls back toNTUSER.DAT's lastwrite time when ProfileList doesn't record a
LastUseTime.UserDeletionService.RemoveStaleProfile(profile)kills anylingering procs owned by the user, removes the
ProfileListregistrysubtree (if a SID is known), then deletes the profile folder. Honours
the existing
-simulateflag.ManageUsersEnginewires stale-profile cleanup into the run loopalongside the orphaned-user and hidden-users passes, and applies the
same
DeletionPolicybranches (CreationOnly,LoginAndCreation,never-delete, force-term). Also drops the "no users to process —
exit" early return so stale profiles still get swept on devices
where nobody has logged in recently.
Test plan
manageusers.exe -simulate -verbose— verify discovery logs name,path, creation, last-use, registry-hit for each candidate, and
the DeletionPolicy branch output.
ProfileListentry and theC:\Users\<folder>directory are both removed cleanly anddeletedCountin the run summary bumps.regardless of age.