Ansible Lesson 20 – User and Permission Management | Dataplexa
Section II · Lesson 20

User and Permission Management

In this lesson

user module group module authorized_key File permissions ACLs & sudoers

User and permission management is the practice of using Ansible to create, modify, and remove user accounts, groups, SSH keys, and file access controls in a consistent, auditable, and repeatable way. Managing access manually — adding users by logging into each server, editing sudoers by hand, copying SSH keys one at a time — is error-prone, unauditable, and impossible to scale. Ansible's user, group, and authorized_key modules make access provisioning as reliable and version-controlled as any other infrastructure change, and they ensure that every server in a fleet has exactly the right users with exactly the right permissions at all times.

The user Module

The ansible.builtin.user module creates, modifies, or removes user accounts. It is fully idempotent — running it on a host where the user already exists with the correct settings reports ok and makes no changes.

Key parameters

name The login name of the user account. Required.
state present (default) creates or updates the user. absent removes the user and, with remove: true, their home directory and mail spool.
groups Supplementary groups to add the user to. Always pair with append: true to avoid removing the user from existing groups not listed here.
append When true, adds the specified groups to the user's existing groups rather than replacing them. Always set this to true unless you explicitly intend to replace all supplementary groups.
shell Login shell path. Use /bin/bash for interactive users, /bin/false or /usr/sbin/nologin for service accounts that should never have interactive shell access.
create_home Whether to create a home directory. Default true. Set to false for system service accounts that do not need a home directory.
system When true, creates a system account with a UID below the system's normal user threshold (UID_MIN). Use for service accounts running daemons — they appear in /etc/passwd but not in user-facing account listings.
- name: Create deploy user for application deployments
  ansible.builtin.user:
    name: deploy
    comment: "Application deploy user"
    shell: /bin/bash
    groups: sudo
    append: true               # add to sudo — do not replace existing groups
    create_home: true
    state: present

- name: Create nginx service account (no shell, no home)
  ansible.builtin.user:
    name: nginx
    system: true               # system account — low UID, not listed in users
    shell: /usr/sbin/nologin   # no interactive login
    create_home: false
    state: present

- name: Remove a decommissioned user and their home directory
  ansible.builtin.user:
    name: oldemployee
    state: absent
    remove: true               # delete home dir and mail spool

# Loop over a list of users to provision multiple accounts
- name: Provision all application team members
  ansible.builtin.user:
    name: "{{ item.name }}"
    shell: "{{ item.shell | default('/bin/bash') }}"
    groups: "{{ item.groups | default('') }}"
    append: true
    state: present
  loop: "{{ app_users }}"      # defined in group_vars

The HR Database Analogy

Managing users with Ansible is like maintaining a company's HR database rather than issuing access cards by hand. The database (your group_vars user list) is the source of truth — everyone on the list has access, everyone not on the list does not. When someone joins, you add them to the list and re-run the playbook. When someone leaves, you remove them and re-run. Every server in the fleet reflects the current HR database automatically, with a full audit trail in Git.

The group Module

The ansible.builtin.group module creates, modifies, or removes system groups. In most playbooks, group creation comes before user creation — you cannot add a user to a group that does not exist yet.

# Create application groups before creating users that belong to them
- name: Create application groups
  ansible.builtin.group:
    name: "{{ item.name }}"
    gid: "{{ item.gid | default(omit) }}"  # omit = let the OS assign a GID
    system: "{{ item.system | default(false) }}"
    state: present
  loop:
    - { name: appusers,  gid: 2000 }
    - { name: deployers, gid: 2001 }
    - { name: monitors,  system: true }   # system group for monitoring agent

- name: Remove a decommissioned group
  ansible.builtin.group:
    name: oldteam
    state: absent

Managing SSH Keys with authorized_key

The ansible.posix.authorized_key module manages SSH public keys in a user's ~/.ssh/authorized_keys file. It is idempotent — adding a key that is already present reports ok. This module is the correct way to provision SSH access for users across a fleet; manually copying keys is error-prone and leaves no audit trail.

# Add a single key from a string variable
- name: Authorise deploy user SSH key
  ansible.posix.authorized_key:
    user: deploy
    key: "{{ deploy_ssh_public_key }}"   # stored in group_vars or Vault
    state: present

# Add a key from a file on the control node
- name: Authorise ops team SSH key
  ansible.posix.authorized_key:
    user: deploy
    key: "{{ lookup('file', 'files/keys/ops_team.pub') }}"
    state: present

# Provision multiple team members' keys in one task
- name: Authorise all developer SSH keys
  ansible.posix.authorized_key:
    user: "{{ item.username }}"
    key: "{{ item.public_key }}"
    state: present
  loop: "{{ developer_accounts }}"      # list of {username, public_key} dicts

# Remove a key — e.g. when an employee leaves
- name: Revoke access for departed team member
  ansible.posix.authorized_key:
    user: deploy
    key: "{{ departed_employee_public_key }}"
    state: absent                        # removes the key from authorized_keys

# Exclusive mode — replace ALL keys with exactly this set
# Use with extreme caution — removes any key not in the list
- name: Set authorised keys exclusively (replaces all existing)
  ansible.posix.authorized_key:
    user: deploy
    key: "{{ item }}"
    state: present
    exclusive: true
  loop: "{{ authorised_keys }}"
TASK [Authorise all developer SSH keys] ***************************************
changed: [web01.example.com] => (item={'username': 'alice', 'public_key': 'ssh-ed25519 AAAA...'})
ok:      [web01.example.com] => (item={'username': 'bob',   'public_key': 'ssh-ed25519 BBBB...'})
changed: [web01.example.com] => (item={'username': 'carol', 'public_key': 'ssh-ed25519 CCCC...'})

# Alice and Carol's keys were new (changed). Bob's key was already present (ok).

What just happened?

Each loop iteration independently checked whether the key was already in authorized_keys. Alice and Carol's keys were new and were added (changed). Bob's key was already authorised, so nothing changed (ok). This per-key idempotency means running this task after onboarding a new developer only adds their key — it does not touch anyone else's.

File Permissions at Scale

File permissions in Ansible are managed through the mode, owner, and group parameters on the file, copy, and template modules. Setting permissions correctly — and consistently across your fleet — is a security requirement, not just a best practice.

Config files

0644 — readable, owner writable

Standard for non-sensitive config files — Nginx configs, application settings, cron files. World-readable, only owner can write.

Secret files

0600 — owner only

Passwords, API keys, private keys, database credentials. Only the owner can read or write. Used for SSH private keys and application secret files.

Directories

0755 — world traversable

Web roots, application directories, shared data. Everyone can traverse and read; only owner can create or delete files.

Executables

0755 — world executable

Scripts, binaries, init scripts. Owner can read/write/execute; everyone else can read and execute but not modify.

# Recursive permission fix — set all files in a directory tree
- name: Fix permissions on application directory
  ansible.builtin.file:
    path: /var/www/app
    owner: "{{ app_user }}"
    group: "{{ app_group }}"
    mode: "0755"
    recurse: true              # apply to all files and subdirectories recursively

# Set ACL to give the web server read access to the app's shared files
- name: Grant www-data read access to shared config via ACL
  ansible.posix.acl:
    path: /var/www/app/shared/config
    entity: www-data
    etype: user
    permissions: rx
    state: present
    recursive: true

Sudoers Management

Granting and revoking sudo access through Ansible keeps privilege escalation auditable and consistent. The correct approach is using lineinfile or — better for complex policies — dedicated drop-in files in /etc/sudoers.d/. Always validate before writing.

# Best practice: deploy a validated drop-in file to /etc/sudoers.d/
# Safer than editing /etc/sudoers directly — one bad file does not break all of sudo
- name: Deploy deploy-user sudoers policy
  ansible.builtin.copy:
    content: |
      # Managed by Ansible — do not edit manually
      # Allows deploy user to restart application services without a password
      deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart myapp, \
                                  /usr/bin/systemctl reload nginx, \
                                  /usr/bin/systemctl status *
    dest: /etc/sudoers.d/deploy
    owner: root
    group: root
    mode: "0440"               # sudoers files must be 0440 — world-unreadable
    validate: "visudo -cf %s"  # validate BEFORE writing — prevents lockout

# Remove a sudoers policy when access is revoked
- name: Remove departed team member sudoers file
  ansible.builtin.file:
    path: /etc/sudoers.d/oldemployee
    state: absent

What just happened?

Deploying a drop-in file to /etc/sudoers.d/ is safer than editing /etc/sudoers directly because a syntax error in a drop-in file only breaks access to that file's policies — not the entire sudo system. The validate parameter runs visudo -cf %s against a temporary copy of the file before writing it to disk. If validation fails, the original file remains untouched and the task reports failed. Your users retain sudo access.

Full Scenario: User Lifecycle Playbook

The scenario: An operations team manages access for a growing engineering organisation. Their policy: every engineer has a personal account on every server, with SSH key authentication, no shared accounts, sudo access only for senior engineers, and immediate access revocation when someone leaves. All of this is driven from a single variable file committed to Git.

Variable file — group_vars/all.yml

---
engineering_users:
  - name: alice
    public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJx... alice@laptop"
    groups: sudo            # senior engineer — gets sudo
    state: present

  - name: bob
    public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKq... bob@laptop"
    groups: ""              # junior engineer — no sudo
    state: present

  - name: charlie
    public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMn... charlie@laptop"
    groups: ""
    state: absent           # left the company — revoke access

The playbook — users.yml

---
- name: Manage engineering user accounts across the fleet
  hosts: all
  become: true

  tasks:
    - name: Create or remove user accounts
      ansible.builtin.user:
        name: "{{ item.name }}"
        shell: /bin/bash
        groups: "{{ item.groups }}"
        append: true
        state: "{{ item.state }}"
        remove: "{{ item.state == 'absent' }}"  # delete home dir when removing
      loop: "{{ engineering_users }}"
      loop_control:
        label: "{{ item.name }} ({{ item.state }})"

    - name: Manage SSH authorised keys
      ansible.posix.authorized_key:
        user: "{{ item.name }}"
        key: "{{ item.public_key }}"
        state: "{{ item.state }}"
      loop: "{{ engineering_users }}"
      loop_control:
        label: "{{ item.name }}"
      # When state=absent, this removes the key from authorized_keys
      # even if the user account still briefly exists during teardown
PLAY [Manage engineering user accounts across the fleet] *********************

TASK [Create or remove user accounts] ***************************************
ok:      [web01] => (item=alice (present))   <-- already exists, no change
ok:      [web01] => (item=bob (present))     <-- already exists, no change
changed: [web01] => (item=charlie (absent))  <-- account removed

TASK [Manage SSH authorised keys] *******************************************
ok:      [web01] => (item=alice)   <-- key already in authorized_keys
ok:      [web01] => (item=bob)     <-- key already in authorized_keys
changed: [web01] => (item=charlie) <-- key removed from authorized_keys

PLAY RECAP ******************************************************************
web01 : ok=2  changed=2  unreachable=0  failed=0  skipped=0

Never Use append: false When Adding a User to Groups

The user module's default for append is false. This means if you specify groups: sudo without append: true, Ansible replaces the user's entire supplementary group membership with just sudo — potentially removing them from other groups like docker, www-data, or adm that were added by other playbooks or system packages. Always set append: true unless you explicitly want to replace all supplementary groups with exactly the ones listed.

Key Takeaways

Always use append: true when adding users to groups — the default is false, which replaces all supplementary groups with only those you specify and silently removes any others.
Service accounts should use shell: /usr/sbin/nologin and system: true — this prevents interactive login, assigns a low UID, and clearly marks the account as a daemon rather than a human user.
Store SSH public keys in group_vars, not playbooks — keeping keys in variable files makes it easy to add or revoke access by editing one place and re-running the playbook across the whole fleet.
Deploy sudoers rules as drop-in files in /etc/sudoers.d/ — always with mode: "0440" and validate: "visudo -cf %s" to prevent syntax errors from locking users out of sudo.
Drive user management from a variable file in Git — the user list is your source of truth. Adding state: absent for a departing engineer and re-running the playbook revokes their access on every server in the fleet simultaneously.

Teacher's Note

Build the user lifecycle playbook from this lesson for your own team — even if it is just two or three people. The moment you use it to onboard someone new and watch their SSH key appear on every server in seconds, the value of managing access as code becomes immediately, permanently obvious.

Practice Questions

1. When using ansible.builtin.user to add a user to the docker group without removing them from any groups they already belong to, which parameter and value must you include?



2. What file permission mode must be set on files in /etc/sudoers.d/ for sudo to accept them?



3. Which Ansible module manages SSH public keys in a user's authorized_keys file?



Quiz

1. You run a user task that specifies groups: sudo but does not include append: true. The user was previously in the docker and www-data groups. What happens?


2. What is the safest way to grant a user specific sudo permissions across your fleet using Ansible?


3. An engineer leaves the company. Using the user lifecycle pattern from this lesson, what is the correct Ansible approach to revoke their access across the entire fleet?


Up Next · Lesson 21

Error Handling

Learn to write playbooks that handle failures gracefully — rescuing from errors, running cleanup tasks on failure, and building automation that is resilient to the unexpected.