Add LdapClient

This commit is contained in:
Benjamin Renard 2021-06-02 18:59:09 +02:00
parent 1cd9432e25
commit a325803130

View file

@ -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
#