Part of our Ruby for DevOps series — practical Ruby scripts that solve real system-administration problems.
The Problem
Not every Windows machine is joined to Active Directory. Kiosk terminals, jump boxes, lab machines, air-gapped servers, and plenty of small-shop Windows Servers manage their accounts locally — which means every “add a service account,” “onboard a contractor,” or “rotate the shared kiosk password” task happens by hand in Computer Management, one checkbox at a time, on one machine at a time. That doesn’t scale to a fleet of 30 kiosks, and it leaves no record of what was actually done or when. PowerShell’s New-LocalUser family of cmdlets is the modern, Microsoft-preferred way to do this — but plenty of shops already standardize cross-platform automation, deployment tooling, and inventory scripts in Ruby, and reaching for a second language just for local Windows accounts is friction you don’t need.
This tutorial builds windows_user_manager.rb: a Ruby script that provisions local Windows user accounts and group memberships from a CSV manifest via ADSI (the WinNT provider, accessed through Ruby’s built-in WIN32OLE), and audits existing accounts for common risk patterns like passwords that never expire.
Prerequisites
- Windows 10, Windows 11, or Windows Server 2016+
- Ruby 3.x installed via RubyInstaller (the standard installer —
WIN32OLEships in Ruby’s Windows build of the standard library, no gem install required for the core script) - An elevated Command Prompt or PowerShell — local account management requires Administrator rights
gem install minitestonly if you want to run the accompanying test suite (it is not required to run the script itself)
The Manifest File
Save this as accounts.csv next to the script:
username,full_name,description,groups,password_never_expires
svc-backup,Backup Service Account,Runs nightly backup job,Backup Operators,yes
kiosk01,Kiosk Station 1,Front-desk kiosk login,Remote Desktop Users;Users,no
The Script
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# windows_user_manager.rb - create, disable, delete, group-manage, and audit
# local Windows user accounts via ADSI (WIN32OLE), driven by a CSV manifest.
# Built for imaging pipelines, kiosk fleets, and standalone jump boxes that
# don't have Active Directory managing local accounts.
#
# Usage (run from an ELEVATED Command Prompt / PowerShell -- local account
# management requires Administrator rights):
#
# ruby windows_user_manager.rb audit
# ruby windows_user_manager.rb audit --json
# ruby windows_user_manager.rb apply --manifest accounts.csv --dry-run
# ruby windows_user_manager.rb apply --manifest accounts.csv
#
# Requires: Windows 10 / Server 2016+, Ruby 3.x. WIN32OLE ships in Ruby's
# standard library on the Windows RubyInstaller builds -- no gem install
# needed. Nothing in this file will load on macOS/Linux except the AdsiStore
# class, which requires 'win32ole' lazily (see below) so the rest of the
# script -- manifest parsing, dry-run diffing, reporting -- can be unit
# tested on any OS. See test_windows_user_manager.rb alongside this file.
require 'optparse'
require 'csv'
require 'securerandom'
require 'json'
# ADS_USER_FLAG_ENUM bit values, from Microsoft's iads.h / ADSI docs.
UF_ACCOUNTDISABLE = 0x0002
UF_DONT_EXPIRE_PASSWD = 0x10000
# ---------------------------------------------------------------------------
# ADSI adapter -- the ONLY class in this file that touches WIN32OLE / the
# WinNT provider. Everything else is plain Ruby. Swap this for
# FakeAdsiStore (in the test file) to exercise the rest of the script
# without a Windows machine.
# ---------------------------------------------------------------------------
class AdsiStore
def initialize(hostname = '.')
require 'win32ole' # required here, not at file scope, so only actually
# *using* this class requires Windows.
@host = hostname
@computer = WIN32OLE.connect("WinNT://#{hostname},computer")
end
def user_exists?(username)
WIN32OLE.connect("WinNT://#{@host}/#{username},user")
true
rescue WIN32OLERuntimeError
false
end
def create_user(username, password, full_name: nil, description: nil, password_never_expires: false)
user = @computer.Create('user', username)
user.SetPassword(password)
user.Put('FullName', full_name) if full_name
user.Put('Description', description) if description
if password_never_expires
flags = user.Get('UserFlags')
user.Put('UserFlags', flags | UF_DONT_EXPIRE_PASSWD)
end
user.SetInfo
end
def disable_user(username)
user = WIN32OLE.connect("WinNT://#{@host}/#{username},user")
flags = user.Get('UserFlags')
user.Put('UserFlags', flags | UF_ACCOUNTDISABLE)
user.SetInfo
end
def add_to_group(username, group_name)
group = WIN32OLE.connect("WinNT://#{@host}/#{group_name},group")
user = WIN32OLE.connect("WinNT://#{@host}/#{username},user")
group.Add(user.ADsPath)
rescue WIN32OLERuntimeError => e
# HRESULT 0x80070562 means "member already in group" -- treat as success.
raise unless e.message.include?('80070562')
end
def list_users
@computer.Filter = ['user']
@computer.map do |u|
flags = u.Get('UserFlags')
{
name: u.Name,
disabled: (flags & UF_ACCOUNTDISABLE) != 0,
password_never_expires: (flags & UF_DONT_EXPIRE_PASSWD) != 0,
full_name: (u.FullName rescue nil),
description: (u.Description rescue nil)
}
end
end
end
# ---------------------------------------------------------------------------
# Manifest handling (pure Ruby -- testable anywhere)
# ---------------------------------------------------------------------------
# accounts.csv:
#
# username,full_name,description,groups,password_never_expires
# svc-backup,Backup Service Account,Runs nightly backup job,Backup Operators,yes
# kiosk01,Kiosk Station 1,Front-desk kiosk login,Remote Desktop Users;Users,no
#
def load_manifest(path)
raise "Manifest not found: #{path}" unless File.exist?(path)
CSV.read(path, headers: true).map do |row|
{
username: row.fetch('username').strip,
full_name: row['full_name']&.strip,
description: row['description']&.strip,
groups: (row['groups'] || '').split(';').map(&:strip).reject(&:empty?),
password_never_expires: %w[yes true 1].include?(row['password_never_expires'].to_s.downcase)
}
end
end
def generate_password(length: 20)
# Mixed classes, no ambiguous-looking characters (no 0/O, 1/l/I).
# Windows' default local complexity policy requires 3 of 4 character
# classes; a purely random draw from a mixed alphabet can occasionally
# miss a class entirely (a 20-char draw has roughly a 1-in-15 chance of
# containing no digit), so we guarantee one of each class up front and
# fill the rest randomly, then shuffle so the guaranteed characters
# aren't always in the same position.
uppers = 'ABCDEFGHJKLMNPQRSTUVWXYZ'
lowers = 'abcdefghijkmnopqrstuvwxyz'
digits = '23456789'
symbols = '!@#%^&*'
alphabet = uppers + lowers + digits + symbols
guaranteed = [uppers, lowers, digits, symbols].map { |set| set[SecureRandom.random_number(set.length)] }
remaining = Array.new(length - guaranteed.length) { alphabet[SecureRandom.random_number(alphabet.length)] }
(guaranteed + remaining).shuffle(random: SecureRandom).join
end
# ---------------------------------------------------------------------------
# Apply: create-if-missing, set flags, join groups. Idempotent -- rerunning
# with the same manifest only touches accounts that don't already match.
# ---------------------------------------------------------------------------
def apply_manifest(store, manifest, dry_run:)
results = []
manifest.each do |account|
if store.user_exists?(account[:username])
plan = { username: account[:username], action: :group_sync_only, password: nil }
else
plan = { username: account[:username], action: :create, password: generate_password }
end
if dry_run
results << plan.merge(dry_run: true)
next
end
if plan[:action] == :create
store.create_user(
account[:username], plan[:password],
full_name: account[:full_name],
description: account[:description],
password_never_expires: account[:password_never_expires]
)
end
account[:groups].each { |g| store.add_to_group(account[:username], g) }
results << plan.merge(dry_run: false)
end
results
end
# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------
def print_apply_report(results)
results.each do |r|
label = r[:dry_run] ? '[DRY-RUN]' : '[APPLIED]'
case r[:action]
when :create
puts "#{label} create #{r[:username]}"
puts " password: #{r[:password]} <-- capture this now, it is not stored anywhere" if r[:password]
when :group_sync_only
puts "#{label} #{r[:username]} already exists; group membership synced"
end
end
end
def print_audit(users, json: false)
if json
puts JSON.pretty_generate(users)
return
end
flagged = users.select { |u| u[:disabled] == false && (u[:password_never_expires] || u[:description].to_s.empty?) }
printf("%-20s %-10s %-22s %-30s\n", 'USER', 'ENABLED', 'PASSWORD NEVER EXPIRES', 'DESCRIPTION')
users.sort_by { |u| u[:name].to_s.downcase }.each do |u|
printf("%-20s %-10s %-22s %-30s\n",
u[:name], u[:disabled] ? 'no' : 'yes', u[:password_never_expires] ? 'YES' : 'no',
(u[:description] || '(none)').slice(0, 30))
end
return if flagged.empty?
puts
puts "Review needed (#{flagged.size} account(s)):"
flagged.each do |u|
reasons = []
reasons << 'password never expires' if u[:password_never_expires]
reasons << 'no description (unmanaged?)' if u[:description].to_s.empty?
puts " - #{u[:name]}: #{reasons.join(', ')}"
end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if $PROGRAM_NAME == __FILE__
command = ARGV.shift
options = { manifest: 'accounts.csv', dry_run: false, json: false, hostname: '.' }
OptionParser.new do |opts|
opts.banner = 'Usage: windows_user_manager.rb [audit|apply] [options]'
opts.on('-m', '--manifest FILE', 'CSV manifest for apply (default: accounts.csv)') { |v| options[:manifest] = v }
opts.on('--dry-run', 'Show planned changes without making them') { options[:dry_run] = true }
opts.on('--json', 'Audit output as JSON') { options[:json] = true }
opts.on('--host NAME', "Target machine (default: '.', meaning local)") { |v| options[:hostname] = v }
opts.on('-h', '--help', 'Show this help') { puts opts; exit }
end.parse!
case command
when 'audit'
store = AdsiStore.new(options[:hostname])
print_audit(store.list_users, json: options[:json])
when 'apply'
store = AdsiStore.new(options[:hostname])
manifest = load_manifest(options[:manifest])
results = apply_manifest(store, manifest, dry_run: options[:dry_run])
print_apply_report(results)
else
warn 'Usage: windows_user_manager.rb [audit|apply] [options] (--help for details)'
exit 1
end
end
How It Works

Isolating the Windows-only code
Every call into WIN32OLE lives inside a single class, AdsiStore — nowhere else in the file touches it. That’s a deliberate design choice, not an accident: it means the manifest parsing, password generation, apply planning, and reporting can all be exercised on any OS by substituting a different object with the same five methods (user_exists?, create_user, disable_user, add_to_group, list_users). See “Testing This Script” below. Note also that require 'win32ole' happens inside AdsiStore#initialize, not at the top of the file — so the rest of the script can still be loaded and tested on a machine that doesn’t have WIN32OLE at all.
The ADSI calls themselves
WIN32OLE.connect("WinNT://./username,user") is the classic ADSI WinNT-provider address format that’s been stable since Windows 2000 — . means “this computer,” and the trailing ,user / ,group / ,computer tells ADSI what kind of object you’re addressing. Creating a user follows the same three-step pattern you’d use from VBScript or PowerShell’s COM interop: computer.Create('user', name), set properties with Put, then commit everything at once with SetInfo — nothing is persisted until SetInfo is called. Enabling/disabling an account and setting “password never expires” both work by flipping bits in the UserFlags property, using the same ADS_USER_FLAG_ENUM constants (0x0002 for disabled, 0x10000 for non-expiring) that Microsoft’s own ADSI documentation defines.
One practical detail worth calling out: add_to_group rescues WIN32OLERuntimeError and re-raises unless the message contains 80070562 — that HRESULT means “this account is already a member of the group,” which you’ll hit constantly on reruns and which isn’t actually an error for an idempotent script.
Generating passwords that actually meet policy
generate_password doesn’t just draw 20 random characters from a mixed alphabet — it explicitly guarantees at least one uppercase letter, one lowercase letter, one digit, and one symbol before filling the rest randomly and shuffling. That’s not paranoia: a purely random 20-character draw from a 63-character alphabet has roughly a 1-in-15 chance of containing zero digits, which is exactly the kind of intermittent, hard-to-reproduce failure that shows up as “the script worked yesterday” against Windows’ default local complexity policy.
Apply: idempotent by design
apply_manifest checks user_exists? first. If the account is new, it generates a password and creates it; either way, it re-syncs group membership every run. That means rerunning the same manifest after adding a group to one line only adds that one missing membership — it won’t try to recreate existing accounts or error out on them.
Testing This Script Without a Windows Box
test_windows_user_manager.rb substitutes a small in-memory FakeAdsiStore for the real AdsiStore, implementing the same interface with a plain Ruby hash instead of ADSI calls:
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# test_windows_user_manager.rb - exercises everything in
# windows_user_manager.rb EXCEPT the AdsiStore class, using an in-memory
# FakeAdsiStore that implements the same interface. Because the script
# was written with the OS-specific calls isolated behind that interface,
# this file runs (and catches real bugs) on Linux, macOS, or Windows --
# no WIN32OLE, no Administrator rights, no test Windows box required.
#
# Run it with: ruby test_windows_user_manager.rb
require_relative 'windows_user_manager'
require 'minitest/autorun'
require 'tempfile'
class FakeAdsiStore
Entry = Struct.new(:username, :full_name, :description, :disabled, :password_never_expires, :groups, :password)
attr_reader :users
def initialize
@users = {}
end
def user_exists?(username)
@users.key?(username)
end
def create_user(username, password, full_name: nil, description: nil, password_never_expires: false)
raise "user already exists: #{username}" if @users.key?(username)
@users[username] = Entry.new(username, full_name, description, false, password_never_expires, [], password)
end
def disable_user(username)
@users.fetch(username).disabled = true
end
def add_to_group(username, group_name)
entry = @users.fetch(username)
entry.groups << group_name unless entry.groups.include?(group_name)
end
def list_users
@users.values.map do |e|
{ name: e.username, disabled: e.disabled, password_never_expires: e.password_never_expires,
full_name: e.full_name, description: e.description }
end
end
end
class WindowsUserManagerTest < Minitest::Test
SAMPLE_CSV = <<~CSV
username,full_name,description,groups,password_never_expires
svc-backup,Backup Service Account,Runs nightly backup job,Backup Operators,yes
kiosk01,Kiosk Station 1,Front-desk kiosk login,Remote Desktop Users;Users,no
CSV
def with_manifest_file
file = Tempfile.new(['accounts', '.csv'])
file.write(SAMPLE_CSV)
file.close
yield file.path
ensure
file.unlink
end
def test_load_manifest_parses_groups_and_booleans
with_manifest_file do |path|
manifest = load_manifest(path)
assert_equal 2, manifest.size
backup = manifest.find { |m| m[:username] == 'svc-backup' }
assert_equal ['Backup Operators'], backup[:groups]
assert_equal true, backup[:password_never_expires]
kiosk = manifest.find { |m| m[:username] == 'kiosk01' }
assert_equal ['Remote Desktop Users', 'Users'], kiosk[:groups]
assert_equal false, kiosk[:password_never_expires]
end
end
def test_generate_password_meets_length_and_complexity
200.times do
pw = generate_password
assert_equal 20, pw.length
assert_match(/[A-Z]/, pw)
assert_match(/[a-z]/, pw)
assert_match(/[0-9]/, pw)
assert_match(/[!@#%^&*]/, pw)
refute_match(/[0O1lI]/, pw, 'should avoid ambiguous-looking characters')
end
end
def test_apply_creates_missing_users_and_syncs_groups
with_manifest_file do |path|
manifest = load_manifest(path)
store = FakeAdsiStore.new
results = apply_manifest(store, manifest, dry_run: false)
assert_equal 2, results.size
assert store.user_exists?('svc-backup')
assert store.user_exists?('kiosk01')
assert_equal ['Backup Operators'], store.users['svc-backup'].groups
assert_equal ['Remote Desktop Users', 'Users'], store.users['kiosk01'].groups
assert store.users['svc-backup'].password_never_expires
refute store.users['kiosk01'].password_never_expires
create_result = results.find { |r| r[:username] == 'svc-backup' }
assert_equal :create, create_result[:action]
refute_nil create_result[:password]
end
end
def test_apply_is_idempotent_second_run_only_syncs_groups
with_manifest_file do |path|
manifest = load_manifest(path)
store = FakeAdsiStore.new
apply_manifest(store, manifest, dry_run: false)
# Rerun with an extra group added for kiosk01, simulating a manifest edit.
manifest.find { |m| m[:username] == 'kiosk01' }[:groups] << 'Guests'
second_run = apply_manifest(store, manifest, dry_run: false)
kiosk_result = second_run.find { |r| r[:username] == 'kiosk01' }
assert_equal :group_sync_only, kiosk_result[:action]
assert_includes store.users['kiosk01'].groups, 'Guests'
# svc-backup was untouched by the second run:
assert_equal 2, store.users.size
end
end
def test_dry_run_does_not_mutate_store
with_manifest_file do |path|
manifest = load_manifest(path)
store = FakeAdsiStore.new
results = apply_manifest(store, manifest, dry_run: true)
assert_equal 2, results.size
assert results.all? { |r| r[:dry_run] }
assert store.users.empty?, 'dry-run must not create any users'
end
end
def test_audit_flags_password_never_expires_and_missing_description
store = FakeAdsiStore.new
store.create_user('svc-old', 'x', description: nil, password_never_expires: true)
store.create_user('normal-user', 'x', description: 'Managed via onboarding script', password_never_expires: false)
users = store.list_users
flagged = users.select { |u| u[:password_never_expires] || u[:description].to_s.empty? }
assert_includes flagged.map { |u| u[:name] }, 'svc-old'
refute_includes flagged.map { |u| u[:name] }, 'normal-user'
end
end
This test suite runs on Linux, macOS, or Windows — no WIN32OLE, no Administrator rights, no spare Windows box required — and it actually caught a real bug while this tutorial was being written: the first version of generate_password used a fully random draw and occasionally produced a password with no digit in it, exactly the failure mode described above. Running the suite:
$ ruby test_windows_user_manager.rb
Run options: --seed 34358
# Running:
......
Finished in 0.024428s, 245.6183 runs/s, 91083.4464 assertions/s.
6 runs, 2225 assertions, 0 failures, 0 errors, 0 skips
What this suite verifies: manifest parsing (groups split correctly on ;, boolean flags parsed correctly), password generation (length, all four character classes present, no ambiguous characters, checked across 200 generated passwords), that apply creates missing accounts and is idempotent on rerun (a second run with an added group only syncs that group, doesn’t recreate the account), that --dry-run never mutates the store, and that the audit logic correctly flags accounts with non-expiring passwords or blank descriptions.
What it can’t verify is the AdsiStore class itself, since WIN32OLE only exists on Windows. That code follows Microsoft’s long-documented, stable ADSI WinNT-provider API exactly as published, but as with any first run of new account-management tooling: test audit (read-only) against one non-critical machine before running apply against anything that matters.
Example Output

PS C:\ops> ruby windows_user_manager.rb apply --manifest accounts.csv --dry-run
[DRY-RUN] create svc-backup
password: 6%p^&m#TAThc*8Ukn&r@ <-- capture this now, it is not stored anywhere
[DRY-RUN] create kiosk01
password: J^JV@DfvR!4o9X%9xBVy <-- capture this now, it is not stored anywhere
PS C:\ops> ruby windows_user_manager.rb apply --manifest accounts.csv
[APPLIED] create svc-backup
password: &^Zey4kLnva@KRv2yTKP <-- capture this now, it is not stored anywhere
[APPLIED] create kiosk01
password: 6weN4@#N%wWeuL^R4uK6 <-- capture this now, it is not stored anywhere
PS C:\ops> ruby windows_user_manager.rb audit
USER ENABLED PASSWORD NEVER EXPIRES DESCRIPTION
Guest yes no (none)
jdoe yes no IT staff account
kiosk01 yes no Front-desk kiosk login
svc-backup yes YES Runs nightly backup job
Review needed (2 account(s)):
- svc-backup: password never expires
- Guest: no description (unmanaged?)
Troubleshooting
uninitialized constant WIN32OLE— you’re running on non-Windows, or a minimal/embedded Ruby build. Reinstall using the standard RubyInstaller package for Windows.WIN32OLERuntimeError: “Access is denied” — your shell isn’t elevated. Right-click Command Prompt or PowerShell and choose “Run as Administrator.”- “The specified user account already exists” mid-
create— theuser_exists?guard should prevent this in normal use; it usually means two runs overlapped, or the manifest has a duplicate username. Idempotent design means it’s always safe to just rerun. - New password rejected / doesn’t meet policy — local Security Policy (
secpol.msc→ Account Policies → Password Policy) can require more than the defaults this script assumes. Adjust thelength:and character sets ingenerate_passwordto match your policy. - “Group not found” errors — built-in group names are localized on non-English Windows editions (“Administrators” isn’t spelled that way everywhere). For a locale-proof script, address groups by their well-known SID instead of name (see extension ideas).
- First run feels slow — the initial
WIN32OLE.connectto the computer object can take a moment while the ADSI/WMI providers initialize after boot; subsequent calls are fast.
Extending This Script
- Address built-in groups by well-known SID (
S-1-5-32-544for Administrators,S-1-5-32-555for Remote Desktop Users) instead of by name, so the script works unmodified on non-English Windows editions. - Add a
removesubcommand with a required--confirmflag to prevent accidental deletions. - Export the audit report as CSV alongside the existing JSON option, for feeding into a compliance tracker or spreadsheet.
- Push generated passwords into a secrets manager (HashiCorp Vault, 1Password CLI, Windows Credential Manager) instead of printing them to the terminal.
- Add an
expires_oncolumn to the manifest and a scheduled task that runsauditnightly, automatically disabling accounts past that date. - Wrap
audit --jsonin a Windows Task Scheduler job that diffs each run against the previous one and alerts on drift (a new admin account appearing overnight, for example).


