AnsibleXVIII · Files and Configuration ManagementFiles and configuration management
Permissions and context as desired state
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
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.builtin — copy, template,
file, lineinfile, blockinfile, assemble — takes the same
metadata options, because they share a documentation fragment:
| Option | What it sets |
|---|---|
owner | User, as fed to chown |
group | Group, as fed to chown |
mode | Permission bits, octal string or symbolic |
seuser, serole, setype, selevel | The four parts of the SELinux context |
attributes | chattr 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
modeis not specified and the destination filesystem object does not exist, the defaultumaskon the system will be used when setting the mode for the newly created filesystem object.If
modeis not specified and the destination filesystem object does exist, the mode of the existing filesystem object will be used.Specifying
modeis 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.
- 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 applicationNote 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:
- 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 %sThe 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.
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"'$ 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
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?
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.
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.
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.