User management can be a bit of a sore subject for some admins, but I’ve found it can really pay off when done correctly, though its not always clear what that means. I’ve been on both sides of that line in the past, sometimes when the line moves, and sometimes not.

I’m a believer that managing the users and groups in your organization proficiently and with low overhead is important. It should be done well, if for no other reason, than to be able to state authoritatively who should have access to what and why, ideally requiring as little administrative time as possible once the system is in place.

Users are bound to be a bit tetchy when they know that they should have access to a system that they are unable to log into, and for good reason. After all they just have a job to do. As an admin, I also like having a pretty good idea who is logging in. To keep the users off our back so we can enjoy doing anything other than troubleshooting their access troubles, build something that will keep them at bay for as long as you can envision, without exposing yourself or your infrastructure to misfortune.

If you have surrendered your environment to FreeIPA, then you should probably stop reading here and go have some kool-aid. Also, if you are not a FreeIPA user, its worth a look.

In any case, what follows is something I’ve been using for a while and has worked out pretty well. It won’t work for all environments nor does it try to, but maybe you get something out of it.

Source of truth

Before embarking on a quest to solve your user management, its a good idea to first decide on a source of truth. I love LDAP. I know that likely puts me in some corner-case category in your book of judgements; I am who I am.

Some folks might like to put users in a giant JSON blob, while others might abuse Hiera for this. You may wish to store all the attributes about a user in a dictionary, and all of those dictionaries in a list. This seems innocent enough until one of two things happens. Either a) the number of users you manage surpasses ten, or b) you need to authenticate something that isn’t SSH and you duplicate all that data somewhere else. Both cases can be addressed by the employment of LDAP. For one, LDAP scales quite well for housing users. Second, LDAP is a well understood protocol with a wide set of integrations that you don’t have to write. Plus its stood the test of time.

Enough about LDAP. Its what I use, and what I will demonstrate some usage of it here.

The Model

At a high level, we are using our configuration management of choice (puppet) to query our source of truth (LDAP) and do something with the results, presumably create users and groups, and provide a means for logging in.

If you are not familiar with some of the semantics around LDAP, the details below might be a bit much. Go read something. Zytrax is pretty good.

Requirements

This happens through the use of a couple of Puppet modules I worked on to build out this concept.

ldapquery()

This is a puppet function that takes a rfc4515 LDAP filter and returns the results from a server.

groupmembership

This is a puppet type that manages the members of a given group. It takes the opposite approach to group membership that the native puppet user type’s ‘groups’ parameter. That is, users are specified on the group. It was PUP-3745 that tickled me to work on it.

I also have some semi-private classes that I will reproduce enough of here due to their public unavailability.

In the examples below, I make use of a private account module. This module is mostly wrapping the ‘group’ and ‘user’ types with a bit of flare and local logic that is specific to my environment. It should still be clear enough what is happening for readers to reproduce or modify as necessary.

Its worth nothing that this approach is modeled from the perspective of the group. In this way, no user is unique enough to treat independently of a group. This means that only groups are allowed to shell in using the AllowGroups declaration in sshd_config(8), which is managed using the ssh::allowgroup define referenced below. A similar approach is taken for sudo, whereby we create sudo entries to allow root access (or not) for a group. Though more granular access can easily be achieved to meet site-specific use cases, I am not demonstrating any of that witchcraft here.

The Code

The sections below follow similar pattern to each other.

  • Query LDAP for objects that meet a certain criteria
  • Massage the results into a form we can use with Puppet
  • Generate some virtual resources based on those results

This model can be extend to lots of other components of infrastructure where inventory is external, though I’m only demonstrating users and groups below.

One could conceivably add Kerberos principals to user objects and create .k5login files or, add some Duo security configurations based on user attributes using the same data store.

With the code snippets below and a bit of glue, you will have a bunch of virtual resources that you can realize based on need.

We realize those users through some code that looks like the following.

define zleslie::group (
  Boolean $root  = false,
  Boolean $shell = false,
) {
  include virtual::groups
  realize(Account::Group[$name])

  # Grant full sudo privileges
  if $root {
    sudo::allowgroup { $name: }
  }

  # Grant SSH access for the group
  if $shell {
    ssh::allowgroup { $name: }
  }
}

The above define wraps up the logic of realizing the groups, granting sudo if desired, and granting ssh. This is the single entry point for getting users on a given system. A simple resource of zleslie::group { ‘humans’: shell => true } does everything we need to grant users in the group ‘humans’ access to the system.

Creating virtual groups

Here is my virtual::groups class to generate virtual account::group resources that get realized elsewhere.

class virtual::groups {
  include virtual::users

  $posix_attributes = [
    'gidnumber',
    'cn',
    'dn',
  ]

  $group_query = '(&(objectClass=posixGroup)(gidNumber=*))'
  $posix_groups = ldapquery($group_query, $posix_attributes)

  any2array($posix_groups).each |$g| {
    $cn  = $g['cn'][0]
    $dn  = $g['dn'][0]
    $gid = $g['gidnumber'][0]

    $member_results = ldapquery("(memberOf=${dn})", 'uid')
    $members = $member_results.map |$m| { $m['uid'][0] }

    @account::group { $cn:
      ensure    => present,
      gid       => $gid,
      members   => $members,
    }
  }
}

Creating virtual users

Second verse, same as the first. A virtual::users class to generate virtual account::user resources that get realized elsewhere.

class virtual::users {

  include profile::shells
  include virtual::groups
  include virtual::ssh_authorized_keys

  $attributes = [
    'uid',
    'uidNumber',
    'homeDirectory',
    'cn',
    'loginShell',
  ]

  $user_query = '(&(objectClass=posixAccount)(uid=*)(cn=*)(loginShell=*))'
  $user_accounts = ldapquery($user_query, $attributes)

  any2array($user_accounts).each |$u| {

    $_home = $::kernel ? {
      'Darwin' => "/Users/${u['uid'][0]}",
      default  => $u['homedirectory'][0],
    }

    @account::user { $u['uid']:
      ensure    => present,
      shell     => $u['loginshell'][0],
      uid       => $u['uidnumber'][0]+0,
      home      => $_home,
      comment   => $u['cn'][0],
      purgekeys => true,
    }
  }
}

Note that the account::group type realizes all account::user resources named in in the members parameter.

The results from the ldapquery() look more complicated than they really are. See the module README for more information.

Results

With what I have outlined above, we now have a central source of truth to house data about users, allowing our configuration management to create system users from that data and manage access to those systems. LDAP can be scaled out behind a load balancer, or to multiple datacenters as desired. Other systems that are not SSH servers are able to use native LDAP protocols to authenticate users without having to duplicate user data into other systems.

Something else I appreciate is not having to manage nsswitch.conf or any of those pesky user caching daemons to manage real time connections to the LDAP servers, meaning we don’t need to expose our LDAP servers to our fleet of nodes at all. In this model, only the Puppet masters need access to the LDAP servers, and only during compile. This can mean significantly reduced load on your LDAP servers. Users logging into an SSH server won’t end up causing a query against and LDAP server to get the uid for their username, since all of that information is managed in the local user database. The same can be applied to SSH keys. This improves resilience considerably.

This model can also be Puppet environment independent. Since the virtual resources are generated based on data from the LDAP server, if the data changes, the virtual resources change. This means that user information is not stored in a git branch, whereby depending on which environment a node is checked into changes which users are available on the system. “Need to change an account status or group members? Just rebase your thirty environments.” That gets old quick.

Drawbacks

The biggest drawback to this approach I see, is time to convergence. That is, users change state at the speed of Puppet, meaning that if you change a user’s status or a groups members in the central database, that change is reflected across your infrastructure when Puppet runs. There are ways to make this less of an issue, but fundamentally when you are using Puppet to manage user accounts, you need to run Puppet to make changes. There are trade-offs here that one could make, such as using Kerberos in place of SSH keys, but Kerberos comes with its own joys(pains). This time sensitivity may be less of an issue for some environments than for others. Twenty or so minutes to converge on user management state is Good Enough for my environment.

Conclusion

I believe that’s enough detail to kick start this kind of model in another environment. If you have questions or additions/suggestions for this work, I’d love to hear about them.