Managing users
Basic user management in Chef is achieved through the use of the user
resource. This resource allows you to add, remove, or otherwise manipulate users on your hosts. However, you can't possibly write recipes that contain one resource per user; it simply wouldn't scale. In order to make large-scale user management easier, we can combine some of Chef's capabilities such as data bags, per-role, per-node, and per-environment configuration to enable scalable user management.
Let's take a look at a user
cookbook that can provide these abilities.
Evolution of a shell user recipe
First, let's take a look at a very naive user management recipe. This cookbook has a hardcoded users
list; initially, it contains frodo
and samwise
and simply iterates through the list, creating users as it goes. Here is what the list may look like:
users = [ { 'id' => 'frodo', 'uid' => '100', 'gid' => 100, 'shell' => '/bin/hobbitshell', 'comment' => 'Frodo of the nine fingers' }, { 'id' => 'samwise', 'uid' => '101', 'gid' => 101, 'shell' => '/bin/gardenshell', 'comment' => 'Samwise the strong' } ] users.each do |u| home_dir = "/home/#{u['id']}" user u['id'] do uid u['uid'] gid u['gid'] shell u['shell'] comment u['comment'] supports :manage_home => true home home_dir end end
This approach will work for a handful of users, but it has the problem of being very limited in scope and difficult to maintain. It also isolates the list of users to this recipe, making it difficult to access data from other recipes and very brittle. The first thing we can improve is make the users accessible to this and any other recipe through the use of data bags. Let's take a look at how we can use data bags to make user data management simpler and more flexible.
Storing data in data bags
Data bags are designed to store arbitrary configuration data that pertains to your entire infrastructure. This may include users, global settings, firewall rules, and so on; if it can be modeled using basic JSON data structures such as arrays and dictionaries, it can be included in a data bag. We haven't touched much on these yet, so now is a good time to take a look at what they can do while modeling users.
Data bags are collections of data that are related to one another; for example, users, firewall rules, database servers, and so on. Here we will create a data bag that contains our user data. This is not intended to be a replacement for a directory service such as LDAP, though you could potentially use it to store all your user data and then write recipes to populate an LDAP server with user data (in this way, you may be able to keep an Active Directory system and a separate LDAP system in sync by making your Chef data bag the authoritative source for user data). Let's take a look at how to create and manipulate a data bag with user information:
[jewart]% knife data bag create users Created data_bag[users]
Now, create a new user, frodo
(you will need to have the EDITOR
variable set to a text editor such as vim
on Linux systems):
[jewart]% export EDITOR=vim [jewart]% knife data bag create users frodo Data bag users already exists
You will be presented with a new entity template that contains only one key, id
, which is set to the name of the entity you created; in our case, frodo
:
1 { 2 "id": "frodo", 3 }
Save this file and you will now have one, mostly empty, entity in your users
data bag named frodo
. You can check this with the show
subcommand:
[jewart]% knife data bag show users frodo
Every item in a data bag has to have a unique identifier, which can be meaningful or just a random identifier; in our case, it will double up as the login name for the user. We can take our previous data from the recipe and convert that to data bag elements by writing them to JSON files and uploading them with knife
. To take advantage of uploads, we can create a directory, users
, and create one JSON file per entry:
{ "id" : "frodo", "uid" : "100", "gid" : 100, "shell" : "/bin/hobbitshell", "comment" : "Frodo of the nine fingers" } { "id" : "samwise", "uid" : "101", "gid" : 101, "shell" : "/bin/gardenshell", "comment" : "Samwise the strong" }
Once you have created these, you should have two files, frodo.json
and samwise.json
inside a users
directory. In order to bulk upload them, we use a knife
data bag from the <dir> <data bag name>
file in the following manner:
[jewart]% knife data bag from file users users Updated data_bag_item[users::frodo] Updated data_bag_item[users::samwise]
You can verify whether the entries were created correctly with the knife data bag show <databag> <entity_id>
command:
[jewart]% knife data bag show users frodo comment: Frodo of the nine fingers gid: 100 id: frodo shell: /bin/hobbitshell uid: 100
Now that we have our data in a data bag in Chef, we can search for it using the search criteria. For example, if we wanted only all users whose names start with the letter s, we can search with the following command:
[jewart]% knife search users 'id:s*' 1 items found chef_type: data_bag_item comment: Samwise the strong data_bag: users gid: 101 id: samwise shell: /bin/gardenshell uid: 101
Alternatively, if we wanted all the users in a given data bag, we can perform the following search:
[jewart]% knife search users 'id:*' 2 items found chef_type: data_bag_item comment: Frodo of the nine fingers data_bag: users gid: 100 id: frodo shell: /bin/hobbitshell uid: 100 chef_type: data_bag_item comment: Samwise the strong data_bag: users gid: 101 id: samwise shell: /bin/gardenshell uid: 101
Searching inside recipes
Now that we have some data bag data created and can perform basic searches, let's see how we can use that to enhance our recipe using the built-in search
method. This allows us to perform the searches we just ran with knife
inside our recipes. The search method has a similar format to the knife
command:
search(search_scope, search_criteria)
The following are some simple examples:
all_users = search(:users, 'id:*') users_s = search(:users, 'id:s*') all_nodes = search(:node, '*')
With this, we can enhance our shell user recipe to use the entities in the users
data bag rather than hard code them. Our new recipe would look like the following:
# Replace the hard-coded users array with a search: users = search(:users, 'id:*') # Same as before, we've just moved our data source users.each do |u| home_dir = "/home/#{u['id']}" user u['id'] do uid u['uid'] gid u['gid'] shell u['shell'] comment u['comment'] supports :manage_home => true home home_dir end end
This is just a simple search; this will work for a small-scale infrastructure with a fixed set of users, where there's no need to restrict certain groups of users to certain hosts. You can easily imagine, however, a situation where some users are provisioned only to certain hosts through groups. Let's look at how we can achieve this with some better user metadata and a more advanced search.
Enhancing your user cookbook
In our previous example, we used the search method to find all of the users in our user's data bag. Here we will go one step further to isolate users based on arbitrary groups and see how we can limit the list of users to be provisioned using a combination of search, user metadata, and node configuration.
First, we need to add a groups
key to our users. Let's add that to our existing user JSON data files and add a few more users, legolas
and gimli
:
{ "id" : "frodo", "uid" : 100, "gid" : 100, "shell" : "/bin/hobbitshell", "comment" : "Frodo of the nine fingers", "groups" : ["hobbits", "fellowship"] } { "id" : "gimli", "uid" : 201, "gid" : 201, "shell" : "/bin/csh", "comment" : "Grumpy old dwarf", "groups" : [ "dwarves", "fellowship" ] } { "id" : "legolas", "uid" : 200, "gid" : 200, "shell" : "/bin/zsh", "comment" : "Keen eyed Legolas", "groups" : [ "elves", "fellowship" ] } { "id" : "samwise", "uid" : "101", "gid" : 101, "shell" : "/bin/gardenshell", "comment" : "Samwise the strong", "groups" : ["hobbits", "fellowship"] }
Once again, we update the existing records and create our new records using knife data bag from file
:
[jewart]% knife data bag from file users users Updated data_bag_item[users::frodo] Updated data_bag_item[users::gimli] Updated data_bag_item[users::legolas] Updated data_bag_item[users::samwise]
Now that you have a few additional users in your data bag, and each user has some group metadata attached to it, let's take a look at how we can use this to provision only certain users on specific hosts. First, we need to be able to limit our search scope dynamically; otherwise, we will need to modify our recipe on a per-host basis and that just won't scale. We need to add a dynamic search query to our recipe with something like the following code:
search_criteria = "groups:#{node[:shell_users][:group]}"
This creates a search criteria string that will match objects that have the value specified somewhere in their groups
key. In order to make this dynamic per host, we will store this value in a shell_users
hash under the group
key. For example, if you wanted to add all users that are in the hobbits
group to a specific node, then your node's configuration would need to contain the following:
{ "shell_users": { "group": "hobbits", } }
This will build a search criteria of "groups" : "hobbits"
, which if we pass to the search
method will yield all entries in the users
data bag that have "hobbits"
inside their groups list. Consider the following recipe code:
users = search(:users, search_criteria)
The node configuration data will expand the search criteria during an execution on this node to be the following:
search_criteria = "groups:hobbits"
Given the data we have stored in our users data bag, this would match samwise
and frodo
as they have the hobbits group in their groups list. We can verify this by trying the same search on the command line with knife
:
[jewart]% knife search users "groups:hobbits" 2 items found chef_type: data_bag_item comment: Frodo of the nine fingers data_bag: users gid: 100 groups: hobbits fellowship id: frodo shell: /bin/hobbitshell uid: 100 chef_type: data_bag_item comment: Samwise the strong data_bag: users gid: 101 groups: hobbits fellowship id: samwise shell: /bin/gardenshell uid: 101
As you can see, this allows us to narrowly define the list of users to be managed through the combination of entity metadata and dynamic search criteria. You can build more advanced applications using this methodology with more advanced search criteria and incorporating more of the entities' metadata.
Distributing SSH keys
In addition to managing user accounts, we can also use Chef to manage SSH keys. Because a given user's accepted SSH keys are stored in a per-user configuration file, it is quite simple to manipulate them. By creating a template for SSH-authorized keys, we can build a recipe that will take the SSH key data from the data bag and populate the authorized keys file on the host. By doing this, users' SSH keys can be stored in Chef and distributed to any number of hosts with just one command. This solves the problems typically associated with distribution and revocation of SSH keys inside an organization.
Here is a sample template we will use for our user's authorized keys file; this would be defined in an authorized_keys.erb
file:
<% if @ssh_keys.is_a?(Array) %> <%= @ssh_keys.join("\n") %> <% else %> <%= @ssh_keys %> <% end %>
This is a very simple template that has only two cases: if the template variable ssh_keys
is an array, it will print them out with a new line in between them; otherwise, it will simply print out the contents of the variable.
To use this template, we will simply provide it with a list of SSH-compatible key strings:
template "#{home_dir}/.ssh/authorized_keys" do source "authorized_keys.erb" owner u['id'] group u['gid'] || u['id'] mode "0600" variables :ssh_keys => u['ssh_keys'] end
Now, we can modify one of our previous user JSON entities to add SSH keys:
{ "id" : "frodo", "uid" : 100, "gid" : 100, "shell" : "/bin/hobbitshell", "comment" : "Frodo of the nine fingers", "groups" : ["hobbits", "fellowship"], "ssh_keys": [ "ssh-dss RG9uJ3Qgd29ycnksIFNhbS4gUm9zaWUga25vd3MgYW4gaWRpb3Qgd2hlbiBzaGUgc2VlcyBvbmUu frodo@shire", "ssh-dss TXkgbWFzdGVyLCBTYXVyb24gdGhlIEdyZWF0LCBiaWRzIHRoZWUgd2VsY29tZS4gSXMgdGhlcmUgYW55IGluIHRoaXMgcm91dCB3aXRoIGF1dGhvcml0eSB0byB0cmVhdCB3aXRoIG1lPyA= sauron@mordor" ] } % knife data bag from file users ssh_keys/frodo.json Updated data_bag_item[users::frodo]
Once your user has been updated, check whether your newly added metadata has been updated, looking for your new ssh_keys
key in the entity. In order to do that, you can show the contents of your data bag using the following command:
% knife data bag show users frodo
The output of this should line up with your newly updated JSON content. With these added, we can write a new recipe that will allow us to deploy authorized_keys
files for each user on our hosts. Our recipe will use the same search criteria from our previous recipe as we want to apply our SSH keys to all of our shell users.
This recipe is responsible for making sure that the proper directory for SSH is created and has the correct permissions, as well as creating the authorized_keys
file with the necessary permissions and storing the SSH keys associated with the user in /home/user/.ssh/authorized_keys
:
search_criteria = "groups:#{node[:shell_users][:group]}" search(:users, search_criteria) do |u| home_dir = "/home/#{u['id']}" directory "#{home_dir}/.ssh" do owner u['id'] group u['gid'] mode "0700" recursive true end template "#{home_dir}/.ssh/authorized_keys" do source "authorized_keys.erb" owner u['id'] group u['gid'] mode "0600" variables :ssh_keys => u['ssh_keys'] end end
If you have ever deployed a Rails application to hosts that need to have access to your source code in a GitHub or BitBucket repository, then you will know how handy it is to manage deployment keys across a fleet of hosts. We can easily generate a recipe that looks at a node's list of deployment users following our previous examples as a starting point. Here, we look for deploy users instead of shell users, as these are the ones we want to manage deployment keys for. Note that in this example, these users would also need to be included in the shell_users
group to ensure that they get created by our previous recipe:
search_criteria = "groups:#{node[:deploy_users][:group]}" search(:users, search_criteria) do |u| home_dir = "/home/#{u['id']}" directory "#{home_dir}/.ssh" do owner u['id'] group u['gid'] || u['id'] mode "0700" recursive true end template "#{home_dir}/.ssh/id_rsa" do source "deploy_key.erb" owner u['id'] group u['gid'] || u['id'] mode "0600" variables :key => u['deploy_key'] end end
To use this new recipe, the deployment users would need to be modified to include a group identifier and their private key. The group would be reserved for users involved in deploying your application and be added to the user's groups key in Chef. Additionally, an unencrypted SSH private key would need to be present in a deploy_key
field.