DevOps / System Administration · Linux · Ruby 3.x

The Problem

Every team that grows past a handful of engineers ends up with the same chore: someone joins, and five servers each need a new Linux account, the right group memberships, and their SSH public key installed with exactly the right permissions. Someone leaves, and those same five servers need the account locked and removed — today, not whenever someone remembers. Doing this by hand with useradd and vim ~/.ssh/authorized_keys is slow, inconsistent, and leaves no record of who did what.

This tutorial builds user_provision.rb: a single Ruby script that reads a YAML manifest of “the accounts that should exist” and reconciles it against the real system — creating missing accounts, syncing group membership, installing SSH keys with correct permissions, and cleanly deprovisioning departures. It supports a --dry-run mode so you can preview every change before it touches a production box, and it writes a timestamped audit log of every action.

Prerequisites

  • Ruby 3.x (uses only the standard library: yaml, etc, fileutils, optparse, logger — no gems to install)
  • A Linux host with useradd, usermod, and userdel (any distro with shadow-utils)
  • Root privileges (via sudo) to actually create/modify/remove accounts — everything works read-only without root in --dry-run mode
  • Any groups referenced in your manifest (e.g. developers, docker) must already exist — create them with groupadd first

The Manifest

Accounts are declared in a small YAML file:

users:
  - username: alice
    full_name: "Alice Nakamura"
    groups: [developers, docker]
    shell: /bin/bash
    ssh_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... alice@laptop"

  - username: bob-smith
    full_name: "Bob Smith"
    groups: [developers]
    ssh_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... bob@laptop"

The Full Script

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# user_provision.rb
#
# Bulk Linux user provisioning / deprovisioning tool.
# Reads a YAML manifest describing the accounts that should exist on a
# server (or fleet) and reconciles reality with that manifest: creates
# missing accounts, adds them to the right groups, installs their SSH
# public key with correct permissions, and can also cleanly deprovision
# accounts that have left the team.
#
# Usage:
#   sudo ruby user_provision.rb --config users.yml --dry-run
#   sudo ruby user_provision.rb --config users.yml
#   sudo ruby user_provision.rb --config users.yml --revoke alice
#
# Author: Tha-Shed Ruby for DevOps series

require 'yaml'
require 'etc'
require 'fileutils'
require 'optparse'
require 'time'
require 'logger'

# ---------------------------------------------------------------------------
# Command-line options
# ---------------------------------------------------------------------------
options = {
  config: 'users.yml',
  dry_run: false,
  revoke: nil,
  log_file: 'user_provision.log'
}

OptionParser.new do |opts|
  opts.banner = "Usage: user_provision.rb [options]"

  opts.on('-c', '--config FILE', 'YAML manifest of users (default: users.yml)') do |f|
    options[:config] = f
  end

  opts.on('-n', '--dry-run', 'Show what would happen without changing the system') do
    options[:dry_run] = true
  end

  opts.on('-r', '--revoke USERNAME', 'Deprovision a single user (removes account + home dir)') do |u|
    options[:revoke] = u
  end

  opts.on('-l', '--log-file FILE', 'Where to write the audit log (default: user_provision.log)') do |f|
    options[:log_file] = f
  end

  opts.on('-h', '--help', 'Show this help') do
    puts opts
    exit
  end
end.parse!

# ---------------------------------------------------------------------------
# Logger: every action is written both to STDOUT and to an audit log file,
# because "who provisioned what, and when" is exactly the kind of question
# an auditor (or your future self) will ask.
# ---------------------------------------------------------------------------
logger = Logger.new(options[:log_file], 'daily')
logger.formatter = proc do |severity, datetime, _progname, msg|
  "[#{datetime.iso8601}] #{severity}: #{msg}\n"
end

def announce(logger, msg, dry_run:)
  prefix = dry_run ? '[DRY-RUN] ' : ''
  puts "#{prefix}#{msg}"
  logger.info("#{prefix}#{msg}")
end

# ---------------------------------------------------------------------------
# Small wrapper around shelling out. Centralizing this means dry-run mode,
# logging, and error handling are all consistent no matter which system
# command we're invoking (useradd, usermod, userdel, chpasswd, etc.).
# ---------------------------------------------------------------------------
def run_cmd(cmd, logger, dry_run:)
  announce(logger, "RUN: #{cmd}", dry_run: dry_run)
  return true if dry_run

  success = system(cmd)
  unless success
    logger.error("Command failed (exit #{$?.exitstatus}): #{cmd}")
  end
  success
end

# ---------------------------------------------------------------------------
# Validate a username against the POSIX portable filename character set
# rules Linux enforces for login names. Rejecting bad input early avoids
# building a shell command from untrusted YAML data.
# ---------------------------------------------------------------------------
def valid_username?(name)
  !!(name =~ /\A[a-z_][a-z0-9_-]{0,31}\z/)
end

def user_exists?(username)
  Etc.getpwnam(username)
  true
rescue ArgumentError
  false
end

# ---------------------------------------------------------------------------
# Install an SSH public key for a user with the permissions sshd insists on:
# ~/.ssh must be 700, authorized_keys must be 600, and both must be owned
# by the target user (not root, even though we're running as root).
# ---------------------------------------------------------------------------
def install_ssh_key(username, pubkey, logger, dry_run:)
  if dry_run
    # The account may not exist yet in dry-run mode (it's only "pretend"
    # created), so we can't resolve a real home directory via Etc.getpwnam.
    # Just announce the intent without touching the filesystem.
    announce(logger, "Would install SSH key for #{username} in ~#{username}/.ssh/authorized_keys", dry_run: true)
    return
  end

  pw = Etc.getpwnam(username)
  ssh_dir = File.join(pw.dir, '.ssh')
  auth_keys = File.join(ssh_dir, 'authorized_keys')

  announce(logger, "Installing SSH key for #{username} -> #{auth_keys}", dry_run: dry_run)

  FileUtils.mkdir_p(ssh_dir, mode: 0700)

  existing = File.exist?(auth_keys) ? File.read(auth_keys) : ''
  unless existing.include?(pubkey.strip)
    File.open(auth_keys, 'a', 0600) { |f| f.puts(pubkey.strip) }
  end

  File.chmod(0700, ssh_dir)
  File.chmod(0600, auth_keys)
  FileUtils.chown_R(pw.uid, pw.gid, ssh_dir)
rescue Errno::EACCES, Errno::EPERM => e
  logger.error("Permission error installing SSH key for #{username}: #{e.message}")
end

# ---------------------------------------------------------------------------
# Create one user per the manifest entry.
# ---------------------------------------------------------------------------
def provision_user(entry, logger, dry_run:)
  username = entry['username']

  unless valid_username?(username)
    logger.error("Skipping invalid username: #{username.inspect}")
    return :invalid
  end

  if user_exists?(username)
    announce(logger, "User #{username} already exists - checking group membership", dry_run: dry_run)
    groups = Array(entry['groups'])
    unless groups.empty?
      run_cmd("usermod -aG #{groups.join(',')} #{username}", logger, dry_run: dry_run)
    end
    install_ssh_key(username, entry['ssh_key'], logger, dry_run: dry_run) if entry['ssh_key']
    return :updated
  end

  shell = entry['shell'] || '/bin/bash'
  comment = entry['full_name'] || username
  groups = Array(entry['groups'])

  cmd = +"useradd -m -s #{shell} -c \"#{comment}\""
  cmd << " -G #{groups.join(',')}" unless groups.empty?
  cmd << " #{username}"

  ok = run_cmd(cmd, logger, dry_run: dry_run)
  return :failed unless ok

  install_ssh_key(username, entry['ssh_key'], logger, dry_run: dry_run) if entry['ssh_key']
  :created
end

# ---------------------------------------------------------------------------
# Deprovision: lock the account first (immediate effect even if userdel is
# deferred), then remove the account and home directory.
# ---------------------------------------------------------------------------
def deprovision_user(username, logger, dry_run:)
  unless user_exists?(username)
    announce(logger, "User #{username} does not exist - nothing to revoke", dry_run: dry_run)
    return :missing
  end

  run_cmd("usermod -L #{username}", logger, dry_run: dry_run)          # lock password immediately
  run_cmd("pkill -KILL -u #{username}", logger, dry_run: dry_run)      # end active sessions
  ok = run_cmd("userdel -r #{username}", logger, dry_run: dry_run)     # remove account + home dir
  ok ? :removed : :failed
end

# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
unless File.exist?(options[:config])
  warn "Config file not found: #{options[:config]}"
  exit 1
end

manifest = YAML.safe_load(File.read(options[:config])) || {}
users = manifest['users'] || []

summary = Hash.new(0)

if options[:revoke]
  result = deprovision_user(options[:revoke], logger, dry_run: options[:dry_run])
  summary[result] += 1
else
  users.each do |entry|
    result = provision_user(entry, logger, dry_run: options[:dry_run])
    summary[result] += 1
  end
end

puts "\n--- Summary ---"
summary.each { |status, count| puts "#{status}: #{count}" }
logger.info("Run complete. Summary: #{summary}")

Walkthrough

1. Options and logging

OptionParser gives us --config, --dry-run, --revoke USERNAME, and --log-file. Every meaningful action goes through announce, which prints to STDOUT and writes to a Logger instance configured with daily rotation — so a month from now you can still answer “when was this account created, and by which run of this script?”

2. Validating input before it touches a shell command

valid_username? checks the username against the same character-set rules Linux enforces for login names before it’s ever interpolated into a useradd command string. The manifest is trusted-ish (it’s your own config), but validating early is cheap insurance against a typo turning into a broken shell command, and it’s the same discipline you’d want if this manifest were ever generated from a less-trusted source like an HR system export.

3. Idempotent reconciliation, not blind creation

provision_user checks user_exists? (via Etc.getpwnam, rescuing the ArgumentError it raises when the user isn’t found) before doing anything. If the account already exists, the script only syncs group membership and re-checks the SSH key — it never re-runs useradd against an existing user. Run the script twice in a row and the second run is a no-op except for those two idempotent checks. That’s what makes it safe to run from a cron job or a CI pipeline on every commit to the manifest.

4. SSH keys with the permissions sshd actually requires

install_ssh_key is the part most hand-rolled scripts get subtly wrong. OpenSSH’s sshd will silently refuse to use an authorized_keys file if the permissions are too loose, so the script explicitly sets ~/.ssh to 700 and authorized_keys to 600, and chowns both to the target user (not root, even though the script runs as root). It also checks whether the key is already present before appending, so re-running the script doesn’t pile up duplicate lines.

5. Dry-run mode without lying to the user

In dry-run mode, install_ssh_key can’t call Etc.getpwnam to find the (not-yet-created) user’s home directory, so it prints a generic “would install” message instead of pretending to know the exact path. This is a real bug I hit and fixed while testing this script: the first version called Etc.getpwnam unconditionally, which crashed with can't find user for alice (ArgumentError) the moment you dry-ran a manifest containing a brand-new user. The fix is the if dry_run guard at the top of the method — check it before you assume the account exists.

6. Deprovisioning in the right order

deprovision_user locks the password first (usermod -L, which takes effect immediately even though the account still exists), kills any active sessions with pkill -KILL -u, and only then removes the account and home directory with userdel -r. Locking first matters: if you jump straight to userdel and it fails partway (a busy home directory mount is a common cause), you don’t want to have left the account fully active in the meantime.

Example Output

Terminal output of user_provision.rb running in dry-run mode
$ sudo ruby user_provision.rb --config users.yml
RUN: useradd -m -s /bin/bash -c "Alice Nakamura" -G developers,docker alice
Installing SSH key for alice -> /home/alice/.ssh/authorized_keys
RUN: useradd -m -s /bin/bash -c "Bob Smith" -G developers bob-smith
Installing SSH key for bob-smith -> /home/bob-smith/.ssh/authorized_keys

--- Summary ---
created: 2

$ sudo ruby user_provision.rb --config users.yml --revoke bob-smith
RUN: usermod -L bob-smith
RUN: pkill -KILL -u bob-smith
RUN: userdel -r bob-smith

--- Summary ---
removed: 1

Troubleshooting

SymptomCause & Fix
useradd: group 'developers' does not existThis is a real error I hit while testing — groups referenced in a manifest must be created first with groupadd developers. The script deliberately doesn’t auto-create groups, since group membership often maps to access-control decisions someone should make explicitly.
can't find user for X (ArgumentError) during dry-runFixed in the version above by guarding install_ssh_key‘s dry-run path before calling Etc.getpwnam. If you see this, you’re likely running an older/modified copy that lost that guard.
SSH key installed but login still failsCheck that /home, the user’s home directory, and ~/.ssh aren’t group/world-writable anywhere in the path — sshd‘s StrictModes yes (the default) rejects keys if any of those are too permissive, even if authorized_keys itself is 600.
Script exits with “Config file not found”--config defaults to users.yml in the current directory — pass an absolute path if running from cron or a deployment tool.
Need to run against many servers at onceThis script is intentionally single-host. Pair it with the SSH Fleet Command Runner from this same series to push it out and run it across an inventory.

Extending It

  • sudoers snippets: add a sudo_nopasswd: [command1, command2] field to the manifest and have the script drop a file in /etc/sudoers.d/ per user (always validate with visudo -c before considering it applied).
  • Multiple SSH keys per user: change ssh_key to ssh_keys: [...] and loop, which is a one-line change to install_ssh_key.
  • Expiring accounts: pass an expire date from the manifest into useradd -e YYYY-MM-DD for contractor or intern accounts that should self-disable.
  • Slack/webhook notification: wrap the final summary hash in an HTTP POST so provisioning runs announce themselves in a team channel.
  • Windows equivalent: the same manifest-driven reconciliation pattern maps to net user / ADSI calls via win32ole for local Windows accounts, or to the AD-side net-ldap gem for domain accounts.

Leave a Reply

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