Automating user access management at scale: from Bash and cPanel WHM to Ansible orchestration
Back to blog

Automating user access management at scale: from Bash and cPanel WHM to Ansible orchestration

9/23/2026 · 3 min · DevOps

Anyone managing multiple Linux servers knows that manual user onboarding, offboarding, and privilege delegation (sudo/wheel) frequently lead to operational drift and security inconsistencies.

In hybrid server fleets (where some machines run control panels like WHM/cPanel and others run standard Linux distributions) the challenge is ensuring that account provisioning executes consistently and auditably across dozens of nodes.

Here is the practical evolution of an access automation workflow: from building a dual-mode Bash script with automated logrotate provisioning to scaling it with Ansible without rewriting established logic.


1. The starting point: a dual-mode Bash script#

The initial goal was creating a CLI tool (jvx_mngr) to automate creating (-a) and removing (-d) administrative users while adapting to the target host environment.

Key script architecture requirements:#

  1. Execution and Parameter Validation: Ensure root execution and parse arguments via getopts.
  2. Dual Execution Engine (WHM API vs. Native Linux):
  1. Human Error Safeguards: A protected accounts array (USUARIO_PROTEGIDO=(admin_master backup_user root_corp)) to prevent accidental deletion of core accounts.
  2. Audit Logging: Record timestamped entries including the operator (whoami) and hostname to /var/log/jvx_mngr.log.

Idempotent self-provisioned logrotate#

To prevent log files from growing indefinitely without requiring manual setup on each node, the script includes an internal logrotate configuration function:

# --- Logrotate Configuration ---
function configurar_logrotate() {
    local LOGROTATE_CONFIG="/etc/logrotate.d/jvx_mngr"

    if [[ -f "${LOGROTATE_CONFIG}" ]]; then
        return 0
    fi

    print_info "Creating logrotate configuration in ${LOGROTATE_CONFIG}..."

    cat <<'EOF' > "${LOGROTATE_CONFIG}"
# Logrotate configuration for jvx_mngr script
/var/log/jvx_mngr.log {
    monthly
    rotate 6
    compress
    delaycompress
    missingok
    notifempty
    create 640 root root
}
EOF

    if [[ $? -eq 0 ]]; then
        chmod 644 "${LOGROTATE_CONFIG}"
        print_success "Logrotate configuration created successfully."
        log "Created logrotate rule in ${LOGROTATE_CONFIG}."
    else
        print_error "Failed to create logrotate configuration."
        log "ERROR: Failed to create logrotate rule in ${LOGROTATE_CONFIG}."
    fi
}

Invoking this during the main execution routine provides idempotency: if the configuration exists in /etc/logrotate.d/, execution proceeds silently; otherwise, it sets up monthly rotation with compression and 640 permissions.


2. Scaling out: orchestrating without rewriting#

Once verified locally, the next requirement was running the routine across server fleets using Ansible.

Rewriting proven procedural Bash scripts into native Ansible modules often introduces subtle bugs in complex API edge cases (such as WHM reseller creation). The optimal architecture is using Ansible as the orchestrator while keeping Bash as the domain tool.

Two-playbook architecture#

To maintain a clean separation of concerns:

  1. Installation Playbook (instalar_jvx_mngr.yml): Deploys the script to /usr/local/bin and sets 0755 permissions. Runs only during node provisioning or version updates.
  2. Daily Operations Playbook (usar_jvx_mngr.yml): Prompts the operator for input and invokes the deployed binary across target hosts.

3. Solving Ansible terminal UX challenges#

Building the operational playbook presented two visual and prompt sequencing challenges:

Problem 1: Broken ASCII banners in debug#

Attempting to output an ASCII banner using the debug: msg: module resulted in escaped newline characters (\n) inside a JSON block:

TASK [Displaying banner] **************************************
ok: [localhost] => {
    "msg": "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⢴⣃\n⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⢴⣫⢗⡯⡖..."
}

Solution: The debug module is intended for variable inspection. The fix was running a localhost task using the shell module with a Here Document (cat <<'EOF'), printing cleanly to standard output:

    - name: Display banner using shell module
      shell: |
        cat <<'EOF'
        [ASCII ART HERE]
        EOF
      args:
        warn: false

Problem 2: Displaying instructions before vars_prompt#

Because vars_prompt blocks immediately before any tasks in a play execute, displaying instructions required chaining two distinct plays in a single YAML file:


4. The complete operational playbook#

# usar_jvx_mngr.yml

# --- Play 1: UI & Instructions on Localhost ---
- name: Display Banner and Instructions
  hosts: localhost
  gather_facts: no

  tasks:
    - name: Print ASCII Banner
      shell: |
        cat <<'EOF'
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⢴⣃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⢴⣫⢗⡯⡖⠀⠀⠀⠀⠀⢠⡄⠀⠀⠀⠀⠀⠀⠀⠀⢠⡤⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⢨⢷⡹⢮⣳⠇⠀⠀⠀⠀⠀⠈⠁⠀⠀⠀⠀⠀⠀⠀⠀⠈⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠠⠐⣀⠂⡀⢸⢧⡻⣝⢮⡏⠀⠀⠀⠀⠀⢸⡇⢀⣾⠟⠿⠻⠿⣷⠀⢸⣏⠀⣿⡟⠿⠻⠿⣶⡀⢸⣿⠀⠀⠀⢀⣿⠃⢸⡏⠀⠙⢿⣦⣀⣼⡟⠁⠀⠀
⠀⠀⠤⠑⡈⢡⠀⠆⡁⢢⣉⠓⢯⡳⡇⠀⠀⠀⠀⠀⢸⡇⠨⣿⠀⠀⠀⠀⣿⡁⢸⡧⠀⣿⡂⠀⠀⠀⢿⡃⢸⡷⠀⠀⢠⣾⠋⠀⢸⡗⠀⠀⠀⣹⣿⣇⠀⠀⠀⠀
⠀⠀⠀⠀⠁⠂⡌⠐⡄⢡⠢⡙⢤⠉⠇⠀⠀⠀⠀⠀⢸⡇⠈⣿⣤⣤⣤⣴⡿⠁⢸⡧⠀⣿⡅⠀⠀⠀⢿⡇⢘⣿⣤⡾⠟⠁⠀⠀⢸⡧⠀⢠⣾⠟⠈⢻⣦⡀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠡⠐⢨⡑⠘⠀⠀⠀⠀⠀⠀⠀⠀⣸⡇⠀⠀⠉⠉⠉⠈⠁⠀⠈⠁⠀⠉⠀⠀⠀⠀⠉⠁⠀⠉⠁⠀⠀⠀⠀⠀⠈⠁⠀⠉⠁⠀⠀⠀⠉⠉⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠿⠻⠟⠟⠁⠀User Access Management via Ansible
        EOF
      args:
        warn: false

    - name: Print Usage Instructions
      shell: |
        cat <<'EOF'
------------------------------------------------------------------------------------
INSTRUCTIONS:
- Enter the required parameters for remote server execution.
- When deleting an account (-d), press [Enter] to leave email and level blank.
------------------------------------------------------------------------------------
        EOF
      args:
        warn: false

# --- Play 2: Input Collection & Remote Execution ---
- name: Execute jvx_mngr to manage user access
  hosts: target_servers
  become: yes

  vars_prompt:
    - name: "acao"
      prompt: "Action to perform? ('-a' to add, '-d' to delete)"
      private: no
    - name: "usuario"
      prompt: "Username"
      private: no
    - name: "email"
      prompt: "User email (press Enter if deleting)"
      private: no
      default: ""
    - name: "nivel"
      prompt: "ACL level (n1, n2, n3) (press Enter if deleting)"
      private: no
      default: ""

  tasks:
    - name: Execute jvx_mngr on remote targets
      command:
        cmd: "/usr/local/bin/jvx_mngr {{ acao }} {{ usuario }} {{ email }} {{ nivel }}"
      register: command_output

    - name: Display remote execution output
      debug:
        var: command_output.stdout_lines

Practical automation takeaways#

  1. Avoid Unnecessary Refactoring: Field-tested Bash scripts with specific API bindings (whmapi1) pair effectively with Ansible's parallel execution model.
  2. Decouple Deployment from Operations: Separating deployment tasks from operational playbooks reduces execution overhead.
  3. Prioritize Operator Ergonomics: Clear terminal banners, formatted instructions, and structured inputs minimize operational errors during bulk operations.

Was this article helpful?

Leave a quick reaction to help prioritize future technical guides:

CC BY-NC

This post is licensed under CC BY-NC.

Comments

Join the discussion below.

0 comments