Every box’s local accounts drift from what they’re supposed to be, one hand-run useradd at a time. This turns “what should exist” into YAML and reconciles the difference — dry-run by default.
Step through the build below:
Local-account management on a fleet of Linux boxes tends to end up as a pile of one-off useradd/usermod commands run by hand, or copy-pasted into a runbook nobody re-reads before running it again. There’s no single place that says what the box’s accounts should look like — only a history of commands someone ran at some point.
This script flips that: a YAML file declares the desired users, groups, shells, lock state, group memberships, and SSH keys, and the script diffs that against what Etc reports is actually on the box — then prints (or, with --apply, executes) exactly the commands needed to close the gap. Nothing else is touched.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# account_provisioner.rb
#
# Idempotent local Linux account provisioning: reads a YAML spec of the
# users and groups a box *should* have, compares it against what actually
# exists (via Etc), and reconciles the difference by shelling out to
# useradd/usermod/userdel/groupadd/gpasswd -- only touching accounts that
# are actually out of spec. Dry-run by default, so you can review a diff
# before anything changes.
#
# This is the "user account management" counterpart to a read-only
# auditor: instead of just reporting that an account is wrong, it can
# fix it -- the same idea as config_state_engine.rb elsewhere in this
# repo, applied specifically to /etc/passwd, /etc/group, and authorized
# SSH keys.
#
# Only the standard library is used: etc, yaml, open3, fileutils.
require 'etc'
require 'yaml'
require 'open3'
require 'fileutils'
require 'optparse'
# --------------------------------------------------------------------------
# Wraps the handful of system calls this script needs so tests can inject a
# fake runner instead of actually calling useradd/usermod/userdel. Kept as
# one small seam rather than stubbing Open3 globally.
# --------------------------------------------------------------------------
class SystemRunner
Result = Struct.new(:success, :stdout, :stderr, keyword_init: true)
def run(*cmd)
stdout, stderr, status = Open3.capture3(*cmd)
Result.new(success: status.success?, stdout: stdout, stderr: stderr)
end
end
# --------------------------------------------------------------------------
# Read-only view of what accounts currently exist. Wraps Etc so it can be
# swapped for a fixture in tests without touching the real system's user
# database.
# --------------------------------------------------------------------------
class SystemState
def user(name)
Etc.getpwnam(name)
rescue ArgumentError
nil
end
def group(name)
Etc.getgrnam(name)
rescue ArgumentError
nil
end
def group_members(name)
grp = group(name)
grp ? grp.mem : []
end
def authorized_keys_path(home)
File.join(home, '.ssh', 'authorized_keys')
end
def authorized_keys(home)
path = authorized_keys_path(home)
File.exist?(path) ? File.read(path).lines.map(&:strip).reject(&:empty?) : []
end
# All accounts currently on the box, as [name, uid] pairs -- used only by
# the (opt-in) unmanaged-account pruning pass.
def all_users
Etc.passwd.map { |pw| [pw.name, pw.uid] }
end
end
# --------------------------------------------------------------------------
# A single planned change. #describe gives the human-readable line used in
# both dry-run output and the real-run log, so the two never drift apart.
# --------------------------------------------------------------------------
Action = Struct.new(:kind, :target, :detail) do
def describe
"#{kind.to_s.upcase.ljust(14)} #{target.to_s.ljust(20)} #{detail}"
end
end
# --------------------------------------------------------------------------
# Core reconciler: spec (desired state) + system (actual state) -> a list
# of Actions, which #apply! then executes (or, in dry-run mode, just
# prints).
# --------------------------------------------------------------------------
class AccountProvisioner
def initialize(spec, system_state: SystemState.new, runner: SystemRunner.new, logger: method(:puts))
@spec = spec
@system = system_state
@runner = runner
@logger = logger
end
# Returns an Array of Action describing every change needed to bring the
# box into line with the spec. Never touches the system.
def plan
actions = []
(@spec['groups'] || []).each { |g| actions.concat(plan_group(g)) }
(@spec['users'] || []).each { |u| actions.concat(plan_user(u)) }
actions.concat(plan_removals) if @spec['prune_unmanaged']
actions
end
# Executes a plan. dry_run: true just logs what *would* happen.
def apply!(actions, dry_run: true)
actions.each do |action|
@logger.call((dry_run ? '[dry-run] ' : '[apply] ') + action.describe)
next if dry_run
execute(action)
end
end
private
def plan_group(spec)
name = spec.fetch('name')
existing = @system.group(name)
return [Action.new(:create_group, name, "gid=#{spec['gid'] || 'auto'}")] unless existing
[]
end
def plan_user(spec)
name = spec.fetch('name')
actions = []
existing = @system.user(name)
state = spec.fetch('state', 'present')
if state == 'absent'
actions << Action.new(:remove_user, name, 'account should not exist') if existing
return actions
end
if existing.nil?
actions << Action.new(:create_user, name, describe_user_spec(spec))
actions.concat(plan_groups_for(name, spec))
actions.concat(plan_ssh_key(name, spec))
return actions
end
actions.concat(plan_user_drift(name, existing, spec))
actions.concat(plan_groups_for(name, spec))
actions.concat(plan_ssh_key(name, spec))
actions
end
def describe_user_spec(spec)
parts = []
parts << "shell=#{spec['shell']}" if spec['shell']
parts << "uid=#{spec['uid']}" if spec['uid']
parts << "comment=#{spec['comment'].inspect}" if spec['comment']
parts.join(' ')
end
def plan_user_drift(name, existing, spec)
actions = []
if spec['shell'] && existing.shell != spec['shell']
actions << Action.new(:modify_shell, name, "#{existing.shell} -> #{spec['shell']}")
end
if spec['comment'] && existing.gecos != spec['comment']
actions << Action.new(:modify_comment, name, "#{existing.gecos.inspect} -> #{spec['comment'].inspect}")
end
if spec.key?('locked')
currently_locked = locked?(name)
if spec['locked'] && !currently_locked
actions << Action.new(:lock, name, 'password login should be disabled')
elsif !spec['locked'] && currently_locked
actions << Action.new(:unlock, name, 'password login should be enabled')
end
end
actions
end
def plan_groups_for(name, spec)
wanted = Array(spec['groups'])
return [] if wanted.empty?
current = wanted.reject { |g| @system.group_members(g).include?(name) }
return [] if current.empty?
[Action.new(:add_to_groups, name, current.join(','))]
end
def plan_ssh_key(name, spec)
return [] unless spec['ssh_authorized_key']
home = spec['home'] || "/home/#{name}"
existing_keys = @system.authorized_keys(home)
wanted_key = spec['ssh_authorized_key'].strip
return [] if existing_keys.include?(wanted_key)
[Action.new(:install_ssh_key, name, "authorized_keys += 1 key (#{wanted_key.split.last(1).first || 'unlabeled'})")]
end
# Accounts that exist under a managed UID range but aren't in the spec at
# all -- only offered when prune_unmanaged is explicitly turned on,
# since silently deleting unlisted accounts is exactly the kind of
# surprise this script is designed to avoid by default.
def plan_removals
managed_names = (@spec['users'] || []).map { |u| u['name'] }
min_uid = @spec.fetch('managed_uid_min', 1000)
max_uid = @spec.fetch('managed_uid_max', 60_000)
@system.all_users.each_with_object([]) do |(name, uid), actions|
next unless uid.between?(min_uid, max_uid)
next if managed_names.include?(name)
actions << Action.new(:remove_user, name, 'unmanaged account in provisioned UID range')
end
end
def locked?(name)
result = @runner.run('passwd', '-S', name)
return false unless result.success
# `passwd -S` second field is L (locked), NP (no password), or P (usable password)
fields = result.stdout.split
fields[1] == 'L'
end
def execute(action)
case action.kind
when :create_group then run!('groupadd', action.target)
when :create_user then create_user(action)
when :remove_user then run!('userdel', '-r', action.target)
when :modify_shell then run!('usermod', '--shell', shell_for(action), action.target)
when :modify_comment then run!('usermod', '--comment', comment_for(action), action.target)
when :lock then run!('usermod', '--lock', action.target)
when :unlock then run!('usermod', '--unlock', action.target)
when :add_to_groups then run!('usermod', '--append', '--groups', action.detail, action.target)
when :install_ssh_key then install_ssh_key(action)
else raise "unknown action kind #{action.kind}"
end
end
# These two pull the value back out of the human-readable detail string
# set during planning, so the plan/apply steps can't drift apart on what
# the actual target value is.
def shell_for(action) = action.detail.split(' -> ').last
def comment_for(action) = action.detail.split(' -> ').last.gsub(/\A"|"\z/, '')
def create_user(action)
spec = @spec['users'].find { |u| u['name'] == action.target }
cmd = ['useradd', '--create-home']
cmd += ['--shell', spec['shell']] if spec['shell']
cmd += ['--uid', spec['uid'].to_s] if spec['uid']
cmd += ['--comment', spec['comment']] if spec['comment']
cmd << action.target
run!(*cmd)
end
def install_ssh_key(action)
spec = @spec['users'].find { |u| u['name'] == action.target }
home = spec['home'] || "/home/#{action.target}"
ssh_dir = File.join(home, '.ssh')
FileUtils.mkdir_p(ssh_dir, mode: 0o700)
path = @system.authorized_keys_path(home)
File.write(path, spec['ssh_authorized_key'].strip + "\n", mode: 'a')
FileUtils.chmod(0o600, path)
FileUtils.chown_R(action.target, action.target, ssh_dir) if Process.uid.zero?
end
def run!(*cmd)
result = @runner.run(*cmd)
@logger.call(" -> FAILED: #{cmd.join(' ')}: #{result.stderr}") unless result.success
result
end
end
# --------------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
options = { apply: false }
OptionParser.new do |opts|
opts.banner = 'Usage: account_provisioner.rb --spec accounts.yml [--apply]'
opts.on('--spec PATH', 'YAML spec of desired users/groups (required)') { |v| options[:spec] = v }
opts.on('--apply', 'Actually make the changes (default: dry-run only)') { options[:apply] = true }
opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }
end.parse!(ARGV)
unless options[:spec]
warn 'error: --spec is required'
exit 4
end
spec = YAML.safe_load_file(options[:spec])
provisioner = AccountProvisioner.new(spec)
actions = provisioner.plan
if actions.empty?
puts 'No drift detected -- system already matches spec.'
exit 0
end
puts "#{actions.size} change(s) needed:"
provisioner.apply!(actions, dry_run: !options[:apply])
if !options[:apply]
puts "\nDry run only -- re-run with --apply to make these changes."
end
exit 0
end
SystemState is the only thing that touches Etc directly — a thin seam so tests can swap in fixture data instead of depending on whatever accounts happen to exist on the box running the test suite.
AccountProvisioner#plan is pure: it walks the spec’s groups then users and returns an array of Action structs describing drift — missing user, wrong shell, wrong lock state, missing group membership, missing SSH key, or (for state: absent) an account that shouldn’t exist at all. It never touches the system.
prune_unmanaged is opt-in and scoped to a UID range on purpose. Silently deleting any account not mentioned in your YAML file is exactly the kind of surprise a provisioning tool should never spring on you by default — so it’s off unless you turn it on, and even then it only looks inside the UID range you give it.
$ ruby account_provisioner.rb --spec example_accounts.yml 5 change(s) needed: [dry-run] CREATE_GROUP deploy gid=auto [dry-run] CREATE_USER svc_deploy shell=/usr/sbin/nologin comment="CI deploy service account" [dry-run] ADD_TO_GROUPS svc_deploy deploy [dry-run] INSTALL_SSH_KEY svc_deploy authorized_keys += 1 key (svc_deploy@ci-runner) [dry-run] CREATE_USER alice shell=/bin/bash comment="Alice Nguyen" Dry run only -- re-run with --apply to make these changes. exit status: 0
Full script + README on GitHub: ruby-devops-toolkit/account-provisioner
What you need
- Ruby >= 3.0 (developed and tested on 3.3.6)
- No gems —
etc,yaml,open3,fileutils,optparseare all standard library - Linux or macOS — anything with
Etc,useradd/usermod/userdel,passwd -S - Root for
--applyto actually change anything; the dry-run default needs no special privileges beyond reading/etc/passwd
How it works, end to end
Four pieces, each doing one job:
- SystemState — read-only view of the live account database, backed by
Etc.getpwnam/getgrnam. - Action — a single planned change (kind, target, human-readable detail). Its
#describeis what both dry-run output and the real-run log print, so the two can never say different things about the same change. - AccountProvisioner#plan — walks groups, then users, producing an
Actionfor every piece of drift; completely side-effect free. - AccountProvisioner#apply! — takes that plan and either logs it (
dry_run: true, the default) or executes each action for real throughSystemRunner, a one-methodOpen3.capture3wrapper that’s the single point where the script actually shells out.
account_provisioner.rb
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# account_provisioner.rb
#
# Idempotent local Linux account provisioning: reads a YAML spec of the
# users and groups a box *should* have, compares it against what actually
# exists (via Etc), and reconciles the difference by shelling out to
# useradd/usermod/userdel/groupadd/gpasswd -- only touching accounts that
# are actually out of spec. Dry-run by default, so you can review a diff
# before anything changes.
#
# This is the "user account management" counterpart to a read-only
# auditor: instead of just reporting that an account is wrong, it can
# fix it -- the same idea as config_state_engine.rb elsewhere in this
# repo, applied specifically to /etc/passwd, /etc/group, and authorized
# SSH keys.
#
# Only the standard library is used: etc, yaml, open3, fileutils.
require 'etc'
require 'yaml'
require 'open3'
require 'fileutils'
require 'optparse'
# --------------------------------------------------------------------------
# Wraps the handful of system calls this script needs so tests can inject a
# fake runner instead of actually calling useradd/usermod/userdel. Kept as
# one small seam rather than stubbing Open3 globally.
# --------------------------------------------------------------------------
class SystemRunner
Result = Struct.new(:success, :stdout, :stderr, keyword_init: true)
def run(*cmd)
stdout, stderr, status = Open3.capture3(*cmd)
Result.new(success: status.success?, stdout: stdout, stderr: stderr)
end
end
# --------------------------------------------------------------------------
# Read-only view of what accounts currently exist. Wraps Etc so it can be
# swapped for a fixture in tests without touching the real system's user
# database.
# --------------------------------------------------------------------------
class SystemState
def user(name)
Etc.getpwnam(name)
rescue ArgumentError
nil
end
def group(name)
Etc.getgrnam(name)
rescue ArgumentError
nil
end
def group_members(name)
grp = group(name)
grp ? grp.mem : []
end
def authorized_keys_path(home)
File.join(home, '.ssh', 'authorized_keys')
end
def authorized_keys(home)
path = authorized_keys_path(home)
File.exist?(path) ? File.read(path).lines.map(&:strip).reject(&:empty?) : []
end
# All accounts currently on the box, as [name, uid] pairs -- used only by
# the (opt-in) unmanaged-account pruning pass.
def all_users
Etc.passwd.map { |pw| [pw.name, pw.uid] }
end
end
# --------------------------------------------------------------------------
# A single planned change. #describe gives the human-readable line used in
# both dry-run output and the real-run log, so the two never drift apart.
# --------------------------------------------------------------------------
Action = Struct.new(:kind, :target, :detail) do
def describe
"#{kind.to_s.upcase.ljust(14)} #{target.to_s.ljust(20)} #{detail}"
end
end
# --------------------------------------------------------------------------
# Core reconciler: spec (desired state) + system (actual state) -> a list
# of Actions, which #apply! then executes (or, in dry-run mode, just
# prints).
# --------------------------------------------------------------------------
class AccountProvisioner
def initialize(spec, system_state: SystemState.new, runner: SystemRunner.new, logger: method(:puts))
@spec = spec
@system = system_state
@runner = runner
@logger = logger
end
# Returns an Array of Action describing every change needed to bring the
# box into line with the spec. Never touches the system.
def plan
actions = []
(@spec['groups'] || []).each { |g| actions.concat(plan_group(g)) }
(@spec['users'] || []).each { |u| actions.concat(plan_user(u)) }
actions.concat(plan_removals) if @spec['prune_unmanaged']
actions
end
# Executes a plan. dry_run: true just logs what *would* happen.
def apply!(actions, dry_run: true)
actions.each do |action|
@logger.call((dry_run ? '[dry-run] ' : '[apply] ') + action.describe)
next if dry_run
execute(action)
end
end
private
def plan_group(spec)
name = spec.fetch('name')
existing = @system.group(name)
return [Action.new(:create_group, name, "gid=#{spec['gid'] || 'auto'}")] unless existing
[]
end
def plan_user(spec)
name = spec.fetch('name')
actions = []
existing = @system.user(name)
state = spec.fetch('state', 'present')
if state == 'absent'
actions << Action.new(:remove_user, name, 'account should not exist') if existing
return actions
end
if existing.nil?
actions << Action.new(:create_user, name, describe_user_spec(spec))
actions.concat(plan_groups_for(name, spec))
actions.concat(plan_ssh_key(name, spec))
return actions
end
actions.concat(plan_user_drift(name, existing, spec))
actions.concat(plan_groups_for(name, spec))
actions.concat(plan_ssh_key(name, spec))
actions
end
def describe_user_spec(spec)
parts = []
parts << "shell=#{spec['shell']}" if spec['shell']
parts << "uid=#{spec['uid']}" if spec['uid']
parts << "comment=#{spec['comment'].inspect}" if spec['comment']
parts.join(' ')
end
def plan_user_drift(name, existing, spec)
actions = []
if spec['shell'] && existing.shell != spec['shell']
actions << Action.new(:modify_shell, name, "#{existing.shell} -> #{spec['shell']}")
end
if spec['comment'] && existing.gecos != spec['comment']
actions << Action.new(:modify_comment, name, "#{existing.gecos.inspect} -> #{spec['comment'].inspect}")
end
if spec.key?('locked')
currently_locked = locked?(name)
if spec['locked'] && !currently_locked
actions << Action.new(:lock, name, 'password login should be disabled')
elsif !spec['locked'] && currently_locked
actions << Action.new(:unlock, name, 'password login should be enabled')
end
end
actions
end
def plan_groups_for(name, spec)
wanted = Array(spec['groups'])
return [] if wanted.empty?
current = wanted.reject { |g| @system.group_members(g).include?(name) }
return [] if current.empty?
[Action.new(:add_to_groups, name, current.join(','))]
end
def plan_ssh_key(name, spec)
return [] unless spec['ssh_authorized_key']
home = spec['home'] || "/home/#{name}"
existing_keys = @system.authorized_keys(home)
wanted_key = spec['ssh_authorized_key'].strip
return [] if existing_keys.include?(wanted_key)
[Action.new(:install_ssh_key, name, "authorized_keys += 1 key (#{wanted_key.split.last(1).first || 'unlabeled'})")]
end
# Accounts that exist under a managed UID range but aren't in the spec at
# all -- only offered when prune_unmanaged is explicitly turned on,
# since silently deleting unlisted accounts is exactly the kind of
# surprise this script is designed to avoid by default.
def plan_removals
managed_names = (@spec['users'] || []).map { |u| u['name'] }
min_uid = @spec.fetch('managed_uid_min', 1000)
max_uid = @spec.fetch('managed_uid_max', 60_000)
@system.all_users.each_with_object([]) do |(name, uid), actions|
next unless uid.between?(min_uid, max_uid)
next if managed_names.include?(name)
actions << Action.new(:remove_user, name, 'unmanaged account in provisioned UID range')
end
end
def locked?(name)
result = @runner.run('passwd', '-S', name)
return false unless result.success
# `passwd -S` second field is L (locked), NP (no password), or P (usable password)
fields = result.stdout.split
fields[1] == 'L'
end
def execute(action)
case action.kind
when :create_group then run!('groupadd', action.target)
when :create_user then create_user(action)
when :remove_user then run!('userdel', '-r', action.target)
when :modify_shell then run!('usermod', '--shell', shell_for(action), action.target)
when :modify_comment then run!('usermod', '--comment', comment_for(action), action.target)
when :lock then run!('usermod', '--lock', action.target)
when :unlock then run!('usermod', '--unlock', action.target)
when :add_to_groups then run!('usermod', '--append', '--groups', action.detail, action.target)
when :install_ssh_key then install_ssh_key(action)
else raise "unknown action kind #{action.kind}"
end
end
# These two pull the value back out of the human-readable detail string
# set during planning, so the plan/apply steps can't drift apart on what
# the actual target value is.
def shell_for(action) = action.detail.split(' -> ').last
def comment_for(action) = action.detail.split(' -> ').last.gsub(/\A"|"\z/, '')
def create_user(action)
spec = @spec['users'].find { |u| u['name'] == action.target }
cmd = ['useradd', '--create-home']
cmd += ['--shell', spec['shell']] if spec['shell']
cmd += ['--uid', spec['uid'].to_s] if spec['uid']
cmd += ['--comment', spec['comment']] if spec['comment']
cmd << action.target
run!(*cmd)
end
def install_ssh_key(action)
spec = @spec['users'].find { |u| u['name'] == action.target }
home = spec['home'] || "/home/#{action.target}"
ssh_dir = File.join(home, '.ssh')
FileUtils.mkdir_p(ssh_dir, mode: 0o700)
path = @system.authorized_keys_path(home)
File.write(path, spec['ssh_authorized_key'].strip + "\n", mode: 'a')
FileUtils.chmod(0o600, path)
FileUtils.chown_R(action.target, action.target, ssh_dir) if Process.uid.zero?
end
def run!(*cmd)
result = @runner.run(*cmd)
@logger.call(" -> FAILED: #{cmd.join(' ')}: #{result.stderr}") unless result.success
result
end
end
# --------------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
options = { apply: false }
OptionParser.new do |opts|
opts.banner = 'Usage: account_provisioner.rb --spec accounts.yml [--apply]'
opts.on('--spec PATH', 'YAML spec of desired users/groups (required)') { |v| options[:spec] = v }
opts.on('--apply', 'Actually make the changes (default: dry-run only)') { options[:apply] = true }
opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }
end.parse!(ARGV)
unless options[:spec]
warn 'error: --spec is required'
exit 4
end
spec = YAML.safe_load_file(options[:spec])
provisioner = AccountProvisioner.new(spec)
actions = provisioner.plan
if actions.empty?
puts 'No drift detected -- system already matches spec.'
exit 0
end
puts "#{actions.size} change(s) needed:"
provisioner.apply!(actions, dry_run: !options[:apply])
if !options[:apply]
puts "\nDry run only -- re-run with --apply to make these changes."
end
exit 0
end
example_accounts.yml
# Example spec for account_provisioner.rb.
# Run: ruby account_provisioner.rb --spec example_accounts.yml (dry-run, default)
# ruby account_provisioner.rb --spec example_accounts.yml --apply
managed_uid_min: 1000
managed_uid_max: 2000
prune_unmanaged: false
groups:
- name: deploy
users:
- name: svc_deploy
shell: /usr/sbin/nologin
comment: "CI deploy service account"
groups: [deploy]
ssh_authorized_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyOnlyNotReal svc_deploy@ci-runner"
- name: alice
shell: /bin/bash
comment: "Alice Nguyen"
locked: false
- name: old_contractor
state: absent
How this was verified
- Real account provisioning is exactly the side effect a test suite should never perform for real, so
account_provisioner_test.rbinjectsFakeSystemState/FakeRunnerdoubles shaped like the real classes. - Scenario 1 — a brand-new user in the spec plans create_group + create_user + add_to_groups + install_ssh_key, nothing more.
- Scenario 2 — an existing user’s shell and lock state have drifted; exactly those two actions are planned, and the comment (which already matched) gets none.
- Scenario 3 — dry-run issues zero real commands;
--applyissues the exact expecteduseraddargv. - Scenario 4 — explicit
state: absentremoval, plus UID-scopedprune_unmanagedflagging an unmanaged account in range while leaving root alone. - The plan/dry-run path was also run live against this sandbox’s real
/etc/passwd(see the output tab above) to confirm theEtc-backed path behaves the same as the fixture-backed one used in tests.
Common issues
- Nothing happens even with –apply — check you’re root; a permissions failure shows up as a logged
-> FAILED: ...line, not a crash. - ADD_TO_GROUPS keeps showing up every run — double-check the group name in your spec for stray whitespace from a copy-pasted YAML file.
- SSH key gets appended twice — the drift check is an exact stripped-line match; keep the key on one line exactly as
ssh-keygenprinted it. - prune_unmanaged seems to ignore an account — it only ever looks inside
managed_uid_min..managed_uid_max; accounts below 1000 are never touched, by design.
Where to take this next
expiry:support (usermod --expiredate) for contractor accounts that should self-deactivate on a known date.- Group-membership drift the other direction — removing a user from a group they’re in but shouldn’t be.
- A
--diffmode that prints old → new values for every field, for easier code review of generated specs.