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 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",