Skip to content

Selection Logic

Rod Christiansen edited this page Sep 3, 2026 · 1 revision

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.

Four independent deletion passes

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.

Choosing the policy

The device's retention policy is chosen once per run, before any pass, from Inventory.yaml and the rules in Config.yaml.

Inputs

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.

Rule matching

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 with Regex.IsMatch against the corresponding inventory value.
  • This is a substring/unanchored match. area: "Lab" matches the inventory value Chemistry 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; use default_policy for that.

The project README states that area and room are 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 resulting policy

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

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_term on default_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.

Pass 1 — account retention

Which accounts are even considered

UserEnumerationService.GetUserSessions builds the candidate list:

  1. 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.
  2. 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.
  3. Accounts in the effective exclusion set are skipped.
  4. The SID is resolved (LookupAccountName). If the SID cannot be resolved the account is skipped with a warning.
  5. 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.

The timestamps

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 == null is evaluated as TimeSpan.MaxValue, so under login_and_creation the 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.

The decision

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.

What deletion does

For each account selected, in order:

  1. If the account has an Active session at the console, the deletion is deferred — the name is appended to DeferredDeletes in Sessions.yaml and retried at the start of the next run. Nothing is destroyed.
  2. taskkill /f /fi "USERNAME eq <MACHINE>\<user>".
  3. logoff <session id> for any session listed by quser for that name.
  4. A cached-credential step that currently only enumerates HKLM\SOFTWARE\Microsoft\IdentityStore\Cache and logs; it deletes nothing.
  5. A BitLocker step that runs manage-bde -status C: and logs; it removes no key protectors.
  6. The SID and ProfileImagePath are resolved while the account still exists (via NTAccount.Translate, not by string-matching profile paths).
  7. The account's per-SID recycle bin is cleared on every fixed volume.
  8. net user "<name>" /delete. Success is detected by the string successfully appearing in the command output. If the account removal fails, the profile is left alone and the run continues.
  9. The profile is removed: DeleteProfileW when a SID is known and the hive is not loaded, otherwise a manual recursive folder delete followed by removal of the ProfileList\<SID> key.
  10. Verification: net user "<name>" must fail, and the profile directory must be gone. Failure is logged as USER_DELETE_FAILED.

The orphan pass (no age check)

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: false and 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.

Pass 3 — stale profiles

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:

  1. Resolve the SID of every local account, enabled or disabled.
  2. Read ProfileList (only SIDs starting S-1-5-21- or S-1-12-1-) and collect the normalized ProfileImagePath of every entry whose SID belongs to a local account. Normalization expands environment variables, resolves to a full path, and trims trailing separators.
  3. Enumerate the directories in C:\Users and 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 ProfileList entry yet.
  4. 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.

Pass 4 — corrupt profiles (no age check)

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.

Recycle bins

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, SweepOrphaned clears any $Recycle.Bin\S-1-… folder whose SID is not present in ProfileList and whose hive is not loaded.
  • If ProfileList cannot 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.ini are left in place, because Windows owns them.

What is NOT cleaned up

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.

Worked example

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_creation

Kiosks 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

See also

ManageUsers

Home

Before you deploy

Reference

Internals

Help


This tool deletes user accounts and profiles. Run --simulate first.

Clone this wiki locally