AnsibleIII · Installing and Designing the ControllerController design
Controller filesystem layout and ownership
What you'll learn
- Lay out a controller filesystem with an owner and a mode for every path
- Explain why Ansible ignores ansible.cfg in a world-writable directory, and recognise the symptom
- Identify which controller paths hold secret material and what a read of each one yields an attacker
- Choose permissions on a shared controller that do not turn repository write access into fleet root
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 controller is a small collection of files with an unusually large reach. Write access to some of them is write access to every host in the estate — not by exploiting anything, just by using the tool as designed.
This lesson is the layout: which paths exist, who owns each, what mode it carries, and what somebody gets by reading it. Hardening the controller as a host — SSH configuration, key rotation, audit — is a later part. This is the design decision you make on day one, which determines how much that later part has to fix.
The layout
/opt/estate/ root:ansible-ops 0750 the checkout
├── ansible.cfg root:ansible-ops 0644 behaviour
├── requirements.txt root:ansible-ops 0644 runtime pin
├── requirements.yml root:ansible-ops 0644 collection pins
├── inventory/ root:ansible-ops 0750 who gets changed
│ ├── production/
│ └── staging/
├── group_vars/ root:ansible-ops 0750 what they get
├── host_vars/ root:ansible-ops 0750
├── playbooks/ root:ansible-ops 0750 the changes
├── roles/ root:ansible-ops 0750
├── collections/ root:ansible-ops 0750 vendored code
└── bin/build-controller root:ansible-ops 0750
/opt/estate/venv/ root:root 0755 the runtime
/etc/ansible/vault-pass root:ansible-ops 0640 decrypts secrets
/home/svc-ansible/.ssh/id_ed25519 svc-ansible 0600 fleet access
/var/log/ansible/ansible.log svc-ansible 0640 what was done
/home/svc-ansible/.ansible/tmp/ svc-ansible 0700 payload staging
/home/svc-ansible/.ansible/fact_cache/ svc-ansible 0700 gathered facts
The modes are a starting point, not a doctrine. What matters is that each one was chosen, and that you can say what a person in the group gets.
Read this list as an attacker would
The useful discipline is to ask, for each path, what does reading it buy someone, and what does writing it buy someone. The answers are not symmetric, and the write column is where the estate lives.
| Path | Reading it gives | Writing it gives |
|---|---|---|
inventory/ | The complete list of your hosts, addresses, ports and admin usernames | The ability to add a host, or to move an existing host into a group whose playbook does something useful to the attacker |
group_vars/ | Configuration, and any secret stored unencrypted | The ability to change what every host in a group is configured to |
playbooks/, roles/ | What your automation does | Arbitrary code execution as root on every host the play targets |
ansible.cfg | Which paths and settings are in effect | Redirection of the inventory, the vault password file, the roles path, or the SSH command line |
| Vault password file | Decryption of every vault-encrypted secret in the repository | Nothing extra, but it is a read that is a full compromise |
| SSH private key | Direct login to every managed host as the automation user | — |
~/.ansible/tmp/ | Module payloads, occasionally including arguments | Substitution of a payload mid-run |
fact_cache/ | A map of the estate: interpreters, addresses, packages, mounts | Fabricated facts, which change what conditional tasks decide |
Two conclusions fall out of that table, and they are the whole lesson.
Write access to the repository is root on the fleet. Not “could
lead to” — is. A person who can commit a task to a playbook that runs
with become: true against all has, by construction, root everywhere.
This is not a flaw in Ansible; it is what a configuration management
tool is. It means repository permissions are fleet permissions, and
should be set by whoever decides who may be root on production.
ansible.cfg is as sensitive as the keys. It names the vault
password file, the roles path, the collections path, the inventory, and
the SSH arguments. Writing it is enough to redirect any of them at
attacker-controlled content, without touching a single playbook.
The world-writable rule you will meet by accident
Ansible refuses to read an ansible.cfg from a world-writable
directory. The reasoning is sound — anyone on the machine could drop a
config there and hijack the next run — but the symptom is confusing
because the config does not fail loudly. It is ignored, and everything
downstream of it goes missing at once.
$ ansible-inventory --graph[WARNING]: Ansible is being run in a world writable directory (/opt/estate), ignoring it as an ansible.cfg source. For more information see https://docs.ansible.com/ansible/devel/reference_appendices/config.html#cfg-in-world-writable-dir
[WARNING]: No inventory was parsed, only implicit localhost is available
@all:
|--@ungrouped:Read that carefully, because the second warning is the dangerous one.
The inventory did not fail to parse — it was never looked for, because
the ansible.cfg that named it was discarded. What remains is the
implicit localhost.
ansible --version | head -2
ansible-config dump --only-changedThe paths people forget
~/.ansible/tmp/ is where the controller stages module payloads
before shipping them. It is per-user and should be mode 0700. On a
shared controller where several people run plays as the same service
account, it is a directory containing, transiently, the arguments of
whatever is being executed — which for a task that passes a credential
means the credential.
The fact cache, if you enable one, is a description of your estate
sitting on disk: every host’s interpreter, addresses, mounted
filesystems, installed packages and network configuration. It is not
secret in the sense of holding credentials, and it is precisely the
reconnaissance an attacker would otherwise have to perform. Treat it as
0700, and know where it is.
The log destination. Set log_path in ansible.cfg and Ansible
appends a record of every run. That file is your audit trail and it is
also a place where task output lands, so it inherits the sensitivity of
whatever your tasks print. Create it with the right owner and mode
before the first run — a file Ansible creates for you gets the
service account’s umask, which may be more generous than you want.
Getting this wrong does not degrade gracefully. If the path is not writable and cannot be created, Ansible stops:
$ ansible-config dump --only-changed[WARNING]: log file at '/var/log/ansible/ansible.log' is not writeable and we cannot create it, abortingThat is a good design — an audit trail you believe exists but which is
not being written is worse than no audit trail. It does mean that
creating /var/log/ansible/ with the right ownership is part of
building the controller, not a follow-up task, and that a run as an
unexpected user fails immediately rather than quietly logging nowhere.
The vault password file. Mode 0640, owned by root, group-readable
by the automation group at most. Never in the repository — a
.gitignore entry is not a security control, it is a reminder. Keep it
outside the checkout entirely so that a mistake in the checkout cannot
expose it.
Knowledge check
Knowledge check · 4 questions
Q1. A nightly playbook run reports success but changes nothing, and has done so for a week. `ansible --version` shows `config file = None`, though /opt/estate/ansible.cfg exists. What is the most likely cause?
Q2. Which controller paths grant an attacker the ability to run code as root on managed hosts if they can WRITE to them? Select all that apply.
Q3. Setting ANSIBLE_CONFIG to an absolute path makes Ansible read that file even when it lives in a world-writable directory.
Q4. Two engineers need to edit playbooks on a shared controller. What is the right way to give them both access?
Passing score: 75%. Answers are checked in this browser.