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 — WIN32OLE ships 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 minitest only 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

Diagram of windows_user_manager.rb: a CSV manifest flows into apply, which goes through the AdsiStore adapter to the WinNT ADSI provider; a separate audit path lists accounts and flags risky ones; a FakeAdsiStore test double lets the rest of the logic run on non-Windows machines

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

Terminal output of windows_user_manager.rb audit, listing five local accounts and flagging two for review

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 — the user_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 the length: and character sets in generate_password to 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.connect to 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-544 for Administrators, S-1-5-32-555 for Remote Desktop Users) instead of by name, so the script works unmodified on non-English Windows editions.
  • Add a remove subcommand with a required --confirm flag 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_on column to the manifest and a scheduled task that runs audit nightly, automatically disabling accounts past that date.
  • Wrap audit --json in 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).

Leave a Reply

Your email address will not be published. Required fields are marked *