LdapClient: add support to config lib

This commit is contained in:
Benjamin Renard 2021-11-03 17:41:15 +01:00
parent dc0f17dd20
commit 73c19816f7

View file

@ -5,6 +5,7 @@
import copy import copy
import datetime import datetime
import logging import logging
import pytz
import dateutil.parser import dateutil.parser
import dateutil.tz import dateutil.tz
@ -12,11 +13,11 @@ import ldap
from ldap.controls import SimplePagedResultsControl from ldap.controls import SimplePagedResultsControl
from ldap.controls.simple import RelaxRulesControl from ldap.controls.simple import RelaxRulesControl
import ldap.modlist as modlist import ldap.modlist as modlist
import pytz
from mylib import pretty_format_dict from mylib import pretty_format_dict
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
DEFAULT_ENCODING = 'utf-8'
class LdapServer: class LdapServer:
@ -81,6 +82,7 @@ class LdapServer:
def search(self, basedn, filterstr=None, attrs=None, sizelimit=0, scope=None): def search(self, basedn, filterstr=None, attrs=None, sizelimit=0, scope=None):
""" Run a search on LDAP server """ """ Run a search on LDAP server """
assert self.con or self.connect()
res_id = self.con.search( res_id = self.con.search(
basedn, basedn,
self.get_scope(scope if scope else 'sub'), 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): def paged_search(self, basedn, filterstr, attrs, scope='sub', pagesize=500):
""" Run a paged search on LDAP server """ """ Run a paged search on LDAP server """
assert not self.v2, "Paged search is not available on LDAP version 2" assert not self.v2, "Paged search is not available on LDAP version 2"
assert self.con or self.connect()
# Initialize SimplePagedResultsControl object # Initialize SimplePagedResultsControl object
page_control = SimplePagedResultsControl( page_control = SimplePagedResultsControl(
True, True,
@ -177,6 +180,7 @@ class LdapServer:
def add_object(self, dn, attrs): def add_object(self, dn, attrs):
""" Add an object in LDAP directory """ """ Add an object in LDAP directory """
ldif = modlist.addModlist(attrs) ldif = modlist.addModlist(attrs)
assert self.con or self.connect()
try: try:
self.logger.debug("LdapServer - Add %s", dn) self.logger.debug("LdapServer - Add %s", dn)
self.con.add_s(dn, ldif) self.con.add_s(dn, ldif)
@ -195,6 +199,7 @@ class LdapServer:
) )
if ldif == []: if ldif == []:
return True return True
assert self.con or self.connect()
try: try:
if relax: if relax:
self.con.modify_ext_s(dn, ldif, serverctrls=[RelaxRulesControl()]) self.con.modify_ext_s(dn, ldif, serverctrls=[RelaxRulesControl()])
@ -256,6 +261,7 @@ class LdapServer:
new_dn_parts = new_rdn.split(',') new_dn_parts = new_rdn.split(',')
new_sup = ','.join(new_dn_parts[1:]) new_sup = ','.join(new_dn_parts[1:])
new_rdn = new_dn_parts[0] new_rdn = new_dn_parts[0]
assert self.con or self.connect()
try: try:
self.logger.debug( self.logger.debug(
"LdapServer - Rename %s in %s (new superior: %s, delete old: %s)", "LdapServer - Rename %s in %s (new superior: %s, delete old: %s)",
@ -282,6 +288,7 @@ class LdapServer:
def drop_object(self, dn): def drop_object(self, dn):
""" Drop an object in LDAP directory """ """ Drop an object in LDAP directory """
assert self.con or self.connect()
try: try:
self.logger.debug("LdapServer - Delete %s", dn) self.logger.debug("LdapServer - Delete %s", dn)
self.con.delete_s(dn) self.con.delete_s(dn)
@ -331,52 +338,139 @@ class LdapClient:
""" LDAP Client (based on python-mylib.LdapServer) """ """ LDAP Client (based on python-mylib.LdapServer) """
options = {} _options = {}
_config = None
_config_section = None
# Connection
_conn = None
# Cache objects # Cache objects
_cached_objects = dict() _cached_objects = dict()
def __init__(self, options): def __init__(self, options=None, options_prefix=None, config=None, config_section=None, initialize=False):
self.options = options self._options = options if options else {}
log.info("Connect to LDAP server %s as %s", options.ldap_uri, options.ldap_binddn) self._options_prefix = options_prefix if options_prefix else 'ldap_'
self.cnx = LdapServer(options.ldap_uri, dn=options.ldap_binddn, pwd=options.ldap_bindpwd, raiseOnError=True) self._config = config if config else None
self.cnx.connect() self._config_section = config_section if config_section else 'ldap'
if initialize:
self.initialize()
@classmethod def __get_option(self, option, default=None, required=False):
def decode(cls, value): """ 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): if isinstance(value, list):
return [cls.decode(v) for v in value] return [self.decode(v) for v in value]
if isinstance(value, str): if isinstance(value, str):
return value 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(self, value):
def encode(cls, value): """ Encode LDAP attribute value """
if isinstance(value, list): if isinstance(value, list):
return [cls.encode(v) for v in value] return [self.encode(v) for v in value]
if isinstance(value, bytes): if isinstance(value, bytes):
return value 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) obj = dict(dn=dn)
for attr in attrs: 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 return obj
@staticmethod @staticmethod
def get_attr(obj, attr, default="", all_values=False): 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, []) vals = obj.get(attr, [])
if vals: if vals:
return vals if all_values else vals[0] return vals if all_values else vals[0]
return default if default or not all_values else [] return default if default or not all_values else []
def get_objects(self, name, filterstr, basedn, attrs, key_attr=None, warn=True): 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: if name in self._cached_objects:
log.debug('Retreived %s objects from cache', name) log.debug('Retreived %s objects from cache', name)
else: else:
assert self._conn or self.initialize()
log.debug('Looking for LDAP %s with (filter="%s" / basedn="%s")', name, filterstr, basedn) 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, basedn=basedn,
filterstr=filterstr, filterstr=filterstr,
attrs=attrs attrs=attrs
@ -391,7 +485,7 @@ class LdapClient:
objects = {} objects = {}
for obj_dn, obj_attrs in ldap_data.items(): 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 self._cached_objects[name] = objects
if not key_attr or key_attr == 'dn': if not key_attr or key_attr == 'dn':
return self._cached_objects[name] return self._cached_objects[name]
@ -401,8 +495,24 @@ class LdapClient:
) )
def get_object(self, type_name, object_name, filterstr, basedn, attrs, warn=True): 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) 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, basedn=basedn, filterstr=filterstr,
attrs=attrs attrs=attrs
) )
@ -418,9 +528,21 @@ class LdapClient:
raise LdapClientException('More than one %s "%s": %s' % (type_name, object_name, ' / '.join(ldap_data.keys()))) raise LdapClientException('More than one %s "%s": %s' % (type_name, object_name, ' / '.join(ldap_data.keys())))
dn = next(iter(ldap_data)) 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): 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 type_name not in self._cached_objects:
if not populate_cache_method: if not populate_cache_method:
return False return False
@ -441,11 +563,35 @@ class LdapClient:
@classmethod @classmethod
def object_attr_mached(cls, obj, attr, value, case_sensitive=False): 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: if case_sensitive:
return value in cls.get_attr(obj, attr, all_values=True) 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)] 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): 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 type_name not in self._cached_objects:
if not populate_cache_method: if not populate_cache_method:
return False return False
@ -468,7 +614,7 @@ class LdapClient:
log.debug('No %s found with %s="%s"', type_name, attr, value) log.debug('No %s found with %s="%s"', type_name, attr, value)
return None return None
if len(matched) > 1: 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)) dn = next(iter(matched))
return matched[dn] return matched[dn]
@ -504,7 +650,15 @@ class LdapClient:
return (old, new) return (old, new)
def format_changes(self, changes, protected_attrs=None, prefix=None): 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], changes[0], changes[1],
ignore_attrs=protected_attrs, prefix=prefix ignore_attrs=protected_attrs, prefix=prefix
) )
@ -521,10 +675,11 @@ class LdapClient:
for attr, values in attrs.items() for attr, values in attrs.items()
) )
try: 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') log.debug('Just-try mode : do not really add object in LDAP')
return True return True
return self.cnx.add_object(dn, attrs) assert self._conn or self.initialize()
return self._conn.add_object(dn, attrs)
except LdapServerException: except LdapServerException:
log.error( log.error(
"An error occurred adding object %s in LDAP:\n%s\n", "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) log.debug('%s: No change detected on RDN attibute %s', ldap_obj['dn'], rdn_attr)
try: 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') log.debug('Just-try mode : do not really update object in LDAP')
return True return True
return self.cnx.update_object( assert self._conn or self.initialize()
return self._conn.update_object(
ldap_obj['dn'], ldap_obj['dn'],
changes[0], changes[0],
changes[1], changes[1],
@ -614,10 +770,11 @@ class LdapClient:
:param new_dn_or_rdn: The new LDAP object's DN (or RDN) :param new_dn_or_rdn: The new LDAP object's DN (or RDN)
""" """
try: 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') log.debug('Just-try mode : do not really move object in LDAP')
return True 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: except LdapServerException:
log.error( log.error(
"An error occurred moving object %s in LDAP (destination: %s)", "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 :param ldap_obj: The original LDAP object to delete/drop
""" """
try: 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') log.debug('Just-try mode : do not really drop object in LDAP')
return True 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: except LdapServerException:
log.error( log.error(
"An error occurred removing object %s in LDAP", "An error occurred removing object %s in LDAP",