User & Permission #
In modern server infrastructure architecture, user and permission management is the main foundation of system security. Doing this manually on dozens or hundreds of servers isn’t just time-consuming — it’s also highly prone to human error, like granting excessive sudo rights, weak passwords, or SSH keys left uncleaned after an admin leaves. Ansible offers an automated, declarative, and idempotent approach to centrally manage user lifecycles, group memberships, shell configuration, SSH authentication, and privilege escalation. With Ansible, you can ensure that all systems stay in a safe, documented state that complies with applicable security standards.
The Philosophy and Challenges of User Management on Production Servers #
When you manage one or two servers, running adduser commands or editing /etc/sudoers manually might seem easy. However, as your infrastructure scales to dozens or hundreds of nodes, several critical challenges start emerging. One of the biggest challenges is UID (User ID) and GID (Group ID) inconsistency. Without automation, the operating system on each server assigns the next available UID/GID randomly based on user creation order. This can cause big problems when you use shared storage like NFS or GlusterFS, where file access permissions depend on UID and GID consistency across all nodes.
Additionally, manual SSH key management is highly vulnerable to security gaps. When a developer or system administrator leaves the team, removing their public SSH keys from every authorized_keys file on every server is a task that’s easily missed. As a result, your servers might stay open to parties who no longer have authority. The same goes for granting administrative access. Without centralized, abstracted sudoers configuration, you often see bad practices where users get unrestricted full access, or even use the root user directly for day-to-day operations.
Ansible solves these problems by treating user configuration as code (User Management as Code). You define the desired state of your server identities in YAML files, and Ansible ensures that state materializes on every managed node consistently and idempotently.
flowchart TD
A["Start Ansible Playbook"] --> B["1. Ensure System & Application Groups Exist"]
B --> C["2. Create System Users (nologin)"]
B --> D["3. Create Operator Users (deployer)"]
C --> E["Apply Consistent UID/GID Configuration"]
D --> F["Apply SHA-512 Password & Bash Shell"]
F --> G["4. Distribute SSH Public Keys"]
G --> H["5. Write Sudoers with visudo Validation"]
H --> I["Done: Server Ready for Use"]Managing System Groups and Custom Groups #
Before creating users, you must make sure the group that will host those users already exists on the system. Groups in Linux act as the first abstraction layer for granting file and directory access permissions. Ansible provides the ansible.builtin.group module for managing system groups.
There are two important parameters in the group module: name to specify the group name, and gid to explicitly set the group ID. Explicitly setting gid is highly recommended for custom application groups so you get full consistency across the server cluster.
Let’s look at the difference between the anti-pattern writing and the recommended solution:
# ANTI-PATTERN: Creating a group without explicitly setting GID, causing random IDs across servers
- name: Create the deployer group
group:
name: deployer
state: present
# CORRECT: Explicitly setting GID to maintain UID/GID consistency across your entire infrastructure
- name: Create the deployer group with a fixed GID
group:
name: deployer
gid: 2000
state: present
When you set gid: 2000, Ansible ensures the deployer group always uses GID 2000 on Ubuntu, CentOS, Debian, and RHEL servers. If the group already exists but uses a different GID, Ansible updates it (unless that GID is already used by another group, in which case Ansible triggers an error to prevent conflicts).
You can also manage several groups at once using a loop so your playbook code stays cleaner and more modular:
- name: Ensure the operational groups are configured
group:
name: "{{ item.name }}"
gid: "{{ item.gid }}"
state: "{{ item.state | default('present') }}"
loop:
- { name: 'admins', gid: 2100 }
- { name: 'developers', gid: 2200 }
- { name: 'auditors', gid: 2300 }
- { name: 'oldgroup', gid: 2400, state: 'absent' }
loop_control:
label: "{{ item.name }}"
In the example above, you’re not only creating new groups, but also ensuring an old unused group (oldgroup) is removed by setting state: absent.
User Account Lifecycle with the user Module #
Once groups are ready, you can start managing user accounts using the ansible.builtin.user module. This module abstracts Linux backend commands like useradd, usermod, and userdel into declarative parameters.
Here are some key parameters you must understand:
name: The username managed on the target server.uid: The unique User ID for that user. Like GID, manually setting UID is highly recommended for non-system users for consistency.group: Sets the primary group for the user.groups: Sets the list of supplementary groups as a list.append: This is a very crucial parameter. If set totrue, Ansible adds the user to new groups without removing them from old groups not listed in thegroupsparameter. If set tofalse(default), the user is removed from all supplementary groups not registered in your playbook.shell: Sets the login shell for the user. For interactive users, you usually use/bin/bash. However, for service accounts (for example users only used to run database daemons or monitoring), you must set it to/usr/sbin/nologinor/sbin/nologinso the account can’t be used for direct login.create_home: Determines whether Ansible should create a home directory (for example/home/username) or not.system: If set totrue, the user is created as a system user with a UID below 1000 and no password expiration time limit.
Let’s look at a complete user lifecycle configuration example:
- name: Manage deployment users and service accounts
block:
# Creating an interactive user with supplementary groups
- name: Create the deployer operator user
user:
name: deployer
uid: 2001
group: deployers
groups:
- sudo
- docker
append: true
shell: /bin/bash
create_home: true
state: present
# Creating a non-interactive service account for security
- name: Create the Prometheus service account
user:
name: prometheus
shell: /usr/sbin/nologin
system: true
create_home: false
state: present
# Cleanly deleting an old user
- name: Remove a former employee user
user:
name: badrun
state: absent
remove: true # Removes the user's home directory and mail spool
Deleting a user with the remove: true option ensures no leftover junk files remain in your server’s /home/ directory that could consume storage space without you noticing.
Password Security with Password Hashing #
When creating a user that requires password authentication (for example for physical console access or as a backup when SSH keys can’t be accessed), you must never write the password in plaintext inside your Ansible code. Writing plaintext passwords in playbook files is a fatal security violation because anyone with access to your Git repository can read those passwords.
Linux stores hashed passwords in the /etc/shadow file. Ansible needs the password already crypted using a hashing algorithm supported by the target operating system (usually SHA-512 for modern Linux distros). You can use the Python password_hash filter to generate this hash dynamically on the control node before it’s sent to managed nodes.
However, to use this filter, make sure the Python passlib library is installed on your control node. You can install it with pip.
Here’s the comparison between the wrong way (plaintext) and the correct way using hashing:
# ANTI-PATTERN: Storing the password in plaintext in play files or inventory
- name: Create a user with a plaintext password
user:
name: adminuser
password: "MySuperSecretPassword123"
# CORRECT: Using a SHA-512 hash with a safe random salt through the password_hash filter
- name: Create a user with a secure SHA-512 password hash
user:
name: adminuser
password: "{{ 'MySuperSecretPassword123' | password_hash('sha512', 'mycustomsalt12345') }}"
state: present
For further security, the plaintext password value should not be written directly in the playbook — instead, store it in an encrypted variable using Ansible Vault, then reference it with variable interpolation:
# vars/main.yml (Ideally encrypted with ansible-vault)
vault_admin_password: "MySuperSecretPassword123"
# tasks/main.yml
- name: Create a user with the password from the vault
user:
name: adminuser
password: "{{ vault_admin_password | password_hash('sha512') }}"
state: present
If you don’t include a salt manually in the password_hash('sha512') filter, Ansible generates a random salt automatically. The advantage is that every time the playbook runs, the generated hash stays the same if the password hasn’t changed, so this task remains idempotent and won’t trigger a changed status if there’s no real password change.
Distributing SSH Keys Safely #
In production environments, password-based authentication for the SSH protocol must be fully disabled. You should force all users to log in using SSH key pairs. Ansible makes distributing users’ public keys to the ~/.ssh/authorized_keys file on managed nodes easy through the ansible.builtin.authorized_key module.
There’s one very powerful yet often overlooked parameter in this module: exclusive. If you set exclusive: true, Ansible not only adds the new SSH keys you specify — it also removes other SSH keys in the target server’s authorized_keys file that aren’t registered in your playbook. This option is crucial for security audits, ensuring no “stealth” SSH keys manually added by outsiders or former administrators remain.
Here’s the implementation of safe, dynamic SSH key distribution:
- name: Distribute SSH keys for operators
authorized_key:
user: deployer
key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOmG... deployer-key"
state: present
exclusive: true
- name: Distribute SSH keys for the developer team using file lookup
authorized_key:
user: "{{ item.username }}"
key: "{{ lookup('file', 'ssh_keys/' + item.username + '.pub') }}"
state: present
exclusive: true
loop: "{{ developers_list }}"
loop_control:
label: "{{ item.username }}"
In the second pattern, you separate user data into a list variable (developers_list) and read the public key files from your local directory (ssh_keys/alice.pub, ssh_keys/bob.pub, etc.) using the file lookup plugin. This pattern is very clean because it separates playbook logic from SSH key data. If you want to add a new developer, just put their .pub file in that folder and add their username to the list variable.
Privilege Escalation with Become #
By default, Ansible logs into managed nodes using the user you specify in the SSH configuration (for example the deployer or ubuntu user). However, most system configuration tasks (like installing packages or editing configuration in /etc/) require administrative (root) privileges. This is where the become feature (privilege escalation) comes in.
You can enable become at the play, block, or individual task level. You can also specify the escalation method (usually sudo) and the destination user (become_user, defaulting to root).
Let’s look at an example implementation of privilege escalation levels:
- name: Server system configuration playbook
hosts: all
become: true # Enables escalation to root for all tasks in this play
tasks:
- name: Write global configuration requiring root
copy:
content: "net.ipv4.ip_forward = 1"
dest: /etc/sysctl.d/99-ip-forward.conf
- name: Run a command as the database user (postgres)
postgresql_db:
name: myapp_db
state: present
become: true
become_user: postgres # Specific escalation to the postgres user, not root
When you use become: true with become_user: postgres, Ansible logs into the server using your SSH user, then runs a sudo command to switch identity to the postgres user before running the database module. This follows the principle of least privilege because the database task is run directly by the database owner user, not by root.
Writing Sudoers Configuration Safely and Validated #
Granting sudo access must be handled very carefully. The /etc/sudoers file and the files inside the /etc/sudoers.d/ directory have very strict syntax rules. A single character typo in a sudoers file can break the entire sudo authentication system on the target server, lock out your administrative access, and prevent you (or Ansible) from fixing the mistake.
To avoid this catastrophe, you must not edit the /etc/sudoers file directly using the lineinfile module without validation. Instead, you should write a separate configuration file in the /etc/sudoers.d/ directory and always use the validate parameter to check the file’s syntax before it’s written to the final destination.
Ansible provides this validation mechanism. If you include the validate: 'visudo -cf %s' parameter, Ansible writes the template content to a temporary file on the target server first, then runs the visudo -cf <temp_file> command. If that command succeeds (exit code 0), Ansible copies the temporary file to the destination (dest). If it fails, Ansible stops task execution, displays an error, and leaves the original file on the server intact without changes.
Let’s compare the anti-pattern and solution for writing sudoers configuration:
# ANTI-PATTERN: Editing /etc/sudoers directly with lineinfile without validation
- name: Add sudo access for deployer
lineinfile:
path: /etc/sudoers
line: "deployer ALL=(ALL) NOPASSWD: ALL"
state: present
# CORRECT: Using a template in /etc/sudoers.d/ with strict visudo validation
- name: Deploy a separate sudoers file for deployer
template:
src: templates/sudoers_deployer.j2
dest: /etc/sudoers.d/deployer
owner: root
group: root
mode: '0440' # sudoers.d files MUST have 0440 permissions
validate: 'visudo -cf %s'
The contents of the templates/sudoers_deployer.j2 template can be limited to only the specific commands that user needs, rather than full passwordless access to all commands:
{# templates/sudoers_deployer.j2 #}
# Giving the deployer user limited permission to manage application services
deployer ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart nginx
deployer ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload nginx
deployer ALL=(ALL) NOPASSWD: /usr/bin/systemctl status nginx
By limiting sudo access to only the systemctl commands for nginx, you narrow the attack vector if that deployer account ever gets compromised by an irresponsible party.
Case Study: Complete User & Permission Role Implementation #
To tie together all the concepts we’ve discussed, let’s build a complete Ansible role that handles user, group, SSH key, and sudo access management dynamically and safely.
Our role’s file structure will look like this:
roles/user_management/
├── defaults/
│ └── main.yml
├── tasks/
│ └── main.yml
└── templates/
└── sudoers.j2
Here’s the contents of the defaults file (roles/user_management/defaults/main.yml) defining our user and group data:
# defaults/main.yml
---
# List of custom groups we want to create
sys_groups:
- { name: "sysadmins", gid: 3000 }
- { name: "devs", gid: 3001 }
- { name: "deployers", gid: 3002 }
# List of users whose lifecycle we manage
sys_users:
- username: "alice"
uid: 3001
primary_group: "sysadmins"
groups: ["sudo"]
shell: "/bin/bash"
password: "$6$rounds=656000$randomsalt$F2e5..." # SHA-512 Hash
ssh_keys:
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAliceKey..."
sudo_rules: "ALL=(ALL) NOPASSWD: ALL"
state: "present"
- username: "bob"
uid: 3002
primary_group: "devs"
groups: []
shell: "/bin/bash"
password: "*" # Disable password login, SSH only
ssh_keys:
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBobKey..."
sudo_rules: "" # No sudo access
state: "present"
- username: "olddev"
state: "absent"
Here’s the main tasks file (roles/user_management/tasks/main.yml) that executes the installation logic based on the defaults data idempotently:
# tasks/main.yml
---
- name: Ensure custom groups are configured on the server
group:
name: "{{ item.name }}"
gid: "{{ item.gid }}"
state: present
loop: "{{ sys_groups }}"
loop_control:
label: "{{ item.name }}"
- name: Manage system and interactive user accounts
user:
name: "{{ item.username }}"
uid: "{{ item.uid | default(omit) }}"
group: "{{ item.primary_group | default(omit) }}"
groups: "{{ item.groups | default([]) }}"
append: true
shell: "{{ item.shell | default('/usr/sbin/nologin') }}"
password: "{{ item.password | default('*') }}"
create_home: "{{ 'true' if item.state | default('present') == 'present' else 'false' }}"
state: "{{ item.state | default('present') }}"
remove: "{{ 'true' if item.state | default('present') == 'absent' else 'false' }}"
loop: "{{ sys_users }}"
loop_control:
label: "{{ item.username }}"
- name: Distribute SSH authorized keys exclusively
authorized_key:
user: "{{ item.username }}"
key: "{{ item.ssh_keys | join('\n') }}"
state: present
exclusive: true
loop: "{{ sys_users }}"
loop_control:
label: "{{ item.username }}"
when:
- item.state | default('present') == 'present'
- item.ssh_keys is defined and (item.ssh_keys | length > 0)
- name: Configure sudoers access for users who have it
template:
src: sudoers.j2
dest: "/etc/sudoers.d/{{ item.username }}"
owner: root
group: root
mode: '0440'
validate: 'visudo -cf %s'
loop: "{{ sys_users }}"
loop_control:
label: "{{ item.username }}"
when:
- item.state | default('present') == 'present'
- item.sudo_rules is defined and (item.sudo_rules | length > 0)
- name: Clean up sudoers files for deleted users
file:
path: "/etc/sudoers.d/{{ item.username }}"
state: absent
loop: "{{ sys_users }}"
loop_control:
label: "{{ item.username }}"
when: item.state | default('present') == 'absent'
And this is the dynamic sudoers template (roles/user_management/templates/sudoers.j2):
{# templates/sudoers.j2 #}
# Sudoers file automatically managed by Ansible for user {{ item.username }}
# Do not edit this file manually because your changes will be overwritten.
{{ item.username }} {{ item.sudo_rules }}
By applying this role, you’ve built a fully declarative user and access management system. Team membership changes, SSH key rotations, and sudo access modifications are now all documented in your Git repository, reviewable through Pull Requests, and safely deployed with syntax validation certainty.
Summary #
- Centralized User Management — Use Ansible to automate user and group lifecycles to avoid UID/GID inconsistencies and security gaps.
- Groups with Fixed GIDs — Always set GIDs explicitly using the
groupmodule to ensure file access alignment across all servers.- Service Account Security — Use the
/usr/sbin/nologinshell and thesystem: trueparameter for non-interactive accounts to minimize security risk.- Safe Group Addition — Set the
append: trueparameter on theusermodule so users aren’t accidentally removed from other groups they already belong to.- SHA-512 Password Encryption — Never store plaintext passwords in playbooks. Use the
password_hash('sha512')filter and secure the values in Ansible Vault.- SSH Key Auditing with Exclusive — Enable
exclusive: trueon theauthorized_keymodule to remove unofficial SSH keys registered on servers.- visudo Validation Before Writing — Always use the
validate: 'visudo -cf %s'parameter when editing or deploying sudoers configuration files to prevent system failures.- Proper Sudoers File Permissions — Apply
0440permissions to files in/etc/sudoers.d/so they’re read correctly by the Linux security system.