#!/usr/bin/python # -*- coding: utf-8 -*- # Copyright (c) 2019, Maciej Delmanowski # Copyright (c) 2017, Alexander Korinek # Copyright (c) 2016, Peter Sagerson # Copyright (c) 2016, Jiri Tyr # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later from __future__ import absolute_import, division, print_function __metaclass__ = type DOCUMENTATION = r''' --- module: ldap_attrs short_description: Add or remove multiple LDAP attribute values description: - Add or remove multiple LDAP attribute values. notes: - This only deals with attributes on existing entries. To add or remove whole entries, see M(community.general.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 O(bind_dn) and O(bind_pw). - For O(state=present) and O(state=absent), all value comparisons are performed on the server for maximum accuracy. For O(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: '0.2.0' author: - Jiri Tyr (@jtyr) - Alexander Korinek (@noles) - Maciej Delmanowski (@drybjed) requirements: - python-ldap attributes: check_mode: support: full diff_mode: support: full version_added: 8.5.0 options: state: required: false type: str choices: [present, absent, exact] default: present description: - The state of the attribute values. If V(present), all given attribute values will be added if they're missing. If V(absent), all given attribute values will be removed if present. If V(exact), the set of attribute values will be forced to exactly those provided and no others. If O(state=exact) and the attribute value is empty, all values for this attribute will be removed. attributes: required: true type: dict description: - The attribute(s) and value(s) to add or remove. - Each attribute value can be a string for single-valued attributes or a list of strings for multi-valued attributes. - If you specify values for this option in YAML, please note that you can improve readability for long string values by using YAML block modifiers as seen in the examples for this module. - Note that when using values that YAML/ansible-core interprets as other types, like V(yes), V(no) (booleans), or V(2.10) (float), make sure to quote them if these are meant to be strings. Otherwise the wrong values may be sent to LDAP. ordered: required: false type: bool default: false description: - If V(true), prepend list values with X-ORDERED index numbers in all attributes specified in the current task. This is useful mostly with C(olcAccess) attribute to easily manage LDAP Access Control Lists. extends_documentation_fragment: - community.general.ldap.documentation - community.general.attributes ''' EXAMPLES = r''' - name: Configure directory number 1 for example.com community.general.ldap_attrs: dn: olcDatabase={1}hdb,cn=config attributes: olcSuffix: dc=example,dc=com state: exact # The complex argument format is required here to pass a list of ACL strings. - name: Set up the ACL community.general.ldap_attrs: dn: olcDatabase={1}hdb,cn=config attributes: olcAccess: - >- {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 # An alternative approach with automatic X-ORDERED numbering - name: Set up the ACL community.general.ldap_attrs: dn: olcDatabase={1}hdb,cn=config attributes: olcAccess: - >- to attrs=userPassword,shadowLastChange by self write by anonymous auth by dn="cn=admin,dc=example,dc=com" write by * none' - >- to dn.base="dc=example,dc=com" by dn="cn=admin,dc=example,dc=com" write by * read ordered: true state: exact - name: Declare some indexes community.general.ldap_attrs: dn: olcDatabase={1}hdb,cn=config attributes: olcDbIndex: - objectClass eq - uid eq - name: Set up a root user, which we can use later to bootstrap the directory community.general.ldap_attrs: dn: olcDatabase={1}hdb,cn=config attributes: olcRootDN: cn=root,dc=example,dc=com olcRootPW: "{SSHA}tabyipcHzhwESzRaGA7oQ/SDoBZQOGND" state: exact - name: Remove an attribute with a specific value community.general.ldap_attrs: dn: uid=jdoe,ou=people,dc=example,dc=com attributes: description: "An example user account" state: absent server_uri: ldap://localhost/ bind_dn: cn=admin,dc=example,dc=com bind_pw: password - name: Remove specified attribute(s) from an entry community.general.ldap_attrs: dn: uid=jdoe,ou=people,dc=example,dc=com attributes: description: [] state: exact server_uri: ldap://localhost/ bind_dn: cn=admin,dc=example,dc=com bind_pw: password ''' RETURN = r''' modlist: description: list of modified parameters returned: success type: list sample: - [2, "olcRootDN", ["cn=root,dc=example,dc=com"]] ''' import traceback from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils.common.text.converters import to_native, to_bytes, to_text from ansible_collections.community.general.plugins.module_utils.ldap import LdapGeneric, gen_specs, ldap_required_together import re LDAP_IMP_ERR = None try: import ldap import ldap.filter HAS_LDAP = True except ImportError: LDAP_IMP_ERR = traceback.format_exc() HAS_LDAP = False class LdapAttrs(LdapGeneric): def __init__(self, module): LdapGeneric.__init__(self, module) # Shortcuts self.attrs = self.module.params['attributes'] self.state = self.module.params['state'] self.ordered = self.module.params['ordered'] def _order_values(self, values): """ Prepend X-ORDERED index numbers to attribute's values. """ ordered_values = [] if isinstance(values, list): for index, value in enumerate(values): cleaned_value = re.sub(r'^\{\d+\}', '', value) ordered_values.append('{' + str(index) + '}' + cleaned_value) return ordered_values def _normalize_values(self, values): """ Normalize attribute's values. """ norm_values = [] if isinstance(values, list): if self.ordered: norm_values = list(map(to_bytes, self._order_values(list(map(str, values))))) else: norm_values = list(map(to_bytes, values)) else: norm_values = [to_bytes(str(values))] return norm_values def add(self): modlist = [] new_attrs = {} for name, values in self.module.params['attributes'].items(): norm_values = self._normalize_values(values) added_values = [] for value in norm_values: if self._is_value_absent(name, value): modlist.append((ldap.MOD_ADD, name, value)) added_values.append(value) if added_values: new_attrs[name] = norm_values return modlist, {}, new_attrs def delete(self): modlist = [] old_attrs = {} new_attrs = {} for name, values in self.module.params['attributes'].items(): norm_values = self._normalize_values(values) removed_values = [] for value in norm_values: if self._is_value_present(name, value): removed_values.append(value) modlist.append((ldap.MOD_DELETE, name, value)) if removed_values: old_attrs[name] = norm_values new_attrs[name] = [value for value in norm_values if value not in removed_values] return modlist, old_attrs, new_attrs def exact(self): modlist = [] old_attrs = {} new_attrs = {} for name, values in self.module.params['attributes'].items(): norm_values = self._normalize_values(values) try: results = self.connection.search_s( self.dn, ldap.SCOPE_BASE, attrlist=[name]) except ldap.LDAPError as e: self.fail("Cannot search for attribute %s" % name, e) current = results[0][1].get(name, []) if frozenset(norm_values) != frozenset(current): if len(current) == 0: modlist.append((ldap.MOD_ADD, name, norm_values)) elif len(norm_values) == 0: modlist.append((ldap.MOD_DELETE, name, None)) else: modlist.append((ldap.MOD_REPLACE, name, norm_values)) old_attrs[name] = current new_attrs[name] = norm_values if len(current) == 1 and len(norm_values) == 1: old_attrs[name] = current[0] new_attrs[name] = norm_values[0] return modlist, old_attrs, new_attrs def _is_value_present(self, name, value): """ True if the target attribute has the given value. """ try: escaped_value = ldap.filter.escape_filter_chars(to_text(value)) filterstr = "(%s=%s)" % (name, escaped_value) dns = self.connection.search_s(self.dn, ldap.SCOPE_BASE, filterstr) is_present = len(dns) == 1 except ldap.NO_SUCH_OBJECT: is_present = False return is_present def _is_value_absent(self, name, value): """ True if the target attribute doesn't have the given value. """ return not self._is_value_present(name, value) def main(): module = AnsibleModule( argument_spec=gen_specs( attributes=dict(type='dict', required=True), ordered=dict(type='bool', default=False, required=False), state=dict(type='str', default='present', choices=['absent', 'exact', 'present']), ), supports_check_mode=True, required_together=ldap_required_together(), ) if not HAS_LDAP: module.fail_json(msg=missing_required_lib('python-ldap'), exception=LDAP_IMP_ERR) # Instantiate the LdapAttr object ldap = LdapAttrs(module) old_attrs = None new_attrs = None state = module.params['state'] # Perform action if state == 'present': modlist, old_attrs, new_attrs = ldap.add() elif state == 'absent': modlist, old_attrs, new_attrs = ldap.delete() elif state == 'exact': modlist, old_attrs, new_attrs = ldap.exact() changed = False if len(modlist) > 0: changed = True if not module.check_mode: try: ldap.connection.modify_s(ldap.dn, modlist) except Exception as e: module.fail_json(msg="Attribute action failed.", details=to_native(e)) module.exit_json(changed=changed, modlist=modlist, diff={"before": old_attrs, "after": new_attrs}) if __name__ == '__main__': main()