Skip to main content
RunBook Academy

AnsibleIII · Installing and Designing the ControllerController design

Controller filesystem layout and ownership

Intermediate⏱ ~21 minbashssh

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

Not yet marked complete on this device.

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.

PathReading it givesWriting it gives
inventory/The complete list of your hosts, addresses, ports and admin usernamesThe 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 unencryptedThe ability to change what every host in a group is configured to
playbooks/, roles/What your automation doesArbitrary code execution as root on every host the play targets
ansible.cfgWhich paths and settings are in effectRedirection of the inventory, the vault password file, the roles path, or the SSH command line
Vault password fileDecryption of every vault-encrypted secret in the repositoryNothing extra, but it is a read that is a full compromise
SSH private keyDirect login to every managed host as the automation user
~/.ansible/tmp/Module payloads, occasionally including argumentsSubstitution of a payload mid-run
fact_cache/A map of the estate: interpreters, addresses, packages, mountsFabricated 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.

Read-only / Safethe same command, from a world-writable directory
$ 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.

Read-only / Safeconfirm the config actually in effect
ansible --version | head -2
ansible-config dump --only-changed

The 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:

Read-only / Safea log path the service account cannot write
$ ansible-config dump --only-changed
[WARNING]: log file at '/var/log/ansible/ansible.log' is not writeable and we cannot create it, aborting

That 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

  1. 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?

  2. 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.

  3. Q3. Setting ANSIBLE_CONFIG to an absolute path makes Ansible read that file even when it lives in a world-writable directory.

  4. 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.