Skip to content

Add stale Entra profile cleanup#2

Open
rodchristiansen wants to merge 2 commits intomainfrom
feat/stale-entra-profile-cleanup
Open

Add stale Entra profile cleanup#2
rodchristiansen wants to merge 2 commits intomainfrom
feat/stale-entra-profile-cleanup

Conversation

@rodchristiansen
Copy link
Copy Markdown
Contributor

Summary

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 PR extends ManageUsersEngine to sweep them the same way we sweep
regular inactive accounts, reusing the existing per-area/location
DeletionPolicy so 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 ProfileList registry entry exists.

  • UserEnumerationService.GetStaleProfiles(exclusions) 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.

    Each entry is joined with its HKLM\...\ProfileList entry (by
    LocalPath) when available, and falls back to NTUSER.DAT's last
    write time when ProfileList doesn't record a LastUseTime.

  • UserDeletionService.RemoveStaleProfile(profile) 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 existing -simulate flag.

  • ManageUsersEngine wires stale-profile cleanup into the run loop
    alongside the orphaned-user and hidden-users passes, and applies the
    same DeletionPolicy branches (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

  • Simulate mode against a test box with real stale profiles —
    manageusers.exe -simulate -verbose — verify discovery logs name,
    path, creation, last-use, registry-hit for each candidate, and
    the DeletionPolicy branch output.
  • Real mode (non-simulate) — verify ProfileList entry and the
    C:\Users\<folder> directory are both removed cleanly and
    deletedCount in the run summary bumps.
  • Never-delete area — confirm no stale profiles get touched.
  • End-of-term force-delete — confirm all stale profiles get swept
    regardless of age.
  • Exclusions — confirm excluded folder names are skipped.

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.
Copilot AI review requested due to automatic review settings April 9, 2026 18:36
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 StaleProfileInfo model to represent orphaned profile folder + registry metadata.
  • Extended UserEnumerationService to enumerate C:\Users and identify profiles without a matching local account, optionally enriching with ProfileList data.
  • Wired stale-profile evaluation + deletion into ManageUsersEngine, and implemented deletion logic in UserDeletionService.

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.

Comment on lines +90 to +95
// Skip excluded users
if (exclusions.Contains(folderName)) continue;

// Skip if there's a matching local account
if (localUserNames.Contains(folderName)) continue;

Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +96 to +99
// Find matching registry entry by profile path
var profileEntry = profiles.Values.FirstOrDefault(p =>
p.LocalPath.Equals(dir, StringComparison.OrdinalIgnoreCase));

Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +101 to +102
try { creationDate = Directory.GetCreationTime(dir); }
catch { creationDate = DateTime.Now; }
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)).

Suggested change
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;
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +132 to +133
profileList?.DeleteSubKeyTree(profile.Sid, throwOnMissingSubKey: false);
_log.Info($"Registry entry removed for {profile.FolderName} (SID: {profile.Sid})");
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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})");
}

Copilot uses AI. Check for mistakes.
/// 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
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants