From 73c19816f797a41bb638453207ba0b9c8eefb553 Mon Sep 17 00:00:00 2001 From: Benjamin Renard Date: Wed, 3 Nov 2021 17:41:15 +0100 Subject: [PATCH] LdapClient: add support to config lib --- mylib/ldap.py | 220 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 189 insertions(+), 31 deletions(-) diff --git a/mylib/ldap.py b/mylib/ldap.py index 056b5df..ce30951 100644 --- a/mylib/ldap.py +++ b/mylib/ldap.py @@ -5,6 +5,7 @@ import copy import datetime import logging +import pytz import dateutil.parser import dateutil.tz @@ -12,11 +13,11 @@ import ldap from ldap.controls import SimplePagedResultsControl from ldap.controls.simple import RelaxRulesControl import ldap.modlist as modlist -import pytz from mylib import pretty_format_dict log = logging.getLogger(__name__) +DEFAULT_ENCODING = 'utf-8' class LdapServer: @@ -81,6 +82,7 @@ class LdapServer: def search(self, basedn, filterstr=None, attrs=None, sizelimit=0, scope=None): """ Run a search on LDAP server """ + assert self.con or self.connect() res_id = self.con.search( basedn, self.get_scope(scope if scope else 'sub'), @@ -106,6 +108,7 @@ class LdapServer: def paged_search(self, basedn, filterstr, attrs, scope='sub', pagesize=500): """ Run a paged search on LDAP server """ assert not self.v2, "Paged search is not available on LDAP version 2" + assert self.con or self.connect() # Initialize SimplePagedResultsControl object page_control = SimplePagedResultsControl( True, @@ -177,6 +180,7 @@ class LdapServer: def add_object(self, dn, attrs): """ Add an object in LDAP directory """ ldif = modlist.addModlist(attrs) + assert self.con or self.connect() try: self.logger.debug("LdapServer - Add %s", dn) self.con.add_s(dn, ldif) @@ -195,6 +199,7 @@ class LdapServer: ) if ldif == []: return True + assert self.con or self.connect() try: if relax: self.con.modify_ext_s(dn, ldif, serverctrls=[RelaxRulesControl()]) @@ -256,6 +261,7 @@ class LdapServer: new_dn_parts = new_rdn.split(',') new_sup = ','.join(new_dn_parts[1:]) new_rdn = new_dn_parts[0] + assert self.con or self.connect() try: self.logger.debug( "LdapServer - Rename %s in %s (new superior: %s, delete old: %s)", @@ -282,6 +288,7 @@ class LdapServer: def drop_object(self, dn): """ Drop an object in LDAP directory """ + assert self.con or self.connect() try: self.logger.debug("LdapServer - Delete %s", dn) self.con.delete_s(dn) @@ -331,52 +338,139 @@ class LdapClient: """ LDAP Client (based on python-mylib.LdapServer) """ - options = {} + _options = {} + _config = None + _config_section = None + + # Connection + _conn = None # 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() + def __init__(self, options=None, options_prefix=None, config=None, config_section=None, initialize=False): + self._options = options if options else {} + self._options_prefix = options_prefix if options_prefix else 'ldap_' + self._config = config if config else None + self._config_section = config_section if config_section else 'ldap' + if initialize: + self.initialize() - @classmethod - def decode(cls, value): + def __get_option(self, option, default=None, required=False): + """ Retreive option value """ + if self._options and hasattr(self._options, self._options_prefix + option): + return getattr(self._options, self._options_prefix + option) + + if self._config and self._config.defined(self._config_section, option): + return self._config.get(self._config_section, option) + + assert not required, "Options %s not defined" % option + + return default + + def configure(self, comment=None, **kwargs): + """ Configure options on registered mylib.Config object """ + assert self._config, "mylib.Config object not registered. Must be passed to __init__ as config keyword argument." + + from mylib.config import StringOption, PasswordOption + + section = self._config.add_section( + self._config_section, + comment=comment if comment else 'LDAP connection', + loaded_callback=self.initialize, **kwargs) + + section.add_option( + StringOption, 'uri', default='ldap://localhost', + comment='LDAP server URI') + section.add_option( + StringOption, 'binddn', comment='LDAP Bind DN') + section.add_option( + PasswordOption, 'bindpwd', + comment='LDAP Bind password (set to "keyring" to use XDG keyring)', + username_option='binddn', keyring_value='keyring') + + return section + + def initialize(self, loaded_config=None): + """ Initialize LDAP connection """ + if loaded_config: + self.config = loaded_config + uri = self.__get_option('uri', required=True) + binddn = self.__get_option('binddn') + log.info("Connect to LDAP server %s as %s", uri, binddn if binddn else 'annonymous') + self._conn = LdapServer( + uri, dn=binddn, pwd=self.__get_option('bindpwd'), + raiseOnError=True + ) + return self._conn.connect() + + def decode(self, value): + """ Decode LDAP attribute value """ if isinstance(value, list): - return [cls.decode(v) for v in value] + return [self.decode(v) for v in value] if isinstance(value, str): return value - return value.decode('utf-8', 'ignore') + return value.decode( + self.__get_option('encoding', default=DEFAULT_ENCODING), + self.__get_option('encoding_error_policy', default='ignore') + ) - @classmethod - def encode(cls, value): + def encode(self, value): + """ Encode LDAP attribute value """ if isinstance(value, list): - return [cls.encode(v) for v in value] + return [self.encode(v) for v in value] if isinstance(value, bytes): return value - return value.encode('utf-8') + return value.encode(self.__get_option('encoding', default=DEFAULT_ENCODING)) - def get_attrs(self, dn, attrs): + def _get_obj(self, dn, attrs): + """ + Build and return LDAP object as dict + + :param dn: The object DN + :param attrs: The object attributes as return by python-ldap search + """ obj = dict(dn=dn) for attr in attrs: - obj[attr] = [self.decode(v) for v in self.cnx.get_attr(attrs, attr, all=True)] + obj[attr] = [self.decode(v) for v in self._conn.get_attr(attrs, attr, all=True)] return obj @staticmethod def get_attr(obj, attr, default="", all_values=False): + """ + Get LDAP object attribute value(s) + + :param obj: The LDAP object as returned by get_object()/get_objects + :param attr: The attribute name + :param all_values: If True, all values of the attribute will be + returned instead of the first value only + (optinal, default: 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): + """ + Retrieve objects from LDAP + + :param name: The object type name + :param filterstr: The LDAP filter to use to search objects on LDAP directory + :param basedn: The base DN of the search + :param attrs: The list of attribute names to retreive + :param key_attr: The attribute name or 'dn' to use as key in result + (optional, if leave to None, the result will be a list) + :param warn: If True, a warning message will be logged if no object is found + in LDAP directory (otherwise, it will be just a debug message) + (optional, default: True) + """ if name in self._cached_objects: log.debug('Retreived %s objects from cache', name) else: + assert self._conn or self.initialize() log.debug('Looking for LDAP %s with (filter="%s" / basedn="%s")', name, filterstr, basedn) - ldap_data = self.cnx.search( + ldap_data = self._conn.search( basedn=basedn, filterstr=filterstr, attrs=attrs @@ -391,7 +485,7 @@ class LdapClient: objects = {} for obj_dn, obj_attrs in ldap_data.items(): - objects[obj_dn] = self.get_attrs(obj_dn, obj_attrs) + objects[obj_dn] = self._get_obj(obj_dn, obj_attrs) self._cached_objects[name] = objects if not key_attr or key_attr == 'dn': return self._cached_objects[name] @@ -401,8 +495,24 @@ class LdapClient: ) def get_object(self, type_name, object_name, filterstr, basedn, attrs, warn=True): + """ + Retrieve an object from LDAP specified using LDAP search parameters + + Only one object is excepted to be returned by the LDAP search, otherwise, one + LdapClientException will be raised. + + :param type_name: The object type name + :param object_name: The object name (only use in log messages) + :param filterstr: The LDAP filter to use to search the object on LDAP directory + :param basedn: The base DN of the search + :param attrs: The list of attribute names to retreive + :param warn: If True, a warning message will be logged if no object is found + in LDAP directory (otherwise, it will be just a debug message) + (optional, default: True) + """ + assert self._conn or self.initialize() log.debug('Looking for LDAP %s "%s" with (filter="%s" / basedn="%s")', type_name, object_name, filterstr, basedn) - ldap_data = self.cnx.search( + ldap_data = self._conn.search( basedn=basedn, filterstr=filterstr, attrs=attrs ) @@ -418,9 +528,21 @@ class LdapClient: 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]) + return self._get_obj(dn, ldap_data[dn]) def get_object_by_dn(self, type_name, dn, populate_cache_method=None, warn=True): + """ + Retrieve an LDAP object specified by its DN from cache + + :param type_name: The object type name + :param dn: The object DN + :param populate_cache_method: The method to use is cache of LDAP object type + is not already populated (optional, default, + False is returned) + :param warn: If True, a warning message will be logged if object is not found + in cache (otherwise, it will be just a debug message) + (optional, default: True) + """ if type_name not in self._cached_objects: if not populate_cache_method: return False @@ -441,11 +563,35 @@ class LdapClient: @classmethod def object_attr_mached(cls, obj, attr, value, case_sensitive=False): + """ + Determine if object's attribute matched with specified value + + :param obj: The LDAP object (as returned by get_object/get_objects) + :param attr: The attribute name + :param value: The value for the match test + :param case_sensitive: If True, the match test will be case-sensitive + (optional, default: 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): + """ + Retrieve an LDAP object specified by one of its attribute + + :param type_name: The object type name + :param attr: The attribute name + :param value: The value for the match test + :param populate_cache_method: The method to use is cache of LDAP object type + is not already populated (optional, default, + False is returned) + :param case_sensitive: If True, the match test will be case-sensitive + (optional, default: False) + :param warn: If True, a warning message will be logged if object is not found + in cache (otherwise, it will be just a debug message) + (optional, default: True) + """ if type_name not in self._cached_objects: if not populate_cache_method: return False @@ -468,7 +614,7 @@ class LdapClient: 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()))) + 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] @@ -504,7 +650,15 @@ class LdapClient: return (old, new) def format_changes(self, changes, protected_attrs=None, prefix=None): - return self.cnx.format_changes( + """ + Format changes as string + + :param changes: The changes as returned by get_changes + :param protected_attrs: Optional list of protected attributes + :param prefix: Optional prefix string for each line of the returned string + """ + assert self._conn or self.initialize() + return self._conn.format_changes( changes[0], changes[1], ignore_attrs=protected_attrs, prefix=prefix ) @@ -521,10 +675,11 @@ class LdapClient: for attr, values in attrs.items() ) try: - if self.options.just_try: + if self.__get_option('just_try', default=False): log.debug('Just-try mode : do not really add object in LDAP') return True - return self.cnx.add_object(dn, attrs) + assert self._conn or self.initialize() + return self._conn.add_object(dn, attrs) except LdapServerException: log.error( "An error occurred adding object %s in LDAP:\n%s\n", @@ -589,10 +744,11 @@ class LdapClient: log.debug('%s: No change detected on RDN attibute %s', ldap_obj['dn'], rdn_attr) try: - if self.options.just_try: + if self.__get_option('just_try', default=False): log.debug('Just-try mode : do not really update object in LDAP') return True - return self.cnx.update_object( + assert self._conn or self.initialize() + return self._conn.update_object( ldap_obj['dn'], changes[0], changes[1], @@ -614,10 +770,11 @@ class LdapClient: :param new_dn_or_rdn: The new LDAP object's DN (or RDN) """ try: - if self.options.just_try: + if self.__get_option('just_try', default=False): 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) + assert self._conn or self.initialize() + return self._conn.rename_object(ldap_obj['dn'], new_dn_or_rdn) except LdapServerException: log.error( "An error occurred moving object %s in LDAP (destination: %s)", @@ -632,10 +789,11 @@ class LdapClient: :param ldap_obj: The original LDAP object to delete/drop """ try: - if self.options.just_try: + if self.__get_option('just_try', default=False): log.debug('Just-try mode : do not really drop object in LDAP') return True - return self.cnx.drop_object(ldap_obj['dn']) + assert self._conn or self.initialize() + return self._conn.drop_object(ldap_obj['dn']) except LdapServerException: log.error( "An error occurred removing object %s in LDAP",