diff --git a/mylib/ldap.py b/mylib/ldap.py index e48f1b3..7b97445 100644 --- a/mylib/ldap.py +++ b/mylib/ldap.py @@ -14,6 +14,10 @@ from ldap.controls.simple import RelaxRulesControl import ldap.modlist as modlist import pytz +from mylib import pretty_format_dict + +log = logging.getLogger(__name__) + class LdapServer: """ LDAP server connection helper """ # pylint: disable=useless-object-inheritance @@ -314,6 +318,313 @@ class LdapServerException(BaseException): def __init__(self, msg): BaseException.__init__(self, msg) + +class LdapClientException(LdapServerException): + """ Generic exception raised by LdapServer """ + + def __init__(self, msg): + LdapServerException.__init__(self, msg) + + +class LdapClient: + + """ LDAP Client (based on python-mylib.LdapServer) """ + + options = {} + + # Cache objects + _cached_objects = dict() + + def __init__(self, options): + self.options = options + log.info("Connect to LDAP server %s as %s", options.ldap_uri, options.ldap_binddn) + self.cnx = LdapServer(options.ldap_uri, dn=options.ldap_binddn, pwd=options.ldap_bindpwd, raiseOnError=True) + self.cnx.connect() + + @classmethod + def decode(cls, value): + if isinstance(value, list): + return [cls.decode(v) for v in value] + if isinstance(value, str): + return value + return value.decode('utf-8', 'ignore') + + @classmethod + def encode(cls, value): + if isinstance(value, list): + return [cls.encode(v) for v in value] + if isinstance(value, bytes): + return value + return value.encode('utf-8') + + def get_attrs(self, dn, attrs): + obj = dict(dn=dn) + for attr in attrs: + obj[attr] = [self.decode(v) for v in self.cnx.get_attr(attrs, attr, all=True)] + return obj + + @staticmethod + def get_attr(obj, attr, default="", all_values=False): + vals = obj.get(attr, []) + if vals: + return vals if all_values else vals[0] + return default if default or not all_values else [] + + def get_objects(self, name, filterstr, basedn, attrs, key_attr=None, warn=True): + if name in self._cached_objects: + log.debug('Retreived %s objects from cache', name) + else: + log.debug('Looking for LDAP %s with (filter="%s" / basedn="%s")', name, filterstr, basedn) + ldap_data = self.cnx.search( + basedn=basedn, + filterstr=filterstr, + attrs=attrs + ) + + if not ldap_data: + if warn: + log.warning('No %s found in LDAP', name) + else: + log.debug('No %s found in LDAP', name) + return {} + + objects = {} + for obj_dn, obj_attrs in ldap_data.items(): + objects[obj_dn] = self.get_attrs(obj_dn, obj_attrs) + self._cached_objects[name] = objects + if not key_attr or key_attr == 'dn': + return self._cached_objects[name] + return dict( + (self.get_attr(self._cached_objects[name][dn], key_attr), self._cached_objects[name][dn]) + for dn in self._cached_objects[name] + ) + + def get_object(self, type_name, object_name, filterstr, basedn, attrs, warn=True): + log.debug('Looking for LDAP %s "%s" with (filter="%s" / basedn="%s")', type_name, object_name, filterstr, basedn) + ldap_data = self.cnx.search( + basedn=basedn, filterstr=filterstr, + attrs=attrs + ) + + if not ldap_data: + if warn: + log.warning('No %s "%s" found in LDAP', type_name, object_name) + else: + log.debug('No %s "%s" found in LDAP', type_name, object_name) + return None + + if len(ldap_data) > 1: + raise LdapClientException('More than one %s "%s": %s' % (type_name, object_name, ' / '.join(ldap_data.keys()))) + + dn = next(iter(ldap_data)) + return self.get_attrs(dn, ldap_data[dn]) + + def get_object_by_dn(self, type_name, dn, populate_cache_method=None, warn=True): + if type_name not in self._cached_objects: + if not populate_cache_method: + return False + populate_cache_method() + if dn not in self._cached_objects[type_name]: + if warn: + log.warning('No %s found with DN "%s"', type_name, dn) + else: + log.debug('No %s found with DN "%s"', type_name, dn) + return None + return self._cached_objects[type_name][dn] + + @classmethod + def object_attr_mached(cls, obj, attr, value, case_sensitive=False): + if case_sensitive: + return value in cls.get_attr(obj, attr, all_values=True) + return value.lower() in [v.lower() for v in cls.get_attr(obj, attr, all_values=True)] + + def get_object_by_attr(self, type_name, attr, value, populate_cache_method=None, case_sensitive=False, warn=True): + if type_name not in self._cached_objects: + if not populate_cache_method: + return False + populate_cache_method() + matched = dict( + (dn, obj) + for dn, obj in self._cached_objects[type_name].items() + if self.object_attr_mached(obj, attr, value, case_sensitive=case_sensitive) + ) + if not matched: + if warn: + log.warning('No %s found with %s="%s"', type_name, attr, value) + else: + log.debug('No %s found with %s="%s"', type_name, attr, value) + return None + if len(matched) > 1: + raise LdapClientException('More than one %s with %s="%s" found: %s' % (type_name, attr, value , ' / '.join(matched.keys()))) + + dn = next(iter(matched)) + return matched[dn] + + def get_changes(self, ldap_obj, attrs, protected_attrs=None): + """ + Retrieve changes on a LDAP object + + :param ldap_obj: The original LDAP object + :param attrs: The new LDAP object attributes values + :param protected_attrs: Optional list of protected attributes + """ + old = {} + new = {} + protected_attrs = [a.lower() for a in protected_attrs or list()] + protected_attrs.append('dn') + # New/updated attributes + for attr in attrs: + if protected_attrs and attr.lower() in protected_attrs: + continue + if attr in ldap_obj and ldap_obj[attr]: + if sorted(ldap_obj[attr]) == sorted(attrs[attr]): + continue + old[attr] = self.encode(ldap_obj[attr]) + new[attr] = self.encode(attrs[attr]) + + # Deleted attributes + for attr in ldap_obj: + if (not protected_attrs or attr.lower() not in protected_attrs) and ldap_obj[attr] and attr not in attrs: + old[attr] = self.encode(ldap_obj[attr]) + if old == new: + return None + return (old, new) + + def add_object(self, dn, attrs): + """ + Add an object + + :param dn: The LDAP object DN + :param attrs: The LDAP object attributes (as dict) + """ + attrs = dict( + (attr, self.encode(values)) + for attr, values in attrs.items() + ) + try: + if self.options.just_try: + log.debug('Just-try mode : do not really add object in LDAP') + return True + return self.cnx.add_object(dn, attrs) + except LdapServerException: + log.error( + "An error occurred adding object %s in LDAP:\n%s\n", + dn, pretty_format_dict(attrs), exc_info=True + ) + return False + + def update_object(self, ldap_obj, changes, protected_attrs=None, rdn_attr=None): + """ + Update an object + + :param ldap_obj: The original LDAP object + :param changes: The changes to make on LDAP object (as formated by get_changes() method) + :param protected_attrs: An optional list of protected attributes + :param rdn_attr: The LDAP object RDN attribute (to detect renaming, default: auto-detected) + """ + assert isinstance(changes, (list, tuple)) and len(changes) == 2 and isinstance(changes[0], dict) and isinstance(changes[1], dict), "changes parameter must be a result of get_changes() method (%s given)" % type(changes) + if not rdn_attr: + rdn_attr = ldap_obj['dn'].split('=')[0] + log.debug('Auto-detected RDN attribute from DN: %s => %s', ldap_obj['dn'], rdn_attr) + old_rdn_values = self.get_attr(changes[0], rdn_attr, all_values=True) + new_rdn_values = self.get_attr(changes[1], rdn_attr, all_values=True) + if old_rdn_values or new_rdn_values: + if not new_rdn_values: + log.error( + "%s : Attribute %s can't be deleted because it's used as RDN.", + ldap_obj['dn'], rdn_attr + ) + return False + log.debug( + '%s: Changes detected on %s RDN attribute: must rename object before updating it', + ldap_obj['dn'], rdn_attr + ) + + # Compute new object DN + dn_parts = ldap_obj['dn'].split(',') + basedn = ','.join(dn_parts[1:]) + new_rdn = '%s=%s' % (rdn_attr, new_rdn_values[0]) + new_dn = '%s,%s' % (new_rdn, basedn) + + # Rename object + log.debug('%s: Rename to %s', ldap_obj['dn'], new_dn) + if not self.move_object(ldap_obj, new_rdn): + return False + + # Remove RDN in changes list + for attr in changes[0].keys(): + if attr.lower() == rdn_attr.lower(): + del changes[0][attr] + for attr in changes[1].keys(): + if attr.lower() == rdn_attr.lower(): + del changes[1][attr] + + # Check that there are other changes + if not changes[0] and not changes[1]: + log.debug('%s: No other change after renaming', new_dn) + return True + + # Otherwise, update object DN + ldap_obj['dn'] = new_dn + else: + log.debug('%s: No change detected on RDN attibute %s', ldap_obj['dn'], rdn_attr) + + try: + if self.options.just_try: + log.debug('Just-try mode : do not really update object in LDAP') + return True + return self.cnx.update_object( + ldap_obj['dn'], + changes[0], + changes[1], + ignore_attrs=protected_attrs + ) + except LdapServerException: + log.error( + "An error occurred updating object %s in LDAP:\n%s\n -> \n%s\n\n", + ldap_obj['dn'], pretty_format_dict(changes[0]), pretty_format_dict(changes[1]), + exc_info=True + ) + return False + + def move_object(self, ldap_obj, new_dn_or_rdn): + """ + Move/rename an object + + :param ldap_obj: The original LDAP object + :param new_dn_or_rdn: The new LDAP object's DN (or RDN) + """ + try: + if self.options.just_try: + log.debug('Just-try mode : do not really move object in LDAP') + return True + return self.cnx.rename_object(ldap_obj['dn'], new_dn_or_rdn) + except LdapServerException: + log.error( + "An error occurred moving object %s in LDAP (destination: %s)", + ldap_obj['dn'], new_dn_or_rdn, exc_info=True + ) + return False + + def drop_object(self, ldap_obj): + """ + Drop/delete an object + + :param ldap_obj: The original LDAP object to delete/drop + """ + try: + if self.options.just_try: + log.debug('Just-try mode : do not really drop object in LDAP') + return True + return self.cnx.drop_object(ldap_obj['dn']) + except LdapServerException: + log.error( + "An error occurred removing object %s in LDAP", + ldap_obj['dn'], exc_info=True + ) + return False + + # # LDAP date string helpers #