Repository: privacyidea/privacyidea

Issue:
Read event handler definitions from database only if config changed
Currently, privacyIDEA reads all event handler definitions from the database (along with their conditions and options) on each request. This is because each request instantiates a new ``EventConfiguration`` object:

https://github.com/privacyidea/privacyidea/blob/26088893aa3089e8461a70e02246d447c9d4fc04/privacyidea/lib/event.py#L244

Analogously to policies, we could only read event handler definitions from the database if the config timestamp has changed. This would save us at least one SQL query per request.

Then, event handler definitions would also respect the ``PI_CHECK_RELOAD_CONFIG`` setting.


Discussion:

Resolving patch:
diff --git a/privacyidea/lib/config.py b/privacyidea/lib/config.py
--- a/privacyidea/lib/config.py
+++ b/privacyidea/lib/config.py
@@ -41,7 +41,7 @@
 
 from .log import log_with
 from ..models import (Config, db, Resolver, Realm, PRIVACYIDEA_TIMESTAMP,
-                      save_config_timestamp, Policy)
+                      save_config_timestamp, Policy, EventHandler)
 from privacyidea.lib.framework import get_request_local_store, get_app_config_value, get_app_local_store
 from .crypto import encryptPassword
 from .crypto import decryptPassword
@@ -66,8 +66,8 @@
 class SharedConfigClass(object):
     """
     A shared config class object is shared between threads and is supposed
-    to store the current configuration with resolvers, realms and policies,
-    along with the timestamp of the configuration.
+    to store the current configuration with resolvers, realms, policies
+    and event handler definitions along with the timestamp of the configuration.
 
     The method ``_reload_from_db()`` compares this timestamp against the
     timestamp in the database (while taking the PI_CHECK_RELOAD_CONFIG
@@ -85,6 +85,7 @@ def __init__(self):
         self.realm = {}
         self.default_realm = None
         self.policies = []
+        self.events = []
         self.timestamp = None
 
     def _reload_from_db(self):
@@ -104,6 +105,7 @@ def _reload_from_db(self):
                 realmconfig = {}
                 default_realm = None
                 policies = []
+                events = []
                 # Load system configuration
                 for sysconf in Config.query.all():
                     config[sysconf.Key] = {
@@ -140,6 +142,9 @@ def _reload_from_db(self):
                 # Load all policies
                 for pol in Policy.query.all():
                     policies.append(pol.get())
+                # Load all events
+                for event in EventHandler.query.order_by(EventHandler.ordering):
+                    events.append(event.get())
                 # Finally, set the current timestamp
                 timestamp = datetime.datetime.now()
                 with self._config_lock:
@@ -148,6 +153,7 @@ def _reload_from_db(self):
                     self.realm = realmconfig
                     self.default_realm = default_realm
                     self.policies = policies
+                    self.events = events
                     self.timestamp = timestamp
 
     def _clone(self):
@@ -161,6 +167,7 @@ def _clone(self):
                 self.realm,
                 self.default_realm,
                 self.policies,
+                self.events,
                 self.timestamp
             )
 
@@ -177,17 +184,18 @@ def reload_and_clone(self):
 class LocalConfigClass(object):
     """
     The Config_Object will contain all database configuration of system
-    config, resolvers, realms and policies.
+    config, resolvers, realms, policies and event handler definitions.
 
     It will be cloned from the shared config object at the beginning of the
     request and is supposed to stay alive and unchanged during the request.
     """
-    def __init__(self, config, resolver, realm, default_realm, policies, timestamp):
+    def __init__(self, config, resolver, realm, default_realm, policies, events, timestamp):
         self.config = config
         self.resolver = resolver
         self.realm = realm
         self.default_realm = default_realm
         self.policies = policies
+        self.events = events
         self.timestamp = timestamp
 
     def get_config(self, key=None, default=None, role="admin",
diff --git a/privacyidea/lib/event.py b/privacyidea/lib/event.py
--- a/privacyidea/lib/event.py
+++ b/privacyidea/lib/event.py
@@ -22,9 +22,9 @@
 # License along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
 #
+from privacyidea.lib.config import get_config_object
 from privacyidea.lib.utils import fetch_one_resource
 from privacyidea.models import EventHandler, EventHandlerOption, db
-from privacyidea.lib.error import ParameterError
 from privacyidea.lib.audit import getAudit
 import functools
 import logging
@@ -244,17 +244,19 @@ def delete_event(event_id):
 class EventConfiguration(object):
     """
     This class is supposed to contain the event handling configuration during
-    the Request. It can be read initially (in the init method) an can be
-    accessed later during the request.
+    the Request.
+    The currently defined events are fetched from the request-local config object.
     """
 
     def __init__(self):
-        self.eventlist = []
-        self._read_events()
+        pass
 
     @property
     def events(self):
-        return self.eventlist
+        """
+        Shortcut for retrieving the currently defined event handlers from the request-local config object.
+        """
+        return get_config_object().events
 
     def get_handled_events(self, eventname, position="post"):
         """
@@ -265,7 +267,7 @@ def get_handled_events(self, eventname, position="post"):
         :param position: the position of the event definition
         :return:
         """
-        eventlist = [e for e in self.eventlist if (
+        eventlist = [e for e in self.events if (
             eventname in e.get("event") and e.get("active") and e.get("position") == position)]
         return eventlist
 
@@ -280,13 +282,7 @@ def get_event(self, eventid):
         """
         if eventid is not None:
             eventid = int(eventid)
-            eventlist = [e for e in self.eventlist if e.get("id") == eventid]
+            eventlist = [e for e in self.events if e.get("id") == eventid]
             return eventlist
         else:
-            return self.eventlist
-
-    def _read_events(self):
-        q = EventHandler.query.order_by(EventHandler.ordering)
-        for e in q:
-            self.eventlist.append(e.get())
-
+            return self.events
diff --git a/privacyidea/models.py b/privacyidea/models.py
--- a/privacyidea/models.py
+++ b/privacyidea/models.py
@@ -1759,7 +1759,6 @@ def save(self):
         if self.id is None:
             # create a new one
             db.session.add(self)
-            db.session.commit()
         else:
             # update
             EventHandler.query.filter_by(id=self.id).update({
@@ -1772,7 +1771,8 @@ def save(self):
                 "condition": self.condition,
                 "action": self.action
             })
-            db.session.commit()
+        save_config_timestamp()
+        db.session.commit()
         return self.id
 
     def delete(self):
@@ -1786,6 +1786,7 @@ def delete(self):
         db.session.query(EventHandlerCondition) \
             .filter(EventHandlerCondition.eventhandler_id == ret) \
             .delete()
+        save_config_timestamp()
         db.session.commit()
         return ret
 


Test patch:
diff --git a/tests/test_lib_events.py b/tests/test_lib_events.py
--- a/tests/test_lib_events.py
+++ b/tests/test_lib_events.py
@@ -12,6 +12,7 @@
 from .base import MyTestCase, FakeFlaskG, FakeAudit
 from privacyidea.lib.eventhandler.usernotification import (
     UserNotificationEventHandler, NOTIFY_TYPE)
+from privacyidea.lib.config import get_config_object
 from privacyidea.lib.eventhandler.tokenhandler import (TokenEventHandler,
                                                        ACTION_TYPE, VALIDITY)
 from privacyidea.lib.eventhandler.scripthandler import ScriptEventHandler, SCRIPT_WAIT, SCRIPT_BACKGROUND
@@ -56,6 +57,8 @@ def test_01_create_update_delete(self):
                       conditions={},
                       options={"emailconfig": "themis",
                                "always": "immer"})
+        # retrieve the current config timestamp
+        current_timestamp = get_config_object().timestamp
 
         self.assertEqual(r, 2)
         # Update the first event
@@ -67,11 +70,19 @@ def test_01_create_update_delete(self):
                       id=eid)
         self.assertEqual(r, eid)
 
+        # check that the config timestamp has been updated
+        self.assertGreater(get_config_object().timestamp, current_timestamp)
+        current_timestamp = get_config_object().timestamp
+
         event_config = EventConfiguration()
         self.assertEqual(len(event_config.events), 2)
         # delete
         r = delete_event(eid)
         self.assertTrue(r)
+
+        # check that the config timestamp has been updated
+        self.assertGreater(get_config_object().timestamp, current_timestamp)
+        current_timestamp = get_config_object().timestamp
         event_config = EventConfiguration()
         self.assertEqual(len(event_config.events), 1)
 
@@ -82,6 +93,8 @@ def test_01_create_update_delete(self):
         n_eid = events[0].get("id")
         # Disable this event in the database
         enable_event(n_eid, False)
+        # check that the config timestamp has been updated
+        self.assertGreater(get_config_object().timestamp, current_timestamp)
         # Reread event config from the database
         event_config = EventConfiguration()
         events = event_config.get_handled_events("token_init")
@@ -92,7 +105,6 @@ def test_01_create_update_delete(self):
         event_config = EventConfiguration()
         events = event_config.get_handled_events("token_init")
         self.assertEqual(len(events), 1)
-
         # If eventid is None, then the whole list is returned
         r = event_config.get_event(None)
         self.assertEqual(r, event_config.events)