diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ced1ea9..5754ca9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Public API for webhooks @mderynck ([#2790](https://github.com/grafana/oncall/pull/2790)) + ### Changed +- Public API for actions now wraps webhooks @mderynck ([#2790](https://github.com/grafana/oncall/pull/2790)) - Allow mobile app to access status endpoint @mderynck ([#2791](https://github.com/grafana/oncall/pull/2791)) ## v1.3.26 (2023-08-22) diff --git a/docs/sources/oncall-api-reference/outgoing_webhooks.md b/docs/sources/oncall-api-reference/outgoing_webhooks.md index 547bdb77..f7f2d4f2 100644 --- a/docs/sources/oncall-api-reference/outgoing_webhooks.md +++ b/docs/sources/oncall-api-reference/outgoing_webhooks.md @@ -1,17 +1,21 @@ --- canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/outgoing_webhooks/ -title: Outgoing webhooks HTTP API +title: Outgoing Webhooks HTTP API weight: 700 --- -# Outgoing webhooks (actions) +# Outgoing Webhooks -Used in escalation policies with type `trigger_action`. +> ⚠️ A note about actions: Before version **v1.3.11** webhooks existed as actions within the API, the /actions +> endpoint remains available and is compatible with previous callers but under the hood it will interact with the +> new webhooks objects. It is recommended to use the /webhooks endpoint going forward which has more features. -## List actions +For more details about specific fields of a webhook see [outgoing webhooks][outgoing-webhooks] documentation. + +## List webhooks ```shell -curl "{{API_URL}}/api/v1/actions/" \ +curl "{{API_URL}}/api/v1/webhooks/" \ --request GET \ --header "Authorization: meowmeowmeow" \ --header "Content-Type: application/json" @@ -21,21 +25,210 @@ The above command returns JSON structured in the following way: ```json { - "count": 1, "next": null, "previous": null, "results": [ { - "id": "KGEFG74LU1D8L", - "name": "Publish alert group notification to JIRA" + "id": "{{WEBHOOK_UID}}", + "name": "Demo Webhook", + "is_webhook_enabled": true, + "team": null, + "data": "{\"labels\" : {{ alert_payload.commonLabels | tojson()}}}", + "username": null, + "password": null, + "authorization_header": "****************", + "trigger_template": null, + "headers": null, + "url": "https://example.com", + "forward_all": false, + "http_method": "POST", + "trigger_type": "acknowledge", + "integration_filter": [ + "CRV8A5MXC751A" + ] } ], - "current_page_number": 1, "page_size": 50, + "count": 1, + "current_page_number": 1, "total_pages": 1 } ``` -**HTTP request** +## Get webhook -`GET {{API_URL}}/api/v1/actions/` +```shell +curl "{{API_URL}}/api/v1/webhooks/{{WEBHOOK_UID}}/" \ + --request GET \ + --header "Authorization: meowmeowmeow" \ + --header "Content-Type: application/json" +``` + +The above command returns JSON structured in the following way: + +```json +{ + "id": "{{WEBHOOK_UID}}", + "name": "Demo Webhook", + "is_webhook_enabled": true, + "team": null, + "data": "{\"labels\" : {{ alert_payload.commonLabels | tojson()}}}", + "username": null, + "password": null, + "authorization_header": "****************", + "trigger_template": null, + "headers": null, + "url": "https://example.com", + "forward_all": false, + "http_method": "POST", + "trigger_type": "acknowledge", + "integration_filter": [ + "CRV8A5MXC751A" + ] +} +``` + +## Create webhook + +```shell +curl "{{API_URL}}/api/v1/webhooks/" \ + --request POST \ + --header "Authorization: meowmeowmeow" \ + --header "Content-Type: application/json" \ + --data '{ + "name": "New Webhook", + "url": "https://example.com", + "http_method": "POST", + "trigger_type" : "resolve" + }' +``` + +### Trigger Types + +See [here](outgoing-webhooks#event-types) for details + +- `escalation` +- `alert group created` +- `acknowledge` +- `resolve` +- `silence` +- `unsilence` +- `unresolve` +- `unacknowledge` + +### HTTP Methods + +- `POST` +- `GET` +- `PUT` +- `DELETE` +- `OPTIONS` + +The above command returns JSON structured in the following way: + +```json +{ + "id": "{{WEBHOOK_UID}}", + "name": "New Webhook", + "is_webhook_enabled": true, + "team": null, + "data": null, + "username": null, + "password": null, + "authorization_header": null, + "trigger_template": null, + "headers": null, + "url": "https://example.com", + "forward_all": true, + "http_method": "POST", + "trigger_type": "resolve", + "integration_filter": null +} +``` + +## Update webhook + +```shell +curl "{{API_URL}}/api/v1/webhooks/{{WEBHOOK_UID}}/" \ + --request PUT \ + --header "Authorization: meowmeowmeow" \ + --header "Content-Type: application/json" \ + --data '{ + "is_webhook_enabled": false + }' +``` + +The above command returns JSON structured in the following way: + +```json +{ + "id": "{{WEBHOOK_UID}}", + "name": "New Webhook", + "is_webhook_enabled": false, + "team": null, + "data": null, + "username": null, + "password": null, + "authorization_header": null, + "trigger_template": null, + "headers": null, + "url": "https://example.com", + "forward_all": true, + "http_method": "POST", + "trigger_type": "resolve", + "integration_filter": null +} +``` + +## Delete webhook + +```shell +curl "{{API_URL}}/api/v1/webhooks/{{WEBHOOK_UID}}/" \ + --request DELETE \ + --header "Authorization: meowmeowmeow" \ + --header "Content-Type: application/json" +``` + +## Get webhook responses + +```shell +curl "{{API_URL}}/api/v1/webhooks/{{WEBHOOK_UID}}/responses" \ + --request GET \ + --header "Authorization: meowmeowmeow" \ + --header "Content-Type: application/json" +``` + +The above command returns JSON structured in the following way: + +```json +{ + "next": null, + "previous": null, + "results": [ + { + "timestamp": "2023-08-18T16:38:23.106015Z", + "url": "https://example.com", + "request_trigger": "", + "request_headers": "{\"Authorization\": \"****************\"}", + "request_data": "{\"labels\": {\"alertname\": \"InstanceDown\", \"job\": \"node\", \"severity\": \"critical\"}}", + "status_code": 200, + "content": "", + "event_data": "{\"event\": {\"type\": \"acknowledge\", \"time\": \"2023-08-18T16:38:21.442981+00:00\"}, \"user\": {\"id\": \"UK49JJNPZMFLJ\", \"username\": \"oncall\", \"email\": \"admin@localhost\"}, \"alert_group\": {\"id\": \"IZQERPWKWCGH1\", \"integration_id\": \"CRV8A5MXC751A\", \"route_id\": \"RWNCT6C77M3WM\", \"alerts_count\": 1, \"state\": \"acknowledged\", \"created_at\": \"2023-08-18T16:34:27.678406Z\", \"resolved_at\": null, \"acknowledged_at\": \"2023-08-18T16:38:21.442981Z\", \"title\": \"[firing:2] InstanceDown \", \"permalinks\": {\"slack\": null, \"telegram\": null, \"web\": \"http://localhost:3000/a/grafana-oncall-app/alert-groups/IZQERPWKWCGH1\"}}, \"alert_group_id\": \"IZQERPWKWCGH1\", \"alert_payload\": {\"alerts\": [{\"endsAt\": \"0001-01-01T00:00:00Z\", \"labels\": {\"job\": \"node\", \"group\": \"production\", \"instance\": \"localhost:8081\", \"severity\": \"critical\", \"alertname\": \"InstanceDown\"}, \"status\": \"firing\", \"startsAt\": \"2023-06-12T08:24:38.326Z\", \"annotations\": {\"title\": \"Instance localhost:8081 down\", \"description\": \"localhost:8081 of job node has been down for more than 1 minute.\"}, \"fingerprint\": \"f404ecabc8dd5cd7\", \"generatorURL\": \"\"}, {\"endsAt\": \"0001-01-01T00:00:00Z\", \"labels\": {\"job\": \"node\", \"group\": \"canary\", \"instance\": \"localhost:8082\", \"severity\": \"critical\", \"alertname\": \"InstanceDown\"}, \"status\": \"firing\", \"startsAt\": \"2023-06-12T08:24:38.326Z\", \"annotations\": {\"title\": \"Instance localhost:8082 down\", \"description\": \"localhost:8082 of job node has been down for more than 1 minute.\"}, \"fingerprint\": \"f8f08d4e32c61a9d\", \"generatorURL\": \"\"}], \"status\": \"firing\", \"version\": \"4\", \"groupKey\": \"{}:{alertname=\\\"InstanceDown\\\"}\", \"receiver\": \"combo\", \"numFiring\": 2, \"externalURL\": \"\", \"groupLabels\": {\"alertname\": \"InstanceDown\"}, \"numResolved\": 0, \"commonLabels\": {\"job\": \"node\", \"severity\": \"critical\", \"alertname\": \"InstanceDown\"}, \"truncatedAlerts\": 0, \"commonAnnotations\": {}}, \"integration\": {\"id\": \"CRV8A5MXC751A\", \"type\": \"alertmanager\", \"name\": \"One - Alertmanager\", \"team\": null}, \"notified_users\": [], \"users_to_be_notified\": []}" + }, + { + "timestamp": "2023-08-18T16:34:38.580574Z", + "url": "https://example.com", + "request_trigger": "", + "request_headers": null, + "request_data": "Data - Template Warning: Object of type Undefined is not JSON serializable", + "status_code": null, + "content": null, + "event_data": "{\"event\": {\"type\": \"acknowledge\", \"time\": \"2023-08-18T16:34:37.940655+00:00\"}, \"user\": {\"id\": \"UK49JJNPZMFLJ\", \"username\": \"oncall\", \"email\": \"admin@localhost\"}, \"alert_group\": {\"id\": \"IZQERPWKWCGH1\", \"integration_id\": \"CRV8A5MXC751A\", \"route_id\": \"RWNCT6C77M3WM\", \"alerts_count\": 1, \"state\": \"acknowledged\", \"created_at\": \"2023-08-18T16:34:27.678406Z\", \"resolved_at\": null, \"acknowledged_at\": \"2023-08-18T16:34:37.940655Z\", \"title\": \"[firing:2] InstanceDown \", \"permalinks\": {\"slack\": null, \"telegram\": null, \"web\": \"http://localhost:3000/a/grafana-oncall-app/alert-groups/IZQERPWKWCGH1\"}}, \"alert_group_id\": \"IZQERPWKWCGH1\", \"alert_payload\": {\"alerts\": [{\"endsAt\": \"0001-01-01T00:00:00Z\", \"labels\": {\"job\": \"node\", \"group\": \"production\", \"instance\": \"localhost:8081\", \"severity\": \"critical\", \"alertname\": \"InstanceDown\"}, \"status\": \"firing\", \"startsAt\": \"2023-06-12T08:24:38.326Z\", \"annotations\": {\"title\": \"Instance localhost:8081 down\", \"description\": \"localhost:8081 of job node has been down for more than 1 minute.\"}, \"fingerprint\": \"f404ecabc8dd5cd7\", \"generatorURL\": \"\"}, {\"endsAt\": \"0001-01-01T00:00:00Z\", \"labels\": {\"job\": \"node\", \"group\": \"canary\", \"instance\": \"localhost:8082\", \"severity\": \"critical\", \"alertname\": \"InstanceDown\"}, \"status\": \"firing\", \"startsAt\": \"2023-06-12T08:24:38.326Z\", \"annotations\": {\"title\": \"Instance localhost:8082 down\", \"description\": \"localhost:8082 of job node has been down for more than 1 minute.\"}, \"fingerprint\": \"f8f08d4e32c61a9d\", \"generatorURL\": \"\"}], \"status\": \"firing\", \"version\": \"4\", \"groupKey\": \"{}:{alertname=\\\"InstanceDown\\\"}\", \"receiver\": \"combo\", \"numFiring\": 2, \"externalURL\": \"\", \"groupLabels\": {\"alertname\": \"InstanceDown\"}, \"numResolved\": 0, \"commonLabels\": {\"job\": \"node\", \"severity\": \"critical\", \"alertname\": \"InstanceDown\"}, \"truncatedAlerts\": 0, \"commonAnnotations\": {}}, \"integration\": {\"id\": \"CRV8A5MXC751A\", \"type\": \"alertmanager\", \"name\": \"One - Alertmanager\", \"team\": null}, \"notified_users\": [], \"users_to_be_notified\": []}" + } + ], + "page_size": 50, + "count": 2, + "current_page_number": 1, + "total_pages": 1 +} +``` diff --git a/docs/sources/outgoing-webhooks/_index.md b/docs/sources/outgoing-webhooks/_index.md index 0fa1be74..19cbf10c 100644 --- a/docs/sources/outgoing-webhooks/_index.md +++ b/docs/sources/outgoing-webhooks/_index.md @@ -371,7 +371,7 @@ To fix change the template to: ```json { - "labels": "{{ alert_payload.labels | tojson()}}" + "labels": {{ alert_payload.labels | tojson()}} } ``` diff --git a/engine/apps/api/serializers/webhook.py b/engine/apps/api/serializers/webhook.py index d5945de7..2a38200c 100644 --- a/engine/apps/api/serializers/webhook.py +++ b/engine/apps/api/serializers/webhook.py @@ -44,7 +44,6 @@ class WebhookSerializer(serializers.ModelSerializer): "is_webhook_enabled", "is_legacy", "team", - "data", "user", "username", "password", diff --git a/engine/apps/api/tests/test_webhooks.py b/engine/apps/api/tests/test_webhooks.py index 4e7fc0e2..e6c2624d 100644 --- a/engine/apps/api/tests/test_webhooks.py +++ b/engine/apps/api/tests/test_webhooks.py @@ -64,8 +64,8 @@ def test_get_list_webhooks(webhook_internal_api_setup, make_user_auth_headers): "event_data": "", }, "trigger_template": None, - "trigger_type": None, - "trigger_type_name": "", + "trigger_type": "0", + "trigger_type_name": "Escalation step", } ] @@ -106,8 +106,8 @@ def test_get_detail_webhook(webhook_internal_api_setup, make_user_auth_headers): "event_data": "", }, "trigger_template": None, - "trigger_type": None, - "trigger_type_name": "", + "trigger_type": "0", + "trigger_type_name": "Escalation step", } response = client.get(url, format="json", **make_user_auth_headers(user, token)) diff --git a/engine/apps/api/views/webhooks.py b/engine/apps/api/views/webhooks.py index 8e61bf63..f3674cf2 100644 --- a/engine/apps/api/views/webhooks.py +++ b/engine/apps/api/views/webhooks.py @@ -18,6 +18,7 @@ from apps.webhooks.utils import apply_jinja_template_for_json from common.api_helpers.exceptions import BadRequest from common.api_helpers.filters import ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, TeamModelMultipleChoiceFilter from common.api_helpers.mixins import PublicPrimaryKeyMixin, TeamFilteringMixin +from common.insight_log import EntityEvent, write_resource_insight_log from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning NEW_WEBHOOK_PK = "new" @@ -60,6 +61,30 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet): search_fields = ["public_primary_key", "name"] filterset_class = WebhooksFilter + def perform_create(self, serializer): + serializer.save() + write_resource_insight_log(instance=serializer.instance, author=self.request.user, event=EntityEvent.CREATED) + + def perform_update(self, serializer): + prev_state = serializer.instance.insight_logs_serialized + serializer.save() + new_state = serializer.instance.insight_logs_serialized + write_resource_insight_log( + instance=serializer.instance, + author=self.request.user, + event=EntityEvent.UPDATED, + prev_state=prev_state, + new_state=new_state, + ) + + def perform_destroy(self, instance): + write_resource_insight_log( + instance=instance, + author=self.request.user, + event=EntityEvent.DELETED, + ) + instance.delete() + def get_queryset(self, ignore_filtering_by_available_teams=False): queryset = Webhook.objects.filter( organization=self.request.auth.organization, diff --git a/engine/apps/public_api/serializers/action.py b/engine/apps/public_api/serializers/action.py index f4674004..853369f8 100644 --- a/engine/apps/public_api/serializers/action.py +++ b/engine/apps/public_api/serializers/action.py @@ -1,106 +1,64 @@ -import json -from collections import defaultdict - -from django.core.validators import URLValidator, ValidationError -from jinja2 import TemplateError from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator -from apps.alerts.models import CustomButton +from apps.public_api.serializers.webhooks import WebhookCreateSerializer, WebhookTriggerTypeField +from apps.webhooks.models import Webhook from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField -from common.api_helpers.utils import CurrentOrganizationDefault -from common.jinja_templater import jinja_template_env +from common.api_helpers.utils import CurrentTeamDefault -class ActionCreateSerializer(serializers.ModelSerializer): - id = serializers.CharField(read_only=True, source="public_primary_key") - organization = serializers.HiddenField(default=CurrentOrganizationDefault()) - team_id = TeamPrimaryKeyRelatedField(required=False, allow_null=True, source="team") - url = serializers.CharField(required=True, allow_null=False, allow_blank=False, source="webhook") +class ActionCreateSerializer(WebhookCreateSerializer): + team_id = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault(), source="team") + user = serializers.CharField(required=False, source="username") + trigger_type = WebhookTriggerTypeField(required=False) + forward_whole_payload = serializers.BooleanField(required=False, source="forward_all") class Meta: - model = CustomButton + model = Webhook fields = [ "id", "name", + "is_webhook_enabled", "organization", "team_id", - "url", + "user", "data", "user", "password", "authorization_header", + "trigger_template", + "headers", + "url", "forward_whole_payload", + "http_method", + "trigger_type", + "integration_filter", ] extra_kwargs = { "name": {"required": True, "allow_null": False, "allow_blank": False}, - "data": {"required": False, "allow_null": True, "allow_blank": False}, - "user": {"required": False, "allow_null": True, "allow_blank": False}, - "password": {"required": False, "allow_null": True, "allow_blank": False}, - "authorization_header": {"required": False, "allow_null": True, "allow_blank": False}, - "forward_whole_payload": {"required": False, "allow_null": True}, + "url": {"required": True, "allow_null": False, "allow_blank": False}, } - validators = [UniqueTogetherValidator(queryset=CustomButton.objects.all(), fields=["name", "organization"])] - - def validate_url(self, url): - if url: - try: - URLValidator()(url) - except ValidationError: - raise serializers.ValidationError("URL is incorrect") - return url - return None - - def validate_data(self, data): - if not data: - return None - - try: - template = jinja_template_env.from_string(data) - except TemplateError: - raise serializers.ValidationError("Data has incorrect template") - - try: - rendered = template.render( - { - # Validate that the template can be rendered with a JSON-ish alert payload. - # We don't know what the actual payload will be, so we use a defaultdict - # so that attribute access within a template will never fail - # (provided it's only one level deep - we won't accept templates that attempt - # to do nested attribute access). - # Every attribute access should return a string to ensure that users are - # correctly using `tojson` or wrapping fields in strings. - # If we instead used a `defaultdict(dict)` or `defaultdict(lambda: 1)` we - # would accidentally accept templates such as `{"name": {{ alert_payload.name }}}` - # which would then fail at the true render time due to the - # lack of explicit quotes around the template variable; this would render - # as `{"name": some_alert_name}` which is not valid JSON. - "alert_payload": defaultdict(str), - "alert_group_id": "abcd", - } - ) - json.loads(rendered) - except ValueError: - raise serializers.ValidationError("Data has incorrect format") - - return data - - def validate_forward_whole_payload(self, data): - if data is None: - return False - return data + validators = [UniqueTogetherValidator(queryset=Webhook.objects.all(), fields=["name", "organization"])] class ActionUpdateSerializer(ActionCreateSerializer): - url = serializers.CharField(required=False, allow_null=False, allow_blank=False, source="webhook") + user = serializers.CharField(required=False, source="username") + trigger_type = WebhookTriggerTypeField(required=False) + forward_whole_payload = serializers.BooleanField(required=False, source="forward_all") class Meta(ActionCreateSerializer.Meta): extra_kwargs = { "name": {"required": False, "allow_null": False, "allow_blank": False}, - "data": {"required": False, "allow_null": True, "allow_blank": False}, + "is_webhook_enabled": {"required": False, "allow_null": False}, "user": {"required": False, "allow_null": True, "allow_blank": False}, "password": {"required": False, "allow_null": True, "allow_blank": False}, "authorization_header": {"required": False, "allow_null": True, "allow_blank": False}, - "forward_whole_payload": {"required": False, "allow_null": True}, + "trigger_template": {"required": False, "allow_null": True, "allow_blank": False}, + "headers": {"required": False, "allow_null": True, "allow_blank": False}, + "url": {"required": False, "allow_null": False, "allow_blank": False}, + "data": {"required": False, "allow_null": True, "allow_blank": False}, + "forward_whole_payload": {"required": False, "allow_null": False}, + "http_method": {"required": False, "allow_null": False, "allow_blank": False}, + "integration_filter": {"required": False, "allow_null": True}, } diff --git a/engine/apps/public_api/serializers/webhooks.py b/engine/apps/public_api/serializers/webhooks.py new file mode 100644 index 00000000..44a92634 --- /dev/null +++ b/engine/apps/public_api/serializers/webhooks.py @@ -0,0 +1,170 @@ +from collections import defaultdict + +from rest_framework import fields, serializers +from rest_framework.validators import UniqueTogetherValidator + +from apps.alerts.models import AlertReceiveChannel +from apps.webhooks.models import Webhook, WebhookResponse +from apps.webhooks.models.webhook import PUBLIC_WEBHOOK_HTTP_METHODS, WEBHOOK_FIELD_PLACEHOLDER +from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField +from common.api_helpers.exceptions import BadRequest +from common.api_helpers.utils import CurrentOrganizationDefault, CurrentTeamDefault, CurrentUserDefault +from common.jinja_templater import apply_jinja_template +from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning + +INTEGRATION_FILTER_MESSAGE = "integration_filter must be a list of valid integration ids" + + +class WebhookTriggerTypeField(fields.CharField): + def to_representation(self, value): + return Webhook.PUBLIC_TRIGGER_TYPES_MAP[value] + + def to_internal_value(self, data): + try: + trigger_type = [ + key + for key, value in Webhook.PUBLIC_TRIGGER_TYPES_MAP.items() + if value == data and key in Webhook.PUBLIC_TRIGGER_TYPES_MAP + ][0] + except IndexError: + raise BadRequest(detail=f"trigger_type must one of {Webhook.PUBLIC_ALL_TRIGGER_TYPES}") + return trigger_type + + +class WebhookResponseSerializer(serializers.ModelSerializer): + class Meta: + model = WebhookResponse + fields = [ + "timestamp", + "url", + "request_trigger", + "request_headers", + "request_data", + "status_code", + "content", + "event_data", + ] + + +class WebhookCreateSerializer(serializers.ModelSerializer): + id = serializers.CharField(read_only=True, source="public_primary_key") + organization = serializers.HiddenField(default=CurrentOrganizationDefault()) + team = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault()) + user = serializers.HiddenField(default=CurrentUserDefault()) + trigger_type = WebhookTriggerTypeField() + + class Meta: + model = Webhook + fields = [ + "id", + "name", + "is_webhook_enabled", + "organization", + "team", + "user", + "data", + "username", + "password", + "authorization_header", + "trigger_template", + "headers", + "url", + "forward_all", + "http_method", + "trigger_type", + "integration_filter", + ] + extra_kwargs = { + "name": {"required": True, "allow_null": False, "allow_blank": False}, + "url": {"required": True, "allow_null": False, "allow_blank": False}, + "http_method": {"required": True, "allow_null": False, "allow_blank": False}, + } + + validators = [UniqueTogetherValidator(queryset=Webhook.objects.all(), fields=["name", "organization"])] + + def to_representation(self, instance): + result = super().to_representation(instance) + if instance.password: + result["password"] = WEBHOOK_FIELD_PLACEHOLDER + if instance.authorization_header: + result["authorization_header"] = WEBHOOK_FIELD_PLACEHOLDER + return result + + def to_internal_value(self, data): + webhook = self.instance + if data.get("password") == WEBHOOK_FIELD_PLACEHOLDER: + data["password"] = webhook.password + if data.get("authorization_header") == WEBHOOK_FIELD_PLACEHOLDER: + data["authorization_header"] = webhook.authorization_header + return super().to_internal_value(data) + + def _validate_template_field(self, template): + try: + apply_jinja_template(template, alert_payload=defaultdict(str), alert_group_id="alert_group_1") + except JinjaTemplateError as e: + raise serializers.ValidationError(e.fallback_message) + except JinjaTemplateWarning: + # Suppress render exceptions since we do not have a representative payload to test with + pass + return template + + def validate_trigger_template(self, trigger_template): + if not trigger_template: + return None + return self._validate_template_field(trigger_template) + + def validate_headers(self, headers): + if not headers: + return None + return self._validate_template_field(headers) + + def validate_url(self, url): + if not url: + return None + return self._validate_template_field(url) + + def validate_data(self, data): + if not data: + return None + return self._validate_template_field(data) + + def validate_forward_all(self, data): + if data is None: + return False + return data + + def validate_http_method(self, http_method): + if http_method not in PUBLIC_WEBHOOK_HTTP_METHODS: + raise serializers.ValidationError(f"Must be one of {PUBLIC_WEBHOOK_HTTP_METHODS}") + return http_method + + def validate_integration_filter(self, integration_filter): + if integration_filter: + if type(integration_filter) is not list: + raise serializers.ValidationError(INTEGRATION_FILTER_MESSAGE) + integrations = AlertReceiveChannel.objects.filter( + organization=self.context["request"].auth.organization, public_primary_key__in=integration_filter + ) + if len(integrations) != len(integration_filter): + raise serializers.ValidationError(INTEGRATION_FILTER_MESSAGE) + return integration_filter + + +class WebhookUpdateSerializer(WebhookCreateSerializer): + trigger_type = WebhookTriggerTypeField(required=False) + + class Meta(WebhookCreateSerializer.Meta): + extra_kwargs = { + "name": {"required": False, "allow_null": False, "allow_blank": False}, + "is_webhook_enabled": {"required": False, "allow_null": False}, + "username": {"required": False, "allow_null": True, "allow_blank": False}, + "password": {"required": False, "allow_null": True, "allow_blank": False}, + "authorization_header": {"required": False, "allow_null": True, "allow_blank": False}, + "trigger_template": {"required": False, "allow_null": True, "allow_blank": False}, + "headers": {"required": False, "allow_null": True, "allow_blank": False}, + "url": {"required": False, "allow_null": False, "allow_blank": False}, + "data": {"required": False, "allow_null": True, "allow_blank": False}, + "forward_all": {"required": False, "allow_null": False}, + "http_method": {"required": False, "allow_null": False, "allow_blank": False}, + "integration_filter": {"required": False, "allow_null": True}, + } diff --git a/engine/apps/public_api/tests/test_custom_actions.py b/engine/apps/public_api/tests/test_custom_actions.py index 36904940..f763b7a3 100644 --- a/engine/apps/public_api/tests/test_custom_actions.py +++ b/engine/apps/public_api/tests/test_custom_actions.py @@ -3,18 +3,15 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient -from apps.alerts.models import CustomButton +from apps.webhooks.models import Webhook @pytest.mark.django_db -def test_get_custom_actions( - make_organization_and_user_with_token, - make_custom_action, -): +def test_get_custom_actions(make_organization_and_user_with_token, make_custom_webhook): organization, user, token = make_organization_and_user_with_token() client = APIClient() - custom_action = make_custom_action(organization=organization) + custom_action = make_custom_webhook(organization=organization) url = reverse("api-public:actions-list") @@ -29,12 +26,18 @@ def test_get_custom_actions( "id": custom_action.public_primary_key, "name": custom_action.name, "team_id": None, - "url": custom_action.webhook, + "url": custom_action.url, "data": custom_action.data, "user": custom_action.user, "password": custom_action.password, "authorization_header": custom_action.authorization_header, - "forward_whole_payload": custom_action.forward_whole_payload, + "forward_whole_payload": custom_action.forward_all, + "is_webhook_enabled": custom_action.is_webhook_enabled, + "trigger_template": custom_action.trigger_template, + "headers": custom_action.headers, + "http_method": custom_action.http_method, + "trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type], + "integration_filter": custom_action.integration_filter, } ], "current_page_number": 1, @@ -49,13 +52,13 @@ def test_get_custom_actions( @pytest.mark.django_db def test_get_custom_actions_filter_by_name( make_organization_and_user_with_token, - make_custom_action, + make_custom_webhook, ): organization, user, token = make_organization_and_user_with_token() client = APIClient() - custom_action = make_custom_action(organization=organization) - make_custom_action(organization=organization) + custom_action = make_custom_webhook(organization=organization) + make_custom_webhook(organization=organization) url = reverse("api-public:actions-list") response = client.get(f"{url}?name={custom_action.name}", format="json", HTTP_AUTHORIZATION=f"{token}") @@ -69,12 +72,18 @@ def test_get_custom_actions_filter_by_name( "id": custom_action.public_primary_key, "name": custom_action.name, "team_id": None, - "url": custom_action.webhook, + "url": custom_action.url, "data": custom_action.data, - "user": custom_action.user, + "user": custom_action.username, "password": custom_action.password, "authorization_header": custom_action.authorization_header, - "forward_whole_payload": custom_action.forward_whole_payload, + "forward_whole_payload": custom_action.forward_all, + "is_webhook_enabled": custom_action.is_webhook_enabled, + "trigger_template": custom_action.trigger_template, + "headers": custom_action.headers, + "http_method": custom_action.http_method, + "trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type], + "integration_filter": custom_action.integration_filter, } ], "current_page_number": 1, @@ -89,12 +98,12 @@ def test_get_custom_actions_filter_by_name( @pytest.mark.django_db def test_get_custom_actions_filter_by_name_empty_result( make_organization_and_user_with_token, - make_custom_action, + make_custom_webhook, ): organization, user, token = make_organization_and_user_with_token() client = APIClient() - make_custom_action(organization=organization) + make_custom_webhook(organization=organization) url = reverse("api-public:actions-list") @@ -117,12 +126,12 @@ def test_get_custom_actions_filter_by_name_empty_result( @pytest.mark.django_db def test_get_custom_action( make_organization_and_user_with_token, - make_custom_action, + make_custom_webhook, ): organization, user, token = make_organization_and_user_with_token() client = APIClient() - custom_action = make_custom_action(organization=organization) + custom_action = make_custom_webhook(organization=organization) url = reverse("api-public:actions-detail", kwargs={"pk": custom_action.public_primary_key}) @@ -132,12 +141,18 @@ def test_get_custom_action( "id": custom_action.public_primary_key, "name": custom_action.name, "team_id": None, - "url": custom_action.webhook, + "url": custom_action.url, "data": custom_action.data, - "user": custom_action.user, + "user": custom_action.username, "password": custom_action.password, "authorization_header": custom_action.authorization_header, - "forward_whole_payload": custom_action.forward_whole_payload, + "forward_whole_payload": custom_action.forward_all, + "is_webhook_enabled": custom_action.is_webhook_enabled, + "trigger_template": custom_action.trigger_template, + "headers": custom_action.headers, + "http_method": custom_action.http_method, + "trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type], + "integration_filter": custom_action.integration_filter, } assert response.status_code == status.HTTP_200_OK @@ -158,18 +173,24 @@ def test_create_custom_action(make_organization_and_user_with_token): response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") - custom_action = CustomButton.objects.get(public_primary_key=response.data["id"]) + custom_action = Webhook.objects.get(public_primary_key=response.data["id"]) expected_result = { "id": custom_action.public_primary_key, "name": custom_action.name, "team_id": None, - "url": custom_action.webhook, + "url": custom_action.url, "data": custom_action.data, - "user": custom_action.user, + "user": custom_action.username, "password": custom_action.password, "authorization_header": custom_action.authorization_header, - "forward_whole_payload": custom_action.forward_whole_payload, + "forward_whole_payload": custom_action.forward_all, + "is_webhook_enabled": custom_action.is_webhook_enabled, + "trigger_template": custom_action.trigger_template, + "headers": custom_action.headers, + "http_method": custom_action.http_method, + "trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type], + "integration_filter": custom_action.integration_filter, } assert response.status_code == status.HTTP_201_CREATED @@ -195,18 +216,24 @@ def test_create_custom_action_nested_data(make_organization_and_user_with_token) response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") - custom_action = CustomButton.objects.get(public_primary_key=response.data["id"]) + custom_action = Webhook.objects.get(public_primary_key=response.data["id"]) expected_result = { "id": custom_action.public_primary_key, "name": custom_action.name, "team_id": None, - "url": custom_action.webhook, + "url": custom_action.url, "data": custom_action.data, - "user": custom_action.user, + "user": custom_action.username, "password": custom_action.password, "authorization_header": custom_action.authorization_header, - "forward_whole_payload": custom_action.forward_whole_payload, + "forward_whole_payload": custom_action.forward_all, + "is_webhook_enabled": custom_action.is_webhook_enabled, + "trigger_template": custom_action.trigger_template, + "headers": custom_action.headers, + "http_method": custom_action.http_method, + "trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type], + "integration_filter": custom_action.integration_filter, } assert response.status_code == status.HTTP_201_CREATED @@ -232,18 +259,24 @@ def test_create_custom_action_valid_after_render(make_organization_and_user_with response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") - custom_action = CustomButton.objects.get(public_primary_key=response.data["id"]) + custom_action = Webhook.objects.get(public_primary_key=response.data["id"]) expected_result = { "id": custom_action.public_primary_key, "name": custom_action.name, "team_id": None, - "url": custom_action.webhook, + "url": custom_action.url, "data": custom_action.data, - "user": custom_action.user, + "user": custom_action.username, "password": custom_action.password, "authorization_header": custom_action.authorization_header, - "forward_whole_payload": custom_action.forward_whole_payload, + "forward_whole_payload": custom_action.forward_all, + "is_webhook_enabled": custom_action.is_webhook_enabled, + "trigger_template": custom_action.trigger_template, + "headers": custom_action.headers, + "http_method": custom_action.http_method, + "trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type], + "integration_filter": custom_action.integration_filter, } assert response.status_code == status.HTTP_201_CREATED @@ -269,94 +302,39 @@ def test_create_custom_action_valid_after_render_use_all_data(make_organization_ response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") - custom_action = CustomButton.objects.get(public_primary_key=response.data["id"]) + custom_action = Webhook.objects.get(public_primary_key=response.data["id"]) expected_result = { "id": custom_action.public_primary_key, "name": custom_action.name, "team_id": None, - "url": custom_action.webhook, + "url": custom_action.url, "data": custom_action.data, - "user": custom_action.user, + "user": custom_action.username, "password": custom_action.password, "authorization_header": custom_action.authorization_header, - "forward_whole_payload": custom_action.forward_whole_payload, + "forward_whole_payload": custom_action.forward_all, + "is_webhook_enabled": custom_action.is_webhook_enabled, + "trigger_template": custom_action.trigger_template, + "headers": custom_action.headers, + "http_method": custom_action.http_method, + "trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type], + "integration_filter": custom_action.integration_filter, } assert response.status_code == status.HTTP_201_CREATED assert response.json() == expected_result -@pytest.mark.django_db -def test_create_custom_action_invalid_data( - make_organization_and_user_with_token, -): - organization, user, token = make_organization_and_user_with_token() - client = APIClient() - - url = reverse("api-public:actions-list") - - data = { - "name": "Test outgoing webhook", - "url": "invalid_url", - } - - response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") - - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.data["url"][0] == "URL is incorrect" - - data = { - "name": "Test outgoing webhook", - } - - response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") - - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.data["url"][0] == "This field is required." - - data = { - "url": "https://example.com", - } - - response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") - - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.data["name"][0] == "This field is required." - - data = { - "name": "Test outgoing webhook", - "url": "https://example.com", - "data": "invalid_json", - } - - response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") - - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.data["data"][0] == "Data has incorrect format" - - data = { - "name": "Test outgoing webhook", - "url": "https://example.com", - # This would need a `| tojson` or some double quotes around it to pass validation. - "data": "{{ alert_payload.name }}", - } - - response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") - - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.data["data"][0] == "Data has incorrect format" - - @pytest.mark.django_db def test_update_custom_action( make_organization_and_user_with_token, - make_custom_action, + make_custom_webhook, ): organization, user, token = make_organization_and_user_with_token() client = APIClient() - custom_action = make_custom_action(organization=organization) + custom_action = make_custom_webhook(organization=organization) url = reverse("api-public:actions-detail", kwargs={"pk": custom_action.public_primary_key}) @@ -372,12 +350,18 @@ def test_update_custom_action( "id": custom_action.public_primary_key, "name": data["name"], "team_id": None, - "url": custom_action.webhook, + "url": custom_action.url, "data": custom_action.data, - "user": custom_action.user, + "user": custom_action.username, "password": custom_action.password, "authorization_header": custom_action.authorization_header, - "forward_whole_payload": custom_action.forward_whole_payload, + "forward_whole_payload": custom_action.forward_all, + "is_webhook_enabled": custom_action.is_webhook_enabled, + "trigger_template": custom_action.trigger_template, + "headers": custom_action.headers, + "http_method": custom_action.http_method, + "trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[custom_action.trigger_type], + "integration_filter": custom_action.integration_filter, } assert response.status_code == status.HTTP_200_OK @@ -389,12 +373,12 @@ def test_update_custom_action( @pytest.mark.django_db def test_delete_custom_action( make_organization_and_user_with_token, - make_custom_action, + make_custom_webhook, ): organization, user, token = make_organization_and_user_with_token() client = APIClient() - custom_action = make_custom_action(organization=organization) + custom_action = make_custom_webhook(organization=organization) url = reverse("api-public:actions-detail", kwargs={"pk": custom_action.public_primary_key}) assert custom_action.deleted_at is None diff --git a/engine/apps/public_api/tests/test_webhooks.py b/engine/apps/public_api/tests/test_webhooks.py new file mode 100644 index 00000000..0e6feb3f --- /dev/null +++ b/engine/apps/public_api/tests/test_webhooks.py @@ -0,0 +1,320 @@ +import json + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from apps.webhooks.models import Webhook + + +def _get_expected_result(webhook): + return { + "id": webhook.public_primary_key, + "name": webhook.name, + "team": webhook.team, + "url": webhook.url, + "data": webhook.data, + "username": webhook.username, + "password": webhook.password, + "authorization_header": webhook.authorization_header, + "forward_all": webhook.forward_all, + "is_webhook_enabled": webhook.is_webhook_enabled, + "trigger_template": webhook.trigger_template, + "headers": webhook.headers, + "http_method": webhook.http_method, + "trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[webhook.trigger_type], + "integration_filter": webhook.integration_filter, + } + + +@pytest.mark.django_db +def test_get_webhooks(make_organization_and_user_with_token, make_custom_webhook): + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + webhook = make_custom_webhook(organization=organization) + + url = reverse("api-public:webhooks-list") + + response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}") + + expected_payload = { + "count": 1, + "next": None, + "previous": None, + "results": [_get_expected_result(webhook)], + "current_page_number": 1, + "page_size": 50, + "total_pages": 1, + } + + assert response.status_code == status.HTTP_200_OK + assert response.data == expected_payload + + +@pytest.mark.django_db +def test_get_webhooks_filter_by_name( + make_organization_and_user_with_token, + make_custom_webhook, +): + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + webhook = make_custom_webhook(organization=organization) + make_custom_webhook(organization=organization) + url = reverse("api-public:webhooks-list") + + response = client.get(f"{url}?name={webhook.name}", format="json", HTTP_AUTHORIZATION=f"{token}") + + expected_payload = { + "count": 1, + "next": None, + "previous": None, + "results": [_get_expected_result(webhook)], + "current_page_number": 1, + "page_size": 50, + "total_pages": 1, + } + + assert response.status_code == status.HTTP_200_OK + assert response.data == expected_payload + + +@pytest.mark.django_db +def test_get_webhooks_filter_by_name_empty_result( + make_organization_and_user_with_token, + make_custom_webhook, +): + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + make_custom_webhook(organization=organization) + + url = reverse("api-public:webhooks-list") + + response = client.get(f"{url}?name=NonExistentName", format="json", HTTP_AUTHORIZATION=f"{token}") + + expected_payload = { + "count": 0, + "next": None, + "previous": None, + "results": [], + "current_page_number": 1, + "page_size": 50, + "total_pages": 1, + } + + assert response.status_code == status.HTTP_200_OK + assert response.data == expected_payload + + +@pytest.mark.django_db +def test_get_webhook( + make_organization_and_user_with_token, + make_custom_webhook, +): + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + webhook = make_custom_webhook(organization=organization) + + url = reverse("api-public:webhooks-detail", kwargs={"pk": webhook.public_primary_key}) + + response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}") + + expected_payload = _get_expected_result(webhook) + + assert response.status_code == status.HTTP_200_OK + assert response.data == expected_payload + + +@pytest.mark.django_db +def test_create_webhook(make_organization_and_user_with_token): + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + url = reverse("api-public:webhooks-list") + + data = {} + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + assert response.status_code == status.HTTP_400_BAD_REQUEST + data["name"] = "Test outgoing webhook" + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + assert response.status_code == status.HTTP_400_BAD_REQUEST + data["url"] = "https://example.com" + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + assert response.status_code == status.HTTP_400_BAD_REQUEST + data["trigger_type"] = "escalation" + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + assert response.status_code == status.HTTP_400_BAD_REQUEST + data["http_method"] = "POST" + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + assert response.status_code == status.HTTP_201_CREATED + webhook = Webhook.objects.get(public_primary_key=response.data["id"]) + + expected_result = _get_expected_result(webhook) + + assert response.data == expected_result + + +@pytest.mark.django_db +def test_create_webhook_nested_data(make_organization_and_user_with_token): + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + url = reverse("api-public:webhooks-list") + + data = { + "name": "Test outgoing webhook with nested data", + "url": "https://example.com", + "data": '{"nested_item": "{{ alert_payload.foo.bar | to_json }}"}', + "http_method": "POST", + "trigger_type": "acknowledge", + } + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + assert response.status_code == status.HTTP_400_BAD_REQUEST + data["data"] = '{"nested_item": "{{ alert_payload.foo.bar | tojson() }}"}' + + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + webhook = Webhook.objects.get(public_primary_key=response.data["id"]) + + expected_result = _get_expected_result(webhook) + + assert response.status_code == status.HTTP_201_CREATED + assert response.json() == expected_result + + +@pytest.mark.django_db +def test_update_webhook( + make_organization_and_user_with_token, + make_custom_webhook, +): + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + webhook = make_custom_webhook(organization=organization) + url = reverse("api-public:webhooks-detail", kwargs={"pk": webhook.public_primary_key}) + data = { + "name": "RENAMED", + } + assert webhook.name != data["name"] + + response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + + expected_result = _get_expected_result(webhook) + expected_result["name"] = data["name"] + + assert response.status_code == status.HTTP_200_OK + webhook.refresh_from_db() + assert webhook.name == expected_result["name"] + assert response.data == expected_result + + +@pytest.mark.django_db +def test_delete_webhook( + make_organization_and_user_with_token, + make_custom_webhook, +): + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + webhook = make_custom_webhook(organization=organization) + url = reverse("api-public:webhooks-detail", kwargs={"pk": webhook.public_primary_key}) + + assert webhook.deleted_at is None + + response = client.delete(url, format="json", HTTP_AUTHORIZATION=f"{token}") + assert response.status_code == status.HTTP_204_NO_CONTENT + + webhook.refresh_from_db() + assert webhook.deleted_at is not None + + response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}") + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.data["detail"] == "Not found." + + +@pytest.mark.django_db +def test_get_webhook_responses( + make_organization_and_user_with_token, + make_custom_webhook, + make_webhook_response, +): + organization, user, token = make_organization_and_user_with_token() + client = APIClient() + + webhook = make_custom_webhook(organization=organization) + webhook.refresh_from_db() + + response_count = 20 + for i in range(0, response_count): + make_webhook_response( + webhook=webhook, + trigger_type=webhook.trigger_type, + status_code=200, + content=json.dumps({"id": "third-party-id"}), + event_data=json.dumps({"test": "abc"}), + ) + + url = reverse("api-public:webhooks-responses", kwargs={"pk": webhook.public_primary_key}) + response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}") + webhook_response = response.data["results"][0] + assert webhook_response["status_code"] == 200 + assert webhook_response["content"] == '{"id": "third-party-id"}' + assert webhook_response["event_data"] == '{"test": "abc"}' + assert response.data["count"] == 20 + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_webhook_validate_integration_filters( + make_organization_and_user_with_token, + make_custom_webhook, + make_alert_receive_channel, +): + organization, user, token = make_organization_and_user_with_token() + alert_receive_channel = make_alert_receive_channel(organization) + webhook = make_custom_webhook(organization=organization) + url = reverse("api-public:webhooks-detail", kwargs={"pk": webhook.public_primary_key}) + data = {"integration_filter": alert_receive_channel.public_primary_key} + + client = APIClient() + response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + assert response.status_code == 400 + + data["integration_filter"] = ["abc"] + response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + assert response.status_code == 400 + + data["integration_filter"] = [alert_receive_channel.public_primary_key, alert_receive_channel.public_primary_key] + response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + assert response.status_code == 400 + + data["integration_filter"] = [alert_receive_channel.public_primary_key] + response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + webhook.refresh_from_db() + assert response.status_code == 200 + assert response.data["integration_filter"] == data["integration_filter"] + assert webhook.integration_filter == data["integration_filter"] + + data["integration_filter"] = [] + response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + webhook.refresh_from_db() + assert response.status_code == 200 + assert response.data["integration_filter"] == data["integration_filter"] + assert webhook.integration_filter == data["integration_filter"] + + data["integration_filter"] = None + response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + webhook.refresh_from_db() + assert response.status_code == 200 + assert response.data["integration_filter"] == data["integration_filter"] + assert webhook.integration_filter == data["integration_filter"] diff --git a/engine/apps/public_api/urls.py b/engine/apps/public_api/urls.py index c12369b9..873b0e10 100644 --- a/engine/apps/public_api/urls.py +++ b/engine/apps/public_api/urls.py @@ -26,6 +26,7 @@ router.register(r"user_groups", views.UserGroupView, basename="user_groups") router.register(r"on_call_shifts", views.CustomOnCallShiftView, basename="on_call_shifts") router.register(r"teams", views.TeamView, basename="teams") router.register(r"shift_swaps", views.ShiftSwapViewSet, basename="shift_swap") +router.register(r"webhooks", views.WebhooksView, basename="webhooks") urlpatterns = [ diff --git a/engine/apps/public_api/views/__init__.py b/engine/apps/public_api/views/__init__.py index 4a8af434..47fad290 100644 --- a/engine/apps/public_api/views/__init__.py +++ b/engine/apps/public_api/views/__init__.py @@ -17,3 +17,4 @@ from .slack_channels import SlackChannelView # noqa: F401 from .teams import TeamView # noqa: F401 from .user_groups import UserGroupView # noqa: F401 from .users import UserView # noqa: F401 +from .webhooks import WebhooksView # noqa: F401 diff --git a/engine/apps/public_api/views/action.py b/engine/apps/public_api/views/action.py index eb8af372..f8ff0f23 100644 --- a/engine/apps/public_api/views/action.py +++ b/engine/apps/public_api/views/action.py @@ -2,10 +2,11 @@ from django_filters import rest_framework as filters from rest_framework.permissions import IsAuthenticated from rest_framework.viewsets import ModelViewSet -from apps.alerts.models import CustomButton +from apps.api.serializers.webhook import WebhookSerializer from apps.auth_token.auth import ApiTokenAuthentication from apps.public_api.serializers.action import ActionCreateSerializer, ActionUpdateSerializer from apps.public_api.throttlers.user_throttle import UserThrottle +from apps.webhooks.models import Webhook from common.api_helpers.filters import ByTeamFilter from common.api_helpers.mixins import PublicPrimaryKeyMixin, RateLimitHeadersMixin, UpdateSerializerMixin from common.api_helpers.paginators import FiftyPageSizePaginator @@ -18,7 +19,7 @@ class ActionView(RateLimitHeadersMixin, PublicPrimaryKeyMixin, UpdateSerializerM pagination_class = FiftyPageSizePaginator throttle_classes = [UserThrottle] - model = CustomButton + model = WebhookSerializer serializer_class = ActionCreateSerializer update_serializer_class = ActionUpdateSerializer @@ -27,7 +28,7 @@ class ActionView(RateLimitHeadersMixin, PublicPrimaryKeyMixin, UpdateSerializerM def get_queryset(self): action_name = self.request.query_params.get("name", None) - queryset = CustomButton.objects.filter(organization=self.request.auth.organization) + queryset = Webhook.objects.filter(organization=self.request.auth.organization) if action_name: queryset = queryset.filter(name=action_name) diff --git a/engine/apps/public_api/views/webhooks.py b/engine/apps/public_api/views/webhooks.py new file mode 100644 index 00000000..b6a43c13 --- /dev/null +++ b/engine/apps/public_api/views/webhooks.py @@ -0,0 +1,94 @@ +from django_filters import rest_framework as filters +from rest_framework.decorators import action +from rest_framework.exceptions import NotFound +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from apps.auth_token.auth import ApiTokenAuthentication +from apps.public_api.serializers.webhooks import ( + WebhookCreateSerializer, + WebhookResponseSerializer, + WebhookUpdateSerializer, +) +from apps.public_api.throttlers import UserThrottle +from apps.webhooks.models import Webhook, WebhookResponse +from common.api_helpers.filters import ByTeamFilter +from common.api_helpers.mixins import RateLimitHeadersMixin, UpdateSerializerMixin +from common.api_helpers.paginators import FiftyPageSizePaginator +from common.insight_log import EntityEvent, write_resource_insight_log + + +class WebhooksView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet): + authentication_classes = (ApiTokenAuthentication,) + permission_classes = (IsAuthenticated,) + pagination_class = FiftyPageSizePaginator + throttle_classes = [UserThrottle] + + model = Webhook + serializer_class = WebhookCreateSerializer + update_serializer_class = WebhookUpdateSerializer + + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = ByTeamFilter + + def get_queryset(self): + webhook_name = self.request.query_params.get("name", None) + queryset = Webhook.objects.filter(organization=self.request.auth.organization) + + if webhook_name: + queryset = queryset.filter(name=webhook_name) + + return queryset.order_by("id") + + def get_object(self): + public_primary_key = self.kwargs["pk"] + + try: + return Webhook.objects.filter(organization=self.request.auth.organization).get( + public_primary_key=public_primary_key + ) + except Webhook.DoesNotExist: + raise NotFound + + def perform_create(self, serializer): + serializer.save() + write_resource_insight_log( + instance=serializer.instance, + author=self.request.user, + event=EntityEvent.CREATED, + ) + + def perform_update(self, serializer): + prev_state = serializer.instance.insight_logs_serialized + serializer.save() + new_state = serializer.instance.insight_logs_serialized + write_resource_insight_log( + instance=serializer.instance, + author=self.request.user, + event=EntityEvent.UPDATED, + prev_state=prev_state, + new_state=new_state, + ) + + def perform_destroy(self, instance): + write_resource_insight_log( + instance=instance, + author=self.request.user, + event=EntityEvent.DELETED, + ) + instance.delete() + + @action(methods=["get"], detail=True) + def responses(self, request, pk): + webhook = self.get_object() + queryset = WebhookResponse.objects.filter(webhook_id=webhook.id, trigger_type=webhook.trigger_type).order_by( + "-timestamp" + ) + page = self.paginate_queryset(queryset) + if page is not None: + response_serializer = WebhookResponseSerializer(page, many=True) + return self.get_paginated_response(response_serializer.data) + + response_serializer = WebhookResponseSerializer(queryset, many=True) + return Response(response_serializer.data) diff --git a/engine/apps/webhooks/migrations/0010_alter_webhook_trigger_type.py b/engine/apps/webhooks/migrations/0010_alter_webhook_trigger_type.py new file mode 100644 index 00000000..d135aeea --- /dev/null +++ b/engine/apps/webhooks/migrations/0010_alter_webhook_trigger_type.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.20 on 2023-08-14 22:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('webhooks', '0009_alter_webhook_authorization_header'), + ] + + operations = [ + migrations.AlterField( + model_name='webhook', + name='trigger_type', + field=models.IntegerField(choices=[(0, 'Escalation step'), (1, 'Alert Group Created'), (2, 'Acknowledged'), (3, 'Resolved'), (4, 'Silenced'), (5, 'Unsilenced'), (6, 'Unresolved'), (7, 'Unacknowledged')], default=0, null=True), + ), + ] diff --git a/engine/apps/webhooks/models/webhook.py b/engine/apps/webhooks/models/webhook.py index c9d0360d..fb8956af 100644 --- a/engine/apps/webhooks/models/webhook.py +++ b/engine/apps/webhooks/models/webhook.py @@ -1,8 +1,10 @@ import json +import logging import typing from json import JSONDecodeError import requests +from celery.utils.log import get_task_logger from django.conf import settings from django.core.validators import MinLengthValidator from django.db import models @@ -30,6 +32,10 @@ if typing.TYPE_CHECKING: from apps.alerts.models import EscalationPolicy WEBHOOK_FIELD_PLACEHOLDER = "****************" +PUBLIC_WEBHOOK_HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "OPTIONS"] + +logger = get_task_logger(__name__) +logger.setLevel(logging.DEBUG) def generate_public_primary_key_for_webhook(): @@ -88,6 +94,19 @@ class Webhook(models.Model): (TRIGGER_UNACKNOWLEDGE, "Unacknowledged"), ) + PUBLIC_TRIGGER_TYPES_MAP = { + TRIGGER_ESCALATION_STEP: "escalation", + TRIGGER_ALERT_GROUP_CREATED: "alert group created", + TRIGGER_ACKNOWLEDGE: "acknowledge", + TRIGGER_RESOLVE: "resolve", + TRIGGER_SILENCE: "silence", + TRIGGER_UNSILENCE: "unsilence", + TRIGGER_UNRESOLVE: "unresolve", + TRIGGER_UNACKNOWLEDGE: "unacknowledge", + } + + PUBLIC_ALL_TRIGGER_TYPES = [i for i in PUBLIC_TRIGGER_TYPES_MAP.values()] + public_primary_key = models.CharField( max_length=20, validators=[MinLengthValidator(settings.PUBLIC_PRIMARY_KEY_MIN_LENGTH + 1)], @@ -119,7 +138,7 @@ class Webhook(models.Model): data = models.TextField(null=True, default=None) forward_all = models.BooleanField(default=True) http_method = models.CharField(max_length=32, default="POST") - trigger_type = models.IntegerField(choices=TRIGGER_TYPES, default=None, null=True) + trigger_type = models.IntegerField(choices=TRIGGER_TYPES, default=TRIGGER_ESCALATION_STEP, null=True) is_webhook_enabled = models.BooleanField(null=True, default=True) integration_filter = models.JSONField(default=None, null=True, blank=True) is_legacy = models.BooleanField(null=True, default=False)