Repository: privacyidea/privacyidea

Issue:
Check search parameter in LDAP resolver
The search parameter in `getUserList()` in the LDAP resolver were not properly checked against the available mapping keys. Thus a wrong parameter lead to an exception and a 500 server error when listing users.



Discussion:

Patch:
diff --git a/privacyidea/lib/resolvers/LDAPIdResolver.py b/privacyidea/lib/resolvers/LDAPIdResolver.py
index 4458b7d6aa..df83cb0187 100644
--- a/privacyidea/lib/resolvers/LDAPIdResolver.py
+++ b/privacyidea/lib/resolvers/LDAPIdResolver.py
@@ -53,7 +53,7 @@
 from privacyidea.lib.log import log_with
 from privacyidea.lib.utils import (is_true, to_bytes, to_unicode,
                                    convert_column_to_unicode)
-from privacyidea.lib.error import privacyIDEAError, ResolverError
+from privacyidea.lib.error import privacyIDEAError, ResolverError, ParameterError
 import uuid
 from ldap3.utils.conv import escape_bytes
 from operator import itemgetter
@@ -304,7 +304,7 @@ def __init__(self):
         # The number of seconds that ldap3 waits if no server is left in the pool, before
         # starting the next round
         pooling_loop_timeout = get_app_config_value("PI_LDAP_POOLING_LOOP_TIMEOUT", 10)
-        log.debug("Setting system wide POOLING_LOOP_TIMEOUT to {0!s}.".format(pooling_loop_timeout))
+        log.debug("Setting system-wide POOLING_LOOP_TIMEOUT to {0!s}.".format(pooling_loop_timeout))
         ldap3.set_config_parameter("POOLING_LOOP_TIMEOUT", pooling_loop_timeout)
 
     @log_with(log, hide_args=[2])
@@ -764,6 +764,12 @@ def getUserList(self, search_dict=None):
         if self.uidtype.lower() != "dn":
             attributes.append(str(self.uidtype))
 
+        unknown_search_keys = [x for x in search_dict.keys() if x not in self.userinfo.keys()]
+        if unknown_search_keys:
+            log.error(f"Could not find search key ({unknown_search_keys}) in "
+                      f"the attribute mapping keys ({list(self.userinfo.keys())}).")
+            raise ParameterError(f"Search parameter ({unknown_search_keys}) not "
+                                 f"available in attribute mapping.")
         # do the filter depending on the searchDict
         search_filter = "(&" + self.searchfilter
         for search_key in search_dict.keys():
@@ -1189,6 +1195,7 @@ def testconnection(cls, param):
                 if entry.get('type') == 'searchResRef':
                     continue
                 try:
+                    # TODO: Also check if the returned attributes are defined in the mapping
                     userid = cls._get_uid(entry, uidtype)
                     count += 1
                     if userid:
@@ -1281,6 +1288,7 @@ def delete_user(self, uid):
     def _attributes_to_ldap_attributes(self, attributes):
         """
         takes the attributes and maps them to the LDAP attributes
+
         :param attributes: Attributes to be updated
         :type attributes: dict
         :return: dict with attribute name as keys and values
diff --git a/privacyidea/lib/resolvers/SQLIdResolver.py b/privacyidea/lib/resolvers/SQLIdResolver.py
index 4bb0196cd0..6e49def2ee 100644
--- a/privacyidea/lib/resolvers/SQLIdResolver.py
+++ b/privacyidea/lib/resolvers/SQLIdResolver.py
@@ -47,7 +47,7 @@
 from privacyidea.lib.lifecycle import register_finalizer
 from privacyidea.lib.utils import (is_true, censor_connect_string,
                                    convert_column_to_unicode)
-from privacyidea.lib.error import ParameterError
+from privacyidea.lib.error import ParameterError, ResolverError
 
 from passlib.context import CryptContext
 from passlib.utils import h64
@@ -389,20 +389,22 @@ def getUserList(self, search_dict=None):
         if search_dict is None:
             search_dict = {}
         # Check if all the search keys are available in the mapping
-        broken_keys = list(filter(lambda x: x not in self.map.keys(), search_dict.keys()))
-        if broken_keys:
-            log.error(f"Could not find search key ({broken_keys}) in "
+        unknown_search_keys = [x for x in search_dict.keys() if x not in self.map.keys()]
+        if unknown_search_keys:
+            log.error(f"Could not find search key ({unknown_search_keys}) in "
                       f"the column mapping keys ({list(self.map.keys())}).")
-            raise ParameterError(f"Search parameter ({broken_keys}) not available in mapping.")
+            raise ParameterError(f"Search parameter ({unknown_search_keys}) not available "
+                                 f"in column mapping.")
         for key, value in search_dict.items():
             column = self.map.get(key)
             value = value.replace("*", "%")
             if column in self.TABLE.columns:
                 conditions.append(self.TABLE.columns[column].like(value))
             else:
+                # This is a configuration error, the mapping does not correspond with the table definition
                 log.error(f"Mapped column ('{column}') is not available in the database "
                           f"table '{self.table}' ({list(self.TABLE.columns.keys())}).")
-                raise ParameterError(f"Search parameter ({key}) not available in resolver.")
+                raise ResolverError(f"Search parameter ({key}) not available in resolver.")
 
         conditions = self._append_where_filter(conditions, self.TABLE,
                                                self.where)
diff --git a/privacyidea/lib/user.py b/privacyidea/lib/user.py
index 83f08b8e21..d6dc2f7930 100644
--- a/privacyidea/lib/user.py
+++ b/privacyidea/lib/user.py
@@ -50,7 +50,7 @@
 import logging
 import traceback
 
-from .error import UserError
+from privacyidea.lib.error import ParameterError, ResolverError, UserError
 from ..api.lib.utils import (getParam,
                              optional)
 from .log import log_with
@@ -787,13 +787,11 @@ def get_user_list(param=None, user=None, custom_attributes=False):
             log.debug("Found this userlist: {0!r}".format(ulist))
             users.extend(ulist)
 
-        except KeyError as exx:  # pragma: no cover
-            log.error("{0!r}".format(exx))
-            log.debug("{0!s}".format(traceback.format_exc()))
-            raise exx
-
-        except Exception as ex:
-            log.error(f"Unable to get user list for resolver '{resolver_name}': {ex!r}")
+        except (ResolverError,  ParameterError) as ex:
+            # In case of wrong search parameters or broken resolver we continue
+            # All other errors will be passed down.
+            # TODO: Reflect the broken resolver/query in the result data
+            log.warning(f"Unable to get user list for resolver '{resolver_name}': {ex!r}")
             log.debug("{0!s}".format(traceback.format_exc()))
             continue
 


Test patch:
diff --git a/tests/test_lib_resolver.py b/tests/test_lib_resolver.py
index a496b2efb6..bbfebd2cb6 100644
--- a/tests/test_lib_resolver.py
+++ b/tests/test_lib_resolver.py
@@ -44,8 +44,7 @@
 from privacyidea.lib.realm import (set_realm, delete_realm)
 from privacyidea.models import ResolverConfig, Resolver
 from privacyidea.lib.utils import to_bytes, to_unicode
-from privacyidea.lib.error import ParameterError
-from requests import HTTPError
+from privacyidea.lib.error import ParameterError, ResolverError
 
 PWFILE = "tests/testdata/passwords"
 
@@ -242,7 +241,7 @@ def test_01_sqlite_resolver(self):
                     "password" : "password"}'})
         y.loadConfig(params)
         with LogCapture(level=logging.ERROR) as lc:
-            self.assertRaises(ParameterError, y.getUserList, {"not_in_db": "parameter"})
+            self.assertRaises(ResolverError, y.getUserList, {"not_in_db": "parameter"})
             lc.check_present(("privacyidea.lib.resolvers.SQLIdResolver", "ERROR",
                               f"Mapped column ('not_a_column') is not available in the database "
                               f"table 'users' ({list(y.TABLE.columns.keys())})."))
@@ -877,7 +876,6 @@ def test_01_LDAP_DN(self):
         rtype = y.getResolverType()
         self.assertTrue(rtype == "ldapresolver", rtype)
 
-        rdesc = y.getResolverClassDescriptor()
         rdesc = y.getResolverDescriptor()
         self.assertTrue("ldapresolver" in rdesc, rdesc)
         self.assertTrue("config" in rdesc.get("ldapresolver"), rdesc)
@@ -887,7 +885,14 @@ def test_01_LDAP_DN(self):
         self.assertTrue(uinfo.get("username") == "bob", uinfo)
 
         ret = y.getUserList({"username": "bob"})
-        self.assertTrue(len(ret) == 1, ret)
+        self.assertEqual(1, len(ret),ret)
+
+        # Get user list with a wrong search parameter
+        with LogCapture(level=logging.ERROR) as lc:
+            self.assertRaises(ParameterError, y.getUserList, {"unknown": "parameter"})
+            lc.check_present(("privacyidea.lib.resolvers.LDAPIdResolver", "ERROR",
+                              f"Could not find search key (['unknown']) in the "
+                              f"attribute mapping keys ({list(y.userinfo.keys())})."))
 
         # user list with searchResRef entries
         # we are mocking the mock here
@@ -1953,7 +1958,7 @@ def test_29_sizelimit(self):
         original_search = y.connection.extend.standard.paged_search
         with mock.patch.object(ldap3mock.Connection.Extend.Standard, 'paged_search') as mock_search:
             def _search_with_exception(*args, **kwargs):
-                results = original_search(*args, **kwargs)
+                original_search(*args, **kwargs)
                 raise LDAPOperationResult(result=RESULT_SIZE_LIMIT_EXCEEDED)
                 # This ``yield`` is needed to turn this function into a generator.
                 # If we omit this, the exception above would be raised immediately when ``paged_search`` is called.
@@ -2131,6 +2136,7 @@ def test_32_cache_expiration(self):
                         wraps=datetime.datetime) as mock_datetime:
             mock_datetime.now.return_value = now + datetime.timedelta(seconds=2 * (cache_timeout + 2))
             manager_id = y.getUserId('manager')
+            self.assertEqual(manager_id, objectGUIDs[1])
         self.assertEqual(list(CACHE[y.getResolverId()]['getUserId'].keys()), ['manager'])
 
     @ldap3mock.activate
diff --git a/tests/test_lib_user.py b/tests/test_lib_user.py
index deecc56a75..ae184b7a9f 100644
--- a/tests/test_lib_user.py
+++ b/tests/test_lib_user.py
@@ -569,8 +569,8 @@ def test_17_check_nonascii_user(self):
         self.assertTrue(rid > 0, rid)
 
         (added, failed) = set_realm(realm, [{'name': resolver}])
-        self.assertEqual(len(failed), 0)
-        self.assertEqual(len(added), 1)
+        self.assertEqual(0, len(failed))
+        self.assertEqual(1, len(added))
 
         # check non-ascii password of non-ascii user
         self.assertFalse(User(login="nönäscii",
@@ -590,15 +590,20 @@ def test_17_check_nonascii_user(self):
                          user_repr, user_repr)
 
         # Test with not existing search filter
-        with LogCapture(level=logging.ERROR) as lc:
+        with LogCapture(level=logging.WARNING) as lc:
             userlist = get_user_list({"realm": "sqlrealm",
                                       "unknown": "parameter",
                                       "resolver": "SQL1"})
-            self.assertTrue(len(userlist) == 0, userlist)
-            lc.check_present(("privacyidea.lib.user", "ERROR",
+            self.assertEqual(0, len(userlist), userlist)
+            lc.check_present(("privacyidea.lib.resolvers.SQLIdResolver", "ERROR",
+                              "Could not find search key (['unknown']) in the "
+                              "column mapping keys (['username', 'userid', "
+                              "'email', 'surname', 'givenname', 'password', "
+                              "'phone', 'mobile'])."))
+            lc.check_present(("privacyidea.lib.user", "WARNING",
                               "Unable to get user list for resolver 'SQL1': "
                               "ParameterError(description=\"Search parameter "
-                              "([\'unknown\']) not available in mapping.\", id=905)"))
+                              "(['unknown']) not available in column mapping.\", id=905)"))
 
     @ldap3mock.activate
     def test_18_user_with_several_phones(self):