commit 56d637f9845ad763fe6468857f921e7c7bb4a006 Author: Dhruv Bansal Date: Mon Sep 12 21:49:35 2016 +0000 Initial commit. diff --git a/ldap_attr b/ldap_attr new file mode 100755 index 0000000..0795680 --- /dev/null +++ b/ldap_attr @@ -0,0 +1,293 @@ +#!/usr/bin/env python + +from traceback import format_exc + +import ldap +import ldap.sasl + + +DOCUMENTATION = """ +--- +module: ldap_attr +short_description: Add or remove LDAP attribute values. +description: + - Add or remove LDAP attribute values. +notes: + - This only deals with attributes on existing entries. To add or remove + whole entries, see M(ldap_entry). + - The default authentication settings will attempt to use a SASL EXTERNAL + bind over a UNIX domain socket. This works well with the default Ubuntu + install for example, which includes a cn=peercred,cn=external,cn=auth ACL + rule allowing root to modify the server configuration. If you need to use + a simple bind to access your server, pass the credentials in C(bind_dn) + and C(bind_pw). + - For C(state=present) and C(state=absent), all value comparisons are + performed on the server for maximum accuracy. For C(state=exact), values + have to be compared in Python, which obviously ignores LDAP matching + rules. This should work out in most cases, but it is theoretically + possible to see spurious changes when target and actual values are + semantically identical but lexically distinct. +version_added: null +author: Peter Sagerson +requirements: + - python-ldap +options: + dn: + required: true + description: + - The DN of the entry to modify. + name: + required: true + description: + - The name of the attribute to modify. + values: + required: true + description: + - The value(s) to add or remove. This can be a string or a list of + strings. The complex argument format is required in order to pass + a list of strings (see examples). + state: + required: false + choices: [present, absent, exact] + default: present + description: + - The state of the attribute values. If C(present), all given + values will be added if they're missing. If C(absent), all given + values will be removed if present. If C(exact), the set of values + will be forced to exactly those provided and no others. If + C(state=exact) and C(values) is empty, all values for this + attribute will be removed. + 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 (see note). If this is blank, we'll use an + anonymous bind. + bind_pw: + required: false + description: + - The password to use with C(bind_dn). +""" + + +EXAMPLES = """ +# Configure directory number 1 for example.com. +- ldap_attr: dn='olcDatabase={1}hdb,cn=config' name=olcSuffix values='dc=example,dc=com' state=exact + sudo: true + +# Set up the ACL. The complex argument format is required here to pass a list +# of ACL strings. +- ldap_attr: + sudo: true + args: + dn: olcDatabase={1}hdb,cn=config + name: olcAccess + values: + - '{0}to attrs=userPassword,shadowLastChange + by self write + by anonymous auth + by dn="cn=admin,dc=example,dc=com" write + by * none' + - '{1}to dn.base="dc=example,dc=com" + by dn="cn=admin,dc=example,dc=com" write + by * read' + state: exact + +# Declare some indexes. +- ldap_attr: dn='olcDatabase={1}hdb,cn=config' name=olcDbIndex values={{ item }} + sudo: true + with_items: + - objectClass eq + - uid eq + +# Set up a root user, which we can use later to bootstrap the directory. +- ldap_attr: dn='olcDatabase={1}hdb,cn=config' name={{ item.key }} values={{ item.value }} state=exact + sudo: true + with_dict: + olcRootDN: 'cn=root,dc=example,dc=com' + olcRootPW: '{SSHA}mRskON0Stk+5wO5K+MMk2xmakKt8h7eJ' +""" + + +def main(): + module = AnsibleModule( + argument_spec={ + 'dn': dict(required=True), + 'name': dict(required=True), + 'values': dict(required=True), + 'state': dict(default='present', choices=['present', 'absent', 'exact']), + 'server_uri': dict(default='ldapi:///'), + 'start_tls': dict(default='false', choices=BOOLEANS), + 'bind_dn': dict(default=None), + 'bind_pw': dict(default='', no_log=True), + }, + supports_check_mode=True, + ) + + try: + LdapAttr(module).main() + except ldap.LDAPError, e: + module.fail_json(msg=str(e), exc=format_exc()) + + +class LdapAttr(object): + 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.dn = self._utf8_param('dn') + self.name = self._utf8_param('name') + self.values = self._normalized_values() + self.state = self.module.params['state'] + 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._connection = None + + def _utf8_param(self, name): + return self._force_utf8(self.module.params[name]) + + def _normalized_values(self): + """ Parses the value parameter into a list of utf-8 strings. """ + values = self.module.params['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="values 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): + if self.state == 'present': + modlist = self.handle_present() + elif self.state == 'absent': + modlist = self.handle_absent() + elif self.state == 'exact': + modlist = self.handle_exact() + else: + modlist = [] + + if len(modlist) > 0: + changed = True + if not self.module.check_mode: + self.connection.modify_s(self.dn, modlist) + else: + changed = False + + self.module.exit_json(changed=changed, modlist=modlist) + + # + # State Implementations + # + + def handle_present(self): + values_to_add = filter(self.is_value_absent, self.values) + if len(values_to_add) > 0: + modlist = [(ldap.MOD_ADD, self.name, values_to_add)] + else: + modlist = [] + + return modlist + + def handle_absent(self): + values_to_delete = filter(self.is_value_present, self.values) + if len(values_to_delete) > 0: + modlist = [(ldap.MOD_DELETE, self.name, values_to_delete)] + else: + modlist = [] + + return modlist + + def handle_exact(self): + modlist = [] + + current = self.current_values() + if frozenset(self.values) != frozenset(current): + if len(current) == 0: + modlist = [(ldap.MOD_ADD, self.name, self.values)] + elif len(self.values) == 0: + modlist = [(ldap.MOD_DELETE, self.name, None)] + else: + modlist = [(ldap.MOD_REPLACE, self.name, self.values)] + + return modlist + + # + # Util + # + + def is_value_present(self, value): + """ True if the target attribute has the given value. """ + try: + is_present = bool(self.connection.compare_s(self.dn, self.name, value)) + except ldap.NO_SUCH_ATTRIBUTE: + is_present = False + + return is_present + + def is_value_absent(self, value): + """ True if the target attribute does not have the given value. """ + return (not self.is_value_present(value)) + + def current_values(self): + """ Returns the full list of values on the target attribute. """ + results = self.connection.search_s(self.dn, ldap.SCOPE_BASE, attrlist=[self.name]) + values = results[0][1].get(self.name, []) + + return values + + # + # 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_entry b/ldap_entry new file mode 100755 index 0000000..6ff5fed --- /dev/null +++ b/ldap_entry @@ -0,0 +1,236 @@ +#!/usr/bin/env python + +from traceback import format_exc + +import ldap +import ldap.modlist +import ldap.sasl + + +DOCUMENTATION = """ +--- +module: ldap_entry +short_description: Add or remove LDAP entries. +description: + - Add or remove LDAP entries. This module only asserts the existence or + non-existence of an LDAP entry, not its attributes. To assert the + attribute values of an entry, see M(ldap_attr). +notes: [] +version_added: null +author: Peter Sagerson +requirements: + - python-ldap +options: + dn: + required: true + description: + - The DN of the entry to add or remove. + state: + required: false + choices: [present, absent] + default: present + description: + - The target state of the entry. + objectClass: + required: false + description: + - If C(state=present), this 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. + '...': + required: false + description: + - If C(state=present), all additional arguments are taken to be + LDAP attribute names like C(objectClass), with similar + lists of values. These should only be used to + provide the minimum attributes necessary for creating an entry; + existing entries are never modified. To assert specific attribute + values on an existing entry, see M(ldap_attr). + 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 + 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 + +# Get rid of an old entry. +- ldap_entry: dn='ou=stuff,dc=example,dc=com' state=absent server_uri='ldap://localhost/' bind_dn='cn=admin,dc=example,dc=com' bind_pw=password +""" + + +def main(): + module = AnsibleModule( + argument_spec={ + 'dn': dict(required=True), + 'state': dict(default='present', choices=['present', 'absent']), + 'server_uri': dict(default='ldapi:///'), + 'start_tls': dict(default='false', choices=BOOLEANS), + 'bind_dn': dict(default=None), + 'bind_pw': dict(default='', no_log=True), + }, + check_invalid_arguments=False, + supports_check_mode=True, + ) + + try: + LdapEntry(module).main() + except ldap.LDAPError, e: + module.fail_json(msg=str(e), exc=format_exc()) + + +class LdapEntry(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.dn = self._utf8_param('dn') + self.state = self.module.params['state'] + 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.attrs = {} + + self._load_attrs() + + 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_attrs(self): + 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 _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 main(self): + if self.state == 'present': + action = self.handle_present() + elif self.state == 'absent': + action = self.handle_absent() + else: + action = None + + if (action is not None) and (not self.module.check_mode): + action() + + self.module.exit_json(changed=(action is not None)) + + # + # State Implementations + # + + def handle_present(self): + """ If self.dn does not exist, returns a callable that will add it. """ + if not self.is_entry_present(): + modlist = ldap.modlist.addModlist(self.attrs) + action = lambda: self.connection.add_s(self.dn, modlist) + else: + action = None + + return action + + def handle_absent(self): + """ If self.dn exists, returns a callable that will delete it. """ + if self.is_entry_present(): + action = lambda: self.connection.delete_s(self.dn) + else: + action = None + + return action + + # + # Util + # + + def is_entry_present(self): + try: + self.connection.search_s(self.dn, ldap.SCOPE_BASE) + except ldap.NO_SUCH_OBJECT: + is_present = False + else: + is_present = True + + return is_present + + # + # 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/requirements.txt b/requirements.txt new file mode 100644 index 0000000..079ae1e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +ansible==2.1.0 +pyaml==15.8.2 +python-ldap==2.4.27