Skip to main content
RunBook Academy

AnsibleXVIII · Files and Configuration ManagementFiles and configuration management

Permissions and context as desired state

Intermediate⏱ ~18 minansible-playbookansible-doc

What you'll learn

  • Declare ownership, mode and context in the task that creates the file
  • Explain what an omitted mode does on a new file and why CVE-2020-1736 exists
  • Diagnose a config file that is deployed correctly and unreadable by its service
  • Set SELinux context from a file-writing module rather than a separate command
  • Distinguish a permissions problem from an SELinux denial from the evidence

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

A file has contents and it has metadata. Automation reliably declares the first and routinely forgets the second, and the resulting incident has a characteristic shape: the change deployed successfully and the service is down.

The play is green. diff shows the file is exactly what the template renders. The service cannot read it.

The metadata is part of the state

Every file-writing module in ansible.builtincopy, template, file, lineinfile, blockinfile, assemble — takes the same metadata options, because they share a documentation fragment:

OptionWhat it sets
ownerUser, as fed to chown
groupGroup, as fed to chown
modePermission bits, octal string or symbolic
seuser, serole, setype, selevelThe four parts of the SELinux context
attributeschattr flags, in lsattr order

They belong in the task that writes the file. Not in a file task afterwards, and never in a separate “fix permissions” role that runs later in the play.

What an omitted mode actually does

The documentation states the rule and then, unusually, cites a CVE:

If mode is not specified and the destination filesystem object does not exist, the default umask on the system will be used when setting the mode for the newly created filesystem object.

If mode is not specified and the destination filesystem object does exist, the mode of the existing filesystem object will be used.

Specifying mode is the best way to ensure filesystem objects are created with the correct permissions. See CVE-2020-1736 for further details.

Three things follow.

An omitted mode is a decision, delegated to the host. A host with umask 022 gets 0644; a host with umask 002 gets 0664, group-writable. Nothing in the repository records which you got, and the two hosts differ permanently.

The behaviour is different for a file that already exists. The existing mode is preserved — so a task that omits mode will never correct a wrong mode, and a file that someone chmod 777-ed stays that way through every subsequent run of your automation. The run reports ok.

Both together produce the divergence that drift detection is for. The same play, the same template, two hosts, two different modes, and no task ever reports changed.

The failure this lesson is named after

A config file deployed perfectly and unreadable by the service that needs it. It has one of three causes, and they look identical from the application log.

Cause one: the service does not run as root. The file was written by Ansible as root:root with 0600. nginx reads its main config as root and then drops privileges — but a certificate key read by a worker after the drop is a different matter. The rule is that the mode must suit the identity that opens the file at the moment it opens it, which is not always the identity that started the service.

Cause two: the directory, not the file. A file with 0644 inside a directory with 0700 owned by root is unreadable by anyone else, whatever the file says. Directory traversal needs the execute bit on every component of the path.

Cause three: the account does not exist yet. owner: appsvc on a host where the package has not been installed fails the task with chown failed: failed to look up user appsvc. In a role that installs the package and writes the config, ordering fixes it; in a role that only writes config, the dependency is real and needs stating.

Configuration changethe directory and the file, declared together
- name: Create the application config directory
ansible.builtin.file:
  path: /etc/app
  state: directory
  owner: root
  group: appsvc
  mode: '0750'          # root writes, appsvc traverses, nobody else enters

- name: Install the TLS private key
ansible.builtin.copy:
  src: "{{ app_tls_key_src }}"
  dest: /etc/app/tls.key
  owner: root
  group: appsvc
  mode: '0640'          # appsvc reads via group, no world access
no_log: true
notify: Reload the application

Note the shape: root:appsvc with group read, rather than appsvc:appsvc with owner read. The service account can read the key and cannot rewrite it. That distinction survives a compromise of the service account, and it costs nothing to write.

SELinux context is separate from the mode

On a RHEL-family managed node with SELinux enforcing, a file can have a perfect mode and still be unreadable, because the context is wrong. Every file-writing module accepts the four context parts directly:

Configuration changesetting type context on the writing task
- name: Render the named configuration
ansible.builtin.template:
  src: named.conf.j2
  dest: /etc/named.conf
  owner: root
  group: named
  mode: '0640'
  setype: named_conf_t
  validate: /usr/sbin/named-checkconf %s

The documented values _default for seuser, serole, setype and selevel mean “take this part from the policy”, which is what you want for a file in a standard location. An explicit setype is for files in non-standard locations where the policy would otherwise assign the wrong type.

Verifying what landed

stat returns permissions, ownership and — when the module can get it — the SELinux context, all read-only.

Read-only / Safecheck mode, owner and context together
ansible -i inventories/prod app -m ansible.builtin.stat \
-a "path=/etc/app/tls.key get_checksum=false" \
| grep -E '"mode"|"pw_name"|"gr_name"|"secontext"'
Read-only / Safeone host out of step
$ ansible -i inventories/prod app -m ansible.builtin.stat -a 'path=/etc/app/tls.key get_checksum=false'
app01.example.com | SUCCESS => {
  "stat": {
      "mode": "0640",
      "pw_name": "root",
      "gr_name": "appsvc",
      "secontext": "system_u:object_r:etc_t:s0"
  }
}
app02.example.com | SUCCESS => {
  "stat": {
      "mode": "0644",
      "pw_name": "root",
      "gr_name": "root",
      "secontext": "unconfined_u:object_r:etc_t:s0"
  }
}

Illustrative output

app02 is world-readable and the play has never reported changed for it, because the task that writes the file does not declare mode, and an existing file keeps the mode it has. Adding mode and group to the writing task fixes it on the next run and reports changed once — which is the honest signal an audit trail should carry.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A template task omits mode. The destination file already exists on 40 hosts, with mode 0644 on 38 of them and 0777 on two where someone debugged an outage last year. What does the run do?

  2. Q2. A service cannot read /etc/app/tls.key, which stat shows as mode 0640 owned by root:appsvc, and the service runs as appsvc. Which are plausible causes worth checking? Select all that apply.

  3. Q3. Setting permissions in a file task immediately after the template task that creates the file is equivalent to declaring them on the template task.

  4. Q4. Why does an Ansible-written file sometimes land in /etc with a home-directory SELinux context?

Passing score: 75%. Answers are checked in this browser.