Repository navigation
Selection Logic
This page describes exactly which accounts and profiles ManageUsers deletes, what
timestamps it uses, and where those timestamps come from. Everything here is
taken from PolicyService.cs, UserEnumerationService.cs and
ManageUsersEngine.cs.
If you read only one thing before deploying, read this page and Exclusions.
A single run performs four passes. They have different rules, and only two of them respect the retention threshold.
| # | Pass | Target | Retention threshold applies? |
|---|---|---|---|
| 1 | Account retention | Enabled local accounts | Yes |
| 2 | Orphan cleanup | Local accounts with no profile | No — deleted immediately |
| 3 | Stale profile cleanup | Profile folders with no local account | Yes |
| 4 | Corrupt profile remediation | Broken ProfileList / profile state |
No — deleted immediately |
A fifth pass clears orphaned recycle bins; it deletes no accounts. See Architecture.
The device's retention policy is chosen once per run, before any pass, from
Inventory.yaml and the rules in Config.yaml.
| Policy input | Read from | Inventory key |
|---|---|---|
area |
Inventory.yaml |
area |
room |
Inventory.yaml |
location |
usage |
Inventory.yaml |
usage |
Note the mismatch: the match criterion is called room but it is compared
against the inventory field location. All three values are trimmed before
matching.
If Inventory.yaml is missing, a warning is logged and all three values are
empty strings — which means no rule with a match: criterion can match, and the
device falls through to default_policy.
Rules in policies: are evaluated in file order, first match wins. For each
rule:
- Each criterion (
area,room,usage) that is present and non-blank is treated as a .NET regular expression, matched case-insensitively withRegex.IsMatchagainst the corresponding inventory value. - This is a substring/unanchored match.
area: "Lab"matches the inventory valueChemistry Lab 3. Anchor with^...$if you mean the whole value. - Criteria that are absent or blank are ignored.
- All specified criteria must match — the combination is AND, not OR.
- A rule with
match:empty (no criteria at all) never matches anything. It is not a catch-all; usedefault_policyfor that.
The project README states that
areaandroomare OR'd. The code implements AND (PolicyService.Matches). The code is what runs.
Because the criteria are regexes, characters like ., |, (, ), +, *
and ? are operators. location: "A200" matched against room: "A2.0" will
match. Escape literals you do not intend as metacharacters.
The matched rule (or default_policy) yields three values:
| Field | Meaning |
|---|---|
duration_days |
The retention threshold in days. -1 (any negative value) means never delete. |
strategy |
creation_only or login_and_creation. Any unrecognised string falls back to login_and_creation. |
force_at_end_of_term |
If the matched rule sets this and today is an end-of-term date, the policy becomes an unconditional delete-everything. |
--force overrides all of the above: it produces duration_days: 0,
strategy: login_and_creation, and since every age is >= 0 days, every
non-excluded account is eligible. See CLI Reference.
end_of_term_dates: is a list of month/day pairs. The check is:
today.Month == term.Month AND today.Day >= term.Day
So month: 12, day: 31 is true only on 31 December. month: 4, day: 30 is true
on 30 April only (April has no 31st). A date like month: 8, day: 25 would be
true on 25–31 August, every year.
End of term only forces deletion when the matched rule has
force_at_end_of_term: true. Two consequences worth knowing:
-
force_at_end_of_termondefault_policy:is parsed but never acted on. The end-of-term branch exists only inside the rule-matching loop. A device that falls through to the default policy will never force-delete. - When the forced branch fires, it deletes every non-excluded account and every stale profile on the device, with no age check at all, on every run for as long as the date condition holds.
UserEnumerationService.GetUserSessions builds the candidate list:
-
NetUserEnum(server=null, level=1, filter=FILTER_NORMAL_ACCOUNT)— local normal user accounts. Machine, trust and domain-controller accounts are filtered out by the API. Domain accounts are not enumerated; this is a local-SAM operation. -
Disabled accounts are skipped entirely (
UF_ACCOUNTDISABLE). Disabling an account is therefore an effective, if blunt, way to protect it from pass 1 — but see the warning about pass 2 below. - Accounts in the effective exclusion set are skipped.
- The SID is resolved (
LookupAccountName). If the SID cannot be resolved the account is skipped with a warning. - If administrator protection is on (the default) and the SID is a member of the
local Administrators group, the account is skipped — unless it is named in
deletable_admins.
| Signal | Source | Notes |
|---|---|---|
| Creation date |
Directory.GetCreationTime() on the account's profile folder
|
The folder is ProfileImagePath from ProfileList if a profile entry exists, otherwise C:\Users\<username>. If neither directory exists, the value falls back to now — the account then looks 0 days old and pass 1 will never delete it. |
| Last login |
LocalProfileLoadTimeHigh / LocalProfileLoadTimeLow under HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\<SID>, combined into a FILETIME |
This is the last time the profile was loaded, not a SAM lastLogon value and not a logoff time. If the values are absent or zero, last login is null = "never". |
Two things follow from this that matter operationally:
- The account creation date in the SAM is not used. The proxy is the profile folder's NTFS creation timestamp. Re-imaging, profile migration, folder copies and restore-from-backup all move this date.
-
A never-logged-in account reads as infinitely stale.
LastLogin == nullis evaluated asTimeSpan.MaxValue, so underlogin_and_creationthe login half of the test always passes for such an account; only the creation age decides.
The profile matching in this pass is a suffix comparison: a ProfileList entry
is matched to an account when its ProfileImagePath ends with \<username>.
Collision-suffixed folders (jsmith.MACHINENAME) do not match that test, so such
an account is treated as having no profile — which puts it in scope for pass 2.
strategy: creation_only
delete when (now - creationDate) >= duration_days
Login activity is ignored completely. An account created 40 days ago is deleted under a 30-day policy even if someone used it this morning.
strategy: login_and_creation (the default)
delete when (now - creationDate) >= duration_days
AND (now - lastProfileLoad) >= duration_days
Both conditions must hold. A never-logged-in account satisfies the second condition automatically.
duration_days: -1 short-circuits both: nothing in pass 1 or pass 3 is deleted,
logged as Never-delete policy.
Comparisons use >=, in local machine time (DateTime.Now), against whole
TimeSpan days.
For each account selected, in order:
- If the account has an Active session at the console, the deletion is
deferred — the name is appended to
DeferredDeletesinSessions.yamland retried at the start of the next run. Nothing is destroyed. -
taskkill /f /fi "USERNAME eq <MACHINE>\<user>". -
logoff <session id>for any session listed byquserfor that name. - A cached-credential step that currently only enumerates
HKLM\SOFTWARE\Microsoft\IdentityStore\Cacheand logs; it deletes nothing. - A BitLocker step that runs
manage-bde -status C:and logs; it removes no key protectors. - The SID and
ProfileImagePathare resolved while the account still exists (viaNTAccount.Translate, not by string-matching profile paths). - The account's per-SID recycle bin is cleared on every fixed volume.
-
net user "<name>" /delete. Success is detected by the stringsuccessfullyappearing in the command output. If the account removal fails, the profile is left alone and the run continues. - The profile is removed:
DeleteProfileWwhen a SID is known and the hive is not loaded, otherwise a manual recursive folder delete followed by removal of theProfileList\<SID>key. - Verification:
net user "<name>"must fail, and the profile directory must be gone. Failure is logged asUSER_DELETE_FAILED.
This is the pass to understand before you deploy.
FindOrphanedUsers walks the local accounts again and flags any account for
which no ProfileList entry has a ProfileImagePath ending in \<username>.
Those accounts are passed straight to net user /delete — with no age
evaluation, no strategy, and no duration_days check whatsoever.
Differences from pass 1 that make this sharper than it looks:
- Disabled accounts are not skipped here. Pass 1 skips them; pass 2 does not enumerate the disabled flag at all. A disabled local account with no profile is deleted.
- The retention policy is irrelevant, including
duration_days: -1. - Exclusions and administrator protection do still apply — they are the only things standing between a profile-less account and immediate removal.
Accounts that legitimately have no profile include:
- Service and automation accounts that only ever authenticate non-interactively
(SSH, scheduled tasks, build agents). This is what
delete_admins: falseand the exclusion list exist to protect. - Accounts created but not yet logged into for the first time — provisioning that creates an account today and expects a first logon next week will find the account gone.
- Accounts whose profile folder carries a collision suffix
(
user.MACHINENAME), because the suffix defeats the\<username>suffix match.
Put every such account in exclusions: before the first live run.
GetStaleProfiles finds profile folders that no longer belong to any local
account — on shared devices these are overwhelmingly Entra ID cached profiles
(S-1-12-1-…).
The join is deliberately conservative:
- Resolve the SID of every local account, enabled or disabled.
- Read
ProfileList(only SIDs startingS-1-5-21-orS-1-12-1-) and collect the normalizedProfileImagePathof every entry whose SID belongs to a local account. Normalization expands environment variables, resolves to a full path, and trims trailing separators. - Enumerate the directories in
C:\Usersand skip:- the system folders
Public,Default,Default User,All Users; - any folder whose name is in the exclusion set;
- any folder whose normalized path is owned by a local account (step 2);
- any folder whose name equals a local account name — a fallback for accounts
that have no
ProfileListentry yet.
- the system folders
- Everything left is a stale candidate.
Timestamps for stale profiles:
| Signal | Source |
|---|---|
| Creation date |
Directory.GetCreationTime() on the folder; on failure, GetLastWriteTime; on failure again, now (logged as possibly inaccurate) |
| Last use |
LocalProfileLoadTime from the ProfileList entry if one exists, otherwise the newest LastWriteTime of any entry directly inside the folder, excluding files whose name begins with ntuser.dat or usrclass.dat
|
The hive exclusion matters: registry hives and their .LOG1, .LOG2,
.regtrans-ms and TMContainer siblings are touched by Windows Search, the User
Profile Service and AV scans, which would otherwise make every stale profile look
like it was used today.
Note the last-use scan is not recursive — it looks at the immediate children
of the profile folder only. A profile whose only recent activity is deep inside
AppData can read as older than it is.
The same EvaluateStaleProfile thresholds apply as in pass 1, with LastUseTime
in place of LastLogin.
Deletion of a stale profile: if the SID's hive is loaded under HKEY_USERS the
profile is skipped (it is in use); otherwise processes are killed, the recycle
bin is cleared, and DeleteProfileW runs — falling back to manual folder +
registry removal.
GetCorruptProfiles looks for two states, in ProfileList entries not owned by
a local account:
| Condition | Logged reason |
|---|---|
ProfileImagePath folder does not exist |
dangling ProfileList entry (profile folder missing) |
Folder exists but has no NTUSER.DAT
|
profile folder is missing NTUSER.DAT (partial delete) |
Both states break the next logon for that SID — Windows hands out a temporary
profile. They are therefore remediated regardless of duration_days,
including under a never-delete policy.
One safety valve: if the folder still exists and its name is in the exclusion set, the profile is not touched; a warning asks for manual review. A dangling entry with no folder is removed even for an excluded name, because there is nothing left to preserve.
Deleting a Windows profile does not delete what that user sent to the recycle
bin: those files live in <volume>:\$Recycle.Bin\<SID> on the root of every
fixed volume, outside the profile.
- When ManageUsers removes an account or a stale profile, it clears that SID's bin on every fixed volume first, while the SID still resolves to a name for the audit log.
- At the end of the run,
SweepOrphanedclears any$Recycle.Bin\S-1-…folder whose SID is not present inProfileListand whose hive is not loaded. - If
ProfileListcannot be read the sweep is skipped entirely rather than treating every bin as orphaned. - Only the contents are deleted. The SID folder itself and its
desktop.iniare left in place, because Windows owns them.
Be explicit with yourself about the residue. After a successful deletion the following are not removed by ManageUsers:
| Left behind | Detail |
|---|---|
| Scheduled tasks owned by the user | Nothing enumerates or removes per-user tasks. Tasks registered under the deleted account's name remain, and will fail to run. |
| Cached credentials / IdentityStore entries | The credential step enumerates and logs only. Nothing is deleted. |
| BitLocker key protectors | The BitLocker step runs manage-bde -status C: and logs. No protector is removed. |
SpecialAccounts\UserList entries |
Login-screen hide entries are only ever added, never removed. |
| Locked files inside a profile | Individual UnauthorizedAccessException / IOException failures are swallowed so one file cannot abort the tree — the folder can survive as a partial remnant. USER_DELETE_FAILED / STALE_PROFILE_REMOVE_FAILED is written when it does. |
The $Recycle.Bin\<SID> folder itself |
Only its contents are cleared. |
| Anything on network shares or removable/non-fixed volumes | Recycle-bin enumeration is limited to DriveType.Fixed. |
| Directory objects | No Entra ID / AD object is ever touched. |
Device inventory:
area: "Open Lab"
location: "B120"
usage: "Shared"Config:
policies:
- name: "Kiosks"
match:
area: "Kiosk"
duration_days: 2
strategy: creation_only
- name: "Open labs"
match:
area: "Lab"
usage: "Shared"
duration_days: 30
strategy: login_and_creation
default_policy:
duration_days: 28
strategy: login_and_creationKiosks does not match (Kiosk is not found in Open Lab). Open labs
matches: Lab is a substring of Open Lab, and Shared matches usage. Both
specified criteria match, so the policy is 30 days, login_and_creation.
| Account | Profile created | Last profile load | Outcome |
|---|---|---|---|
student1 |
45 days ago | 3 days ago | Kept — login age 3 < 30 |
student2 |
45 days ago | 40 days ago | Deleted — both ages ≥ 30 |
student3 |
10 days ago | never | Kept — creation age 10 < 30 |
student4 |
60 days ago | never | Deleted — never-logged-in counts as infinitely stale |
svc-backup |
n/a, no profile | n/a | Deleted by the orphan pass unless excluded |
svc-ssh (local admin, no profile) |
n/a | n/a | Kept — administrator protection |
- Exclusions — the only thing that stops any of the above
- Safe Deployment — how to roll this out without losing data
- Configuration — every key and default
ManageUsers — local account and profile retention for shared Windows devices · source repository · see the repository for license terms
Before you deploy
Reference
Internals
Help
This tool deletes user
accounts and profiles.
Run --simulate first.