From 3a76b5a05fe7ad631b59ed3c99fc5379a013d25d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Thu, 11 Apr 2019 10:48:03 +0200
Subject: [PATCH 2/2] workflows: add possibility to trigger global actions with
 a webservice (#32184)

---
 tests/test_api.py             | 68 ++++++++++++++++++++++++++++++++
 tests/test_workflow_import.py |  9 +++++
 wcs/admin/workflows.py        |  1 +
 wcs/api.py                    | 24 ++++++++----
 wcs/forms/workflows.py        | 74 +++++++++++++++++++++++++++++++++++
 wcs/workflows.py              | 46 +++++++++++++++++++++-
 6 files changed, 213 insertions(+), 9 deletions(-)
 create mode 100644 wcs/forms/workflows.py

diff --git a/tests/test_api.py b/tests/test_api.py
index 0fe8aae73..d7dface27 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -28,6 +28,7 @@ from wcs.categories import Category
 from wcs.data_sources import NamedDataSource
 from wcs.workflows import Workflow, EditableWorkflowStatusItem, WorkflowBackofficeFieldsFormDef
 from wcs.wf.jump import JumpWorkflowStatusItem
+from wcs.wf.register_comment import RegisterCommenterWorkflowStatusItem
 from wcs import fields, qommon
 from wcs.api_utils import sign_url, get_secret_and_orig, is_url_signed, DEFAULT_DURATION
 from wcs.qommon.errors import AccessForbiddenError
@@ -2179,6 +2180,73 @@ def test_workflow_trigger_with_condition(pub, local_user):
     resp = get_app(pub).post(sign_uri(formdata.get_url() + 'jump/trigger/XXX'))
     assert resp.json == {'err': 0, 'url': None}
 
+def test_workflow_global_webservice_trigger(pub, local_user):
+    workflow = Workflow(name='test')
+    st1 = workflow.add_status('Status1', 'st1')
+
+    ac1 = workflow.add_global_action('Action', 'ac1')
+    trigger = ac1.append_trigger('webservice')
+    trigger.identifier = 'plop'
+
+    add_to_journal = RegisterCommenterWorkflowStatusItem()
+    add_to_journal.id = '_add_to_journal'
+    add_to_journal.comment = 'HELLO WORLD'
+    ac1.items.append(add_to_journal)
+    add_to_journal.parent = ac1
+
+    workflow.store()
+
+    FormDef.wipe()
+    formdef = FormDef()
+    formdef.name = 'test'
+    formdef.fields = []
+    formdef.workflow_id = workflow.id
+    formdef.store()
+
+    formdef.data_class().wipe()
+    formdata = formdef.data_class()()
+    formdata.just_created()
+    formdata.store()
+    assert formdef.data_class().get(formdata.id).status == 'wf-st1'
+
+    # call to undefined hook
+    resp = get_app(pub).post(sign_uri(formdata.get_url() + 'hooks/XXX/'), status=404)
+    resp = get_app(pub).post(sign_uri(formdata.get_api_url() + 'hooks/XXX/'), status=404)
+
+    # anonymous call
+    resp = get_app(pub).post(formdata.get_url() + 'hooks/plop/', status=200)
+    assert formdef.data_class().get(formdata.id).evolution[-1].parts[-1].content == 'HELLO WORLD'
+
+    add_to_journal.comment = 'HELLO WORLD 2'
+    workflow.store()
+    resp = get_app(pub).post(formdata.get_api_url() + 'hooks/plop/', status=200)
+    assert formdef.data_class().get(formdata.id).evolution[-1].parts[-1].content == 'HELLO WORLD 2'
+
+    # call requiring user
+    add_to_journal.comment = 'HELLO WORLD 3'
+    trigger.roles = ['logged-users']
+    workflow.store()
+    resp = get_app(pub).post(formdata.get_api_url() + 'hooks/plop/', status=403)
+    resp = get_app(pub).post(sign_uri(formdata.get_api_url() + 'hooks/plop/'), status=200)
+    assert formdef.data_class().get(formdata.id).evolution[-1].parts[-1].content == 'HELLO WORLD 3'
+
+    # call requiring roles
+    add_to_journal.comment = 'HELLO WORLD 4'
+    trigger.roles = ['logged-users']
+    workflow.store()
+    Role.wipe()
+    role = Role(name='xxx')
+    role.store()
+    trigger.roles = [role.id]
+    workflow.store()
+    resp = get_app(pub).post(sign_uri(formdata.get_api_url() + 'hooks/plop/'), status=403)
+    resp = get_app(pub).post(sign_uri(formdata.get_api_url() + 'hooks/plop/', user=local_user), status=403)
+
+    local_user.roles = [role.id]
+    local_user.store()
+    resp = get_app(pub).post(sign_uri(formdata.get_api_url() + 'hooks/plop/', user=local_user), status=200)
+    assert formdef.data_class().get(formdata.id).evolution[-1].parts[-1].content == 'HELLO WORLD 4'
+
 def test_tracking_code(pub):
     FormDef.wipe()
     formdef = FormDef()
diff --git a/tests/test_workflow_import.py b/tests/test_workflow_import.py
index eec4e3a19..ed2b48b44 100644
--- a/tests/test_workflow_import.py
+++ b/tests/test_workflow_import.py
@@ -593,6 +593,15 @@ def test_global_timeout_trigger(pub):
     assert wf2.global_actions[0].triggers[-1].id == trigger.id
     assert wf2.global_actions[0].triggers[-1].anchor == trigger.anchor
 
+def test_global_webservice_trigger(pub):
+    wf = Workflow(name='global actions')
+    ac1 = wf.add_global_action('Action', 'ac1')
+    trigger = ac1.append_trigger('webservice')
+    trigger.identifier = 'plop'
+
+    wf2 = assert_import_export_works(wf, include_id=True)
+    assert wf2.global_actions[0].triggers[-1].id == trigger.id
+    assert wf2.global_actions[0].triggers[-1].identifier == trigger.identifier
 
 def test_profile_action(pub):
     wf = Workflow(name='status')
diff --git a/wcs/admin/workflows.py b/wcs/admin/workflows.py
index 38d253426..22cf9db88 100644
--- a/wcs/admin/workflows.py
+++ b/wcs/admin/workflows.py
@@ -1272,6 +1272,7 @@ class GlobalActionPage(WorkflowStatusPage):
         available_triggers = [
             ('timeout', _('Automatic')),
             ('manual', _('Manual')),
+            ('webservice', _('Webservice')),
         ]
         form.add(SingleSelectWidget, 'type', title=_('Type'),
                 required=True, options=available_triggers)
diff --git a/wcs/api.py b/wcs/api.py
index bd058309b..1532f9947 100644
--- a/wcs/api.py
+++ b/wcs/api.py
@@ -147,7 +147,7 @@ class ApiFormdataPage(FormStatusPage):
         if not api_user:
             if get_request().user and get_request().user.is_admin:
                 return # grant access to admins, to ease debug
-            raise AccessForbiddenError('user not authenticated')
+            raise AccessForbiddenError('user not authenticated X')
         if not self.formdef.is_user_allowed_read_status_and_history(api_user, self.filled):
             raise AccessForbiddenError('unsufficient roles')
 
@@ -164,13 +164,13 @@ class ApiFormPage(BackofficeFormPage):
     def check_access(self, api_name=None):
         if 'anonymise' in get_request().form:
             if not is_url_signed() or (get_request().user and get_request().user.is_admin):
-                raise AccessForbiddenError('user not authenticated')
+                raise AccessForbiddenError('user not authenticated Y')
         else:
             api_user = get_user_from_api_query_string(api_name=api_name)
             if not api_user:
                 if get_request().user and get_request().user.is_admin:
                     return # grant access to admins, to ease debug
-                raise AccessForbiddenError('user not authenticated')
+                raise AccessForbiddenError('user not authenticated Z')
             if not self.formdef.is_of_concern_for_user(api_user):
                 raise AccessForbiddenError('unsufficient roles')
 
@@ -178,15 +178,25 @@ class ApiFormPage(BackofficeFormPage):
         if component == 'ics':
             return self.ics()
 
-        # check access for all paths, to block access to formdata that would
-        # otherwise be accessible if the user is the submitter.
-        self.check_access()
+        # check access for all paths (except webooks), to block access to
+        # formdata that would otherwise be accessible if the user is the
+        # submitter.
+        if not self.is_webhook:
+            self.check_access()
         try:
             formdata = self.formdef.data_class().get(component)
         except KeyError:
             raise TraversalError()
         return ApiFormdataPage(self.formdef, formdata)
 
+    def _q_traverse(self, path):
+        self.is_webhook = False
+        if len(path) > 1:
+            # webhooks have their own access checks, request cannot be blocked
+            # at this point.
+            self.is_webhook = bool(path[1] == 'hooks')
+        return super(ApiFormPage, self)._q_traverse(path)
+
 
 class ApiFormsDirectory(Directory):
     _q_exports = ['', 'geojson']
@@ -195,7 +205,7 @@ class ApiFormsDirectory(Directory):
         if not is_url_signed():
             # grant access to admins, to ease debug
             if not (get_request().user and get_request().user.is_admin):
-                raise AccessForbiddenError('user not authenticated')
+                raise AccessForbiddenError('user not authenticated W')
             if get_request().form.get('ignore-roles') == 'on' and not get_request().user.can_go_in_backoffice():
                 raise AccessForbiddenError('user not allowed to ignore roles')
 
diff --git a/wcs/forms/workflows.py b/wcs/forms/workflows.py
new file mode 100644
index 000000000..86835ec92
--- /dev/null
+++ b/wcs/forms/workflows.py
@@ -0,0 +1,74 @@
+# w.c.s. - web application for online forms
+# Copyright (C) 2005-2019  Entr'ouvert
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, see <http://www.gnu.org/licenses/>.
+
+import json
+
+from quixote import get_request, get_response
+from quixote.directory import Directory
+
+from qommon import errors
+
+from wcs.api import get_user_from_api_query_string, is_url_signed
+from wcs.roles import logged_users_role
+from wcs.workflows import (
+        WorkflowGlobalActionWebserviceTrigger,
+        get_role_translation,
+        perform_items)
+
+
+class HookDirectory(Directory):
+    _q_exports = ['']
+
+    def __init__(self, formdata, action, trigger):
+        self.formdata = formdata
+        self.action = action
+        self.trigger = trigger
+
+    def _q_index(self):
+        get_response().set_content_type('application/json')
+
+        if not get_request().get_method() == 'POST':
+            raise errors.AccessForbiddenError('must be POST')
+
+        user = get_user_from_api_query_string() or get_request().user
+        if self.trigger.roles:
+            for role in self.trigger.roles:
+                if role == logged_users_role().id and (user or is_url_signed()):
+                    break
+                if role == '_submitter' and self.formdata.is_submitter(user):
+                    break
+                if not user:
+                    continue
+                if get_role_translation(self.formdata, role) in (user.roles or []):
+                    break
+            else:
+                raise errors.AccessForbiddenError('insufficient roles')
+
+        perform_items(self.action.items, self.formdata)
+        return json.dumps({'err': 0})
+
+
+class WorkflowGlobalActionWebserviceHooksDirectory(Directory):
+    def __init__(self, formdata):
+        self.formdata = formdata
+
+    def _q_lookup(self, component):
+        for action in self.formdata.formdef.workflow.global_actions:
+            for trigger in action.triggers or []:
+                if isinstance(trigger, WorkflowGlobalActionWebserviceTrigger):
+                    if trigger.identifier == component:
+                        return HookDirectory(self.formdata, action, trigger)
+        raise errors.TraversalError()
diff --git a/wcs/workflows.py b/wcs/workflows.py
index fcf4ce704..2a50ce6c8 100644
--- a/wcs/workflows.py
+++ b/wcs/workflows.py
@@ -435,7 +435,12 @@ class Workflow(StorableObject):
         wf_status = formdata.get_status()
         if not wf_status:  # draft
             return []
-        return wf_status.get_subdirectories(formdata)
+        directories = []
+        for action in self.global_actions:
+            for trigger in action.triggers or []:
+                directories.extend(trigger.get_subdirectories(formdata))
+        directories.extend(wf_status.get_subdirectories(formdata))
+        return directories
 
     def __setstate__(self, dict):
         self.__dict__.update(dict)
@@ -950,6 +955,9 @@ class WorkflowGlobalActionTrigger(XmlSerialisable):
                     value = getattr(self, '%s_parse' % f)(value)
                 setattr(self, f, value)
 
+    def get_subdirectories(self, formdata):
+        return []
+
 
 class WorkflowGlobalActionManualTrigger(WorkflowGlobalActionTrigger):
     key = 'manual'
@@ -1159,6 +1167,39 @@ class WorkflowGlobalActionTimeoutTrigger(WorkflowGlobalActionTrigger):
                         break
 
 
+class WorkflowGlobalActionWebserviceTrigger(WorkflowGlobalActionManualTrigger):
+    key = 'webservice'
+    identifier = None
+    roles = None
+
+    def get_parameters(self):
+        return ('identifier', 'roles')
+
+    def render_as_line(self):
+        if self.identifier:
+            return _('Webservice (%s)') % self.identifier
+        else:
+            return _('Webservice (not configured)')
+
+    def form(self, workflow):
+        form = Form(enctype='multipart/form-data')
+        form.add(StringWidget, 'identifier', title=_('Identifier'),
+                required=True, value=self.identifier)
+        options = [(None, '---', None)]
+        options += workflow.get_list_of_roles(include_logged_in_users=True)
+        form.add(WidgetList, 'roles', title=_('Roles'),
+                element_type=SingleSelectWidget,
+                value=self.roles,
+                add_element_label=_('Add Role'),
+                element_kwargs={'render_br': False,
+                                'options': options})
+        return form
+
+    def get_subdirectories(self, formdata):
+        from wcs.forms.workflows import WorkflowGlobalActionWebserviceHooksDirectory
+        return [('hooks', WorkflowGlobalActionWebserviceHooksDirectory(formdata))]
+
+
 class WorkflowGlobalAction(object):
     id = None
     name = None
@@ -1186,7 +1227,8 @@ class WorkflowGlobalAction(object):
     def append_trigger(self, type):
         trigger_types = {
             'manual': WorkflowGlobalActionManualTrigger,
-            'timeout': WorkflowGlobalActionTimeoutTrigger
+            'timeout': WorkflowGlobalActionTimeoutTrigger,
+            'webservice': WorkflowGlobalActionWebserviceTrigger,
         }
         o = trigger_types.get(type)()
         if not self.triggers:
-- 
2.20.1

