diff --git a/src/ManageUsers/Models/StaleProfileInfo.cs b/src/ManageUsers/Models/StaleProfileInfo.cs new file mode 100644 index 0000000..fb8ed9c --- /dev/null +++ b/src/ManageUsers/Models/StaleProfileInfo.cs @@ -0,0 +1,15 @@ +namespace ManageUsers.Models; + +/// +/// 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. +/// +public sealed record StaleProfileInfo +{ + public required string FolderName { get; init; } + public required string ProfilePath { get; init; } + public string? Sid { get; init; } + public DateTime CreationDate { get; init; } + public DateTime? LastUseTime { get; init; } + public bool HasRegistryEntry { get; init; } +} diff --git a/src/ManageUsers/Services/ManageUsersEngine.cs b/src/ManageUsers/Services/ManageUsersEngine.cs index 445ea09..e884d09 100644 --- a/src/ManageUsers/Services/ManageUsersEngine.cs +++ b/src/ManageUsers/Services/ManageUsersEngine.cs @@ -55,12 +55,6 @@ public int Run() var users = _enum.GetUserSessions(exclusions); _log.Info($"Found {users.Count} non-excluded user(s) to evaluate"); - if (users.Count == 0) - { - _log.Info("No users to process — exiting"); - return 0; - } - // Repair user states _repair.RepairUserStates(users); @@ -94,6 +88,21 @@ public int Run() deletedCount += orphans.Count; } + // Clean up stale Entra/cached profiles (no local account) + var staleProfiles = _enum.GetStaleProfiles(exclusions); + if (staleProfiles.Count > 0) + { + _log.Info($"Found {staleProfiles.Count} stale profile(s) with no local account"); + foreach (var profile in staleProfiles) + { + if (EvaluateStaleProfile(profile, policy, now)) + { + if (_delete.RemoveStaleProfile(profile)) + deletedCount++; + } + } + } + // Update hidden users on login screen _repair.UpdateHiddenUsers(exclusions); @@ -164,4 +173,53 @@ private bool EvaluateUser(UserSessionInfo user, DeletionPolicy policy, DateTime return false; } } + + private bool EvaluateStaleProfile(StaleProfileInfo profile, DeletionPolicy policy, DateTime now) + { + if (policy.ForceTermDeletion) + { + _log.Info($"End-of-term force deletion: stale profile {profile.FolderName}"); + return true; + } + + if (policy.DurationDays < 0) + { + _log.Info($"Never-delete policy: stale profile {profile.FolderName} — keep"); + return false; + } + + var threshold = TimeSpan.FromDays(policy.DurationDays); + + switch (policy.Strategy) + { + case DeletionStrategy.CreationOnly: + { + var age = now - profile.CreationDate; + if (age >= threshold) + { + _log.Info($"CreationOnly: stale profile {profile.FolderName} created {age.Days}d ago (threshold {policy.DurationDays}d) — DELETE"); + return true; + } + _log.Info($"CreationOnly: stale profile {profile.FolderName} created {age.Days}d ago (threshold {policy.DurationDays}d) — keep"); + return false; + } + + case DeletionStrategy.LoginAndCreation: + { + var creationAge = now - profile.CreationDate; + var lastUseAge = profile.LastUseTime.HasValue ? now - profile.LastUseTime.Value : TimeSpan.MaxValue; + + if (creationAge >= threshold && lastUseAge >= threshold) + { + _log.Info($"LoginAndCreation: stale profile {profile.FolderName} created {creationAge.Days}d ago, last use {(profile.LastUseTime.HasValue ? $"{lastUseAge.Days}d ago" : "never")} (threshold {policy.DurationDays}d) — DELETE"); + return true; + } + _log.Info($"LoginAndCreation: stale profile {profile.FolderName} created {creationAge.Days}d ago, last use {(profile.LastUseTime.HasValue ? $"{lastUseAge.Days}d ago" : "never")} (threshold {policy.DurationDays}d) — keep"); + return false; + } + + default: + return false; + } + } } diff --git a/src/ManageUsers/Services/UserDeletionService.cs b/src/ManageUsers/Services/UserDeletionService.cs index ed2a86e..5f8ff26 100644 --- a/src/ManageUsers/Services/UserDeletionService.cs +++ b/src/ManageUsers/Services/UserDeletionService.cs @@ -106,6 +106,64 @@ public void RemoveOrphanedUsers(List orphans, SessionsData sessions) } } + /// + /// Remove a stale Entra/cached profile — no local account exists, just registry + folder. + /// + public bool RemoveStaleProfile(StaleProfileInfo profile) + { + if (_simulate) + { + _log.Info($"[SIMULATE] Would remove stale profile: {profile.FolderName}"); + return true; + } + + _log.Info($"Removing stale profile: {profile.FolderName}"); + + // Kill any lingering processes owned by this user + KillUserProcesses(profile.FolderName); + + // Remove ProfileList registry entry if present + if (profile.HasRegistryEntry && profile.Sid != null) + { + try + { + using var profileList = Registry.LocalMachine.OpenSubKey( + @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList", writable: true); + 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})"); + } + } + catch (Exception ex) + { + _log.Warning($"Failed to remove registry entry for {profile.FolderName}: {ex.Message}"); + } + } + + // Delete profile folder + if (Directory.Exists(profile.ProfilePath)) + { + try + { + Directory.Delete(profile.ProfilePath, recursive: true); + _log.Info($"Profile directory removed: {profile.ProfilePath}"); + } + catch (Exception ex) + { + _log.Warning($"Failed to remove profile directory {profile.ProfilePath}: {ex.Message}"); + return false; + } + } + + _log.Info($"Successfully removed stale profile: {profile.FolderName}"); + return true; + } + private void DeferDelete(string username, SessionsData sessions) { if (!sessions.DeferredDeletes.Contains(username, StringComparer.OrdinalIgnoreCase)) diff --git a/src/ManageUsers/Services/UserEnumerationService.cs b/src/ManageUsers/Services/UserEnumerationService.cs index 3834fe6..5f27c87 100644 --- a/src/ManageUsers/Services/UserEnumerationService.cs +++ b/src/ManageUsers/Services/UserEnumerationService.cs @@ -64,6 +64,141 @@ public List GetUserSessions(HashSet exclusions) return results; } + /// + /// Finds profile folders in C:\Users that have no corresponding local user account. + /// These are typically Entra ID cached profiles that accumulate on shared devices. + /// + /// Matching strategy: build the set of SIDs for every local account, then walk + /// ProfileList and collect the LocalPath of any entry whose SID is in that set. + /// Anything in C:\Users not in that path set (and not a system folder / exclusion) + /// is a stale candidate. This avoids the naive foldername-equals-username match + /// which would misclassify collision-suffixed profile directories (e.g. + /// "jsmith.ECU" for local account "jsmith"). + /// + public List GetStaleProfiles(HashSet exclusions) + { + var results = new List(); + + // SIDs of every local account (enabled or disabled — we don't want to delete + // a disabled local user's profile by accident). + var localUsers = EnumerateLocalUsers(); + var localUserSids = new HashSet(StringComparer.OrdinalIgnoreCase); + var localUserNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (name, _) in localUsers) + { + localUserNames.Add(name); + var sid = ResolveSid(name); + if (!string.IsNullOrEmpty(sid)) + localUserSids.Add(sid); + } + + // Pre-index ProfileList by normalized LocalPath so the join below is O(1) + // and tolerant of env-var / trailing-separator variation in ProfileImagePath. + var profiles = LoadProfiles(); + var profilesByNormalizedPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var profile in profiles.Values) + { + var key = NormalizeProfilePath(profile.LocalPath); + if (!string.IsNullOrEmpty(key)) + profilesByNormalizedPath[key] = profile; + } + + // Paths owned by real local accounts — these are NOT stale, even if the + // folder name differs from the account name (collision suffixes, casing, etc). + var localUserProfilePaths = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var profile in profiles.Values) + { + if (localUserSids.Contains(profile.Sid)) + { + var key = NormalizeProfilePath(profile.LocalPath); + if (!string.IsNullOrEmpty(key)) + localUserProfilePaths.Add(key); + } + } + + var usersDir = @"C:\Users"; + if (!Directory.Exists(usersDir)) return results; + + foreach (var dir in Directory.GetDirectories(usersDir)) + { + var folderName = Path.GetFileName(dir); + if (folderName == null) continue; + + // Skip system folders + if (SystemProfileFolders.Contains(folderName)) continue; + + // Skip excluded users + if (exclusions.Contains(folderName)) continue; + + var normalizedDir = NormalizeProfilePath(dir); + + // Skip if this path is owned by a real local account (via ProfileList). + if (localUserProfilePaths.Contains(normalizedDir)) continue; + + // Fallback: folder-name match for local users that have no ProfileList + // entry yet (brand-new accounts that haven't logged in). Safer than + // deleting someone's home dir due to a missing registry join. + if (localUserNames.Contains(folderName)) continue; + + // Look up any ProfileList entry for this directory (helps surface the SID + // for registry cleanup even when no local user owns it). + profilesByNormalizedPath.TryGetValue(normalizedDir, out var profileEntry); + + DateTime creationDate; + 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 '{dir}' either: {fallbackEx.Message}. Using DateTime.Now — policy evaluation may be inaccurate."); + creationDate = DateTime.Now; + } + } + + var lastUseTime = profileEntry?.LastUseTime ?? GetFolderLastActivity(dir); + + results.Add(new StaleProfileInfo + { + FolderName = folderName, + ProfilePath = dir, + Sid = profileEntry?.Sid, + CreationDate = creationDate, + LastUseTime = lastUseTime, + HasRegistryEntry = profileEntry != null + }); + + _log.Info($"Stale profile: {folderName} | Created: {creationDate:yyyy-MM-dd} | LastUse: {lastUseTime?.ToString("yyyy-MM-dd") ?? "unknown"} | Registry: {(profileEntry != null ? "yes" : "no")}"); + } + + return results; + } + + /// + /// Normalize a profile path for comparison: expand env vars, resolve to a + /// canonical full path, and trim trailing separators. Returns empty on failure. + /// + private static string NormalizeProfilePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) return string.Empty; + try + { + var expanded = Environment.ExpandEnvironmentVariables(path); + return Path.GetFullPath(expanded).TrimEnd('\\', '/'); + } + catch + { + return string.Empty; + } + } + public List FindOrphanedUsers(HashSet exclusions) { var orphans = new List(); @@ -270,4 +405,27 @@ private sealed class ProfileInfo public required string LocalPath { get; init; } public DateTime? LastUseTime { get; init; } } + + #region Stale Profile Helpers + + private static readonly HashSet SystemProfileFolders = new(StringComparer.OrdinalIgnoreCase) + { + "Public", "Default", "Default User", "All Users" + }; + + private static DateTime? GetFolderLastActivity(string path) + { + try + { + // NTUSER.DAT last write time is the best proxy for last interactive use + var ntuserDat = Path.Combine(path, "NTUSER.DAT"); + if (File.Exists(ntuserDat)) + return File.GetLastWriteTime(ntuserDat); + + return Directory.GetLastWriteTime(path); + } + catch { return null; } + } + + #endregion }