Repository: privacyidea/privacyidea

Issue:
Event handler script fails but error is not logged
using script handler for event

script fails but there is nothing in log

problem is here

https://github.com/privacyidea/privacyidea/blob/f87a744e6a2026980aa8827682871490f162ff3e/privacyidea/lib/eventhandler/scripthandler.py#L168

Popen doesn't throw exception when script fails, it just returns error code

check_call should be used or check_output, this method throws CalledProcessError exception


Discussion:
I removed the bug label, since this is by design.
``Popen`` does not wait for the process to finish. As this is an event handler, the HTTP Request can return. In many cases it will make sense, that the script is started but also the HTTP Request returns, without the script blocking the HTTP request.

I think there should be two possibilities:
* Start the script, do not wait for completion
* Wait for the script to return and:
  * Fail the http request if the script fails
  * Simply log, that the script has failed.

@p53 What is your scenario - why and how do you need to know, that the script failed?


Resolving patch:
diff --git a/privacyidea/lib/config.py b/privacyidea/lib/config.py
--- a/privacyidea/lib/config.py
+++ b/privacyidea/lib/config.py
@@ -879,4 +879,8 @@ def get_privacyidea_nodes():
     nodes = current_app.config.get("PI_NODES", [])[:]
     if own_node_name not in nodes:
         nodes.append(own_node_name)
-    return nodes
\ No newline at end of file
+    return nodes
+
+
+def get_app_config(key, default=None):
+    return current_app.config.get(key, default)
\ No newline at end of file
diff --git a/privacyidea/lib/eventhandler/scripthandler.py b/privacyidea/lib/eventhandler/scripthandler.py
--- a/privacyidea/lib/eventhandler/scripthandler.py
+++ b/privacyidea/lib/eventhandler/scripthandler.py
@@ -32,9 +32,10 @@
 * logged_in_user
 """
 from privacyidea.lib.eventhandler.base import BaseEventHandler
-from privacyidea.lib.config import get_from_config
+from privacyidea.lib.utils import is_true
+from privacyidea.lib.config import get_app_config
+from privacyidea.lib.error import ServerError
 from privacyidea.lib import _
-import json
 import logging
 import subprocess
 import os
@@ -42,23 +43,31 @@
 
 log = logging.getLogger(__name__)
 
+SCRIPT_BACKGROUND = "background"
+SCRIPT_WAIT = "wait"
+
 
 class ScriptEventHandler(BaseEventHandler):
     """
     An Eventhandler needs to return a list of actions, which it can handle.
     It also returns a list of allowed action and conditions
-    It returns an identifier, which can be used in the eventhandlig definitions
+    It returns an identifier, which can be used in the eventhandling definitions
     """
 
     identifier = "Script"
     description = "This event handler can trigger external scripts."
 
-    try:
-        script_directory = get_from_config("PI_SCRIPT_HANDLER_DIRECTORY",
-                                           "/etc/privacyidea/scripts")
-    except RuntimeError:
-        # In case of the tests we are outside of the application context
-        script_directory = "tests/testdata/scripts"
+    def __init__(self, script_directory=None):
+        if not script_directory:
+            try:
+                self.script_directory = get_app_config("PI_SCRIPT_HANDLER_DIRECTORY",
+                                                       "/etc/privacyidea/scripts")
+            except RuntimeError as e:
+                # In case of the tests we are outside of the application context
+                self.script_directory = "tests/testdata/scripts"
+
+        else:
+            self.script_directory = script_directory
 
     @property
     def allowed_positions(cls):
@@ -80,6 +89,19 @@ def actions(cls):
         actions = {}
         for script in scripts:
             actions[script] = {
+                "background": {
+                    "type": "str",
+                    "required": True,
+                    "description": _("Wait for script to complete or run script in background. This will "
+                                     "either return the HTTP request early or could also block the request."),
+                    "value": [SCRIPT_BACKGROUND, SCRIPT_WAIT]
+                },
+                "raise_error": {
+                    "type": "bool",
+                    "visibleIf": "background",
+                    "visibleValue": SCRIPT_WAIT,
+                    "description": _("On script error raise exception in HTTP request.")
+                },
                 "serial": {
                     "type": "bool",
                     "description": _("Add '--serial <serial number>' as script "
@@ -164,14 +186,25 @@ def do(self, action, options=None):
             proc_args.append("--logged_in_role")
             proc_args.append(logged_in_user.get("role", "none"))
 
+        rcode = 0
         try:
+            log.info("Starting script {script!r}.".format(script=script_name))
             p = subprocess.Popen(proc_args, cwd=self.script_directory)
-            log.info("Started script {script!r}:"
-                     " {process!r}".format(script=script_name, process=p))
+            if handler_options.get("background") == SCRIPT_WAIT:
+                rcode = p.wait()
+
         except Exception as e:
             log.warning("Failed to execute script {0!r}: {1!r}".format(
                 script_name, e))
             log.warning(traceback.format_exc())
+            if handler_options.get("background") == SCRIPT_WAIT and is_true(handler_options.get("raise_error")):
+                raise ServerError("Failed to start script.")
+
+        if rcode:
+            log.warning("Script {script!r} failed to execute with error code {error!r}".format(script=script_name,
+                                                                                               error=rcode))
+            if is_true(handler_options.get("raise_error")):
+                raise ServerError("Error during execution of the script.")
 
         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
@@ -7,12 +7,13 @@
 
 import smtpmock
 import responses
+import os
 from .base import MyTestCase, FakeFlaskG, FakeAudit
 from privacyidea.lib.eventhandler.usernotification import (
     UserNotificationEventHandler, NOTIFY_TYPE)
 from privacyidea.lib.eventhandler.tokenhandler import (TokenEventHandler,
                                                        ACTION_TYPE, VALIDITY)
-from privacyidea.lib.eventhandler.scripthandler import ScriptEventHandler
+from privacyidea.lib.eventhandler.scripthandler import ScriptEventHandler, SCRIPT_WAIT, SCRIPT_BACKGROUND
 from privacyidea.lib.eventhandler.counterhandler import CounterEventHandler
 from privacyidea.models import EventCounter
 from privacyidea.lib.eventhandler.federationhandler import FederationEventHandler
@@ -511,21 +512,68 @@ def test_01_runscript(self):
                    "request": req,
                    "response": resp,
                    "handler_def": {
-                       "options":{
+                       "options": {
                            "user": "1",
                            "realm": "1",
                            "serial": "1",
                            "logged_in_user": "1",
-                           "logged_in_role": "1",}
+                           "logged_in_role": "1"}
                    }
                    }
 
         script_name = "ls.sh"
-        t_handler = ScriptEventHandler()
+        d = os.getcwd()
+        d = "{0!s}/tests/testdata/scripts/".format(d)
+        t_handler = ScriptEventHandler(script_directory=d)
         res = t_handler.do(script_name, options=options)
         self.assertTrue(res)
         remove_token("SPASS01")
 
+    def test_02_failscript(self):
+        g = FakeFlaskG()
+        audit_object = FakeAudit()
+        audit_object.audit_data["serial"] = "SPASS01"
+
+        g.logged_in_user = {"username": "admin",
+                            "role": "admin",
+                            "realm": ""}
+        g.audit_object = audit_object
+
+        builder = EnvironBuilder(method='POST',
+                                 data={'serial': "SPASS01"},
+                                 headers={})
+
+        env = builder.get_environ()
+        # Set the remote address so that we can filter for it
+        env["REMOTE_ADDR"] = "10.0.0.1"
+        g.client_ip = env["REMOTE_ADDR"]
+        req = Request(env)
+        req.all_data = {"serial": "SPASS01", "type": "spass"}
+        req.User = User()
+        resp = Response()
+        resp.data = """{"result": {"value": true}}"""
+
+        options = {"g": g,
+                   "request": req,
+                   "response": resp,
+                   "handler_def": {
+                       "options": {
+                           "background": SCRIPT_WAIT,
+                           "raise_error": True,
+                           "realm": "1",
+                           "serial": "1",
+                           "logged_in_user": "1",
+                           "logged_in_role": "1"}
+                   }
+                   }
+
+        script_name = "fail.sh"
+        d = os.getcwd()
+        d = "{0!s}/tests/testdata/scripts/".format(d)
+        t_handler = ScriptEventHandler(script_directory=d)
+        self.assertRaises(Exception, t_handler.do, script_name, options=options)
+        remove_token("SPASS01")
+
 
 class FederationEventTestCase(MyTestCase):
 
diff --git a/tests/testdata/scripts/fail.sh b/tests/testdata/scripts/fail.sh
new file mode 100755
--- /dev/null
+++ b/tests/testdata/scripts/fail.sh
@@ -0,0 +1,2 @@
+#!/bin/bash
+exit 1