# Copyright 2020 The StackStorm Authors.
# Copyright 2019 Extreme Networks, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import six
import uuid
from six.moves.urllib import parse as urlparse  # pylint: disable=import-error
from six.moves import http_client

from st2common import log as logging
from st2common.constants.auth import (
    HEADER_API_KEY_ATTRIBUTE_NAME,
    HEADER_ATTRIBUTE_NAME,
)
from st2common.constants.triggers import WEBHOOK_TRIGGER_TYPES
from st2common.models.api.trace import TraceContext
from st2common.models.api.trigger import TriggerAPI
from st2common.models.db.webhook import WebhookDB
import st2common.services.triggers as trigger_service
from st2common.rbac.types import PermissionType
from st2common.rbac.backends import get_rbac_backend
from st2common.services.triggerwatcher import TriggerWatcher
from st2common.services.trigger_dispatcher import TriggerDispatcherService
from st2common.router import abort
from st2common.router import Response
from st2common.util.jsonify import get_json_type_for_python_value

LOG = logging.getLogger(__name__)

TRACE_TAG_HEADER = "St2-Trace-Tag"


class HooksHolder(object):
    """
    Maintains a hook to TriggerDB mapping.
    """

    def __init__(self):
        self._triggers_by_hook = {}

    def __contains__(self, key):
        return key in self._triggers_by_hook

    def add_hook(self, hook, trigger):
        if hook not in self._triggers_by_hook:
            self._triggers_by_hook[hook] = []
        self._triggers_by_hook[hook].append(trigger)

    def remove_hook(self, hook, trigger):
        if hook not in self._triggers_by_hook:
            return False
        remove_index = -1
        for idx, item in enumerate(self._triggers_by_hook[hook]):
            if item["id"] == trigger["id"]:
                remove_index = idx
                break
        if remove_index < 0:
            return False
        self._triggers_by_hook[hook].pop(remove_index)
        if not self._triggers_by_hook[hook]:
            del self._triggers_by_hook[hook]
        return True

    def get_triggers_for_hook(self, hook):
        return self._triggers_by_hook.get(hook, [])

    def get_all(self):
        triggers = []
        for values in six.itervalues(self._triggers_by_hook):
            triggers.extend(values)
        return triggers


class WebhooksController(object):
    def __init__(self, *args, **kwargs):
        self._hooks = HooksHolder()
        self._base_url = "/webhooks/"
        self._trigger_types = list(WEBHOOK_TRIGGER_TYPES.keys())

        self._trigger_dispatcher_service = TriggerDispatcherService(LOG)
        queue_suffix = self.__class__.__name__
        self._trigger_watcher = TriggerWatcher(
            create_handler=self._handle_create_trigger,
            update_handler=self._handle_update_trigger,
            delete_handler=self._handle_delete_trigger,
            trigger_types=self._trigger_types,
            queue_suffix=queue_suffix,
            exclusive=True,
        )
        self._trigger_watcher.start()
        self._register_webhook_trigger_types()

    def get_all(self):
        # Return only the hooks known by this controller.
        return self._hooks.get_all()

    def get_one(self, url, requester_user):
        triggers = self._hooks.get_triggers_for_hook(url)

        if not triggers:
            abort(http_client.NOT_FOUND)
            return

        permission_type = PermissionType.WEBHOOK_VIEW
        rbac_utils = get_rbac_backend().get_utils_class()
        rbac_utils.assert_user_has_resource_db_permission(
            user_db=requester_user,
            resource_db=WebhookDB(name=url),
            permission_type=permission_type,
        )

        # For demonstration purpose return 1st
        return triggers[0]

    def post(self, hook, webhook_body_api, headers, requester_user):
        body = webhook_body_api.data

        permission_type = PermissionType.WEBHOOK_SEND
        rbac_utils = get_rbac_backend().get_utils_class()
        rbac_utils.assert_user_has_resource_db_permission(
            user_db=requester_user,
            resource_db=WebhookDB(name=hook),
            permission_type=permission_type,
        )

        headers = self._get_headers_as_dict(headers)
        headers = self._filter_authentication_headers(headers)

        # If webhook contains a trace-tag use that else create create a unique trace-tag.
        trace_context = self._create_trace_context(
            trace_tag=headers.pop(TRACE_TAG_HEADER, None), hook=hook
        )

        if hook == "st2" or hook == "st2/":
            # When using st2 or system webhook, body needs to always be a dict
            if not isinstance(body, dict):
                type_string = get_json_type_for_python_value(body)
                msg = "Webhook body needs to be an object, got: %s" % (type_string)
                raise ValueError(msg)

            trigger = body.get("trigger", None)
            payload = body.get("payload", None)

            if not trigger:
                msg = "Trigger not specified."
                return abort(http_client.BAD_REQUEST, msg)

            self._trigger_dispatcher_service.dispatch_with_context(
                trigger=trigger,
                payload=payload,
                trace_context=trace_context,
                throw_on_validation_error=True,
            )
        else:
            if not self._is_valid_hook(hook):
                self._log_request("Invalid hook.", headers, body)
                msg = "Webhook %s not registered with st2" % hook
                return abort(http_client.NOT_FOUND, msg)

            triggers = self._hooks.get_triggers_for_hook(hook)
            payload = {}

            payload["headers"] = headers
            payload["body"] = body

            # Dispatch trigger instance for each of the trigger found
            for trigger_dict in triggers:
                # TODO: Instead of dispatching the whole dict we should just
                # dispatch TriggerDB.ref or similar
                self._trigger_dispatcher_service.dispatch_with_context(
                    trigger=trigger_dict,
                    payload=payload,
                    trace_context=trace_context,
                    throw_on_validation_error=True,
                )

        # NOTE: For url encoded request bodies, values will be bytes instead of unicode and this
        # doesn't work with orjson so we first need to "cast" all the values from bytes to unicode

        return Response(json=body, status=http_client.ACCEPTED)

    def _is_valid_hook(self, hook):
        # TODO: Validate hook payload with payload_schema.
        return hook in self._hooks

    def _register_webhook_trigger_types(self):
        for trigger_type in WEBHOOK_TRIGGER_TYPES.values():
            trigger_service.create_trigger_type_db(trigger_type)

    def _create_trace_context(self, trace_tag, hook):
        # if no trace_tag then create a unique one
        if not trace_tag:
            trace_tag = "webhook-%s-%s" % (hook, uuid.uuid4().hex)
        return TraceContext(trace_tag=trace_tag)

    def add_trigger(self, trigger):
        # NOTE: trigger is a dictionary
        # Note: Permission checking for creating and deleting a webhook is done during rule
        # creation
        url = self._get_normalized_url(trigger)
        LOG.info("Listening to endpoint: %s", urlparse.urljoin(self._base_url, url))
        self._hooks.add_hook(url, trigger)

    def update_trigger(self, trigger):
        pass

    def remove_trigger(self, trigger):
        # Note: Permission checking for creating and deleting a webhook is done during rule
        # creation
        url = self._get_normalized_url(trigger)

        removed = self._hooks.remove_hook(url, trigger)
        if removed:
            LOG.info(
                "Stop listening to endpoint: %s", urlparse.urljoin(self._base_url, url)
            )

    def _get_normalized_url(self, trigger):
        """
        remove the trailing and leading / so that the hook url and those coming
        from trigger parameters end up being the same.
        """
        return trigger["parameters"]["url"].strip("/")

    def _get_headers_as_dict(self, headers):
        headers_dict = {}
        for key, value in headers.items():
            headers_dict[key] = value
        return headers_dict

    def _filter_authentication_headers(self, headers):
        auth_headers = [HEADER_API_KEY_ATTRIBUTE_NAME, HEADER_ATTRIBUTE_NAME, "Cookie"]
        return {key: value for key, value in headers.items() if key not in auth_headers}

    def _log_request(self, msg, headers, body, log_method=LOG.debug):
        headers = self._get_headers_as_dict(headers)
        body = str(body)
        log_method("%s\n\trequest.header: %s.\n\trequest.body: %s.", msg, headers, body)

    ##############################################
    # Event handler methods for the trigger events
    ##############################################

    def _handle_create_trigger(self, trigger):
        LOG.debug('Calling "add_trigger" method (trigger.type=%s)' % (trigger.type))
        trigger = self._sanitize_trigger(trigger=trigger)
        self.add_trigger(trigger=trigger)

    def _handle_update_trigger(self, trigger):
        LOG.debug('Calling "update_trigger" method (trigger.type=%s)' % (trigger.type))
        trigger = self._sanitize_trigger(trigger=trigger)
        self.update_trigger(trigger=trigger)

    def _handle_delete_trigger(self, trigger):
        LOG.debug('Calling "remove_trigger" method (trigger.type=%s)' % (trigger.type))
        trigger = self._sanitize_trigger(trigger=trigger)
        self.remove_trigger(trigger=trigger)

    def _sanitize_trigger(self, trigger):
        sanitized = TriggerAPI.from_model(trigger).to_dict()
        return sanitized


webhooks_controller = WebhooksController()

st2api/st2api/controllers/v1/webhooks.py
# Copyright 2020 The StackStorm Authors.
# Copyright 2019 Extreme Networks, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import six
import uuid
from six.moves.urllib import parse as urlparse  # pylint: disable=import-error
from six.moves import http_client

from st2common import log as logging
from st2common.constants.auth import (
    HEADER_API_KEY_ATTRIBUTE_NAME,
    HEADER_ATTRIBUTE_NAME,
)
from st2common.constants.triggers import WEBHOOK_TRIGGER_TYPES
from st2common.models.api.trace import TraceContext
from st2common.models.api.trigger import TriggerAPI
from st2common.models.db.webhook import WebhookDB
import st2common.services.triggers as trigger_service
from st2common.rbac.types import PermissionType
from st2common.rbac.backends import get_rbac_backend
from st2common.services.triggerwatcher import TriggerWatcher
from st2common.services.trigger_dispatcher import TriggerDispatcherService
from st2common.router import abort
from st2common.router import Response
from st2common.util.jsonify import get_json_type_for_python_value

LOG = logging.getLogger(__name__)

TRACE_TAG_HEADER = "St2-Trace-Tag"


class HooksHolder(object):
    """
    Maintains a hook to TriggerDB mapping.
    """

    def __init__(self):
        self._triggers_by_hook = {}

    def __contains__(self, key):
        return key in self._triggers_by_hook

    def add_hook(self, hook, trigger):
        if hook not in self._triggers_by_hook:
            self._triggers_by_hook[hook] = []
        self._triggers_by_hook[hook].append(trigger)

    def remove_hook(self, hook, trigger):
        if hook not in self._triggers_by_hook:
            return False
        remove_index = -1
        for idx, item in enumerate(self._triggers_by_hook[hook]):
            if item["id"] == trigger["id"]:
                remove_index = idx
                break
        if remove_index < 0:
            return False
        self._triggers_by_hook[hook].pop(remove_index)
        if not self._triggers_by_hook[hook]:
            del self._triggers_by_hook[hook]
        return True

    def get_triggers_for_hook(self, hook):
        return self._triggers_by_hook.get(hook, [])

    def get_all(self):
        triggers = []
        for values in six.itervalues(self._triggers_by_hook):
            triggers.extend(values)
        return triggers


class WebhooksController(object):
    def __init__(self, *args, **kwargs):
        self._hooks = HooksHolder()
        self._base_url = "/webhooks/"
        self._trigger_types = list(WEBHOOK_TRIGGER_TYPES.keys())

        self._trigger_dispatcher_service = TriggerDispatcherService(LOG)
        queue_suffix = self.__class__.__name__
        self._trigger_watcher = TriggerWatcher(
            create_handler=self._handle_create_trigger,
            update_handler=self._handle_update_trigger,
            delete_handler=self._handle_delete_trigger,
            trigger_types=self._trigger_types,
            queue_suffix=queue_suffix,
            exclusive=True,
        )
        self._trigger_watcher.start()
        self._register_webhook_trigger_types()

    def get_all(self):
        # Return only the hooks known by this controller.
        return self._hooks.get_all()

    def get_one(self, url, requester_user):
        triggers = self._hooks.get_triggers_for_hook(url)

        if not triggers:
            abort(http_client.NOT_FOUND)
            return

        permission_type = PermissionType.WEBHOOK_VIEW
        rbac_utils = get_rbac_backend().get_utils_class()
        rbac_utils.assert_user_has_resource_db_permission(
            user_db=requester_user,
            resource_db=WebhookDB(name=url),
            permission_type=permission_type,
        )

        # For demonstration purpose return 1st
        return triggers[0]

    def post(self, hook, webhook_body_api, headers, requester_user):
        body = webhook_body_api.data

        permission_type = PermissionType.WEBHOOK_SEND
        rbac_utils = get_rbac_backend().get_utils_class()
        rbac_utils.assert_user_has_resource_db_permission(
            user_db=requester_user,
            resource_db=WebhookDB(name=hook),
            permission_type=permission_type,
        )

        headers = self._get_headers_as_dict(headers)
        headers = self._filter_authentication_headers(headers)

        # If webhook contains a trace-tag use that else create create a unique trace-tag.
        trace_context = self._create_trace_context(
            trace_tag=headers.pop(TRACE_TAG_HEADER, None), hook=hook
        )

        if hook == "st2" or hook == "st2/":
            # When using st2 or system webhook, body needs to always be a dict
            if not isinstance(body, dict):
                type_string = get_json_type_for_python_value(body)
                msg = "Webhook body needs to be an object, got: %s" % (type_string)
                raise ValueError(msg)

            trigger = body.get("trigger", None)
            payload = body.get("payload", None)

            if not trigger:
                msg = "Trigger not specified."
                return abort(http_client.BAD_REQUEST, msg)

            self._trigger_dispatcher_service.dispatch_with_context(
                trigger=trigger,
                payload=payload,
                trace_context=trace_context,
                throw_on_validation_error=True,
            )
        else:
            if not self._is_valid_hook(hook):
                self._log_request("Invalid hook.", headers, body)
                msg = "Webhook %s not registered with st2" % hook
                return abort(http_client.NOT_FOUND, msg)

            triggers = self._hooks.get_triggers_for_hook(hook)
            payload = {}

            payload["headers"] = headers
            payload["headers_lower"] = {k.lower(): v for k, v in headers.items()}
            payload["body"] = body

            # Dispatch trigger instance for each of the trigger found
            for trigger_dict in triggers:
                # TODO: Instead of dispatching the whole dict we should just
                # dispatch TriggerDB.ref or similar
                self._trigger_dispatcher_service.dispatch_with_context(
                    trigger=trigger_dict,
                    payload=payload,
                    trace_context=trace_context,
                    throw_on_validation_error=True,
                )

        # NOTE: For url encoded request bodies, values will be bytes instead of unicode and this
        # doesn't work with orjson so we first need to "cast" all the values from bytes to unicode

        return Response(json=body, status=http_client.ACCEPTED)

    def _is_valid_hook(self, hook):
        # TODO: Validate hook payload with payload_schema.
        return hook in self._hooks

    def _register_webhook_trigger_types(self):
        for trigger_type in WEBHOOK_TRIGGER_TYPES.values():
            trigger_service.create_trigger_type_db(trigger_type)

    def _create_trace_context(self, trace_tag, hook):
        # if no trace_tag then create a unique one
        if not trace_tag:
            trace_tag = "webhook-%s-%s" % (hook, uuid.uuid4().hex)
        return TraceContext(trace_tag=trace_tag)

    def add_trigger(self, trigger):
        # NOTE: trigger is a dictionary
        # Note: Permission checking for creating and deleting a webhook is done during rule
        # creation
        url = self._get_normalized_url(trigger)
        LOG.info("Listening to endpoint: %s", urlparse.urljoin(self._base_url, url))
        self._hooks.add_hook(url, trigger)

    def update_trigger(self, trigger):
        pass

    def remove_trigger(self, trigger):
        # Note: Permission checking for creating and deleting a webhook is done during rule
        # creation
        url = self._get_normalized_url(trigger)

        removed = self._hooks.remove_hook(url, trigger)
        if removed:
            LOG.info(
                "Stop listening to endpoint: %s", urlparse.urljoin(self._base_url, url)
            )

    def _get_normalized_url(self, trigger):
        """
        remove the trailing and leading / so that the hook url and those coming
        from trigger parameters end up being the same.
        """
        return trigger["parameters"]["url"].strip("/")

    def _get_headers_as_dict(self, headers):
        headers_dict = {}
        for key, value in headers.items():
            headers_dict[key] = value
        return headers_dict

    def _filter_authentication_headers(self, headers):
        auth_headers = [HEADER_API_KEY_ATTRIBUTE_NAME, HEADER_ATTRIBUTE_NAME, "Cookie"]
        return {key: value for key, value in headers.items() if key not in auth_headers}

    def _log_request(self, msg, headers, body, log_method=LOG.debug):
        headers = self._get_headers_as_dict(headers)
        body = str(body)
        log_method("%s\n\trequest.header: %s.\n\trequest.body: %s.", msg, headers, body)

    ##############################################
    # Event handler methods for the trigger events
    ##############################################

    def _handle_create_trigger(self, trigger):
        LOG.debug('Calling "add_trigger" method (trigger.type=%s)' % (trigger.type))
        trigger = self._sanitize_trigger(trigger=trigger)
        self.add_trigger(trigger=trigger)

    def _handle_update_trigger(self, trigger):
        LOG.debug('Calling "update_trigger" method (trigger.type=%s)' % (trigger.type))
        trigger = self._sanitize_trigger(trigger=trigger)
        self.update_trigger(trigger=trigger)

    def _handle_delete_trigger(self, trigger):
        LOG.debug('Calling "remove_trigger" method (trigger.type=%s)' % (trigger.type))
        trigger = self._sanitize_trigger(trigger=trigger)
        self.remove_trigger(trigger=trigger)

    def _sanitize_trigger(self, trigger):
        sanitized = TriggerAPI.from_model(trigger).to_dict()
        return sanitized


webhooks_controller = WebhooksController()

st2api/st2api/controllers/v1/webhooks.py
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 27c296f7a7..01827e7171 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -71,6 +71,12 @@ Added
 
   Contributed by @khushboobhatia01
 
+* Added `trigger.headers_lower` to webhook trigger payload. This allows rules to match webhook triggers
+  without dealing with the case-sensitive nature of `trigger.headers`, as `triggers.headers_lower` providers
+  the same headers, but with the header name lower cased. #5038
+
+  Contributed by @Rand01ph
+
 Fixed
 ~~~~~
 
diff --git a/st2api/st2api/controllers/v1/webhooks.py b/st2api/st2api/controllers/v1/webhooks.py
index be0bb75dd0..548f73ba27 100644
--- a/st2api/st2api/controllers/v1/webhooks.py
+++ b/st2api/st2api/controllers/v1/webhooks.py
@@ -172,6 +172,7 @@ def post(self, hook, webhook_body_api, headers, requester_user):
             payload = {}
 
             payload["headers"] = headers
+            payload["headers_lower"] = {k.lower(): v for k, v in headers.items()}
             payload["body"] = body
 
             # Dispatch trigger instance for each of the trigger found
diff --git a/st2api/tests/unit/controllers/v1/test_webhooks.py b/st2api/tests/unit/controllers/v1/test_webhooks.py
index 2742b2d09e..f6e17fe39f 100644
--- a/st2api/tests/unit/controllers/v1/test_webhooks.py
+++ b/st2api/tests/unit/controllers/v1/test_webhooks.py
@@ -388,6 +388,33 @@ def test_authentication_headers_should_be_removed(self, dispatch_mock):
         )
         self.assertNotIn("Cookie", dispatch_mock.call_args[1]["payload"]["headers"])
 
+    @mock.patch.object(
+        TriggerInstancePublisher, "publish_trigger", mock.MagicMock(return_value=True)
+    )
+    @mock.patch.object(
+        WebhooksController, "_is_valid_hook", mock.MagicMock(return_value=True)
+    )
+    @mock.patch.object(
+        HooksHolder,
+        "get_triggers_for_hook",
+        mock.MagicMock(return_value=[DUMMY_TRIGGER_DICT]),
+    )
+    @mock.patch("st2common.transport.reactor.TriggerDispatcher.dispatch")
+    def test_st2_webhook_lower_header(self, dispatch_mock):
+        data = WEBHOOK_1
+        post_resp = self.__do_post(
+            "git", data, headers={"X-Github-Token": "customvalue"}
+        )
+        self.assertEqual(post_resp.status_int, http_client.ACCEPTED)
+        self.assertEqual(
+            dispatch_mock.call_args[1]["payload"]["headers"]["X-Github-Token"],
+            "customvalue",
+        )
+        self.assertEqual(
+            dispatch_mock.call_args[1]["payload"]["headers_lower"]["x-github-token"],
+            "customvalue",
+        )
+
     def __do_post(self, hook, webhook, expect_errors=False, headers=None):
         return self.app.post_json(
             "/v1/webhooks/" + hook,

Web Hook Rules check http headers in case sensitive manner
## SUMMARY

The case used for the header name in trigger.headers[<headername>] in a web-hook rule is treated in a case sensitive manner. HTTP headers are case insensitive so the case of the name in the headers should not e relevant.

### STACKSTORM VERSION

3.2.0

##### OS, environment, install method

Seen on one-line install and HA

## Steps to reproduce the problem

See https://github.com/StackStorm/st2/issues/4995 for initial case.
1. Configure webhookrule with trigger.headers['X-GitHub-Event']
2. Send in header via curl of X-GitHub-Event to webhook
3. Rule doesn't match
4. Change rule to be trigger.headers['X-Github-Event'] - rule matches

## Expected Results

As http headers are case insensitive then it should not matter what case is used in the rule. Therefore no matter what case header is or case of rule then they should match.

## Actual Results

Only matched when rule defined as X-Github-Event


