Within an enterprise environment, you’re likely to be using custom sudo rules in /etc/sudoers
to control the security of your users and applications. Ansible is a fantastic tool for automating your environment. The thousands of modules that are available, comprehensive documentation and low barrier of entry have seen it rise to prominence in the enterprise. As with any new tool, everyone soon learns of it’s power and wants to take advantage of it.
Limitations
The enterprise IT environment may well consist of different teams focusing on different parts of the estate. A server O/S team may well build and administer servers, a application team may deploy and manage applications whilst developers focus on application enhancements. In such an environment, it’s not unusual to separate out roles and use privilege escalation to allow teams to perform ‘root’ level privileges where they need to. The most common way of doing this is via SUDO.
A typical setup
Let’s say an application team has rights to start and stop their own applications via systemd, they can install package via DNF and they can edit certain system files. A typical sudoers file might look like this:
appuser ALL=(ALL) NOPASSWD: /bin/dnf install *, /usr/bin/systemctl start myapp.service
If you’re comfortable with Ansible, you’ll have heard of the become, become_user and become_method directives in playbook. The idea is that where you can always use the least privileged account to achieve a task. Only switch to an privileged user where necessary. Given the above, we might naturally think we can apply those same sudo rules to our Ansible user and simply use the ‘become’ directive where we need to in our playbook.
An example
We have the user ‘ansible’ on our Ansible control host and setup SSH equivalence with our target. On the Ansible target we set the following sudo rules for our Ansible user:
ansible ALL=(ALL) NOPASSWD: /bin/dnf install *, /usr/bin/systemctl start myapp.service
We’ll now defined following playbook to attempt to install a software package and start a service.
[ansible@controlhost ~] cat manage_system.yml
---
- hosts: target
tasks:
# Attempt to install the telnet package using the DNF module
# as user 'ansible'
- name: Try and install telnet using the dnf module, regular user
dnf:
name: telnet
state: installed
ignore_errors: true
# Attempt to install the telnet package using the DNF module
# via privellege escalation
- name: Try and install telnet using the dnf module, use become true
dnf:
name: telnet
state: installed
ignore_errors: true
become: true
# Attempt to start the myapp service
- name: Try and start the myapp service via systemctl
systemd:
name: myapp
state: started
ignore_errors: true
What happens when we run this playbook? The Ansible user has rights to run dnf
and systemctl
commands, so it should work, right? Unfortunately not:
[ansible@controlhost ~] ansible-playbook -i target, manage_system.yml
PLAY [target] **********************************************************************************************************************************
TASK [Gathering Facts] *************************************************************************************************************************
ok: [target]
TASK [Try and install telnet using the dnf module, regular user] *******************************************************************************
fatal: [target]: FAILED! => {"changed": false, "msg": "This command has to be run under the root user.", "results": []}
...ignoring
TASK [Try and install telnet using the dnf module, use become true] ****************************************************************************
fatal: [target]: FAILED! => {"msg": "Missing sudo password"}
...ignoring
TASK [Try and start the myapp service via systemctl] *******************************************************************************************
fatal: [target]: FAILED! => {"changed": false, "msg": "Unable to start service myapp: Failed to start myapp.service: Connection timed out\nSee system logs and 'systemctl status myapp.service' for details.\n"}
...ignoring
PLAY RECAP *************************************************************************************************************************************
target : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=3
Privilege escalation with Ansible
Whenever a module is called on a target via Ansible, what actually happens is a compiled tarball is copied over from the control host (and the ansible_user). That tarball is then executed, either as that same user or with root privileges. You can see this by running the same command with the -vvv
option
[ansible@controlhost ~] ansible-playbook -i target, manage_system.yml -vvv | grep EXEC
<target> SSH: EXEC ssh -C -o ControlMaster=auto -o ControlPersist=60s -o KbdInteractiveAuthentication=no -o PreferredAuthentications=gssapi-with-mic,gssapi-keyex,hostbased,publickey -o PasswordAuthentication=no -o ConnectTimeout=10 -o ControlPath=/home/ansible/.ansible/cp/ecebe3fde6 -tt target '/bin/sh -c '"'"'sudo -H -S -n -u root /bin/sh -c '"'"'"'"'"'"'"'"'echo BECOME-SUCCESS-bpvrbwrznnxsjheqbgipxybqliozkbsd ; /usr/libexec/platform-python /home/ansible/.ansible/tmp/ansible-tmp-1590587379.4237497-2208-186162665949670/AnsiballZ_dnf.py'"'"'"'"'"'"'"'"' && sleep 0'"'"''
<target> SSH: EXEC ssh -C -o ControlMaster=auto -o ControlPersist=60s -o KbdInteractiveAuthentication=no -o PreferredAuthentications=gssapi-with-mic,gssapi-keyex,hostbased,publickey -o PasswordAuthentication=no -o ConnectTimeout=10 -o ControlPath=/home/ansible/.ansible/cp/ecebe3fde6 -tt target '/bin/sh -c '"'"'/usr/libexec/platform-python /home/ansible/.ansible/tmp/ansible-tmp-1590587379.570427-2217-55891171115254/AnsiballZ_systemd.py && sleep 0'"'"''
You might think you could just allow the user to run all commands in /home/ansible/.ansible/ansible-tmp-*/*.py
but of course that opens up a big security whole and at that point you might as well have just given all rights as root. In this way, you can’t really have the same modular level.
Workarounds
There are a couple of workarounds depending on what you want to achieve. You can of course still use the shell module. Since the shell module can use those same sudo privileges, the commands will run.
[ansible@controlhost ~] cat manage_system2.yml
---
- hosts: target
tasks:
- name: Create a script that will install telnet via sudo
copy:
content: |
#!/bin/bash
sudo dnf install -y telnet
dest: /tmp/install.sh
mode: 0755
- name: Run the script to install telnet via sudo
shell: /tmp/install.sh
- name: Create a script that will start the myapp service
copy:
content: |
#!/bin/bash
sudo systemctl start myapp.service
dest: /tmp/service.sh
mode: 0755
- name: Run the script to start the myapp service via sudo
shell: /tmp/service.sh
Let’s see what happens when the above works
[ansible@controlhost ~] ansible-playbook -i target, manage_system2.yml
PLAY [target] ******************************************************************
TASK [Gathering Facts] *********************************************************
ok: [target]
TASK [Create a script that will install telnet via sudo] ***********************
changed: [target]
TASK [Run the script to install telnet via sudo] *******************************
changed: [target]
TASK [Create a script that will start the myapp service] ***********************
changed: [target]
TASK [Run the script to start the myapp service via sudo] **********************
changed: [target]
PLAY RECAP *********************************************************************
target : ok=3 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
The drawback here is that you are not getting any of the advantages of the modules, so you may need to put some additional logic into your scripts and/or check for error codes.
Ansible and sudoedit
Consider the scenario where you are happy for a set of users to manage their own /etc/hosts
file using sudoedit. This is achieved with a sudo rule such as:
ansible ALL=(ALL) NOPASSWD: sudoedit /etc/hosts
Clearly, there is some shared responsibility here given the importance of /etc/hosts
and the impact it can have on the running services. However, it is mutually beneficial to allow this rule – the users take responsibility for their own changes and the O/S support team do not act as a roadblock. The question is, how can Ansible make use of this?
I came across the following post: How to sudoedit non-interactively and leveraged that idea into an Ansible playbook. Again, using the same non-root user on the Ansible control host we can do the following:
[ansible@controlhost ~] cat manage_etc_hosts.yml
---
- hosts: target
tasks:
- name: Create a wrapper script that calls sudoedit /etc/hosts
copy:
content: |
#!/bin/bash
export EDITOR="/bin/sh /tmp/editor.sh";
PRE_STAGE=/tmp/stage.tmp;
sudoedit /etc/hosts;
/bin/rm $PRE_STAGE;
exit;
dest: /tmp/wrapper.sh
mode: 0755
- name: Create an editor script
copy:
content: |
#!/bin/bash
CAT=/bin/cat
PRE_STAGE=/tmp/stage.tmp;
$CAT $PRE_STAGE > "$1";
exit;
dest: /tmp/editor.sh
mode: 0755
- name: Create a host file
copy:
content: |
127.0.0.1 localhost localhost.localdomain localhost4 localhost4.localdomain4
::1 localhost localhost.localdomain localhost6 localhost6.localdomain6
192.168.0.1 myhost01.example.com
192.168.0.2 myhost02.example.com
dest: /tmp/stage.tmp
mode: 0755
- name: Run the wrapper script to populate /etc/hosts
shell: /tmp/wrapper.sh
What is this playbook doing? First, we create a wrapper script that calls sudoedit
. When invoking sudoedit, rather than opening up vim or the default editor, the script /tmp/editor.sh
is called. The second part of the playbook generates /tmp/editor.sh
– it’s actually very simple and copies over the contents of /tmp/stage.tmp
to $1
(/etc/hosts
in our case). The third part of the playbook creates /tmp/stage.tmp
using the Ansible copy module. This could be replaced with a template or static file if needed. The forth and final part of the playbook runs our wrapper script with /etc/hosts
as the argument.
Here is the playbook in action:
[ansible@controlhost ~] ansible-playbook -i target, manage_etc_hosts.yml
PLAY [target] ******************************************************************
TASK [Gathering Facts] *********************************************************
ok: [target]
TASK [Create a wrapper script that calls sudoedit /etc/hosts] *****************************
changed: [target]
TASK [Create an editor script] *************************************************
changed: [target]
TASK [Create a host file] ******************************************************
changed: [target]
TASK [Run the wrapper script to populate /etc/hosts] ******************
changed: [target]
PLAY RECAP *********************************************************************
target : ok=5 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
On the target host, we see that /etc/hosts has been populated as we expected:
[ansible@target ~]# cat /etc/hosts
127.0.0.1 localhost localhost.localdomain localhost4 localhost4.localdomain4
::1 localhost localhost.localdomain localhost6 localhost6.localdomain6
192.168.0.1 myhost01.example.com
192.168.0.2 myhost02.example.com
As a reminder, this was only possible because the ansible user had rights to invoke sudoedit /etc/hosts
. No additional privileges were required.
Summary
We see that for system administration tasks such as starting and stopping operating system services and adding a removing packages you’ll need to give full root privileges to the calling Ansible user. Granular permission that matches sudo rules is not possible. The workaround of creating scripts, copying them across and executing them with the sudo method isn’t ideal, but it isn’t necessarily that bad either. The code can be carefully written, stored in Git just like the playbooks and simple checks can be added to ensure the plays are idempotent (running a playbook on a correctly configured host won’t reconfigure or restart services).