In Part 5 of our series, we’ll explore provisioning users and groups with Ansible on our AWS servers.
Anyone who has had to add users to an operating environment knows how complex things can get in a hurry. LDAP, Active Directory, and other technologies are designed to provide a centralized repository of users, groups, and access rules. Or, for Linux systems, you can skip that complexity and provision users directly on the server. If you have a lot of servers, Ansible can easily be used to add and delete users and provision access controls.
Now, if you come from an “enterprise” background you might protest and assert that LDAP is the only way to manage users across your servers. You’re certainly entitled to your opinion. But if you’re managing a few dozen or so machines, there’s nothing wrong (in my book) with straight up Linux user provisioning.
Regardless of the technology used, thought must still be given to how your users will be organized, and what permissions users will be given. For example, you might have operations personnel that require sudo
access on all servers. Some of your developers may be given the title architect
which provides them the luxury of sudo
as well on certain servers. Or, you might have a test group that is granted sudo
access on test servers, but not on staging servers. And so on. The point is, neither LDAP, Active Directory, or Ansible negate your responsbility of giving thought to how users and groups are organized and setting a policy around it.
So, let’s put together a team that we’ll give different privileges on different systems. Our hypothetical team looks like this:
Name | Group |
---|---|
alice | architects |
bob | developers |
carol | operations |
dave | testers |
dave | tptesters |
We’ve decided that access on a given server (or environment) will follow these rules:
Environment | Access |
---|---|
production | Only architects and operations gets access to the environment, and they get sudo access |
staging | All users except tptesters get access to the environment, only architects, operations, and developers get sudo access |
test | All users get access to the environent, and with the exception of tptesters , they get sudo access |
operations | Only operations get access to their environment, and they get sudo access |
Now, let’s look at how we can enforce these rules with Ansible!
users Role
We’re going to introduce Ansible roles in this post. This is by no means a complete tutorial on roles, for that you might want to check out the Ansible documentation.
Note: git clone https://github.com/iachievedit/ansible-helloworld
to get the example repository for this series, and switch to part4
(git checkout part4
) to pick up where we left off.
Let’s get started by creating our roles
directory in ansible-helloworld
.
[code lang=text]
# git clone https://github.com/iachievedit/ansible-helloworld
# cd ansible-helloworld
# git checkout part4
# mkdir roles
[/code]
Now we’re going to use the command ansible-galaxy
to create a template (not to be confused with a Jinja2 template!) for our first role.
[code lang=text]
# cd roles
# ansible-galaxy init users
– users was created successfully
[/code]
Drop in to the users
directory that was just created and you’ll see:
[code lang=text]
# cd users
# ls
README.md files meta templates vars
defaults handlers tasks tests
[/code]
We’ll be working in three directories, vars
, files
, and tasks
. In roles/vars/main.yml
add the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
--- usergroups: - architects - developers - operations - testers - tptesters users: - {name: alice, group: architects} - {name: bob, group: developers} - {name: carol, group: operations} - {name: dave, group: testers} - {name: eve, group: tptesters} |
Recall in previous tutorials our variables definitions were simple tag-value pairs (like HOSTNAME: helloworld
). In this post we’re going to take advantage of the fact that variables can be complex types that include lists and dictionaries.
Now, let’s create our users
role tasks. We’ll start with creating our groups. In roles/tasks/main.yml
:
1 2 3 4 5 6 |
--- - name: Create groups group: name: "{{ item }}" state: present loop: "{{ usergroups }}" |
There’s another new Ansible keyword in use here, loop
. loop
will take the items in the list given by usergroups
and iterate over them, with each item being “plugged in” to item
. The Python equivalent might look like:
1 2 |
for item in usergroups: groupadd(item) # Assume groupadd is implemented |
Loops are powerful constructs in roles and playbooks, so make sure and review the Ansible documentation to review what all can be accomplished with them. Also check out Chris Torgalson’s Untangling Ansible’s Loops, a great overview of Ansible loops and how to leverage them in your playbooks. It also turns out this post is using loops and various constructs to provision users, so definitely compare and contrast the different approaches!
Our next Ansible task will create the users and place them in the appropriate group.
1 2 3 4 5 6 7 |
# Creating users - name: Create users user: name: "{{ item.name }}" state: present groups: "{{ item.group }}" loop: "{{ users }}" |
Here it’s important to note that users
is being looped over (that is, every item in the list users
), and that we’re using a dot-notation to access values inside item
. For the first entry in the list, item
would have:
[code lang=text]
item.name = alice
item.group = architects
[/code]
Now, we could have chosen to allow for multiple groups for each user, in which case we might have defined something like:
1 2 |
users: - {name: alice, groups: [architects, developers]} |
That looks pretty good so we’ll stick with that for the final product.
With our user definitions in hand, let’s create an appropriate task to create them in the correct environment. There are two more keywords to introduce: block
and when
. Let’s take a look:
1 2 3 4 5 6 7 8 9 10 11 |
- block: - name: Create users for production servers user: name: "{{ item.name }}" state: present groups: "{{ item.groups }}" loop: "{{ users }}" when: - "'production' in group_names" - "('architects' in item.groups) or ('operations' in item.groups)" |
The block
keyword allows us to group a set of tasks together, and is frequently used in conjunction with some type of “scoping” keyword. In this example, we’re using the when
keyword to execute the block when a certain condition is met. The tags
keyword is another “scoping” keyword that is useful with a block
.
Our when
conditional indicates that the block will run only if the following conditions are met:
- the host is in the
production
group (as defined inansible_hosts
) - the user is in either the
architects
oroperations
group
The syntax for specifiying this logic looks a little contrived, but it’s quite simple and uses in
to return true if a given value is in the specified list. 'production' in group_names
is true if the group_names
list contains the value production
. Likewise for item.groups
, but in this case we use the or
conditional to add the user to the server if their groups
value contains either architects
or operations
.
We’re not quite done! We want our architects and operations groups to have sudo access on the production servers, so we add the following to our block:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
- name: Allow 'operations' group to have passwordless sudo lineinfile: dest: /etc/sudoers state: present regexp: '^%operations' line: '%operations ALL=(ALL) NOPASSWD: ALL' validate: 'visudo -cf %s' - name: Allow 'architects' group to have passwordless sudo lineinfile: dest: /etc/sudoers state: present regexp: '^%architects' line: '%architects ALL=(ALL) NOPASSWD: ALL' validate: 'visudo -cf %s' |
Combining everything together, for production we have:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
# Create users for production servers - block: - name: Create users for production servers user: name: "{{ item.name }}" state: present groups: "{{ item.groups }}" loop: "{{ users }}" - name: Allow 'operations' group to have passwordless sudo lineinfile: dest: /etc/sudoers state: present regexp: '^%operations' line: '%operations ALL=(ALL) NOPASSWD: ALL' validate: 'visudo -cf %s' - name: Allow 'architects' group to have passwordless sudo lineinfile: dest: /etc/sudoers state: present regexp: '^%architects' line: '%architects ALL=(ALL) NOPASSWD: ALL' validate: 'visudo -cf %s' when: - "'production' in group_names" - "('architects' in item.groups) or ('operations' in item.groups)" |
SSH Keys
Users on our servers will gain access through SSH keys. To add them:
1 2 3 4 5 6 |
- name: Create authorized_keys for production server users authorized_key: user: "{{ item.name }}" state: present key: "{{ lookup('file', '{{ ssh_keys/item.name }}') }}" loop: "{{ users }}" |
Another new module! authorized_key
will edit the ~/.ssh/authorized_keys
file of the given user and add the key specified in the key
parameter. The lookup
function will go and get the key contents from a file (the first argument) given in the location {{ ssh_keys/item.name }}
, which will expand to our user’s name.
Note that the lookup
function searches the files
directory in our role. That is, we have the following:
[code lang=text]
roles/files/ssh_keys/alice
bob
carol
dave
eve
[/code]
We do not encrypt public keys (if someone complains you didn’t encrypt their public key, slap them, it’ll make you feel better).
Shells
It was years into my career before I realized there was more to life than ksh. No joke, I didn’t realize there was anything but! Today there are a variety of shells, bash, zsh and fish just to name a few. I’ve also learned that an individual’s shell of choice is often as sacrosanct as their choice of editor. So let’s add the ability to set the user’s shell of preference.
First, we need to specify the list of shells we’re going to support. In roles/users/vars/main.yml
we’ll add:
1 2 3 4 5 |
shells: - zsh - fish - tcsh - ksh |
bash
is already present on our Ubuntu system, so no need to explicitly add it.
Now, in our role task, we add the following before any users are created.
1 2 3 4 5 6 |
- name: Install shells apt: name: "{{ item }}" state: present update_cache: true with_items: "{{ shells }}" |
This will ensure all of the shell options we given users are properly installed on the server.
Back to roles/users/vars/main.yml
, let’s set the shells of our users:
1 2 3 4 5 6 |
users: - {name: alice, groups: [architects, developers], shell: /usr/bin/fish} - {name: bob, groups: [developers], shell: /usr/bin/tcsh} - {name: carol, groups: [operations], shell: /bin/bash} - {name: dave, groups: [testers], shell: /usr/bin/ksh} - {name: eve, groups: [tptesters], shell: /usr/bin/zsh} |
A different shell for everyone!
Then, again in our role task, we update any addition of a user to include their shell:
1 2 3 4 5 6 7 |
- name: Create users for production servers user: name: "{{ item.name }}" state: present groups: "{{ item.groups }}" shell: "{{ item.shell }}" loop: "{{ users }}" |
Quite simple and elegant.
Editor’s Prerogative: Since this is my blog, and you’re reading it, I’ll give you my personal editor preference. Emacs (or an editor with Emacs keybindings, like Sublime Text) for writing (prose or code), and Vim for editing configuration files. No joke.
Staging
Our production environment had a simple rule: only architects and operations are allowed to login, and both get sudo access. Our staging environment is a bit more complicated, all users except tptesters
get access to the environment, but only architects, operations, and developers get sudo access. Moreover, we want to have a single lineinfile
task and use with_items
in it to add the appropriate lines. Unfortunately this isn’t as easy as it sounds, as having with_items
in the lineinfile
task interferes with our loop
tasks. So, we create a separate task specifically for our sudoers
updates, and in the end have:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
# Create users for staging servers - block: - name: Create users for staging servers user: name: "{{ item.name }}" state: present groups: "{{ item.groups }}" shell: "{{ item.shell }}" loop: "{{ users }}" - name: Create authorized_keys for staging server users authorized_key: user: "{{ item.name }}" state: present key: "{{ lookup('file', 'ssh_keys/{{ item.name }}') }}" loop: "{{ users }}" when: - "'staging' in group_names" - "('architects' in item.groups) or ('operations' in item.groups) or ('developers' in item.groups) or ('testers' in item.groups)" # Add sudo for appropriate staging users - name: Allow groups to have passwordless sudo lineinfile: dest: /etc/sudoers state: present regexp: "{{ item.regexp }}" line: "{{ item.line }}" validate: 'visudo -cf %s' with_items: - {regexp: "^%architects", line: "%architects ALL=(ALL) NOPASSWD: ALL"} - {regexp: "^%operations", line: "%operations ALL=(ALL) NOPASSWD: ALL"} - {regexp: "^%developers", line: "%developers ALL=(ALL) NOPASSWD: ALL"} when: "'staging' in group_names" |
Again, note that we first use a block to create our users and authorized_keys updates for the staging group, only doing so for architects, operations, developers, and testers. The second task adds the appropriate lines in the sudoers file.
Deleting Users (or Groups)
We have a way to add users; we’ll also need a way to remove them (my telecom background comes through when I say “deprovision”).
In roles/users/vars/main.yml
we’ll add a new variable deletedusers
which contains a list of user names that are to be removed from a server. While we’re at it, let’s add a section from groups that we want to delete as well.
1 2 3 4 5 |
deletedusers: - {name: frank} deletedgroups: - {name: developer} |
We can then update our user task:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
# Delete users - name: Delete users in deletedusers list user: name: "{{ item.name }}" state: absent loop: "{{ deletedusers }}" # Delete groups - name: Delete groups in deletedgroups list group: name: "{{ item.name }}" state: absent loop: "{{ deletedgroups }}" |
As with the users
, we’ll loop over deletedusers
and use the absent
state to remove the user from the system. Finally, any groups that require deletion can be done so as well with state: absent
on the group
task.
One last note with the user
task with Ansible; we’ve only scratched the surface of its capabilities. There are a variety of parameters that can be set such as expires
, home
, and of particular interest, remove
. Specifying remove: yes
will delete the user’s home directory (and files in it), along with their mail spool. If you truly want to be sure and nuke the user from orbit, specify remove: yes
in your user
task for deletion.
Recap
If you go and look at the part5
branch of the GitHub repository, you’ll see that we’ve heavily refactored the main.yml
file to rely on include statements. Like good code, a single playbook or Ansible task file shouldn’t be too incredibly long. In the end, our roles/users/tasks/main.yml
looks like this:
1 2 3 4 5 6 7 |
--- - include_tasks: groups_and_shells.yml - include_tasks: production.yml - include_tasks: staging.yml - include_tasks: operations.yml - include_tasks: test.yml - include_tasks: deletes.yml |
Hopefully this post has given you some thoughts on how to leverage Ansible for adding and deleting users on your servers. In a future post we might look at how to do the same thing, but with using LDAP and Ansible together.
This Series
Each post in this series is building upon the last. If you missed something, here are the previous posts. We’ve also put everything on this Github repository with branches that contain all of the changes from one part to the next.
To get the most out of walking through this tutorial on your own, download the repository and check out part4
to build your way through to part5
.