Ansible User Management: How to Enforce Exact User Lists and Automate User Removal


8 views

When managing Linux users with Ansible, a common pitfall is assuming that removing a user from your variables file will automatically remove it from target systems. The standard user module only creates or modifies users - it doesn't handle deletions when users are removed from your configuration.

Here's a robust approach that combines multiple techniques to maintain an exact user list:


- name: Get current system users in adm group
  shell: "getent group {{ common_adm_group }} | cut -d: -f4 | tr ',' '\\n'"
  register: current_users
  changed_when: false

- name: Calculate users to be removed
  set_fact:
    users_to_remove: "{{ current_users.stdout_lines | difference(common_adm_users | map(attribute='name')) }}"

- name: Remove obsolete adm users
  user:
    name: "{{ item }}"
    state: absent
    remove: yes
  loop: "{{ users_to_remove }}"
  when: users_to_remove | length > 0

- name: Create/update adm users
  user:
    name: "{{ item.name }}"
    group: "{{ common_adm_group }}"
    createhome: yes
    password: "!!"
    update_password: always
    state: present
  loop: "{{ common_adm_users }}"

For a complete solution, we should also manage SSH keys and other user attributes:


- name: Ensure authorized_keys for adm users
  authorized_key:
    user: "{{ item.name }}"
    key: "{{ item.ssh_key }}"
    state: present
    exclusive: yes
  loop: "{{ common_adm_users }}"
  when: item.ssh_key is defined

For enterprise environments, consider these enhancements:


# vars/main.yml
required_users:
  - name: user1
    uid: 2001
    groups: [adm, docker]
    ssh_key: "ssh-rsa AAAAB3Nza..."
    shell: /bin/bash
  - name: user2
    uid: 2002
    groups: [adm]
    shell: /usr/sbin/nologin

# tasks/main.yml
- name: Ensure exact user list
  block:
    - name: Get current users in specified groups
      shell: |
        for group in adm docker; do
          getent group $group || continue
          getent group $group | cut -d: -f4 | tr ',' '\\n'
        done | sort -u
      register: current_users_all
      changed_when: false

    - name: Remove obsolete users
      user:
        name: "{{ item }}"
        state: absent
        remove: yes
      loop: "{{ current_users_all.stdout_lines | difference(required_users | map(attribute='name')) }}"
      when: current_users_all.stdout_lines | length > 0

    - name: Create/update required users
      user:
        name: "{{ item.name }}"
        uid: "{{ item.uid }}"
        groups: "{{ item.groups | default(omit) }}"
        shell: "{{ item.shell | default('/bin/bash') }}"
        state: present
        system: no
        createhome: yes
      loop: "{{ required_users }}"

The community.general.users module provides a more declarative approach:


- name: Manage users declaratively
  community.general.users:
    name: "{{ item.name }}"
    state: present
    groups: "{{ item.groups | default(['users']) }}"
    append: no
    shell: "{{ item.shell | default('/bin/bash') }}"
  loop: "{{ required_users }}"

- name: Purge unlisted users
  community.general.users:
    name: "{{ item }}"
    state: absent
    purge: yes
  loop: "{{ current_users.stdout_lines | difference(required_users | map(attribute='name')) }}"
  when: current_users.stdout_lines | difference(required_users | map(attribute='name')) | length > 0

Remember to test these playbooks with --check mode before applying to production systems, as user deletion is a destructive operation.


When managing Linux users with Ansible, a common pitfall occurs when trying to maintain an exact set of authorized users across servers. The standard user module creates users but doesn't handle removal when users are deleted from your configuration.

# Typical user creation task
- name: Create admin users
  user:
    name: "{{ item.name }}"
    group: "admins"
    state: present
  loop: "{{ authorized_users }}"

To enforce exact user lists, we need to combine creation with removal logic. Here's a robust approach:

- name: Get current users in admin group
  shell: "getent group admins | cut -d: -f4 | tr ',' '\\n'"
  register: current_admin_users
  changed_when: false

- name: Calculate users to remove
  set_fact:
    users_to_remove: "{{ current_admin_users.stdout_lines | difference(authorized_users | map(attribute='name')) }}"

- name: Remove unauthorized admin users
  user:
    name: "{{ item }}"
    state: absent
    remove: yes
  loop: "{{ users_to_remove }}"
  when: users_to_remove | length > 0

- name: Create/update authorized users
  user:
    name: "{{ item.name }}"
    group: "admins"
    shell: "/bin/bash"
    ssh_key: "{{ item.ssh_key | default(omit) }}"
    state: present
  loop: "{{ authorized_users }}"

For production environments, consider these enhancements:

- name: Protect critical system users
  set_fact:
    users_to_remove: "{{ users_to_remove | difference(['root', 'backup']) }}"

- name: Clean up residual files
  file:
    path: "/home/{{ item }}"
    state: absent
  loop: "{{ users_to_remove }}"
  when: users_to_remove | length > 0

Here's a full implementation with error handling:

- name: User management pre-checks
  assert:
    that:
      - "'root' not in authorized_users | map(attribute='name')"
      - authorized_users is defined
    fail_msg: "Invalid user configuration detected"

- name: Synchronize admin users
  block:
    - name: Get current admins (cross-platform)
      ansible.builtin.shell: |
        if command -v getent >/dev/null; then
          getent group admins | cut -d: -f4 | tr ',' '\\n'
        elif command -v dscl >/dev/null; then # macOS
          dscl . -read /Groups/admins GroupMembership | cut -d' ' -f2-
        fi
      register: current_admins
      changed_when: false
      ignore_errors: true

    - name: Normalize current admin list
      set_fact:
        normalized_current_admins: "{{ (current_admins.stdout_lines | default([])) | flatten | map('trim') | select('match', '^[a-zA-Z]') | list }}"

    - name: Calculate delta
      set_fact:
        users_to_remove: "{{ normalized_current_admins | difference(authorized_users | map(attribute='name') | list) }}"
        users_to_add: "{{ authorized_users | map(attribute='name') | difference(normalized_current_admins) }}"

    - name: Remove obsolete users
      user:
        name: "{{ item }}"
        state: absent
        remove: yes
      loop: "{{ users_to_remove }}"
      when: users_to_remove | length > 0
      tags: [user-removal]

    - name: Deploy authorized users
      user:
        name: "{{ item.name }}"
        uid: "{{ item.uid | default(omit) }}"
        group: "{{ item.group | default('admins') }}"
        groups: "{{ item.groups | default(omit) }}"
        append: "{{ item.append | default(true) }}"
        shell: "{{ item.shell | default('/bin/bash') }}"
        ssh_key: "{{ item.ssh_key | default(omit) }}"
        state: present
      loop: "{{ authorized_users }}"
      tags: [user-deployment]

Always verify with check mode first:

ansible-playbook site.yml --limit=production --tags=user-management --check