diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e99e36 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.pyc \ No newline at end of file diff --git a/README.md b/README.md index d120ef2..8a30447 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,34 @@ # Ansible LDAP Modules For whatever reasons, Ansible doesn't include modules to manipulate an -LDAP server in its [core](http://docs.ansible.com/ansible/modules_by_category.html). +LDAP server in its +[core](http://docs.ansible.com/ansible/modules_by_category.html). Peter Sagerson implemented a pair of modules and has [attempted to get them into Ansible](http://grokbase.com/t/gg/ansible-devel/148892tek3/ldap-modules), but no luck yet. He's instead hosted his code on [Bitbucket](https://bitbucket.org/psagers/ansible-ldap). -This repository is a fork of his original work (with some cosmetic -improvements) hosted on GitHub. +This repository is a fork of his original work with some additions +(and cosmetic improvements) hosted on GitHub. # Installation -You'll need to clone this repository somewhere into your Ansible (use -a git submodule). -``` -$ git clone https://github.com/unchained-capital/ansible-ldap-modules -``` +The `python-ldap` library is required on whatever host is executing +these modules. This can be confusing if you use both +`local_action`-type tasks and tasks which run on remote hosts and both +need to talk do an LDAP server -- you'll need to install `python-ldap` +both locally on your controller as well as remotely on the hosts you +run the tasks on. + +In addition, you'll need to clone this repository itself to your +controller and put it somewhere `ansible` can find it. + -The `python-ldap` library is required on the Ansible controller node -for these modules to load. Install it with +## python-ldap Installation + +Install it locally via the following commands: ``` $ cd ansible-ldap-modules @@ -34,10 +41,38 @@ You may need to install some system dependencies first: $ sudo apt-get install python-dev libsasl2-dev libldap2-dev libssl-dev ``` +For remote hosts, this can be automated via Ansible: + +```yaml +# in prepare_remote_ldap_host.yml, for example +- name: Install dependencies + apt: name="{{ item }}" state=present + with_items: + - python-pip + - libsasl2-dev + - libldap2-dev + - libssl-dev + +- name: Upgrade pip + command: pip install --upgrade pip + +- name: Install python-ldap + pip: name=python-ldap state=present +``` + +## Modules Installation + +You'll need to clone this repository somewhere on your controller +machine so that `ansible` can find it. + +``` +$ git clone https://github.com/unchained-capital/ansible-ldap-modules +``` + Once you have `python-ldap` installed, you'll need to link the executable files in this repository into Ansible's library path. The simplest way to do this is to create a `library` folder at the -top-level of your Ansible repository and create symlinks within it to +top-level of your playbook repository and create symlinks within it to the module files in this repository: ``` @@ -52,14 +87,213 @@ or the `library` entry within the `defaults` section of your # Usage -(copied from psager's [original README](https://bitbucket.org/psagers/ansible-ldap/src/ca4c0025358cdeba33e9ef0369af3430bf4812ad/README.md)) +Remember, if you run these tasks locally, you need `python-ldap` +installed locally. If you run them on a remote machine, you need +`python-ldap` installed on that remote machine. + +## Shared Behavior + +All the modules in this repository share some behaviors. This section +describes those behaviors. The next section describes each module in +detail. + +### Specifying an LDAP Server + +Here is a simple example of creating an entry (more details on this +below): + +```yaml +- hosts: server0 + tasks: + - name: Ensure an LDAP entry exists for ou=People + ldap_entry: + dn: "ou=People,dc=example,dc=com" + ou: People + objectClass: organizationalUnit + description: Getting together and having a good time. +``` + +The target host of an LDAP operation is assumed to be the same host as +the Ansible task is executing so the above task would attempt to talk +to LDAP server running on `server0` listenting on port 389. This example: + +```yaml +- hosts: server0 + tasks: + - name: Ensure an LDAP entry exists for ou=People + ldap_entry: + server_uri: ldapi://server1/ + dn: "ou=People,dc=example,dc=com" + ou: People + objectClass: organizationalUnit + description: Getting together and having a good time. +``` + +would target an LDAP server at `server1`. + +### Choosing Credentials + +Without any credentials (as in the above examples), the request will +be made via a SASL `EXTERNAL` bind (similar to `ldapadd ... -Y +EXTERNAL ...` on the command-line). + +Credentials can be specified as well: + + +```yaml +- hosts: server0 + tasks: + - name: Ensure an LDAP entry exists for ou=People + ldap_entry: + bind_dn: "cn=admin,dc=example,dc=com" + bind_pw: "password" + dn: "ou=People,dc=example,dc=com" + ou: People + objectClass: organizationalUnit + description: Getting together and having a good time. +``` + +## Modules + +This repository provides four different modules: -This project contains a pair of Ansible modules for manipulating an -LDAP directory. `ldap_entry` can be used to ensure that an entire -entry exists and `ldap_attr` can be used to ensure the values of an -entry's attributes. +### ldap_entry + +Ensures that an entry with a given `dn` exists/doesn't exist. If +missing, it creates it. It present it does nothing. In particular, +even if the entry has different attributes from those specified in +Ansible, `ldap_entry` does nothing. You can, however, use +`ldap_entry` to remove LDAP entries if you specify `state=absent`. + +#### Creating an entry + +```yaml +- name: Ensure an LDAP entry exists for ou=People + ldap_entry: + dn: "ou=People,dc=example,dc=com" + ou: People + objectClass: organizationalUnit + description: Getting together and having a good time. +``` + +If the `ou=People,dc=example,dc=com` entry has its `description` field +changed, this task will not update it. It's therefore best to only +specify the minimal number of fields required to successfully create +the entity, given its LDAP `objectClass` values and later use +`ldap_attr` to explicitly declare the expected state for each +attribute individually. Or use `ldap_upsert`. + +#### Removing an entry + +```yaml +- name: Ensure an LDAP entry exists for ou=People + ldap_entry: + dn: "ou=People,dc=example,dc=com" + state: absent +``` + +### ldap_attr + +Ensures that an attribute with a given value exists/doesn't exist for +a given entry. If the entry does not exist, it throws an error. If +the doesn't exist, it creates it. If it exists but has a different +value, it updates it. You can use `ldap_attr` to remove LDAP +attributes if you specify `state=absent`. + +#### Specifying an attribute exactly + +Here's a simple example. + +```yaml +- name: Ensure description is correct for ou=People + ldap_attr: + dn: "ou=People,dc=example,dc=com" + name: description + state: exact + values: Getting together and having a good time. + +- name: Ensure members are correct for cn=Admins,ou=Groups + ldap_attr: + dn: "cn=Admins,ou=Groups,dc=example,dc=com" + name: member + state: exact + values: + - cn=joe,ou=People,dc=example,dc=com + - cn=bob,ou=People,dc=example,dc=com +``` +This is a lot of work for many entries with many attributes. Consider +`ldap_upsert` in these cases. + +#### Appending to an attribute + +```yaml +- name: Ensure members are present in cn=Admins,ou=Groups + ldap_attr: + dn: "cn=Admins,ou=Groups,dc=example,dc=com" + name: member + state: present + values: + - cn=joe,ou=People,dc=example,dc=com + - cn=bob,ou=People,dc=example,dc=com +``` + +The user `cn=mike,ou=People,dc=example,dc=com` could still be a +`member` of `cn=Admins,ou=Groups,dc=example,dc=com` after this task +runs. + +#### Removing an attribute + +```yaml +- name: Ensure user's password is not set + ldap_attr: + dn: "cn=joe,ou=People,dc=example,dc=com" + state: absent + name: userPassword +``` + +### ldap_upsert + +Ensures both that an entry exists as well as that its attributs have +particular values. You cannot use `ldap_upsert` to remove entries or +their attributes, but it useful when creating lots of entities with +their attributes. + +```yaml + +- name: Create a user + ldap_upsert: + dn: "uid=joe1234,ou=People,dc=example,dc=com" + objectClass: + - account + - posixAccount + cn: "Joe Smith" + gn: "Joe" + sn: "Smith" + uid: "joe1234" + homeDirectory: "/home/joe1234" + userPassword: "..." +``` + +### ldap_search + +Perform an LDAP search. Useful in combination with Ansible's +`register` keyword. + +This example performs a task for each LDAP user `account`: + +```yaml +- name: + ldap_search: + base: "ou=People,dc=example,dc=com" + scope: onelevel + filter: "(objectClass=account)" + register: ldap_user_search + +- name: View search results + debug: var=ldap_user_search + +- name: Do something for each user + # ... + with_items: "{{ ldap_user_search.results }}" +``` -Regrettably, Ansible does not have any sensible mechanism for -packaging and distributing third-party modules with rendered -documentation and runnable unit tests. The LDAP modules do have -complete documentation strings embedded. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ldap_attr b/ldap_attr index 0795680..ff0693f 100755 --- a/ldap_attr +++ b/ldap_attr @@ -129,7 +129,7 @@ def main(): 'values': dict(required=True), 'state': dict(default='present', choices=['present', 'absent', 'exact']), 'server_uri': dict(default='ldapi:///'), - 'start_tls': dict(default='false', choices=BOOLEANS), + 'start_tls': dict(default='false', choices=(BOOLEANS+['True', True, 'False', False])), 'bind_dn': dict(default=None), 'bind_pw': dict(default='', no_log=True), }, diff --git a/ldap_entry b/ldap_entry index 6ff5fed..88df511 100755 --- a/ldap_entry +++ b/ldap_entry @@ -95,7 +95,7 @@ def main(): 'dn': dict(required=True), 'state': dict(default='present', choices=['present', 'absent']), 'server_uri': dict(default='ldapi:///'), - 'start_tls': dict(default='false', choices=BOOLEANS), + 'start_tls': dict(default='false', choices=(BOOLEANS+['True', True, 'False', False])), 'bind_dn': dict(default=None), 'bind_pw': dict(default='', no_log=True), }, diff --git a/ldap_search b/ldap_search new file mode 100755 index 0000000..178aee1 --- /dev/null +++ b/ldap_search @@ -0,0 +1,231 @@ +#!/usr/bin/env python + +from traceback import format_exc + +import ldap +import ldap.modlist +import ldap.sasl + + +DOCUMENTATION = """ +--- +module: ldap_search +short_description: Return the results of an LDAP search. +description: + - Return the results of an LDAP search. Use in combination with + Ansible's 'register' statement. + +notes: [] +version_added: null +author: Dhruv Bansal +requirements: + - python-ldap +options: + base: + required: true + description: + - The base to search from. + scope: + required: false + choices: [base, onelevel, subordinate, children] + default: base + description: + - The LDAP scope to use when searching. + filter: + required: false + default: '(objectClass=*)' + description: + - The filter to apply to the search. + attrs: + required: false + default: none + description: + - A list of attrs to limit the results to. Can be an + actual list or just a comma-separated string. + schema: + required: false + default: false + description: + - Return the full attribute schema of entries, not their + attribute values. Overrides C(attrs) when given. + server_uri: + required: false + default: ldapi:/// + description: + - A URI to the LDAP server. The default value lets the underlying + LDAP client library look for a UNIX domain socket in its default + location. + start_tls: + required: false + default: false + description: + - If true, we'll use the START_TLS LDAP extension. + bind_dn: + required: false + description: + - A DN to bind with. If this is omitted, we'll try a SASL bind with + the EXTERNAL mechanism. If this is blank, we'll use an anonymous + bind. + bind_pw: + required: false + description: + - The password to use with C(bind_dn). +""" + + +EXAMPLES = """ +# Return all entries within the 'groups' organizational unit. +- ldap_search: base='ou=groups,dc=example,dc=com' + register: ldap_groups + sudo: true + +# Return GIDs for all groups +- ldap_entry: base='ou=groups,dc=example,dc=com' scope=onelevel attrs="gidNumber" + register: ldap_group_gids + sudo: true +""" + +def main(): + module = AnsibleModule( + argument_spec={ + 'base': dict(required=True), + 'scope': dict(default='base', choices=['base', 'onelevel', 'subordinate', 'children']), + 'filter': dict(default='(objectClass=*)'), + 'attrs': dict(default=None), + 'schema': dict(default=False, choices=(BOOLEANS+['True', True, 'False', False])), + 'server_uri': dict(default='ldapi:///'), + 'start_tls': dict(default='false', choices=(BOOLEANS+['True', True, 'False', False])), + 'bind_dn': dict(default=None), + 'bind_pw': dict(default='', no_log=True), + }, + check_invalid_arguments=False, + supports_check_mode=False, + ) + + try: + LdapSearch(module).main() + except ldap.LDAPError, e: + module.fail_json(msg=str(e), exc=format_exc()) + + +class LdapSearch(object): + _connection = None + + def __init__(self, module): + self.module = module + + # python-ldap doesn't understand unicode strings. Parameters that are + # just going to get passed to python-ldap APIs are stored as utf-8. + self.base = self._utf8_param('base') + self.filterstr = self._utf8_param('filter') + self.server_uri = self.module.params['server_uri'] + self.start_tls = self.module.boolean(self.module.params['start_tls']) + self.bind_dn = self._utf8_param('bind_dn') + self.bind_pw = self._utf8_param('bind_pw') + self.attrlist = [] + + self._load_scope() + self._load_attrs() + self._load_schema() + + # if (self.state == 'present') and ('objectClass' not in self.attrs): + # self.module.fail_json(msg="When state=present, at least one objectClass must be provided") + + def _utf8_param(self, name): + return self._force_utf8(self.module.params[name]) + + def _load_schema(self): + self.schema = self.module.boolean(self.module.params['schema']) + if self.schema: + self.attrsonly = 1 + else: + self.attrsonly = 0 + + def _load_scope(self): + scope = self.module.params['scope'] + if scope == 'base': self.scope = ldap.SCOPE_BASE + elif scope == 'onelevel': self.scope = ldap.SCOPE_ONELEVEL + elif scope == 'subordinate': self.scope = ldap.SCOPE_SUBORDINATE + elif scope == 'children': self.scope = ldap.SCOPE_SUBTREE + else: + self.module.fail_json(msg="scope must be one of: base, onelevel, subordinate, children") + + def _load_attrs(self): + if self.module.params['attrs'] is None: + self.attrlist = None + else: + attrs = self._load_attr_values(self.module.params['attrs']) + if len(attrs) > 0: + self.attrlist = attrs + else: + self.attrlist = None + + def _load_attr_values(self, raw): + if isinstance(raw, basestring): + values = raw.split(',') + else: + values = raw + + if not (isinstance(values, list) and all(isinstance(value, basestring) for value in values)): + self.module.fail_json(msg="attrs must be a string or list of strings.") + + return map(self._force_utf8, values) + + def _force_utf8(self, value): + """ If value is unicode, encode to utf-8. """ + if isinstance(value, unicode): + value = value.encode('utf-8') + + return value + + def main(self): + results = self.perform_search() + self.module.exit_json(changed=True, results=results) + + def perform_search(self): + try: + results = self.connection.search_s(self.base, self.scope, filterstr=self.filterstr, attrlist=self.attrlist, attrsonly=self.attrsonly) + if self.schema: + return [dict(dn=result[0],attrs=result[1].keys()) for result in results] + else: + return [self._extract_entry(result[0], result[1]) for result in results] + except ldap.NO_SUCH_OBJECT: + self.module.fail_json(msg="Base not found: {}".format(self.base)) + + def _extract_entry(self, dn, attrs): + extracted = {'dn': dn} + for attr, val in attrs.iteritems(): + if len(val) == 1: + extracted[attr] = val[0] + else: + extracted[attr] = val + return extracted + + # + # LDAP Connection + # + + @property + def connection(self): + """ An authenticated connection to the LDAP server (cached). """ + if self._connection is None: + self._connection = self._connect_to_ldap() + + return self._connection + + def _connect_to_ldap(self): + connection = ldap.initialize(self.server_uri) + + if self.start_tls: + connection.start_tls_s() + + if self.bind_dn is not None: + connection.simple_bind_s(self.bind_dn, self.bind_pw) + else: + connection.sasl_interactive_bind_s('', ldap.sasl.external()) + + return connection + + +from ansible.module_utils.basic import * # noqa +main() diff --git a/ldap_upsert b/ldap_upsert new file mode 100755 index 0000000..12986ab --- /dev/null +++ b/ldap_upsert @@ -0,0 +1,239 @@ +#!/usr/bin/env python + +from traceback import format_exc + +import ldap +import ldap.modlist +import ldap.sasl + +DOCUMENTATION = """ +--- +module: ldap_upsert +short_description: Insert or update an LDAP entry. +description: + - Insert or update an LDAP entry. Existing entries with matching + C(dn) will have their attributes updated. Otherwise a new entry + will be created with the given attributes. This module cannot + delete entries or remove attributes, see M(ldap_entry) and + M(ldap_attr), respectively. +notes: [] +version_added: null +author: Peter Sagerson +requirements: + - python-ldap +options: + dn: + required: true + description: + - The DN of the entry to insert or update. + objectClass: + required: true + description: + - Must be a list of objectClass values to use when + creating the entry. It can either be a string containing + a comma-separated list of values, or an actual list of + strings. When updating, these values are not used. + '...': + required: false + description: + - All additional arguments are taken to be LDAP attribute + names like C(objectClass), with similar lists of values. + If the entry exists, these attributes will be updated if + necessary. Otherwise the newly created entry will have + these attributes. + server_uri: + required: false + default: ldapi:/// + description: + - A URI to the LDAP server. The default value lets the underlying + LDAP client library look for a UNIX domain socket in its default + location. + start_tls: + required: false + default: false + description: + - If true, we'll use the START_TLS LDAP extension. + bind_dn: + required: false + description: + - A DN to bind with. If this is omitted, we'll try a SASL bind with + the EXTERNAL mechanism. If this is blank, we'll use an anonymous + bind. + bind_pw: + required: false + description: + - The password to use with C(bind_dn). +""" + + +EXAMPLES = """ +# Make sure we have a parent entry for users. +- ldap_entry: dn='ou=users,dc=example,dc=com' objectClass=organizationalUnit description="My Org Unit" + sudo: true + +# Make sure we have an admin user. +- ldap_entry: + dn: 'cn=admin,dc=example,dc=com' + objectClass: simpleSecurityObject,organizationalRole + description: An LDAP administrator + userPassword: '{SSHA}pedsA5Y9wHbZ5R90pRdxTEZmn6qvPdzm' + sudo: true +""" + +def main(): + module = AnsibleModule( + argument_spec={ + 'dn': dict(required=True), + 'server_uri': dict(default='ldapi:///'), + 'start_tls': dict(default='false', choices=(BOOLEANS+['True', True, 'False', False])), + 'bind_dn': dict(default=None), + 'bind_pw': dict(default='', no_log=True), + }, + check_invalid_arguments=False, + supports_check_mode=False, + ) + + try: + LdapUpsert(module).main() + except ldap.LDAPError, e: + module.fail_json(msg=str(e), exc=format_exc()) + + +class LdapUpsert(object): + + def __init__(self, module): + self.module = module + + self.server_uri = self.module.params['server_uri'] + self.start_tls = self._boolean_param('start_tls') + self.bind_dn = self._utf8_param('bind_dn') + self.bind_pw = self._utf8_param('bind_pw') + + self.dn = self._utf8_param('dn') + self._load_attrs() + + if 'objectClass' not in self.attrs: + self.module.fail_json(msg="At least one objectClass must be provided") + + def _boolean_param(self, name): + return self.module.boolean(self.module.params[name]) + + def _utf8_param(self, name): + return self._force_utf8(self.module.params[name]) + + def _load_attr_values(self, name, raw): + if isinstance(raw, basestring): + values = raw.split(',') + else: + values = raw + + if not (isinstance(values, list) and all(isinstance(value, basestring) for value in values)): + self.module.fail_json(msg="{} must be a string or list of strings.".format(name)) + + return map(self._force_utf8, values) + + def _force_utf8(self, value): + """ If value is unicode, encode to utf-8. """ + if isinstance(value, unicode): + value = value.encode('utf-8') + + return value + + def _load_attrs(self): + self.attrs = {} + for name, raw in self.module.params.iteritems(): + if name not in self.module.argument_spec: + self.attrs[name] = self._load_attr_values(name, raw) + + def main(self): + if self.entry_exists(): + results = self.update_entry() + self.module.exit_json(**results) + else: + insert_result = self.insert_entry() + self.module.exit_json(changed=True, results=[insert_result]) + + def entry_exists(self): + try: + results = self.connection.search_s(self.dn, ldap.SCOPE_BASE) + for result in results: + if result[0] == self.dn: + return True + except ldap.NO_SUCH_OBJECT: + return False + + def insert_entry(self): + modlist = ldap.modlist.addModlist(self.attrs) + result = self.connection.add_s(self.dn, modlist) + return result + + def update_entry(self): + results = [] + for attr, value in self.attrs.iteritems(): + if attr == 'objectClass': continue + value = self._extract_value(value) + check = self._attribute_value_check(attr, value) + if check is False: + op = ldap.MOD_REPLACE + elif check is None: + op = ldap.MOD_ADD + else: + op = None # Nothing to see here... + if op is not None: + result = self.connection.modify_s(self.dn, [(op, attr, value)]) + results.append(result) + if len(results) == 0: + return dict(changed=False) + else: + return dict(changed=True, results=results) + + def _attribute_value_check(self, attr, value): + try: + return bool(self.connection.compare_s(self.dn, attr, value)) + except ldap.NO_SUCH_ATTRIBUTE: + return None + + def _extract_value(self, values): + if isinstance(values, basestring): + if values == '': + values = [] + else: + values = [values] + + if not (isinstance(values, list) and all(isinstance(value, basestring) for value in values)): + self.module.fail_json(msg="Attribute values must be strings or lists of strings.") + + values = map(self._force_utf8, values) + if len(values) == 1: + return values[0] + else: + return values + + # + # LDAP Connection + # + _connection = None + + @property + def connection(self): + """ An authenticated connection to the LDAP server (cached). """ + if self._connection is None: + self._connection = self._connect_to_ldap() + + return self._connection + + def _connect_to_ldap(self): + connection = ldap.initialize(self.server_uri) + + if self.start_tls: + connection.start_tls_s() + + if self.bind_dn is not None: + connection.simple_bind_s(self.bind_dn, self.bind_pw) + else: + connection.sasl_interactive_bind_s('', ldap.sasl.external()) + + return connection + +from ansible.module_utils.basic import * # noqa +main()