diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d7acb05..b98def39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## v1.3.62 (2023-11-28) + +### Added + +- Add ability to use Grafana Service Account Tokens for OnCall API (This is only enabled for resolution_notes +endpoint currently) @mderynck ([#3189](https://github.com/grafana/oncall/pull/3189)) +- Add ability for webhook presets to mask sensitive headers @mderynck +([#3189](https://github.com/grafana/oncall/pull/3189)) + +### Fixed + +- Fixed issue that blocked saving webhooks with presets if the preset is controlling the URL @mderynck +([#3189](https://github.com/grafana/oncall/pull/3189)) +- User filter doesn't display current value on Alert Groups page ([1714](https://github.com/grafana/oncall/issues/1714)) +- Remove displaying rotation modal for Terraform/API based schedules +- Filters polishing ([3183](https://github.com/grafana/oncall/issues/3183)) +- Fixed permissions so User settings reader role included list users @mderynck ([#3419](https://github.com/grafana/oncall/pull/3419)) +- Fixed alert group rendering when some links were broken because of replacing `-` to `_` @Ferril ([#3424](https://github.com/grafana/oncall/pull/3424)) + ## v1.3.62 (2023-11-21) ### Added diff --git a/Tiltfile b/Tiltfile index 5a293c92..eb71dad6 100644 --- a/Tiltfile +++ b/Tiltfile @@ -58,7 +58,7 @@ local_resource( allow_parallel=True, ) -yaml = helm("helm/oncall", name=HELM_PREFIX, values=["./dev/helm-local.yml"]) +yaml = helm("helm/oncall", name=HELM_PREFIX, values=["./dev/helm-local.yml", "./dev/helm-local.dev.yml"]) k8s_yaml(yaml) diff --git a/docs/sources/integrations/inbound-email/index.md b/docs/sources/integrations/inbound-email/index.md index ef6add71..f5c78036 100644 --- a/docs/sources/integrations/inbound-email/index.md +++ b/docs/sources/integrations/inbound-email/index.md @@ -16,6 +16,10 @@ weight: 500 Inbound Email integration will consume emails from dedicated email address and make alert groups from them. +## Configure required environment variables + +See [Inbound Email Setup]({{< relref "../../open-source/_index.md#inbound-email-setup" >}}) for details. + ## Configure Inbound Email integration for Grafana OnCall You must have an Admin role to create integrations in Grafana OnCall. diff --git a/docs/sources/open-source/_index.md b/docs/sources/open-source/_index.md index ae0ca4bb..6c9487b6 100644 --- a/docs/sources/open-source/_index.md +++ b/docs/sources/open-source/_index.md @@ -265,7 +265,9 @@ To configure Inbound Email integration for Grafana OnCall OSS populate env varia - `INBOUND_EMAIL_DOMAIN` - Inbound email domain - `INBOUND_EMAIL_WEBHOOK_SECRET` - Inbound email webhook secret -You will also need to configure your ESP to forward messages to the following URL: `/integrations/v1/inbound_email_webhook`. +Required secret syntax: `part1ofsecret:part2ofsecret` (The colon `:` is a mandatory delimiter separating both parts of your secret.) + +You will also need to configure your ESP to forward messages to the following URL: `scheme://@/integrations/v1/inbound_email_webhook`. ## Limits diff --git a/engine/apps/alerts/migrations/0040_alertreceivechannel_alert_group_labels_custom_and_more.py b/engine/apps/alerts/migrations/0040_alertreceivechannel_alert_group_labels_custom_and_more.py new file mode 100644 index 00000000..d35f9626 --- /dev/null +++ b/engine/apps/alerts/migrations/0040_alertreceivechannel_alert_group_labels_custom_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.7 on 2023-11-27 17:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('alerts', '0039_remove_alertreceivechannel_unique_integration_name'), + ] + + operations = [ + migrations.AddField( + model_name='alertreceivechannel', + name='alert_group_labels_custom', + field=models.JSONField(default=None, null=True), + ), + migrations.AddField( + model_name='alertreceivechannel', + name='alert_group_labels_template', + field=models.TextField(default=None, null=True), + ), + ] diff --git a/engine/apps/alerts/models/alert.py b/engine/apps/alerts/models/alert.py index afb4a9e4..203f208a 100644 --- a/engine/apps/alerts/models/alert.py +++ b/engine/apps/alerts/models/alert.py @@ -12,7 +12,7 @@ from django.db.models import JSONField from apps.alerts import tasks from apps.alerts.constants import TASK_DELAY_SECONDS from apps.alerts.incident_appearance.templaters import TemplateLoader -from apps.labels.utils import assign_labels +from apps.labels.alert_group_labels import assign_labels from common.jinja_templater import apply_jinja_template from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length @@ -108,7 +108,7 @@ class Alert(models.Model): ) if group_created: - assign_labels(group, alert_receive_channel) + assign_labels(group, alert_receive_channel, raw_request_data) group.log_records.create(type=AlertGroupLogRecord.TYPE_REGISTERED) group.log_records.create(type=AlertGroupLogRecord.TYPE_ROUTE_ASSIGNED) @@ -144,7 +144,9 @@ class Alert(models.Model): if settings.DEBUG: tasks.distribute_alert(alert.pk) else: - tasks.distribute_alert.apply_async((alert.pk,), countdown=TASK_DELAY_SECONDS) + transaction.on_commit( + partial(tasks.distribute_alert.apply_async, (alert.pk,), countdown=TASK_DELAY_SECONDS) + ) if group_created: # all code below related to maintenance mode diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index 6f6aec12..cbdb587a 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -43,6 +43,7 @@ if typing.TYPE_CHECKING: ResolutionNoteSlackMessage, ) from apps.base.models import UserNotificationPolicyLogRecord + from apps.labels.models import AlertGroupAssociatedLabel from apps.slack.models import SlackMessage logger = logging.getLogger(__name__) @@ -194,6 +195,7 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. slack_log_message: typing.Optional["SlackMessage"] slack_messages: "RelatedManager['SlackMessage']" users: "RelatedManager['User']" + labels: "RelatedManager['AlertGroupAssociatedLabel']" objects: models.Manager["AlertGroup"] = AlertGroupQuerySet.as_manager() diff --git a/engine/apps/alerts/models/alert_receive_channel.py b/engine/apps/alerts/models/alert_receive_channel.py index fb465193..5ecc6a85 100644 --- a/engine/apps/alerts/models/alert_receive_channel.py +++ b/engine/apps/alerts/models/alert_receive_channel.py @@ -42,6 +42,7 @@ if typing.TYPE_CHECKING: from django.db.models.manager import RelatedManager from apps.alerts.models import AlertGroup, ChannelFilter + from apps.labels.models import AlertReceiveChannelAssociatedLabel from apps.user_management.models import Organization, Team logger = logging.getLogger(__name__) @@ -87,10 +88,6 @@ def number_to_smiles_translator(number): return "".join(reversed(smileset)) -class IntegrationAlertGroupLabels(typing.TypedDict): - inheritable: typing.Dict[str, bool] - - class AlertReceiveChannelQueryset(models.QuerySet): def delete(self): self.update(deleted_at=timezone.now()) @@ -123,6 +120,7 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject): channel_filters: "RelatedManager['ChannelFilter']" organization: "Organization" team: typing.Optional["Team"] + labels: "RelatedManager['AlertReceiveChannelAssociatedLabel']" objects = AlertReceiveChannelManager() objects_with_maintenance = AlertReceiveChannelManagerWithMaintenance() @@ -206,6 +204,17 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject): rate_limited_in_slack_at = models.DateTimeField(null=True, default=None) rate_limit_message_task_id = models.CharField(max_length=100, null=True, default=None) + AlertGroupCustomLabels = list[tuple[str, str | None, str | None]] | None + alert_group_labels_custom: AlertGroupCustomLabels = models.JSONField(null=True, default=None) + """ + Stores "custom labels" for alert group labels. Custom labels can be either "plain" or "templated". + For plain labels, the format is: [, , None] + For templated labels, the format is: [, None, ] + """ + + alert_group_labels_template: str | None = models.TextField(null=True, default=None) + """Stores a Jinja2 template for "advanced label templating" for alert group labels.""" + def __str__(self): short_name_with_emojis = emojize(self.short_name, language="alias") return f"{self.pk}: {short_name_with_emojis}" @@ -635,21 +644,6 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject): result["team"] = "General" return result - @property - def alert_group_labels(self) -> IntegrationAlertGroupLabels: - """ - Alert group labels configuration for the integration used by AlertReceiveChannelSerializer. - See AlertReceiveChannelAssociatedLabel.inheritable for more details. - """ - return {"inheritable": {label.key_id: label.inheritable for label in self.labels.all()}} - - @alert_group_labels.setter - def alert_group_labels(self, value: IntegrationAlertGroupLabels) -> None: - """Setter for alert_group_labels used by AlertReceiveChannelSerializer""" - inheritable_key_ids = [key_id for key_id, inheritable in value["inheritable"].items() if inheritable] - self.labels.filter(key_id__in=inheritable_key_ids).update(inheritable=True) - self.labels.filter(~Q(key_id__in=inheritable_key_ids)).update(inheritable=False) - @receiver(post_save, sender=AlertReceiveChannel) def listen_for_alertreceivechannel_model_save( diff --git a/engine/apps/alerts/tests/test_alert.py b/engine/apps/alerts/tests/test_alert.py index 77926a44..1e33abe4 100644 --- a/engine/apps/alerts/tests/test_alert.py +++ b/engine/apps/alerts/tests/test_alert.py @@ -7,22 +7,32 @@ from apps.alerts.tasks import distribute_alert, escalate_alert_group @pytest.mark.django_db -def test_alert_create_default_channel_filter(make_organization, make_alert_receive_channel, make_channel_filter): +@patch("apps.alerts.tasks.distribute_alert.distribute_alert.apply_async", return_value=None) +def test_alert_create_default_channel_filter( + mocked_distribute_alert_task, + make_organization, + make_alert_receive_channel, + make_channel_filter, + django_capture_on_commit_callbacks, +): organization = make_organization() alert_receive_channel = make_alert_receive_channel(organization) channel_filter = make_channel_filter(alert_receive_channel, is_default=True) - alert = Alert.create( - title="the title", - message="the message", - alert_receive_channel=alert_receive_channel, - raw_request_data={}, - integration_unique_data={}, - image_url=None, - link_to_upstream_details=None, - ) + with django_capture_on_commit_callbacks(execute=True) as callbacks: + alert = Alert.create( + title="the title", + message="the message", + alert_receive_channel=alert_receive_channel, + raw_request_data={}, + integration_unique_data={}, + image_url=None, + link_to_upstream_details=None, + ) assert alert.group.channel_filter == channel_filter + assert len(callbacks) == 1 + mocked_distribute_alert_task.assert_called_once_with((alert.pk,), countdown=1) @pytest.mark.django_db diff --git a/engine/apps/api/label_filtering.py b/engine/apps/api/label_filtering.py new file mode 100644 index 00000000..10173c4f --- /dev/null +++ b/engine/apps/api/label_filtering.py @@ -0,0 +1,15 @@ +from typing import List, Tuple + + +def parse_label_query(label_query: List[str]) -> List[Tuple[str, str]]: + """ + parse_label_query returns list of key-value tuples from a list of "raw" labels – key-value pairs separated with ':'. + """ + kv_pairs = [] + for label in label_query: + label_data = label.split(":") + # Check if label_data is a valid key-value label pair]: ["key1", "value1"] + if len(label_data) != 2: + continue + kv_pairs.append((label_data[0], label_data[1])) + return kv_pairs diff --git a/engine/apps/api/serializers/alert_group.py b/engine/apps/api/serializers/alert_group.py index 1205429e..140276d6 100644 --- a/engine/apps/api/serializers/alert_group.py +++ b/engine/apps/api/serializers/alert_group.py @@ -125,6 +125,7 @@ class AlertGroupListSerializer( PREFETCH_RELATED = [ "dependent_alert_groups", "log_records__author", + "labels", ] SELECT_RELATED = [ diff --git a/engine/apps/api/serializers/alert_receive_channel.py b/engine/apps/api/serializers/alert_receive_channel.py index ad9ddaf5..0aae34b6 100644 --- a/engine/apps/api/serializers/alert_receive_channel.py +++ b/engine/apps/api/serializers/alert_receive_channel.py @@ -4,6 +4,7 @@ from collections import OrderedDict from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError as DjangoValidationError +from django.db.models import Q from jinja2 import TemplateSyntaxError from rest_framework import serializers from rest_framework.exceptions import ValidationError @@ -14,30 +15,191 @@ from apps.alerts.models import AlertReceiveChannel from apps.alerts.models.channel_filter import ChannelFilter from apps.base.messaging import get_messaging_backends from apps.integrations.legacy_prefix import has_legacy_prefix +from apps.labels.models import LabelKeyCache, LabelValueCache +from apps.user_management.models import Organization from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField from common.api_helpers.exceptions import BadRequest from common.api_helpers.mixins import APPEARANCE_TEMPLATE_NAMES, EagerLoadingMixin from common.api_helpers.utils import CurrentTeamDefault -from common.jinja_templater import apply_jinja_template, jinja_template_env -from common.jinja_templater.apply_jinja_template import JinjaTemplateWarning +from common.jinja_templater import jinja_template_env from .integration_heartbeat import IntegrationHeartBeatSerializer from .labels import LabelsSerializerMixin -def valid_jinja_template_for_serializer_method_field(template): - for _, val in template.items(): - try: - apply_jinja_template(val, payload={}) - except JinjaTemplateWarning: - # Suppress warnings, template may be valid with payload - pass +class AlertGroupCustomLabelKey(typing.TypedDict): + id: str + name: str + + +class AlertGroupCustomLabelValue(typing.TypedDict): + id: str | None # None for templated labels, label value ID for plain labels + name: str # Jinja template for templated labels, label value name for plain labels + + +class AlertGroupCustomLabel(typing.TypedDict): + key: AlertGroupCustomLabelKey + value: AlertGroupCustomLabelValue + + +AlertGroupCustomLabels = list[AlertGroupCustomLabel] + + +class IntegrationAlertGroupLabels(typing.TypedDict): + inheritable: dict[str, bool] + custom: AlertGroupCustomLabels + template: str | None + + +class CustomLabelSerializer(serializers.Serializer): + """This serializer is consistent with apps.api.serializers.labels.LabelSerializer, but allows null for value ID.""" + + class KeySerializer(serializers.Serializer): + id = serializers.CharField() + name = serializers.CharField() + + class ValueSerializer(serializers.Serializer): + # ID is null for templated labels. For such labels, the "name" value is a Jinja2 template. + id = serializers.CharField(allow_null=True) + name = serializers.CharField() + + key = KeySerializer() + value = ValueSerializer() class IntegrationAlertGroupLabelsSerializer(serializers.Serializer): """Alert group labels configuration for the integration. See AlertReceiveChannel.alert_group_labels for details.""" inheritable = serializers.DictField(child=serializers.BooleanField()) + custom = CustomLabelSerializer(many=True) + template = serializers.CharField(allow_null=True) + + @staticmethod + def pop_alert_group_labels(validated_data: dict) -> IntegrationAlertGroupLabels | None: + """Get alert group labels from validated data.""" + + # the "alert_group_labels" field is optional, so either all 3 fields are present or none + if "inheritable" not in validated_data: + return None + + return { + "inheritable": validated_data.pop("inheritable"), + "custom": validated_data.pop("custom"), + "template": validated_data.pop("template"), + } + + @classmethod + def update( + cls, instance: AlertReceiveChannel, alert_group_labels: IntegrationAlertGroupLabels | None + ) -> AlertReceiveChannel: + if alert_group_labels is None: + return instance + + # update inheritable labels + inheritable_key_ids = [ + key_id for key_id, inheritable in alert_group_labels["inheritable"].items() if inheritable + ] + instance.labels.filter(key_id__in=inheritable_key_ids).update(inheritable=True) + instance.labels.filter(~Q(key_id__in=inheritable_key_ids)).update(inheritable=False) + + # update DB cache for custom labels + cls._create_custom_labels(instance.organization, alert_group_labels["custom"]) + # update custom labels + instance.alert_group_labels_custom = cls._custom_labels_to_internal_value(alert_group_labels["custom"]) + + # update template + instance.alert_group_labels_template = alert_group_labels["template"] + + instance.save(update_fields=["alert_group_labels_custom", "alert_group_labels_template"]) + return instance + + @staticmethod + def _create_custom_labels(organization: Organization, labels: AlertGroupCustomLabels) -> None: + """Create LabelKeyCache and LabelValueCache objects for custom labels.""" + + label_keys = [ + LabelKeyCache(id=label["key"]["id"], name=label["key"]["name"], organization=organization) + for label in labels + ] + + label_values = [ + LabelValueCache(id=label["value"]["id"], name=label["value"]["name"], key_id=label["key"]["id"]) + for label in labels + if label["value"]["id"] # don't create LabelValueCache objects for templated labels + ] + + LabelKeyCache.objects.bulk_create(label_keys, ignore_conflicts=True, batch_size=5000) + LabelValueCache.objects.bulk_create(label_values, ignore_conflicts=True, batch_size=5000) + + @classmethod + def to_representation(cls, instance: AlertReceiveChannel) -> IntegrationAlertGroupLabels: + """ + The API representation of alert group labels is very different from the underlying model. + + "inheritable" is based on AlertReceiveChannelAssociatedLabel.inheritable, a property of another model. + "custom" is based on AlertReceiveChannel.alert_group_labels_custom, a JSONField with a different schema. + "template" is based on AlertReceiveChannel.alert_group_labels_template, this one is straightforward. + """ + + return { + "inheritable": {label.key_id: label.inheritable for label in instance.labels.all()}, + "custom": cls._custom_labels_to_representation(instance.alert_group_labels_custom), + "template": instance.alert_group_labels_template, + } + + @staticmethod + def _custom_labels_to_internal_value( + custom_labels: AlertGroupCustomLabels, + ) -> AlertReceiveChannel.AlertGroupCustomLabels: + """Convert custom labels from API representation to the schema used by the JSONField on the model.""" + + return [ + [label["key"]["id"], label["value"]["id"], None if label["value"]["id"] else label["value"]["name"]] + for label in custom_labels + ] + + @staticmethod + def _custom_labels_to_representation( + custom_labels: AlertReceiveChannel.AlertGroupCustomLabels, + ) -> AlertGroupCustomLabels: + """ + Inverse of the _custom_labels_to_internal_value method above. + Fetches label names from DB cache, so the API response schema is consistent with other label endpoints. + """ + + from apps.labels.models import LabelKeyCache, LabelValueCache + + if custom_labels is None: + return [] + + # get up-to-date label key names + label_key_names = { + k.id: k.name + for k in LabelKeyCache.objects.filter(id__in=[label[0] for label in custom_labels]).only("id", "name") + } + + # get up-to-date label value names + label_value_names = { + v.id: v.name + for v in LabelValueCache.objects.filter(id__in=[label[1] for label in custom_labels if label[1]]).only( + "id", "name" + ) + } + + return [ + { + "key": { + "id": key_id, + "name": label_key_names[key_id], + }, + "value": { + "id": value_id if value_id else None, + "name": label_value_names[value_id] if value_id else typing.cast(str, template), + }, + } + for key_id, value_id, template in custom_labels + if key_id in label_key_names and (value_id in label_value_names or not value_id) + ] class AlertReceiveChannelSerializer( @@ -64,7 +226,7 @@ class AlertReceiveChannelSerializer( connected_escalations_chains_count = serializers.SerializerMethodField() inbound_email = serializers.CharField(required=False) is_legacy = serializers.SerializerMethodField() - alert_group_labels = IntegrationAlertGroupLabelsSerializer(required=False) + alert_group_labels = IntegrationAlertGroupLabelsSerializer(source="*", required=False) # integration heartbeat is in PREFETCH_RELATED not by mistake. # With using of select_related ORM builds strange join @@ -138,8 +300,10 @@ class AlertReceiveChannelSerializer( if _integration.slug == integration: is_able_to_autoresolve = _integration.is_able_to_autoresolve + # pop associated labels and alert group labels, so they are not passed to AlertReceiveChannel.create labels = validated_data.pop("labels", None) - alert_group_labels = validated_data.pop("alert_group_labels", None) + alert_group_labels = IntegrationAlertGroupLabelsSerializer.pop_alert_group_labels(validated_data) + try: instance = AlertReceiveChannel.create( **validated_data, @@ -150,17 +314,22 @@ class AlertReceiveChannelSerializer( except AlertReceiveChannel.DuplicateDirectPagingError: raise BadRequest(detail=AlertReceiveChannel.DuplicateDirectPagingError.DETAIL) - # Create label associations first, then update inheritable labels + # Create label associations first, then update alert group labels self.update_labels_association_if_needed(labels, instance, organization) - if alert_group_labels: - instance.alert_group_labels = alert_group_labels + instance = IntegrationAlertGroupLabelsSerializer.update(instance, alert_group_labels) return instance def update(self, instance, validated_data): + # update associated labels labels = validated_data.pop("labels", None) - organization = self.context["request"].auth.organization - self.update_labels_association_if_needed(labels, instance, organization) + self.update_labels_association_if_needed(labels, instance, self.context["request"].auth.organization) + + # update alert group labels + instance = IntegrationAlertGroupLabelsSerializer.update( + instance, IntegrationAlertGroupLabelsSerializer.pop_alert_group_labels(validated_data) + ) + try: return super().update(instance, validated_data) except AlertReceiveChannel.DuplicateDirectPagingError: diff --git a/engine/apps/api/serializers/channel_filter.py b/engine/apps/api/serializers/channel_filter.py index b9239d6d..7815a584 100644 --- a/engine/apps/api/serializers/channel_filter.py +++ b/engine/apps/api/serializers/channel_filter.py @@ -3,12 +3,12 @@ import typing from rest_framework import serializers from apps.alerts.models import AlertReceiveChannel, ChannelFilter, EscalationChain -from apps.api.serializers.alert_receive_channel import valid_jinja_template_for_serializer_method_field from apps.base.messaging import get_messaging_backend_from_id from apps.telegram.models import TelegramToOrganizationConnector from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField from common.api_helpers.exceptions import BadRequest from common.api_helpers.mixins import EagerLoadingMixin +from common.api_helpers.utils import valid_jinja_template_for_serializer_method_field from common.jinja_templater.apply_jinja_template import JinjaTemplateError from common.utils import is_regex_valid diff --git a/engine/apps/api/serializers/webhook.py b/engine/apps/api/serializers/webhook.py index 832292ce..dfa53a86 100644 --- a/engine/apps/api/serializers/webhook.py +++ b/engine/apps/api/serializers/webhook.py @@ -3,6 +3,7 @@ from collections import defaultdict from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator +from apps.api.serializers.labels import LabelsSerializerMixin from apps.webhooks.models import Webhook, WebhookResponse from apps.webhooks.models.webhook import PUBLIC_WEBHOOK_HTTP_METHODS, WEBHOOK_FIELD_PLACEHOLDER from apps.webhooks.presets.preset_options import WebhookPresetOptions @@ -27,7 +28,7 @@ class WebhookResponseSerializer(serializers.ModelSerializer): ] -class WebhookSerializer(serializers.ModelSerializer): +class WebhookSerializer(LabelsSerializerMixin, serializers.ModelSerializer): id = serializers.CharField(read_only=True, source="public_primary_key") organization = serializers.HiddenField(default=CurrentOrganizationDefault()) team = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault()) @@ -37,6 +38,8 @@ class WebhookSerializer(serializers.ModelSerializer): trigger_type = serializers.CharField(allow_null=True) trigger_type_name = serializers.SerializerMethodField() + PREFETCH_RELATED = ["labels", "labels__key", "labels__value"] + class Meta: model = Webhook fields = [ @@ -61,10 +64,25 @@ class WebhookSerializer(serializers.ModelSerializer): "last_response_log", "integration_filter", "preset", + "labels", ] validators = [UniqueTogetherValidator(queryset=Webhook.objects.all(), fields=["name", "organization"])] + def create(self, validated_data): + organization = self.context["request"].auth.organization + labels = validated_data.pop("labels", None) + + instance = super().create(validated_data) + self.update_labels_association_if_needed(labels, instance, organization) + return instance + + def update(self, instance, validated_data): + labels = validated_data.pop("labels", None) + organization = self.context["request"].auth.organization + self.update_labels_association_if_needed(labels, instance, organization) + return super().update(instance, validated_data) + def to_representation(self, instance): result = super().to_representation(instance) if instance.password: @@ -164,7 +182,9 @@ class WebhookSerializer(serializers.ModelSerializer): for controlled_field in preset_metadata.controlled_fields: if controlled_field in self.initial_data: if self.instance: - if self.initial_data[controlled_field] != getattr(self.instance, controlled_field): + if self.initial_data[controlled_field] is not None and self.initial_data[ + controlled_field + ] != getattr(self.instance, controlled_field): raise serializers.ValidationError( detail=f"{controlled_field} is controlled by preset, cannot update" ) @@ -176,7 +196,7 @@ class WebhookSerializer(serializers.ModelSerializer): return preset def get_last_response_log(self, obj): - return WebhookResponseSerializer(obj.responses.all().last()).data + return WebhookResponseSerializer(obj.responses.last()).data def get_trigger_type_name(self, obj): trigger_type_name = "" diff --git a/engine/apps/api/tests/test_alert_group.py b/engine/apps/api/tests/test_alert_group.py index 5ff8cc81..d5a1d1d4 100644 --- a/engine/apps/api/tests/test_alert_group.py +++ b/engine/apps/api/tests/test_alert_group.py @@ -863,6 +863,72 @@ def test_get_filter_escalation_chain( assert len(response.data["results"]) == 2 +@pytest.mark.django_db +def test_get_filter_by_teams( + make_organization_and_user_with_plugin_token, + make_team, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_user_auth_headers, +): + client = APIClient() + organization, user, token = make_organization_and_user_with_plugin_token() + team1 = make_team(organization) + team2 = make_team(organization) + + alert_receive_channel_0 = make_alert_receive_channel(organization) + alert_receive_channel_1 = make_alert_receive_channel(organization, team=team1) + alert_receive_channel_2 = make_alert_receive_channel(organization, team=team2) + + alert_group_0 = make_alert_group(alert_receive_channel_0) + make_alert(alert_group=alert_group_0, raw_request_data=alert_raw_request_data) + + alert_group_1 = make_alert_group(alert_receive_channel_1) + make_alert(alert_group=alert_group_1, raw_request_data=alert_raw_request_data) + + alert_group_2 = make_alert_group(alert_receive_channel_2) + make_alert(alert_group=alert_group_2, raw_request_data=alert_raw_request_data) + + url = reverse("api-internal:alertgroup-list") + + # check no team is given + response = client.get(url, **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 3 + assert {ag["pk"] for ag in response.data["results"]} == { + alert_group_0.public_primary_key, + alert_group_1.public_primary_key, + alert_group_2.public_primary_key, + } + + # check the "No team" case + response = client.get(url + "?team=null", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 1 + assert {ag["pk"] for ag in response.data["results"]} == {alert_group_0.public_primary_key} + + # check the "No team" + other team case + response = client.get(url + f"?team=null&team={team2.public_primary_key}", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 2 + assert {ag["pk"] for ag in response.data["results"]} == { + alert_group_0.public_primary_key, + alert_group_2.public_primary_key, + } + + # check the multiple teams case + response = client.get( + url + f"?team={team1.public_primary_key}&team={team2.public_primary_key}", **make_user_auth_headers(user, token) + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 2 + assert {ag["pk"] for ag in response.data["results"]} == { + alert_group_1.public_primary_key, + alert_group_2.public_primary_key, + } + + @pytest.mark.django_db def test_get_filter_labels( make_organization_and_user_with_plugin_token, diff --git a/engine/apps/api/tests/test_alert_receive_channel.py b/engine/apps/api/tests/test_alert_receive_channel.py index e5c1ece0..4855a87c 100644 --- a/engine/apps/api/tests/test_alert_receive_channel.py +++ b/engine/apps/api/tests/test_alert_receive_channel.py @@ -9,6 +9,7 @@ from rest_framework.test import APIClient from apps.alerts.models import AlertReceiveChannel, EscalationPolicy from apps.api.permissions import LegacyAccessControlRole +from apps.labels.models import LabelKeyCache, LabelValueCache @pytest.fixture() @@ -1310,7 +1311,6 @@ def test_integration_filter_by_labels( def test_update_alert_receive_channel_labels( make_organization_and_user_with_plugin_token, make_alert_receive_channel, - make_integration_label_association, make_user_auth_headers, ): organization, user, token = make_organization_and_user_with_plugin_token() @@ -1353,7 +1353,6 @@ def test_update_alert_receive_channel_labels( def test_update_alert_receive_channel_labels_duplicate_key( make_organization_and_user_with_plugin_token, make_alert_receive_channel, - make_integration_label_association, make_user_auth_headers, ): organization, user, token = make_organization_and_user_with_plugin_token() @@ -1385,23 +1384,49 @@ def test_update_alert_receive_channel_labels_duplicate_key( def test_alert_group_labels_get( make_organization_and_user_with_plugin_token, make_alert_receive_channel, + make_label_key_and_value, make_integration_label_association, make_user_auth_headers, ): organization, user, token = make_organization_and_user_with_plugin_token() alert_receive_channel = make_alert_receive_channel(organization) + label_key, label_value = make_label_key_and_value(organization) + label_key_1, _ = make_label_key_and_value(organization) client = APIClient() url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key}) response = client.get(url, **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_200_OK - assert response.json()["alert_group_labels"] == {"inheritable": {}} + assert response.json()["alert_group_labels"] == {"inheritable": {}, "custom": [], "template": None} label = make_integration_label_association(organization, alert_receive_channel) + + template = "{{ payload.labels | tojson }}" + alert_receive_channel.alert_group_labels_template = template + + alert_receive_channel.alert_group_labels_custom = [ + (label_key.id, label_value.id, None), + (label_key_1.id, None, "{{ payload.foo }}"), + ] + alert_receive_channel.save(update_fields=["alert_group_labels_custom", "alert_group_labels_template"]) + response = client.get(url, **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_200_OK - assert response.json()["alert_group_labels"] == {"inheritable": {label.key_id: True}} + assert response.json()["alert_group_labels"] == { + "inheritable": {label.key_id: True}, + "custom": [ + { + "key": {"id": label_key.id, "name": label_key.name}, + "value": {"id": label_value.id, "name": label_value.name}, + }, + { + "key": {"id": label_key_1.id, "name": label_key_1.name}, + "value": {"id": None, "name": "{{ payload.foo }}"}, + }, + ], + "template": template, + } @pytest.mark.django_db @@ -1415,14 +1440,75 @@ def test_alert_group_labels_put( alert_receive_channel = make_alert_receive_channel(organization) label_1 = make_integration_label_association(organization, alert_receive_channel) label_2 = make_integration_label_association(organization, alert_receive_channel, inheritable=False) + label_3 = make_integration_label_association(organization, alert_receive_channel, inheritable=False) + + custom = [ + # plain label + { + "key": {"id": label_2.key.id, "name": label_2.key.name}, + "value": {"id": label_2.value.id, "name": label_2.value.name}, + }, + # plain label not present in DB cache + { + "key": {"id": "hello", "name": "world"}, + "value": {"id": "foo", "name": "bar"}, + }, + # templated label + { + "key": {"id": label_3.key.id, "name": label_3.key.name}, + "value": {"id": None, "name": "{{ payload.foo }}"}, + }, + ] + template = "{{ payload.labels | tojson }}" # advanced template client = APIClient() url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key}) - data = {"alert_group_labels": {"inheritable": {label_1.key_id: False, label_2.key_id: True}}} + data = { + "alert_group_labels": { + "inheritable": {label_1.key_id: False, label_2.key_id: True, label_3.key_id: False}, + "custom": custom, + "template": template, + } + } response = client.put(url, data, format="json", **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_200_OK - assert response.json()["alert_group_labels"] == {"inheritable": {label_1.key_id: False, label_2.key_id: True}} + assert response.json()["alert_group_labels"] == { + "inheritable": {label_1.key_id: False, label_2.key_id: True, label_3.key_id: False}, + "custom": custom, + "template": template, + } + + alert_receive_channel.refresh_from_db() + assert alert_receive_channel.alert_group_labels_custom == [ + [label_2.key_id, label_2.value_id, None], + ["hello", "foo", None], + [label_3.key_id, None, "{{ payload.foo }}"], + ] + assert alert_receive_channel.alert_group_labels_template == template + + # check label keys & values are created + key = LabelKeyCache.objects.filter(id="hello", name="world", organization=organization).first() + assert key is not None + assert LabelValueCache.objects.filter(key=key, id="foo", name="bar").exists() + + +@pytest.mark.django_db +def test_alert_group_labels_put_none( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + alert_receive_channel = make_alert_receive_channel(organization) + + client = APIClient() + url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key}) + response = client.put(url, {"verbal_name": "123"}, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["verbal_name"] == "123" + assert response.json()["alert_group_labels"] == {"inheritable": {}, "custom": [], "template": None} @pytest.mark.django_db @@ -1430,7 +1516,11 @@ def test_alert_group_labels_post(alert_receive_channel_internal_api_setup, make_ user, token, _ = alert_receive_channel_internal_api_setup labels = [{"key": {"id": "test", "name": "test"}, "value": {"id": "123", "name": "123"}}] - alert_group_labels = {"inheritable": {"test": False}} + alert_group_labels = { + "inheritable": {"test": False}, + "custom": [{"key": {"id": "test", "name": "test"}, "value": {"id": "123", "name": "123"}}], + "template": "{{ payload.labels | tojson }}", + } data = { "integration": AlertReceiveChannel.INTEGRATION_GRAFANA, "team": None, @@ -1445,3 +1535,7 @@ def test_alert_group_labels_post(alert_receive_channel_internal_api_setup, make_ assert response.status_code == status.HTTP_201_CREATED assert response.json()["labels"] == labels assert response.json()["alert_group_labels"] == alert_group_labels + + alert_receive_channel = AlertReceiveChannel.objects.get(public_primary_key=response.json()["id"]) + assert alert_receive_channel.alert_group_labels_custom == [["test", "123", None]] + assert alert_receive_channel.alert_group_labels_template == "{{ payload.labels | tojson }}" diff --git a/engine/apps/api/tests/test_alert_receive_channel_template.py b/engine/apps/api/tests/test_alert_receive_channel_template.py index 111696cd..f494d776 100644 --- a/engine/apps/api/tests/test_alert_receive_channel_template.py +++ b/engine/apps/api/tests/test_alert_receive_channel_template.py @@ -337,6 +337,37 @@ def test_preview_alert_receive_channel_backend_templater( assert response.json() == {"preview": "title: alert!"} +@pytest.mark.django_db +def test_preview_alert_group_labels( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_alert_receive_channel, + make_channel_filter, + make_alert_group, + make_alert, +): + organization, user, token = make_organization_and_user_with_plugin_token() + alert_receive_channel = make_alert_receive_channel(organization) + default_channel_filter = make_channel_filter(alert_receive_channel, is_default=True) + alert_group = make_alert_group(alert_receive_channel, channel_filter=default_channel_filter) + make_alert(alert_group=alert_group, raw_request_data={"labels": {"1": "2"}}) + + client = APIClient() + url = reverse( + "api-internal:alert_receive_channel-preview-template", + kwargs={"pk": alert_receive_channel.public_primary_key}, + ) + + data = { + "template_body": "{{ payload.labels | tojson }}", + "template_name": "alert_group_labels", + } + response = client.post(url, format="json", data=data, **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"preview": '{"1": "2"}'} + + @pytest.mark.django_db def test_update_alert_receive_channel_templates( make_organization_and_user_with_plugin_token, diff --git a/engine/apps/api/tests/test_escalation_chain.py b/engine/apps/api/tests/test_escalation_chain.py index 9d06af88..1bf25489 100644 --- a/engine/apps/api/tests/test_escalation_chain.py +++ b/engine/apps/api/tests/test_escalation_chain.py @@ -5,6 +5,8 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient +from common.api_helpers.filters import NO_TEAM_VALUE + @pytest.fixture() def escalation_chain_internal_api_setup(make_organization_and_user_with_plugin_token, make_escalation_chain): @@ -103,7 +105,7 @@ def test_escalation_chain_copy( escalation_chain = make_escalation_chain(organization, team=team) data = { "name": "escalation_chain_updated", - "team": new_team.public_primary_key if new_team else "null", + "team": new_team.public_primary_key if new_team else NO_TEAM_VALUE, } client = APIClient() @@ -125,6 +127,8 @@ def test_escalation_chain_copy_empty_name( client = APIClient() url = reverse("api-internal:escalation_chain-copy", kwargs={"pk": escalation_chain.public_primary_key}) - response = client.post(url, {"name": "", "team": "null"}, format="json", **make_user_auth_headers(user, token)) + response = client.post( + url, {"name": "", "team": NO_TEAM_VALUE}, format="json", **make_user_auth_headers(user, token) + ) assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/engine/apps/api/tests/test_labels.py b/engine/apps/api/tests/test_labels.py index e47b7218..41e6be3f 100644 --- a/engine/apps/api/tests/test_labels.py +++ b/engine/apps/api/tests/test_labels.py @@ -43,7 +43,6 @@ def test_get_update_key_get( mocked_get_values, make_organization_and_user_with_plugin_token, make_user_auth_headers, - make_alert_receive_channel, ): organization, user, token = make_organization_and_user_with_plugin_token() client = APIClient() @@ -68,7 +67,6 @@ def test_get_update_key_put( mocked_rename_key, make_organization_and_user_with_plugin_token, make_user_auth_headers, - make_alert_receive_channel, ): organization, user, token = make_organization_and_user_with_plugin_token() client = APIClient() @@ -94,7 +92,6 @@ def test_add_value( mocked_add_value, make_organization_and_user_with_plugin_token, make_user_auth_headers, - make_alert_receive_channel, ): organization, user, token = make_organization_and_user_with_plugin_token() client = APIClient() @@ -120,7 +117,6 @@ def test_rename_value( mocked_rename_value, make_organization_and_user_with_plugin_token, make_user_auth_headers, - make_alert_receive_channel, ): organization, user, token = make_organization_and_user_with_plugin_token() client = APIClient() @@ -146,7 +142,6 @@ def test_get_value( mocked_get_value, make_organization_and_user_with_plugin_token, make_user_auth_headers, - make_alert_receive_channel, ): organization, user, token = make_organization_and_user_with_plugin_token() client = APIClient() @@ -171,7 +166,6 @@ def test_labels_create_label( mocked_create_label, make_organization_and_user_with_plugin_token, make_user_auth_headers, - make_alert_receive_channel, ): organization, user, token = make_organization_and_user_with_plugin_token() client = APIClient() @@ -189,7 +183,6 @@ def test_labels_create_label( def test_labels_feature_false( make_organization_and_user_with_plugin_token, make_user_auth_headers, - make_alert_receive_channel, settings, ): setattr(settings, "FEATURE_LABELS_ENABLED_FOR_ALL", False) @@ -239,7 +232,6 @@ def test_labels_feature_false( def test_labels_permissions_get_actions( make_organization_and_user_with_plugin_token, make_user_auth_headers, - make_alert_receive_channel, role, expected_status, ): @@ -274,7 +266,6 @@ def test_labels_permissions_get_actions( def test_labels_permissions_create_update_actions( make_organization_and_user_with_plugin_token, make_user_auth_headers, - make_alert_receive_channel, role, expected_status, ): diff --git a/engine/apps/api/tests/test_team.py b/engine/apps/api/tests/test_team.py index f66b4436..b438ebbd 100644 --- a/engine/apps/api/tests/test_team.py +++ b/engine/apps/api/tests/test_team.py @@ -10,8 +10,9 @@ from apps.alerts.models import AlertReceiveChannel from apps.api.permissions import LegacyAccessControlRole from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar, OnCallScheduleWeb from apps.user_management.models import Team +from common.api_helpers.filters import NO_TEAM_VALUE -GENERAL_TEAM = Team(public_primary_key="null", name="No team", email=None, avatar_url=None) +GENERAL_TEAM = Team(public_primary_key=NO_TEAM_VALUE, name="No team", email=None, avatar_url=None) def get_payload_from_team(team, long=False): @@ -203,7 +204,7 @@ def test_teams_number_of_users_currently_oncall_attribute_works_properly( team1.public_primary_key: 2, team2.public_primary_key: 1, team3.public_primary_key: 0, - "null": 0, # this covers the case of "No team" + NO_TEAM_VALUE: 0, # this covers the case of "No team" } for team in response.json(): diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index bc876c83..68e38a98 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -410,7 +410,7 @@ def test_user_update_other_permissions( [ (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), - (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), (LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN), ], ) @@ -1291,14 +1291,14 @@ def test_viewer_cant_update_himself(make_organization_and_user_with_plugin_token @pytest.mark.django_db -def test_viewer_cant_list_users(make_organization_and_user_with_plugin_token, make_user_auth_headers): +def test_viewer_can_list_users(make_organization_and_user_with_plugin_token, make_user_auth_headers): _, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.VIEWER) client = APIClient() url = reverse("api-internal:user-list") response = client.get(url, format="json", **make_user_auth_headers(user, token)) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_200_OK @pytest.mark.django_db diff --git a/engine/apps/api/tests/test_webhook_presets.py b/engine/apps/api/tests/test_webhook_presets.py index e87f7587..b2ce4df7 100644 --- a/engine/apps/api/tests/test_webhook_presets.py +++ b/engine/apps/api/tests/test_webhook_presets.py @@ -63,6 +63,7 @@ def test_create_webhook_from_preset( "http_method": "GET", "integration_filter": None, "is_webhook_enabled": True, + "labels": [], "is_legacy": False, "last_response_log": { "request_data": "", diff --git a/engine/apps/api/tests/test_webhooks.py b/engine/apps/api/tests/test_webhooks.py index f3162515..4c5bb35f 100644 --- a/engine/apps/api/tests/test_webhooks.py +++ b/engine/apps/api/tests/test_webhooks.py @@ -52,6 +52,7 @@ def test_get_list_webhooks(webhook_internal_api_setup, make_user_auth_headers): "http_method": "POST", "integration_filter": None, "is_webhook_enabled": True, + "labels": [], "is_legacy": False, "last_response_log": { "request_data": "", @@ -95,6 +96,7 @@ def test_get_detail_webhook(webhook_internal_api_setup, make_user_auth_headers): "http_method": "POST", "integration_filter": None, "is_webhook_enabled": True, + "labels": [], "is_legacy": False, "last_response_log": { "request_data": "", @@ -143,6 +145,7 @@ def test_create_webhook(webhook_internal_api_setup, make_user_auth_headers): "http_method": "POST", "integration_filter": None, "is_webhook_enabled": True, + "labels": [], "is_legacy": False, "last_response_log": { "request_data": "", @@ -203,6 +206,7 @@ def test_create_valid_templated_field(webhook_internal_api_setup, make_user_auth "http_method": "POST", "integration_filter": None, "is_webhook_enabled": True, + "labels": [], "is_legacy": False, "last_response_log": { "request_data": "", @@ -583,6 +587,7 @@ def test_webhook_field_masking(webhook_internal_api_setup, make_user_auth_header "http_method": "POST", "integration_filter": None, "is_webhook_enabled": True, + "labels": [], "is_legacy": False, "last_response_log": { "request_data": "", @@ -642,6 +647,7 @@ def test_webhook_copy(webhook_internal_api_setup, make_user_auth_headers): "http_method": "POST", "integration_filter": None, "is_webhook_enabled": True, + "labels": [], "is_legacy": False, "last_response_log": { "request_data": "", @@ -711,3 +717,184 @@ def test_create_invalid_missing_fields(webhook_internal_api_setup, make_user_aut response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json()["trigger_type"][0] == "This field is required." + + +@pytest.mark.django_db +def test_webhook_filter_by_labels( + make_organization_and_user_with_plugin_token, + make_custom_webhook, + make_webhook_label_association, + make_label_key_and_value, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + webhook_with_label = make_custom_webhook(organization) + label = make_webhook_label_association(organization, webhook_with_label) + + webhook_with_another_label = make_custom_webhook(organization) + another_label = make_webhook_label_association(organization, webhook_with_another_label) + + not_attached_key, not_attached_value = make_label_key_and_value(organization) + + client = APIClient() + + # test filter by label, which is attached to only one webhook + url = reverse("api-internal:webhooks-list") + response = client.get( + f"{url}?label={label.key_id}:{label.value_id}", + content_type="application/json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_200_OK + assert len(response.json()) == 1 + assert response.json()[0]["id"] == webhook_with_label.public_primary_key + + url = reverse("api-internal:webhooks-list") + response = client.get( + f"{url}?label={another_label.key_id}:{another_label.value_id}", + content_type="application/json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_200_OK + assert len(response.json()) == 1 + assert response.json()[0]["id"] == webhook_with_another_label.public_primary_key + + # test filter by label which is not attached to any webhooks + response = client.get( + f"{url}?label={not_attached_key.id}:{not_attached_value.id}", + content_type="application/json", + **make_user_auth_headers(user, token), + ) + assert len(response.json()) == 0 + + +@pytest.mark.django_db +def test_update_webhook_labels( + webhook_internal_api_setup, + make_user_auth_headers, +): + user, token, webhook = webhook_internal_api_setup + client = APIClient() + + url = reverse("api-internal:webhooks-detail", kwargs={"pk": webhook.public_primary_key}) + key_id = "testkey" + value_id = "testvalue" + data = {"labels": [{"key": {"id": key_id, "name": "test"}, "value": {"id": value_id, "name": "testv"}}]} + response = client.patch( + url, + data=json.dumps(data), + content_type="application/json", + **make_user_auth_headers(user, token), + ) + + webhook.refresh_from_db() + + assert response.status_code == status.HTTP_200_OK + assert webhook.labels.count() == 1 + label = webhook.labels.first() + assert label.key_id == key_id + assert label.value_id == value_id + + response = client.patch( + url, + data=json.dumps({"labels": []}), + content_type="application/json", + **make_user_auth_headers(user, token), + ) + + webhook.refresh_from_db() + + assert response.status_code == status.HTTP_200_OK + assert webhook.labels.count() == 0 + + +@pytest.mark.django_db +def test_create_webhook_with_labels( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + + url = reverse("api-internal:webhooks-list") + + key_id = "testkey" + value_id = "testvalue" + data = { + "name": "the_webhook", + "url": TEST_URL, + "trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED, + "http_method": "POST", + "labels": [{"key": {"id": key_id, "name": "test"}, "value": {"id": value_id, "name": "testv"}}], + "team": None, + } + + response = client.post( + url, + data=json.dumps(data), + content_type="application/json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == 201 + webhook = Webhook.objects.get(public_primary_key=response.json()["id"]) + expected_response = data | { + "id": webhook.public_primary_key, + "data": None, + "username": None, + "password": None, + "authorization_header": None, + "forward_all": True, + "headers": None, + "http_method": "POST", + "integration_filter": None, + "is_webhook_enabled": True, + "is_legacy": False, + "last_response_log": { + "request_data": "", + "request_headers": "", + "timestamp": None, + "content": "", + "status_code": None, + "request_trigger": "", + "url": "", + "event_data": "", + }, + "trigger_template": None, + "trigger_type": str(data["trigger_type"]), + "trigger_type_name": "Alert Group Created", + "preset": None, + } + assert response.status_code == status.HTTP_201_CREATED + assert response.json() == expected_response + + +@pytest.mark.django_db +def test_update_webhook_labels_duplicate_key( + webhook_internal_api_setup, + make_user_auth_headers, +): + user, token, webhook = webhook_internal_api_setup + client = APIClient() + + url = reverse("api-internal:webhooks-detail", kwargs={"pk": webhook.public_primary_key}) + key_id = "testkey" + data = { + "labels": [ + {"key": {"id": key_id, "name": "test"}, "value": {"id": "testvalue1", "name": "testv1"}}, + {"key": {"id": key_id, "name": "test"}, "value": {"id": "testvalue2", "name": "testv2"}}, + ] + } + response = client.patch( + url, + data=json.dumps(data), + content_type="application/json", + **make_user_auth_headers(user, token), + ) + + webhook.refresh_from_db() + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert webhook.labels.count() == 0 diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index 81a26704..860c7092 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -18,6 +18,7 @@ from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel, Escalatio from apps.alerts.paging import unpage_user from apps.alerts.tasks import delete_alert_group, send_update_resolution_note_signal from apps.api.errors import AlertGroupAPIError +from apps.api.label_filtering import parse_label_query from apps.api.permissions import RBACPermission from apps.api.serializers.alert_group import AlertGroupListSerializer, AlertGroupSerializer from apps.api.serializers.team import TeamSerializer @@ -27,12 +28,7 @@ from apps.labels.utils import is_labels_feature_enabled from apps.mobile_app.auth import MobileAppAuthTokenAuthentication from apps.user_management.models import Team, User from common.api_helpers.exceptions import BadRequest -from common.api_helpers.filters import ( - ByTeamModelFieldFilterMixin, - DateRangeFilterMixin, - ModelFieldFilterMixin, - TeamModelMultipleChoiceFilter, -) +from common.api_helpers.filters import NO_TEAM_VALUE, DateRangeFilterMixin, ModelFieldFilterMixin from common.api_helpers.mixins import PreviewTemplateMixin, PublicPrimaryKeyMixin, TeamFilteringMixin from common.api_helpers.paginators import TwentyFiveCursorPaginator @@ -83,7 +79,7 @@ class AlertGroupFilterBackend(filters.DjangoFilterBackend): return filterset -class AlertGroupFilter(DateRangeFilterMixin, ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, filters.FilterSet): +class AlertGroupFilter(DateRangeFilterMixin, ModelFieldFilterMixin, filters.FilterSet): """ Examples of possible date formats here https://docs.djangoproject.com/en/1.9/ref/settings/#datetime-input-formats """ @@ -140,7 +136,6 @@ class AlertGroupFilter(DateRangeFilterMixin, ByTeamModelFieldFilterMixin, ModelF ) with_resolution_note = filters.BooleanFilter(method="filter_with_resolution_note") mine = filters.BooleanFilter(method="filter_mine") - team = TeamModelMultipleChoiceFilter(field_name="channel__team") class Meta: model = AlertGroup @@ -336,22 +331,28 @@ class AlertGroupView( if not ignore_filtering_by_available_teams: alert_receive_channels_qs = alert_receive_channels_qs.filter(*self.available_teams_lookup_args) + # Filter by team(s). Since we really filter teams from integrations, this is not an AlertGroup model filter. + # This is based on the common.api_helpers.ByTeamModelFieldFilterMixin implementation + team_values = self.request.query_params.getlist("team", []) + if team_values: + null_team_lookup = Q(team__isnull=True) if NO_TEAM_VALUE in team_values else None + teams_lookup = Q(team__public_primary_key__in=[ppk for ppk in team_values if ppk != NO_TEAM_VALUE]) + if null_team_lookup: + teams_lookup = teams_lookup | null_team_lookup + alert_receive_channels_qs = alert_receive_channels_qs.filter(teams_lookup) + alert_receive_channels_ids = list(alert_receive_channels_qs.values_list("id", flat=True)) queryset = AlertGroup.objects.filter(channel__in=alert_receive_channels_ids) - # filter by labels - labels = self.request.query_params.getlist("label") - for label in labels: - label_split = label.split(":") - if len(label_split) != 2: - continue - key_name, value_name = label_split - + # Filter by labels. Since alert group labels are "static" filter by names, not IDs. + label_query = self.request.query_params.getlist("label", []) + kv_pairs = parse_label_query(label_query) + for key, value in kv_pairs: # Utilize (organization, key_name, value_name, alert_group) index on AlertGroupAssociatedLabel queryset = queryset.filter( labels__organization=self.request.auth.organization, - labels__key_name=key_name, - labels__value_name=value_name, + labels__key_name=key, + labels__value_name=value, ) queryset = queryset.only("id") diff --git a/engine/apps/api/views/alert_receive_channel.py b/engine/apps/api/views/alert_receive_channel.py index aa79da6d..9edab2dc 100644 --- a/engine/apps/api/views/alert_receive_channel.py +++ b/engine/apps/api/views/alert_receive_channel.py @@ -11,6 +11,7 @@ from rest_framework.viewsets import ModelViewSet from apps.alerts.grafana_alerting_sync_manager.grafana_alerting_sync import GrafanaAlertingSyncManager from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel from apps.alerts.models.maintainable_object import MaintainableObject +from apps.api.label_filtering import parse_label_query from apps.api.permissions import RBACPermission from apps.api.serializers.alert_receive_channel import ( AlertReceiveChannelSerializer, @@ -18,13 +19,13 @@ from apps.api.serializers.alert_receive_channel import ( FilterAlertReceiveChannelSerializer, ) from apps.api.throttlers import DemoAlertThrottler -from apps.api.views.labels import LabelsAssociatingMixin +from apps.api.views.labels import schedule_update_label_cache from apps.auth_token.auth import PluginAuthentication from apps.integrations.legacy_prefix import has_legacy_prefix, remove_legacy_prefix from apps.labels.utils import is_labels_feature_enabled from apps.mobile_app.auth import MobileAppAuthTokenAuthentication from common.api_helpers.exceptions import BadRequest -from common.api_helpers.filters import ByTeamModelFieldFilterMixin, TeamModelMultipleChoiceFilter +from common.api_helpers.filters import NO_TEAM_VALUE, ByTeamModelFieldFilterMixin, TeamModelMultipleChoiceFilter from common.api_helpers.mixins import ( FilterSerializerMixin, PreviewTemplateException, @@ -76,7 +77,6 @@ class AlertReceiveChannelView( PublicPrimaryKeyMixin, FilterSerializerMixin, UpdateSerializerMixin, - LabelsAssociatingMixin, ModelViewSet, ): authentication_classes = ( @@ -159,7 +159,17 @@ class AlertReceiveChannelView( if not ignore_filtering_by_available_teams: queryset = queryset.filter(*self.available_teams_lookup_args).distinct() - queryset = self.filter_by_labels(queryset) + # filter labels + label_query = self.request.query_params.getlist("label", []) + kv_pairs = parse_label_query(label_query) + for key, value in kv_pairs: + queryset = queryset.filter( + labels__key_id=key, + labels__value_id=value, + ) + + # distinct to remove duplicates after alert_receive_channels X labels join + queryset = queryset.distinct() return queryset @@ -170,7 +180,11 @@ class AlertReceiveChannelView( """ if self.request.query_params.get("skip_pagination", "false").lower() == "true": return None - return super().paginate_queryset(queryset) + page = super().paginate_queryset(queryset) + if page is not None: + ids = [d.id for d in queryset] + schedule_update_label_cache(self.model.__name__, self.request.auth.organization, ids) + return page @action(detail=True, methods=["post"], throttle_classes=[DemoAlertThrottler]) def send_demo_alert(self, request, pk): @@ -217,7 +231,7 @@ class AlertReceiveChannelView( raise BadRequest(detail="team_id must be specified") team_id = request.query_params["team_id"] - if team_id == "null": + if team_id == NO_TEAM_VALUE: team_id = None try: diff --git a/engine/apps/api/views/escalation_chain.py b/engine/apps/api/views/escalation_chain.py index c125b031..00c5c3d4 100644 --- a/engine/apps/api/views/escalation_chain.py +++ b/engine/apps/api/views/escalation_chain.py @@ -18,7 +18,12 @@ from apps.auth_token.auth import PluginAuthentication from apps.mobile_app.auth import MobileAppAuthTokenAuthentication from apps.user_management.models import Team from common.api_helpers.exceptions import BadRequest -from common.api_helpers.filters import ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, TeamModelMultipleChoiceFilter +from common.api_helpers.filters import ( + NO_TEAM_VALUE, + ByTeamModelFieldFilterMixin, + ModelFieldFilterMixin, + TeamModelMultipleChoiceFilter, +) from common.api_helpers.mixins import ( FilterSerializerMixin, ListSerializerMixin, @@ -128,7 +133,7 @@ class EscalationChainViewSet( name = request.data.get("name") team_id = request.data.get("team") - if team_id == "null": + if team_id == NO_TEAM_VALUE: team_id = None if not name: diff --git a/engine/apps/api/views/labels.py b/engine/apps/api/views/labels.py index 9b3cd36c..dc1df6a4 100644 --- a/engine/apps/api/views/labels.py +++ b/engine/apps/api/views/labels.py @@ -6,7 +6,6 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ViewSet -from apps.alerts.models import AlertReceiveChannel from apps.api.permissions import BasicRolePermission, LegacyAccessControlRole from apps.api.serializers.labels import ( LabelKeySerializer, @@ -172,30 +171,8 @@ class AlertGroupLabelsViewSet(LabelsFeatureFlagViewSet): ) -class LabelsAssociatingMixin: # use for labelable objects views (ex. AlertReceiveChannelView) - def filter_by_labels(self, queryset): - """Call this method in `get_queryset()` to add filtering by labels""" - if not is_labels_feature_enabled(self.request.auth.organization): - return queryset - labels = self.request.query_params.getlist("label") # ["key1:value1", "key2:value2"] - if not labels: - return queryset - for label in labels: - label_data = label.split(":") - if len(label_data) != 2: # ["key1", "value1"] - continue - key_id, value_id = label_data - queryset &= AlertReceiveChannel.objects_with_deleted.filter( - labels__key_id=key_id, labels__value_id=value_id - ).distinct() - return queryset - - def paginate_queryset(self, queryset): - organization = self.request.auth.organization - data = super().paginate_queryset(queryset) - if not is_labels_feature_enabled(self.request.auth.organization): - return data - ids = [d.id for d in data] - logger.info(f"start update_instances_labels_cache for ids: {ids}") - update_instances_labels_cache.apply_async((organization.id, ids, self.model.__name__)) - return data +def schedule_update_label_cache(model_name, org, ids): + if not is_labels_feature_enabled(org): + return + logger.info(f"start update_instances_labels_cache for ids: {ids}") + update_instances_labels_cache.apply_async((org.id, ids, model_name)) diff --git a/engine/apps/api/views/team.py b/engine/apps/api/views/team.py index 650d8025..f2b25e82 100644 --- a/engine/apps/api/views/team.py +++ b/engine/apps/api/views/team.py @@ -10,6 +10,7 @@ from apps.auth_token.auth import PluginAuthentication from apps.mobile_app.auth import MobileAppAuthTokenAuthentication from apps.schedules.ical_utils import get_cached_oncall_users_for_multiple_schedules from apps.user_management.models import Team +from common.api_helpers.filters import NO_TEAM_VALUE from common.api_helpers.mixins import PublicPrimaryKeyMixin @@ -62,7 +63,7 @@ class TeamViewSet( return TeamLongSerializer if self._is_long_request() else TeamSerializer def list(self, request, *args, **kwargs): - general_team = [Team(public_primary_key="null", name="No team", email=None, avatar_url=None)] + general_team = [Team(public_primary_key=NO_TEAM_VALUE, name="No team", email=None, avatar_url=None)] queryset = self.filter_queryset(self.get_queryset()) if self.request.query_params.get("only_include_notifiable_teams", "false") == "true": diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index 7b3557cb..0716e0be 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -159,7 +159,7 @@ class UserView( "timezone_options": [RBACPermission.Permissions.USER_SETTINGS_READ], "check_availability": [RBACPermission.Permissions.USER_SETTINGS_READ], "metadata": [RBACPermission.Permissions.USER_SETTINGS_WRITE], - "list": [RBACPermission.Permissions.USER_SETTINGS_WRITE], + "list": [RBACPermission.Permissions.USER_SETTINGS_READ], "update": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "partial_update": [RBACPermission.Permissions.USER_SETTINGS_WRITE], "verify_number": [RBACPermission.Permissions.USER_SETTINGS_WRITE], diff --git a/engine/apps/api/views/webhooks.py b/engine/apps/api/views/webhooks.py index bd7dc8d7..3ee52f41 100644 --- a/engine/apps/api/views/webhooks.py +++ b/engine/apps/api/views/webhooks.py @@ -11,9 +11,12 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet +from apps.api.label_filtering import parse_label_query from apps.api.permissions import RBACPermission from apps.api.serializers.webhook import WebhookResponseSerializer, WebhookSerializer +from apps.api.views.labels import schedule_update_label_cache from apps.auth_token.auth import PluginAuthentication +from apps.labels.utils import is_labels_feature_enabled from apps.webhooks.models import Webhook, WebhookResponse from apps.webhooks.presets.preset_options import WebhookPresetOptions from apps.webhooks.utils import apply_jinja_template_for_json @@ -91,9 +94,24 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet): def get_queryset(self, ignore_filtering_by_available_teams=False): queryset = Webhook.objects.filter( organization=self.request.auth.organization, - ).prefetch_related("responses") + ) if not ignore_filtering_by_available_teams: queryset = queryset.filter(*self.available_teams_lookup_args).distinct() + + # filter by labels + label_query = self.request.query_params.getlist("label", []) + kv_pairs = parse_label_query(label_query) + for key, value in kv_pairs: + queryset = queryset.filter( + labels__key_id=key, + labels__value_id=value, + ) + # distinct to remove duplicates after webhooks X labels join + queryset = queryset.distinct() + # schedule update of labels cache + ids = [d.id for d in queryset] + schedule_update_label_cache(self.model.__name__, self.request.auth.organization, ids) + return queryset def get_object(self): @@ -132,6 +150,15 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet): }, ] + if is_labels_feature_enabled(self.request.auth.organization): + filter_options.append( + { + "name": "label", + "display_name": "Label", + "type": "labels", + } + ) + if filter_name is not None: filter_options = list(filter(lambda f: filter_name in f["name"], filter_options)) diff --git a/engine/apps/auth_token/auth.py b/engine/apps/auth_token/auth.py index aee403f5..8982fe21 100644 --- a/engine/apps/auth_token/auth.py +++ b/engine/apps/auth_token/auth.py @@ -8,14 +8,16 @@ from rest_framework import exceptions from rest_framework.authentication import BaseAuthentication, get_authorization_header from rest_framework.request import Request -from apps.api.permissions import RBACPermission, user_is_authorized +from apps.api.permissions import GrafanaAPIPermission, LegacyAccessControlRole, RBACPermission, user_is_authorized from apps.grafana_plugin.helpers.gcom import check_token from apps.user_management.exceptions import OrganizationDeletedException, OrganizationMovedException from apps.user_management.models import User from apps.user_management.models.organization import Organization +from settings.base import SELF_HOSTED_SETTINGS from .constants import SCHEDULE_EXPORT_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME from .exceptions import InvalidToken +from .grafana.grafana_auth_token import get_service_account_token_permissions from .models import ApiAuthToken, PluginAuthToken, ScheduleExportAuthToken, SlackAuthToken, UserScheduleExportAuthToken logger = logging.getLogger(__name__) @@ -262,3 +264,71 @@ class UserScheduleExportAuthentication(BaseAuthentication): raise exceptions.AuthenticationFailed("Export token is deactivated") return auth_token.user, auth_token + + +X_GRAFANA_ORG_SLUG = "X-Grafana-Org-Slug" +X_GRAFANA_INSTANCE_SLUG = "X-Grafana-Instance-Slug" +GRAFANA_SA_PREFIX = "glsa_" + + +class GrafanaServiceAccountAuthentication(BaseAuthentication): + def authenticate(self, request): + auth = get_authorization_header(request).decode("utf-8") + if not auth: + raise exceptions.AuthenticationFailed("Invalid token.") + if not auth.startswith(GRAFANA_SA_PREFIX): + return None + + organization = self.get_organization(request) + if not organization: + raise exceptions.AuthenticationFailed("Invalid organization.") + if organization.is_moved: + raise OrganizationMovedException(organization) + if organization.deleted_at: + raise OrganizationDeletedException(organization) + + return self.authenticate_credentials(organization, auth) + + def get_organization(self, request): + org_slug = SELF_HOSTED_SETTINGS["ORG_SLUG"] + instance_slug = SELF_HOSTED_SETTINGS["STACK_SLUG"] + if settings.LICENSE == settings.CLOUD_LICENSE_NAME: + org_slug = request.headers.get(X_GRAFANA_ORG_SLUG) + if not org_slug: + raise exceptions.AuthenticationFailed(f"Missing {X_GRAFANA_ORG_SLUG}") + instance_slug = request.headers.get(X_GRAFANA_INSTANCE_SLUG) + if not instance_slug: + raise exceptions.AuthenticationFailed(f"Missing {X_GRAFANA_INSTANCE_SLUG}") + + return Organization.objects.filter(org_slug=org_slug, stack_slug=instance_slug).first() + + def authenticate_credentials(self, organization, token): + permissions = get_service_account_token_permissions(organization, token) + if not permissions: + raise exceptions.AuthenticationFailed("Invalid token.") + + role = LegacyAccessControlRole.NONE + if not organization.is_rbac_permissions_enabled: + role = self.determine_role_from_permissions(permissions) + + user = User( + organization_id=organization.pk, + name="Grafana Service Account", + username="grafana_service_account", + role=role, + permissions=[GrafanaAPIPermission(action=key) for key, _ in permissions.items()], + ) + + auth_token = ApiAuthToken(organization=organization, user=user, name="Grafana Service Account") + + return user, auth_token + + # Using default permissions as proxies for roles since we cannot explicitly get role from the service account token + def determine_role_from_permissions(self, permissions): + if "plugins:write" in permissions: + return LegacyAccessControlRole.ADMIN + if "dashboards:write" in permissions: + return LegacyAccessControlRole.EDITOR + if "dashboards:read" in permissions: + return LegacyAccessControlRole.VIEWER + return LegacyAccessControlRole.NONE diff --git a/engine/apps/auth_token/exceptions.py b/engine/apps/auth_token/exceptions.py index 0ea79b0c..ddbdff94 100644 --- a/engine/apps/auth_token/exceptions.py +++ b/engine/apps/auth_token/exceptions.py @@ -1,2 +1,6 @@ class InvalidToken(Exception): pass + + +class ServiceAccountDoesNotExist(Exception): + pass diff --git a/engine/apps/auth_token/grafana/__init__.py b/engine/apps/auth_token/grafana/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/auth_token/grafana/grafana_auth_token.py b/engine/apps/auth_token/grafana/grafana_auth_token.py new file mode 100644 index 00000000..07bae644 --- /dev/null +++ b/engine/apps/auth_token/grafana/grafana_auth_token.py @@ -0,0 +1,48 @@ +import typing + +from apps.auth_token.exceptions import ServiceAccountDoesNotExist +from apps.grafana_plugin.helpers import GrafanaAPIClient +from apps.user_management.models import Organization + +SA_ONCALL_API_NAME = "sa-autogen-OnCall" + + +def find_service_account( + organization: Organization, service_account_name=SA_ONCALL_API_NAME +) -> typing.Optional["GrafanaAPIClient.Types.GrafanaServiceAccount"]: + grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token) + response, _ = grafana_api_client.get_service_account(service_account_name) + if response and "serviceAccounts" in response and response["serviceAccounts"]: + return response["serviceAccounts"][0] + return None + + +def create_service_account( + organization: Organization, name: str, role: str +) -> GrafanaAPIClient.Types.GrafanaServiceAccount: + grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token) + response, _ = grafana_api_client.create_service_account(name, role) + return response + + +def create_service_account_token( + organization: Organization, + token_name: str, + seconds_to_live=int | None, + service_account_name=SA_ONCALL_API_NAME, +) -> typing.Optional[str]: + grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=organization.api_token) + service_account = find_service_account(organization, service_account_name) + if not service_account: + raise ServiceAccountDoesNotExist + + response, _ = grafana_api_client.create_service_account_token(service_account["id"], token_name, seconds_to_live) + if response: + return response["key"] + return None + + +def get_service_account_token_permissions(organization: Organization, token: str) -> typing.Dict[str, typing.List[str]]: + grafana_api_client = GrafanaAPIClient(api_url=organization.grafana_url, api_token=token) + permissions, _ = grafana_api_client.get_service_account_token_permissions() + return permissions diff --git a/engine/apps/auth_token/tests/test_grafana_auth.py b/engine/apps/auth_token/tests/test_grafana_auth.py new file mode 100644 index 00000000..b1fdc800 --- /dev/null +++ b/engine/apps/auth_token/tests/test_grafana_auth.py @@ -0,0 +1,80 @@ +import typing +from unittest.mock import patch + +import pytest +from rest_framework import exceptions +from rest_framework.test import APIRequestFactory + +from apps.auth_token.auth import ( + GRAFANA_SA_PREFIX, + X_GRAFANA_INSTANCE_SLUG, + X_GRAFANA_ORG_SLUG, + GrafanaServiceAccountAuthentication, +) +from settings.base import CLOUD_LICENSE_NAME, OPEN_SOURCE_LICENSE_NAME, SELF_HOSTED_SETTINGS + + +def fake_authenticate_credentials(organization, token): + pass + + +@pytest.mark.django_db +def test_grafana_authentication_oss_inputs(make_organization, settings): + settings.LICENSE = OPEN_SOURCE_LICENSE_NAME + + headers, token = check_common_inputs() + organization = make_organization( + stack_slug=SELF_HOSTED_SETTINGS["STACK_SLUG"], org_slug=SELF_HOSTED_SETTINGS["ORG_SLUG"] + ) + request = APIRequestFactory().get("/", **headers) + with patch( + "apps.auth_token.auth.GrafanaServiceAccountAuthentication.authenticate_credentials", + wraps=fake_authenticate_credentials, + ) as mock: + GrafanaServiceAccountAuthentication().authenticate(request) + mock.assert_called_once_with(organization, token) + + +@pytest.mark.django_db +def test_grafana_authentication_cloud_inputs(make_organization, settings): + settings.LICENSE = CLOUD_LICENSE_NAME + headers, token = check_common_inputs() + + test_org_slug = "test_org_123" + test_stack_slug = "test_stack_123" + headers[f"HTTP_{X_GRAFANA_ORG_SLUG}"] = test_org_slug + headers[f"HTTP_{X_GRAFANA_INSTANCE_SLUG}"] = test_stack_slug + request = APIRequestFactory().get("/", **headers) + with pytest.raises(exceptions.AuthenticationFailed): + GrafanaServiceAccountAuthentication().authenticate(request) + + organization = make_organization(stack_slug=test_stack_slug, org_slug=test_org_slug) + with patch( + "apps.auth_token.auth.GrafanaServiceAccountAuthentication.authenticate_credentials", + wraps=fake_authenticate_credentials, + ) as mock: + GrafanaServiceAccountAuthentication().authenticate(request) + mock.assert_called_once_with(organization, token) + + +def check_common_inputs() -> (dict[str, typing.Any], str): + request = APIRequestFactory().get("/") + with pytest.raises(exceptions.AuthenticationFailed): + GrafanaServiceAccountAuthentication().authenticate(request) + + headers = { + "HTTP_AUTHORIZATION": "xyz", + } + request = APIRequestFactory().get("/", **headers) + result = GrafanaServiceAccountAuthentication().authenticate(request) + assert result is None + + token = f"{GRAFANA_SA_PREFIX}xyz" + headers = { + "HTTP_AUTHORIZATION": token, + } + request = APIRequestFactory().get("/", **headers) + with pytest.raises(exceptions.AuthenticationFailed): + GrafanaServiceAccountAuthentication().authenticate(request) + + return headers, token diff --git a/engine/apps/grafana_plugin/helpers/client.py b/engine/apps/grafana_plugin/helpers/client.py index 6597dd54..d9e7faaa 100644 --- a/engine/apps/grafana_plugin/helpers/client.py +++ b/engine/apps/grafana_plugin/helpers/client.py @@ -176,9 +176,27 @@ class GrafanaAPIClient(APIClient): avatarUrl: str memberCount: int + class GrafanaServiceAccount(typing.TypedDict): + id: int + name: str + login: str + orgId: int + isDisabled: bool + role: str + tokens: int + avatarUrl: str + + class GrafanaServiceAccountToken(typing.TypedDict): + id: int + name: str + key: str + class TeamsResponse(_BaseGrafanaAPIResponse): teams: typing.List["GrafanaAPIClient.Types.GrafanaTeam"] + class ServiceAccountResponse(_BaseGrafanaAPIResponse): + serviceAccounts: typing.List["GrafanaAPIClient.Types.GrafanaServiceAccount"] + def __init__(self, api_url: str, api_token: str) -> None: super().__init__(api_url, api_token) @@ -274,6 +292,25 @@ class GrafanaAPIClient(APIClient): def get_grafana_plugin_settings(self, recipient: str) -> APIClientResponse: return self.api_get(f"api/plugins/{recipient}/settings") + def get_service_account(self, login: str) -> APIClientResponse["GrafanaAPIClient.Types.ServiceAccountResponse"]: + return self.api_get(f"api/serviceaccounts/search?query={login}") + + def create_service_account( + self, name: str, role: str + ) -> APIClientResponse["GrafanaAPIClient.Types.GrafanaServiceAccount"]: + return self.api_post("api/serviceaccounts", {"name": name, "role": role}) + + def create_service_account_token( + self, service_account_id: int, name: str, seconds_to_live=int | None + ) -> APIClientResponse["GrafanaAPIClient.Types.GrafanaServiceAccountToken"]: + token_config = {"name": name} + if seconds_to_live: + token_config["secondsToLive"] = seconds_to_live + return self.api_post(f"api/serviceaccounts/{service_account_id}/tokens", token_config) + + def get_service_account_token_permissions(self) -> APIClientResponse[typing.Dict[str, typing.List[str]]]: + return self.api_get("api/access-control/user/permissions") + class GcomAPIClient(APIClient): ACTIVE_INSTANCE_QUERY = "instances?status=active" diff --git a/engine/apps/labels/alert_group_labels.py b/engine/apps/labels/alert_group_labels.py new file mode 100644 index 00000000..68ef6ead --- /dev/null +++ b/engine/apps/labels/alert_group_labels.py @@ -0,0 +1,161 @@ +import json +import logging +import typing + +from apps.labels.utils import is_labels_feature_enabled +from common.jinja_templater import apply_jinja_template +from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning + +if typing.TYPE_CHECKING: + from apps.alerts.models import AlertGroup, AlertReceiveChannel + + +logger = logging.getLogger(__name__) + + +# What can be used as a label key/value coming out from the template +LABEL_VALUE_TYPES = (str, int, float, bool) + + +def assign_labels( + alert_group: "AlertGroup", alert_receive_channel: "AlertReceiveChannel", raw_request_data: typing.Any +) -> None: + from apps.labels.models import AlertGroupAssociatedLabel + + if not is_labels_feature_enabled(alert_receive_channel.organization): + return + + # inherit labels from the integration + labels = { + label.key.name: label.value.name + for label in alert_receive_channel.labels.filter(inheritable=True).select_related("key", "value") + } + + # apply custom labels + labels.update(_custom_labels(alert_receive_channel, raw_request_data)) + + # apply template labels + labels.update(_template_labels(alert_receive_channel, raw_request_data)) + + # create associated labels + alert_group_labels = [ + AlertGroupAssociatedLabel( + alert_group=alert_group, + organization=alert_receive_channel.organization, + key_name=key, + value_name=value, + ) + for key, value in labels.items() + ] + # sort associated labels by key and value + alert_group_labels.sort(key=lambda label: (label.key_name, label.value_name)) + # bulk create associated labels + AlertGroupAssociatedLabel.objects.bulk_create(alert_group_labels) + + +def _custom_labels(alert_receive_channel: "AlertReceiveChannel", raw_request_data: typing.Any) -> dict[str, str]: + from apps.labels.models import MAX_VALUE_NAME_LENGTH, LabelKeyCache, LabelValueCache + + if alert_receive_channel.alert_group_labels_custom is None: + return {} + + # fetch up-to-date label key names + label_key_names = { + k.id: k.name + for k in LabelKeyCache.objects.filter( + id__in=[label[0] for label in alert_receive_channel.alert_group_labels_custom] + ).only("id", "name") + } + + # fetch up-to-date label value names + label_value_names = { + v.id: v.name + for v in LabelValueCache.objects.filter( + id__in=[label[1] for label in alert_receive_channel.alert_group_labels_custom if label[1]] + ).only("id", "name") + } + + rendered_labels = {} + for label in alert_receive_channel.alert_group_labels_custom: + key_id, value_id, template = label + + if key_id in label_key_names: + key = label_key_names[key_id] + else: + logger.warning("Label key cache not found. %s", key_id) + continue + + if value_id: + if value_id in label_value_names: + rendered_labels[key] = label_value_names[value_id] + else: + logger.warning("Label value cache not found. %s", value_id) + continue + else: + try: + rendered_labels[key] = apply_jinja_template(template, raw_request_data) + except (JinjaTemplateError, JinjaTemplateWarning) as e: + logger.warning("Failed to apply template. %s", e.fallback_message) + continue + + labels = {} + for key in rendered_labels: + value = rendered_labels[key] + + # check value length + if len(value) > MAX_VALUE_NAME_LENGTH: + logger.warning("Template result value is too long. %s", value) + continue + + labels[key] = value + + return labels + + +def _template_labels(alert_receive_channel: "AlertReceiveChannel", raw_request_data: typing.Any) -> dict[str, str]: + from apps.labels.models import MAX_KEY_NAME_LENGTH, MAX_VALUE_NAME_LENGTH + + if not alert_receive_channel.alert_group_labels_template: + return {} + + try: + rendered = apply_jinja_template(alert_receive_channel.alert_group_labels_template, raw_request_data) + except (JinjaTemplateError, JinjaTemplateWarning) as e: + logger.warning("Failed to apply template. %s", e.fallback_message) + return {} + + try: + rendered_labels = json.loads(rendered) + except (TypeError, json.JSONDecodeError): + logger.warning("Failed to parse template result. %s", rendered) + return {} + + if not isinstance(rendered_labels, dict): + logger.warning("Template result is not a dict. %s", rendered_labels) + return {} + + labels = {} + for key in rendered_labels: + value = rendered_labels[key] + + # check value type + if not isinstance(value, LABEL_VALUE_TYPES): + logger.warning("Template result value has invalid type. %s", value) + continue + + # convert value to string + value = str(value) + + # check key length + if len(key) > MAX_KEY_NAME_LENGTH: + logger.warning("Template result key is too long. %s", key) + continue + + # check value length + if len(value) > MAX_VALUE_NAME_LENGTH: + logger.warning("Template result value is too long. %s", value) + continue + + labels[key] = value + + return labels diff --git a/engine/apps/labels/migrations/0004_webhookassociatedlabel.py b/engine/apps/labels/migrations/0004_webhookassociatedlabel.py new file mode 100644 index 00000000..7c7f645b --- /dev/null +++ b/engine/apps/labels/migrations/0004_webhookassociatedlabel.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.7 on 2023-11-22 06:10 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('webhooks', '0011_auto_20230920_1813'), + ('user_management', '0017_alter_organization_maintenance_author'), + ('labels', '0003_alertreceivechannelassociatedlabel_inherit'), + ] + + operations = [ + migrations.CreateModel( + name='WebhookAssociatedLabel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='labels.labelkeycache')), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='webhook_labels', to='user_management.organization')), + ('value', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='labels.labelvaluecache')), + ('webhook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='labels', to='webhooks.webhook')), + ], + options={ + 'unique_together': {('key_id', 'value_id', 'webhook_id')}, + }, + ), + ] diff --git a/engine/apps/labels/models.py b/engine/apps/labels/models.py index f9861a72..14cd446c 100644 --- a/engine/apps/labels/models.py +++ b/engine/apps/labels/models.py @@ -10,9 +10,13 @@ if typing.TYPE_CHECKING: from apps.user_management.models import Organization +MAX_KEY_NAME_LENGTH = 200 +MAX_VALUE_NAME_LENGTH = 200 + + class LabelKeyCache(models.Model): id = models.CharField(primary_key=True, editable=False, max_length=36) - name = models.CharField(max_length=200) + name = models.CharField(max_length=MAX_KEY_NAME_LENGTH) organization = models.ForeignKey("user_management.Organization", on_delete=models.CASCADE) last_synced = models.DateTimeField(auto_now=True) @@ -23,7 +27,7 @@ class LabelKeyCache(models.Model): class LabelValueCache(models.Model): id = models.CharField(primary_key=True, editable=False, max_length=36) - name = models.CharField(max_length=200) + name = models.CharField(max_length=MAX_VALUE_NAME_LENGTH) key = models.ForeignKey("labels.LabelKeyCache", on_delete=models.CASCADE, related_name="values") last_synced = models.DateTimeField(auto_now=True) @@ -129,8 +133,8 @@ class AlertGroupAssociatedLabel(models.Model): "user_management.Organization", on_delete=models.CASCADE, related_name="alert_group_labels" ) - key_name = models.CharField(max_length=200) - value_name = models.CharField(max_length=200) + key_name = models.CharField(max_length=MAX_KEY_NAME_LENGTH) + value_name = models.CharField(max_length=MAX_VALUE_NAME_LENGTH) class Meta: constraints = [ @@ -139,3 +143,24 @@ class AlertGroupAssociatedLabel(models.Model): name="unique_alert_group_label", ) ] + + +class WebhookAssociatedLabel(AssociatedLabel): + """Keeps information about label association with outgoing webhooks instances""" + + webhook = models.ForeignKey( + "webhooks.Webhook", + on_delete=models.CASCADE, + related_name="labels", + ) + organization = models.ForeignKey( + "user_management.Organization", on_delete=models.CASCADE, related_name="webhook_labels" + ) + + class Meta: + unique_together = ["key_id", "value_id", "webhook_id"] + + @staticmethod + def get_associating_label_field_name() -> str: + """Returns ForeignKey field name for the associated model""" + return "webhook" diff --git a/engine/apps/labels/tasks.py b/engine/apps/labels/tasks.py index 65c7c550..2ac22ba1 100644 --- a/engine/apps/labels/tasks.py +++ b/engine/apps/labels/tasks.py @@ -6,7 +6,13 @@ from django.conf import settings from django.utils import timezone from apps.labels.client import LabelsAPIClient -from apps.labels.utils import LABEL_OUTDATED_TIMEOUT_MINUTES, LabelKeyData, LabelsData, get_associating_label_model +from apps.labels.utils import ( + LABEL_OUTDATED_TIMEOUT_MINUTES, + LabelKeyData, + LabelsData, + ValueData, + get_associating_label_model, +) from apps.user_management.models import Organization from common.custom_celery_tasks import shared_dedicated_queue_retry_task @@ -14,11 +20,6 @@ logger = get_task_logger(__name__) logger.setLevel(logging.DEBUG) -class ValueData(typing.TypedDict): - value_name: str - key_name: str - - def unify_labels_data(labels_data: LabelsData | LabelKeyData) -> typing.Dict[str, ValueData]: values_data: typing.Dict[str, ValueData] if isinstance(labels_data, list): # LabelsData diff --git a/engine/apps/labels/tests/factories.py b/engine/apps/labels/tests/factories.py index 5f910989..aa4052f7 100644 --- a/engine/apps/labels/tests/factories.py +++ b/engine/apps/labels/tests/factories.py @@ -5,6 +5,7 @@ from apps.labels.models import ( AlertReceiveChannelAssociatedLabel, LabelKeyCache, LabelValueCache, + WebhookAssociatedLabel, ) from common.utils import UniqueFaker @@ -33,3 +34,8 @@ class AlertReceiveChannelAssociatedLabelFactory(factory.DjangoModelFactory): class AlertGroupAssociatedLabelFactory(factory.DjangoModelFactory): class Meta: model = AlertGroupAssociatedLabel + + +class WebhookAssociatedLabelFactory(factory.DjangoModelFactory): + class Meta: + model = WebhookAssociatedLabel diff --git a/engine/apps/labels/tests/test_alert_group.py b/engine/apps/labels/tests/test_alert_group.py index a5ac35ca..935e15af 100644 --- a/engine/apps/labels/tests/test_alert_group.py +++ b/engine/apps/labels/tests/test_alert_group.py @@ -3,9 +3,13 @@ from unittest import mock import pytest from apps.alerts.models import Alert +from apps.labels.models import MAX_KEY_NAME_LENGTH, MAX_VALUE_NAME_LENGTH + +TOO_LONG_KEY_NAME = "k" * (MAX_KEY_NAME_LENGTH + 1) +TOO_LONG_VALUE_NAME = "v" * (MAX_VALUE_NAME_LENGTH + 1) -@mock.patch("apps.labels.utils.is_labels_feature_enabled", return_value=False) +@mock.patch("apps.labels.alert_group_labels.is_labels_feature_enabled", return_value=False) @pytest.mark.django_db def test_assign_labels_feature_flag_disabled( _, make_organization, make_alert_receive_channel, make_integration_label_association @@ -28,11 +32,75 @@ def test_assign_labels_feature_flag_disabled( @pytest.mark.django_db -def test_assign_labels(make_organization, make_alert_receive_channel, make_integration_label_association): +def test_assign_labels( + make_organization, + make_alert_receive_channel, + make_label_key_and_value, + make_label_key, + make_integration_label_association, +): organization = make_organization() - alert_receive_channel = make_alert_receive_channel(organization) - label = make_integration_label_association(organization, alert_receive_channel) - make_integration_label_association(organization, alert_receive_channel, inheritable=False) + + # create label repo labels + label_key, label_value = make_label_key_and_value(organization, key_name="a", value_name="b") + label_key_1 = make_label_key(organization=organization, key_name="c") + label_key_2 = make_label_key(organization=organization) + label_key_3 = make_label_key(organization=organization) + + # create alert receive channel with all 3 types of labels + alert_receive_channel = make_alert_receive_channel( + organization, + alert_group_labels_custom=[ + [label_key.id, label_value.id, None], # plain label + ["nonexistent", label_value.id, None], # plain label with nonexistent key ID + [label_key_2.id, "nonexistent", None], # plain label with nonexistent value ID + [label_key_1.id, None, "{{ payload.c }}"], # templated label + [label_key_3.id, None, TOO_LONG_VALUE_NAME], # templated label too long + ], + alert_group_labels_template="{{ payload.advanced_template | tojson }}", + ) + make_integration_label_association(organization, alert_receive_channel, key_name="e", value_name="f") + + # create alert group + alert = Alert.create( + title="the title", + message="the message", + alert_receive_channel=alert_receive_channel, + raw_request_data={ + "c": "d", + "advanced_template": { + "g": 123, + "too_long": TOO_LONG_VALUE_NAME, + TOO_LONG_KEY_NAME: "too_long", + "invalid_type": {"test": "test"}, + }, + "extra": "hi", + }, + integration_unique_data={}, + image_url=None, + link_to_upstream_details=None, + ) + + # check alert group labels are assigned correctly, in the lexicographical order + assert [(label.key_name, label.value_name) for label in alert.group.labels.all()] == [ + ("a", "b"), + ("c", "d"), + ("e", "f"), + ("g", "123"), + ] + + +@pytest.mark.django_db +def test_assign_labels_custom_labels_none( + make_organization, + make_alert_receive_channel, + make_label_key_and_value, + make_label_key, + make_integration_label_association, +): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization, alert_group_labels_custom=None) + make_integration_label_association(organization, alert_receive_channel, key_name="a", value_name="b") alert = Alert.create( title="the title", @@ -44,6 +112,4 @@ def test_assign_labels(make_organization, make_alert_receive_channel, make_integ link_to_upstream_details=None, ) - assert alert.group.labels.count() == 1 - assert alert.group.labels.first().key_name == label.key.name - assert alert.group.labels.first().value_name == label.value.name + assert [(label.key_name, label.value_name) for label in alert.group.labels.all()] == [("a", "b")] diff --git a/engine/apps/labels/tests/test_labels.py b/engine/apps/labels/tests/test_labels.py index 9d32583a..b4d116c2 100644 --- a/engine/apps/labels/tests/test_labels.py +++ b/engine/apps/labels/tests/test_labels.py @@ -1,8 +1,14 @@ import pytest from apps.alerts.models import AlertReceiveChannel -from apps.labels.models import AlertReceiveChannelAssociatedLabel, AssociatedLabel, LabelValueCache +from apps.labels.models import ( + AlertReceiveChannelAssociatedLabel, + AssociatedLabel, + LabelValueCache, + WebhookAssociatedLabel, +) from apps.labels.utils import get_associating_label_model, is_labels_feature_enabled +from apps.webhooks.models import Webhook @pytest.mark.django_db @@ -104,6 +110,11 @@ def test_get_associating_label_model(): result = get_associating_label_model(model_name) assert result == expected_result + model_name = Webhook.__name__ + expected_result = WebhookAssociatedLabel + result = get_associating_label_model(model_name) + assert result == expected_result + wrong_model_name = "SomeModel" with pytest.raises(LookupError): get_associating_label_model(wrong_model_name) diff --git a/engine/apps/labels/utils.py b/engine/apps/labels/utils.py index 98d1bf95..bacf86c0 100644 --- a/engine/apps/labels/utils.py +++ b/engine/apps/labels/utils.py @@ -1,13 +1,16 @@ +import logging import typing from django.apps import apps # noqa: I251 from django.conf import settings if typing.TYPE_CHECKING: - from apps.alerts.models import AlertGroup, AlertReceiveChannel + from apps.alerts.models import AlertGroup from apps.labels.models import AssociatedLabel from apps.user_management.models import Organization +logger = logging.getLogger(__name__) + LABEL_OUTDATED_TIMEOUT_MINUTES = 30 ASSOCIATED_MODEL_NAME = "AssociatedLabel" @@ -27,6 +30,11 @@ class LabelData(typing.TypedDict): value: LabelParams +class ValueData(typing.TypedDict): + value_name: str + key_name: str + + class LabelKeyData(typing.TypedDict): key: LabelParams values: typing.List[LabelParams] @@ -49,20 +57,10 @@ def is_labels_feature_enabled(organization: "Organization") -> bool: ) -def assign_labels(alert_group: "AlertGroup", alert_receive_channel: "AlertReceiveChannel") -> None: - from apps.labels.models import AlertGroupAssociatedLabel +def get_label_verbal(obj: typing.Any) -> dict[str, str]: + return {label.key.name: label.value.name for label in obj.labels.all().select_related("key", "value")} - if not is_labels_feature_enabled(alert_receive_channel.organization): - return - # inherit labels from the integration - alert_group_labels = [ - AlertGroupAssociatedLabel( - alert_group=alert_group, - organization=alert_receive_channel.organization, - key_name=label.key.name, - value_name=label.value.name, - ) - for label in alert_receive_channel.labels.filter(inheritable=True).select_related("key", "value") - ] - AlertGroupAssociatedLabel.objects.bulk_create(alert_group_labels) +def get_alert_group_label_verbal(alert_group: "AlertGroup") -> dict[str, str]: + """This is different from get_label_verbal because alert group labels store key/value names, not IDs""" + return {label.key_name: label.value_name for label in alert_group.labels.all()} diff --git a/engine/apps/public_api/serializers/resolution_notes.py b/engine/apps/public_api/serializers/resolution_notes.py index 6cf7d7c9..b48f3fa6 100644 --- a/engine/apps/public_api/serializers/resolution_notes.py +++ b/engine/apps/public_api/serializers/resolution_notes.py @@ -34,7 +34,8 @@ class ResolutionNoteSerializer(EagerLoadingMixin, serializers.ModelSerializer): SELECT_RELATED = ["alert_group", "resolution_note_slack_message", "author"] def create(self, validated_data): - validated_data["author"] = self.context["request"].user + if self.context["request"].user.pk: + validated_data["author"] = self.context["request"].user validated_data["source"] = ResolutionNote.Source.WEB return super().create(validated_data) diff --git a/engine/apps/public_api/serializers/routes.py b/engine/apps/public_api/serializers/routes.py index 1907c9e9..bbe9ff55 100644 --- a/engine/apps/public_api/serializers/routes.py +++ b/engine/apps/public_api/serializers/routes.py @@ -1,10 +1,10 @@ from rest_framework import fields, serializers from apps.alerts.models import AlertReceiveChannel, ChannelFilter, EscalationChain -from apps.api.serializers.alert_receive_channel import valid_jinja_template_for_serializer_method_field from apps.base.messaging import get_messaging_backend_from_id, get_messaging_backends from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField from common.api_helpers.exceptions import BadRequest +from common.api_helpers.utils import valid_jinja_template_for_serializer_method_field from common.jinja_templater.apply_jinja_template import JinjaTemplateError from common.ordered_model.serializer import OrderedModelSerializer from common.utils import is_regex_valid diff --git a/engine/apps/public_api/tests/test_resolution_notes.py b/engine/apps/public_api/tests/test_resolution_notes.py index 7c5e3b94..2a44e622 100644 --- a/engine/apps/public_api/tests/test_resolution_notes.py +++ b/engine/apps/public_api/tests/test_resolution_notes.py @@ -1,9 +1,13 @@ +from unittest.mock import patch + import pytest from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient from apps.alerts.models import ResolutionNote +from apps.auth_token.auth import GRAFANA_SA_PREFIX, ApiTokenAuthentication, GrafanaServiceAccountAuthentication +from apps.auth_token.models import ApiAuthToken @pytest.mark.django_db @@ -273,3 +277,75 @@ def test_delete_resolution_note( assert response.status_code == status.HTTP_404_NOT_FOUND assert response.data["detail"] == "Not found." + + +@pytest.mark.django_db +def test_create_resolution_note_grafana_auth(make_organization_and_user, make_alert_receive_channel, make_alert_group): + organization, user = make_organization_and_user() + client = APIClient() + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + + url = reverse("api-public:resolution_notes-list") + + data = { + "alert_group_id": alert_group.public_primary_key, + "text": "Test Resolution Note Message", + } + + api_token_auth = ApiTokenAuthentication() + grafana_sa_auth = GrafanaServiceAccountAuthentication() + + # GrafanaServiceAccountAuthentication handles empty auth + with patch( + "apps.auth_token.auth.ApiTokenAuthentication.authenticate", wraps=api_token_auth.authenticate + ) as mock_api_key_auth, patch( + "apps.auth_token.auth.GrafanaServiceAccountAuthentication.authenticate", wraps=grafana_sa_auth.authenticate + ) as mock_grafana_auth: + response = client.post(url, data=data, format="json") + mock_grafana_auth.assert_called_once() + mock_api_key_auth.assert_not_called() + assert response.status_code == status.HTTP_403_FORBIDDEN + + token = "abc123" + # GrafanaServiceAccountAuthentication passes through api key auth + with patch( + "apps.auth_token.auth.ApiTokenAuthentication.authenticate", wraps=api_token_auth.authenticate + ) as mock_api_key_auth, patch( + "apps.auth_token.auth.GrafanaServiceAccountAuthentication.authenticate", wraps=grafana_sa_auth.authenticate + ) as mock_grafana_auth: + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + mock_grafana_auth.assert_called_once() + mock_api_key_auth.assert_called_once() + assert response.status_code == status.HTTP_403_FORBIDDEN + + token = f"{GRAFANA_SA_PREFIX}123" + # GrafanaServiceAccountAuthentication handle invalid token + with patch( + "apps.auth_token.auth.ApiTokenAuthentication.authenticate", wraps=api_token_auth.authenticate + ) as mock_api_key_auth, patch( + "apps.auth_token.auth.GrafanaServiceAccountAuthentication.authenticate", wraps=grafana_sa_auth.authenticate + ) as mock_grafana_auth: + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + mock_grafana_auth.assert_called_once() + mock_api_key_auth.assert_not_called() + assert response.status_code == status.HTTP_403_FORBIDDEN + + success_token = ApiAuthToken(organization=organization, user=user, name="Grafana Service Account") + # GrafanaServiceAccountAuthentication handle successful token + with patch( + "apps.auth_token.auth.GrafanaServiceAccountAuthentication.authenticate", return_value=(user, success_token) + ): + response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") + assert response.status_code == status.HTTP_201_CREATED + resolution_note = ResolutionNote.objects.get(public_primary_key=response.data["id"]) + result = { + "id": resolution_note.public_primary_key, + "alert_group_id": alert_group.public_primary_key, + "author": user.public_primary_key, + "source": resolution_note.get_source_display(), + "created_at": response.data["created_at"], + "text": data["text"], + } + assert response.data == result diff --git a/engine/apps/public_api/views/incidents.py b/engine/apps/public_api/views/incidents.py index 4b612921..27fc71b4 100644 --- a/engine/apps/public_api/views/incidents.py +++ b/engine/apps/public_api/views/incidents.py @@ -16,7 +16,7 @@ from apps.public_api.helpers import is_valid_group_creation_date, team_has_slack from apps.public_api.serializers import IncidentSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from common.api_helpers.exceptions import BadRequest -from common.api_helpers.filters import ByTeamModelFieldFilterMixin, get_team_queryset +from common.api_helpers.filters import NO_TEAM_VALUE, ByTeamModelFieldFilterMixin, get_team_queryset from common.api_helpers.mixins import RateLimitHeadersMixin from common.api_helpers.paginators import FiftyPageSizePaginator @@ -27,7 +27,7 @@ class IncidentByTeamFilter(ByTeamModelFieldFilterMixin, filters.FilterSet): queryset=get_team_queryset, to_field_name="public_primary_key", null_label="noteam", - null_value="null", + null_value=NO_TEAM_VALUE, method=ByTeamModelFieldFilterMixin.filter_model_field_with_single_value.__name__, ) diff --git a/engine/apps/public_api/views/resolution_notes.py b/engine/apps/public_api/views/resolution_notes.py index f4886efa..06252aa7 100644 --- a/engine/apps/public_api/views/resolution_notes.py +++ b/engine/apps/public_api/views/resolution_notes.py @@ -5,7 +5,8 @@ from rest_framework.viewsets import ModelViewSet from apps.alerts.models import ResolutionNote from apps.alerts.tasks import send_update_resolution_note_signal -from apps.auth_token.auth import ApiTokenAuthentication +from apps.api.permissions import RBACPermission +from apps.auth_token.auth import ApiTokenAuthentication, GrafanaServiceAccountAuthentication from apps.public_api.serializers.resolution_notes import ResolutionNoteSerializer, ResolutionNoteUpdateSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from common.api_helpers.mixins import RateLimitHeadersMixin, UpdateSerializerMixin @@ -13,8 +14,18 @@ from common.api_helpers.paginators import FiftyPageSizePaginator class ResolutionNoteView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet): - authentication_classes = (ApiTokenAuthentication,) - permission_classes = (IsAuthenticated,) + authentication_classes = (GrafanaServiceAccountAuthentication, ApiTokenAuthentication) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "metadata": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "list": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "retrieve": [RBACPermission.Permissions.ALERT_GROUPS_READ], + "create": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "update": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "partial_update": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + "destroy": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + } throttle_classes = [UserThrottle] diff --git a/engine/apps/slack/slack_formatter.py b/engine/apps/slack/slack_formatter.py index ec9cd8d7..9e9a89bf 100644 --- a/engine/apps/slack/slack_formatter.py +++ b/engine/apps/slack/slack_formatter.py @@ -37,6 +37,18 @@ class SlackFormatter(SlackFormatterBase): return message + def slack_to_accepted_emoji(self, message): + """Overridden original method to fix regex that replaces dashes in links""" + message = re.sub( + r":([a-zA-Z0-9<>/:])([^ <>/:]+):", # overridden regex + lambda x: ":{}{}:".format(x.group(1), x.group(2).replace("-", "_")), + message, + ) + + # https://github.com/Ranks/emojione/issues/114 + message = message.replace(":simple_smile:", ":slightly_smiling_face:") + return message + def _sub_hyperlink(self, matchobj): compound = matchobj.group(0)[1:-1] if len(compound.split("|")) == 2: diff --git a/engine/apps/slack/tests/test_slack_formatter.py b/engine/apps/slack/tests/test_slack_formatter.py new file mode 100644 index 00000000..ddcaeb21 --- /dev/null +++ b/engine/apps/slack/tests/test_slack_formatter.py @@ -0,0 +1,16 @@ +from unittest.mock import MagicMock + +import pytest + +from apps.slack.slack_formatter import SlackFormatter + + +@pytest.mark.django_db +def test_slack_to_accepted_emoji(): + sf = SlackFormatter(MagicMock()) + test_message = """[:book: Runbook:link:](https://example-test.com/explore?panes=%7B:%7Bname-with-dash%22:%22FE%22:%5B%7B%22another-one%22:namespace-with-dash) +Test emoji :male-construction-worker:https://another-example.com/test:=%22-dash +:female-construction-worker:""" + expected_result = test_message.replace("-construction-worker", "_construction_worker") + result = sf.slack_to_accepted_emoji(test_message) + assert result == expected_result diff --git a/engine/apps/webhooks/presets/advanced.py b/engine/apps/webhooks/presets/advanced.py index 1983943e..4e380c54 100644 --- a/engine/apps/webhooks/presets/advanced.py +++ b/engine/apps/webhooks/presets/advanced.py @@ -1,3 +1,5 @@ +import typing + from apps.webhooks.models import Webhook from apps.webhooks.presets.preset import WebhookPreset, WebhookPresetMetadata @@ -17,3 +19,6 @@ class AdvancedWebhookPreset(WebhookPreset): def override_parameters_at_runtime(self, webhook: Webhook): pass + + def get_masked_headers(self) -> typing.List[str]: + return [] diff --git a/engine/apps/webhooks/presets/preset.py b/engine/apps/webhooks/presets/preset.py index 8e946476..9ef97c81 100644 --- a/engine/apps/webhooks/presets/preset.py +++ b/engine/apps/webhooks/presets/preset.py @@ -1,3 +1,4 @@ +import typing from abc import ABC, abstractmethod from dataclasses import dataclass from typing import List @@ -34,3 +35,8 @@ class WebhookPreset(ABC): def override_parameters_at_runtime(self, webhook: Webhook): """Implement this to write parameters before the webhook is executed (These will not be persisted)""" pass + + @abstractmethod + def get_masked_headers(self) -> typing.List[str]: + """Implement this to write sensitive header data as ******** when writing to logs""" + return [] diff --git a/engine/apps/webhooks/presets/simple.py b/engine/apps/webhooks/presets/simple.py index dc1db970..ab62f6d3 100644 --- a/engine/apps/webhooks/presets/simple.py +++ b/engine/apps/webhooks/presets/simple.py @@ -1,3 +1,5 @@ +import typing + from apps.webhooks.models import Webhook from apps.webhooks.presets.preset import WebhookPreset, WebhookPresetMetadata @@ -30,3 +32,6 @@ class SimpleWebhookPreset(WebhookPreset): def override_parameters_at_runtime(self, webhook: Webhook): pass + + def get_masked_headers(self) -> typing.List[str]: + return [] diff --git a/engine/apps/webhooks/tasks/trigger_webhook.py b/engine/apps/webhooks/tasks/trigger_webhook.py index f7f71f0a..dcc0f960 100644 --- a/engine/apps/webhooks/tasks/trigger_webhook.py +++ b/engine/apps/webhooks/tasks/trigger_webhook.py @@ -91,15 +91,17 @@ def _build_payload(webhook, alert_group, user): response_data = r.content responses_data[r.webhook.public_primary_key] = response_data - data = serialize_event(event, alert_group, user, responses_data) + data = serialize_event(event, alert_group, user, webhook, responses_data) return data -def mask_authorization_header(headers): +def mask_authorization_header(headers, header_keys_to_mask): masked_headers = headers.copy() - if "Authorization" in masked_headers: - masked_headers["Authorization"] = WEBHOOK_FIELD_PLACEHOLDER + lower_keys = set(k.lower() for k in header_keys_to_mask) + for k in headers.keys(): + if k.lower() in lower_keys: + masked_headers[k] = WEBHOOK_FIELD_PLACEHOLDER return masked_headers @@ -114,6 +116,7 @@ def make_request(webhook, alert_group, data): "webhook": webhook, "event_data": json.dumps(data), } + masked_header_keys = ["Authorization"] exception = error = None try: @@ -121,7 +124,9 @@ def make_request(webhook, alert_group, data): if webhook.preset not in WebhookPresetOptions.WEBHOOK_PRESETS: raise Exception(f"Invalid preset {webhook.preset}") else: - WebhookPresetOptions.WEBHOOK_PRESETS[webhook.preset].override_parameters_at_runtime(webhook) + preset = WebhookPresetOptions.WEBHOOK_PRESETS[webhook.preset] + preset.override_parameters_at_runtime(webhook) + masked_header_keys.extend(preset.get_masked_headers()) if not webhook.check_integration_filter(alert_group): status["request_trigger"] = NOT_FROM_SELECTED_INTEGRATION @@ -131,7 +136,7 @@ def make_request(webhook, alert_group, data): if triggered: status["url"] = webhook.build_url(data) request_kwargs = webhook.build_request_kwargs(data, raise_data_errors=True) - display_headers = mask_authorization_header(request_kwargs.get("headers", {})) + display_headers = mask_authorization_header(request_kwargs.get("headers", {}), masked_header_keys) status["request_headers"] = json.dumps(display_headers) if "json" in request_kwargs: status["request_data"] = json.dumps(request_kwargs["json"]) diff --git a/engine/apps/webhooks/tests/test_trigger_webhook.py b/engine/apps/webhooks/tests/test_trigger_webhook.py index 95483224..4da2e6ed 100644 --- a/engine/apps/webhooks/tests/test_trigger_webhook.py +++ b/engine/apps/webhooks/tests/test_trigger_webhook.py @@ -302,6 +302,7 @@ def test_execute_webhook_ok_forward_all( "type": alert_receive_channel.integration, "name": alert_receive_channel.short_name, "team": None, + "labels": {}, }, "notified_users": [ { @@ -310,10 +311,15 @@ def test_execute_webhook_ok_forward_all( "email": notified_user.email, } ], - "alert_group": IncidentSerializer(alert_group).data, + "alert_group": {**IncidentSerializer(alert_group).data, "labels": {}}, "alert_group_id": alert_group.public_primary_key, "alert_payload": "", "users_to_be_notified": [], + "webhook": { + "id": webhook.public_primary_key, + "name": webhook.name, + "labels": {}, + }, } expected_call = call( "https://something/{}/".format(alert_group.public_primary_key), diff --git a/engine/apps/webhooks/tests/test_webhook_presets.py b/engine/apps/webhooks/tests/test_webhook_presets.py index 70c95151..d73f0aab 100644 --- a/engine/apps/webhooks/tests/test_webhook_presets.py +++ b/engine/apps/webhooks/tests/test_webhook_presets.py @@ -1,8 +1,11 @@ +import json +import typing from unittest.mock import patch import pytest from apps.webhooks.models import Webhook +from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER from apps.webhooks.presets.preset import WebhookPreset, WebhookPresetMetadata from apps.webhooks.tasks.trigger_webhook import make_request from apps.webhooks.tests.test_trigger_webhook import MockResponse @@ -14,6 +17,8 @@ TEST_WEBHOOK_LOGO = "test_logo" TEST_WEBHOOK_PRESET_DESCRIPTION = "Description of test webhook preset" TEST_WEBHOOK_PRESET_CONTROLLED_FIELDS = ["url", "http_method", "data", "authorization_header"] TEST_WEBHOOK_AUTHORIZATION_HEADER = "Test Auth header 12345" +TEST_WEBHOOK_MASK_HEADER = "X-Secret-Header" +TEST_WEBHOOK_MASK_HEADER_VALUE = "abc123" INVALID_PRESET_ID = "invalid_preset_id" @@ -34,6 +39,12 @@ class TestWebhookPreset(WebhookPreset): def override_parameters_at_runtime(self, webhook: Webhook): webhook.authorization_header = TEST_WEBHOOK_AUTHORIZATION_HEADER + webhook.headers = json.dumps( + {"Content-Type": "application/json", TEST_WEBHOOK_MASK_HEADER: TEST_WEBHOOK_MASK_HEADER_VALUE} + ) + + def get_masked_headers(self) -> typing.List[str]: + return [TEST_WEBHOOK_MASK_HEADER] @pytest.mark.django_db @@ -124,11 +135,20 @@ def test_webhook_preset_runtime_override(make_organization, webhook_preset_api_s with patch.object(webhook, "build_url"): response = MockResponse() with patch.object(webhook, "make_request", return_value=response) as mock_make_request: - triggered, webhook_status, error, exception = make_request(webhook, None, None) + triggered, webhook_status, error, exception = make_request(webhook, None, {}) + assert mock_make_request.call_args.args[1]["headers"]["Content-Type"] == "application/json" assert mock_make_request.call_args.args[1]["headers"]["Authorization"] == TEST_WEBHOOK_AUTHORIZATION_HEADER + assert ( + mock_make_request.call_args.args[1]["headers"][TEST_WEBHOOK_MASK_HEADER] + == TEST_WEBHOOK_MASK_HEADER_VALUE + ) assert triggered assert error is None assert exception is None + webhook_status_headers = json.loads(webhook_status["request_headers"]) + assert webhook_status_headers["Content-Type"] == "application/json" + assert webhook_status_headers["Authorization"] == WEBHOOK_FIELD_PLACEHOLDER + assert webhook_status_headers[TEST_WEBHOOK_MASK_HEADER] == WEBHOOK_FIELD_PLACEHOLDER webhook.refresh_from_db() assert webhook.authorization_header is None diff --git a/engine/apps/webhooks/utils.py b/engine/apps/webhooks/utils.py index 5b9cb92a..feafe93e 100644 --- a/engine/apps/webhooks/utils.py +++ b/engine/apps/webhooks/utils.py @@ -7,6 +7,7 @@ from urllib.parse import urlparse from django.conf import settings from apps.base.utils import live_settings +from apps.labels.utils import get_alert_group_label_verbal, get_label_verbal, is_labels_feature_enabled from apps.schedules.ical_utils import list_users_to_notify_from_ical from common.jinja_templater import apply_jinja_template @@ -150,7 +151,7 @@ def _extract_users_from_escalation_snapshot(escalation_snapshot): return list({u["id"]: u for u in users if u}.values()) -def serialize_event(event, alert_group, user, responses=None): +def serialize_event(event, alert_group, user, webhook, responses=None): from apps.public_api.serializers import IncidentSerializer alert_payload = alert_group.alerts.first() @@ -179,4 +180,10 @@ def serialize_event(event, alert_group, user, responses=None): if responses: data["responses"] = responses + # Enrich webhook data with labels payloads if labels feature is enabled + # TODO: once feature flag will be removed this code should go to the 'data' dict declaration + if is_labels_feature_enabled(alert_group.channel.organization): + data["webhook"] = {"id": webhook.public_primary_key, "name": webhook.name, "labels": get_label_verbal(webhook)} + data["integration"]["labels"] = get_label_verbal(alert_group.channel) + data["alert_group"]["labels"] = get_alert_group_label_verbal(alert_group) return data diff --git a/engine/common/api_helpers/filters.py b/engine/common/api_helpers/filters.py index 21d5ac3f..7e12a140 100644 --- a/engine/common/api_helpers/filters.py +++ b/engine/common/api_helpers/filters.py @@ -7,6 +7,8 @@ from django_filters.utils import handle_timezone from apps.user_management.models import Team from common.api_helpers.exceptions import BadRequest +NO_TEAM_VALUE = "null" + class DateRangeFilterMixin: DATE_FORMAT = "%Y-%m-%dT%H:%M:%S" @@ -100,7 +102,7 @@ class ByTeamFilter(ByTeamModelFieldFilterMixin, filters.FilterSet): queryset=get_team_queryset, to_field_name="public_primary_key", null_label="noteam", - null_value="null", + null_value=NO_TEAM_VALUE, method=ByTeamModelFieldFilterMixin.filter_model_field_with_single_value.__name__, ) @@ -112,7 +114,7 @@ class TeamModelMultipleChoiceFilter(filters.ModelMultipleChoiceFilter): queryset=get_team_queryset, to_field_name="public_primary_key", null_label="noteam", - null_value="null", + null_value=NO_TEAM_VALUE, method=ByTeamModelFieldFilterMixin.filter_model_field_with_multiple_values.__name__, ): super().__init__( diff --git a/engine/common/api_helpers/mixins.py b/engine/common/api_helpers/mixins.py index 4204095f..5f15f137 100644 --- a/engine/common/api_helpers/mixins.py +++ b/engine/common/api_helpers/mixins.py @@ -248,6 +248,7 @@ ACKNOWLEDGE_CONDITION = "acknowledge_condition" GROUPING_ID = "grouping_id" SOURCE_LINK = "source_link" ROUTE = "route" +ALERT_GROUP_LABELS = "alert_group_labels" NOTIFICATION_CHANNEL_TO_TEMPLATER_MAP = { SLACK: AlertSlackTemplater, @@ -264,9 +265,15 @@ for backend_id, backend in get_messaging_backends(): NOTIFICATION_CHANNEL_TO_TEMPLATER_MAP[backend.slug] = backend.get_templater_class() APPEARANCE_TEMPLATE_NAMES = [TITLE, MESSAGE, IMAGE_URL] -BEHAVIOUR_TEMPLATE_NAMES = [RESOLVE_CONDITION, ACKNOWLEDGE_CONDITION, GROUPING_ID, SOURCE_LINK] -ROUTE_TEMPLATE_NAMES = [ROUTE] -ALL_TEMPLATE_NAMES = APPEARANCE_TEMPLATE_NAMES + BEHAVIOUR_TEMPLATE_NAMES + ROUTE_TEMPLATE_NAMES +BEHAVIOUR_TEMPLATE_NAMES = [ + RESOLVE_CONDITION, + ACKNOWLEDGE_CONDITION, + GROUPING_ID, + SOURCE_LINK, + ROUTE, + ALERT_GROUP_LABELS, +] +ALL_TEMPLATE_NAMES = APPEARANCE_TEMPLATE_NAMES + BEHAVIOUR_TEMPLATE_NAMES class PreviewTemplateException(Exception): @@ -326,11 +333,6 @@ class PreviewTemplateMixin: templated_attr = apply_jinja_template(template_body, payload=alert_to_template.raw_request_data) except (JinjaTemplateError, JinjaTemplateWarning) as e: return Response({"preview": e.fallback_message}, status.HTTP_200_OK) - elif attr_name in ROUTE_TEMPLATE_NAMES: - try: - templated_attr = apply_jinja_template(template_body, payload=alert_to_template.raw_request_data) - except (JinjaTemplateError, JinjaTemplateWarning) as e: - return Response({"preview": e.fallback_message}, status.HTTP_200_OK) else: templated_attr = None response = {"preview": templated_attr} @@ -346,8 +348,6 @@ class PreviewTemplateMixin: destination = None if template_param.startswith(tuple(BEHAVIOUR_TEMPLATE_NAMES)): attr_name = template_param - if template_param.startswith(tuple(ROUTE_TEMPLATE_NAMES)): - attr_name = template_param elif template_param.startswith(tuple(NOTIFICATION_CHANNEL_OPTIONS)): for notification_channel in NOTIFICATION_CHANNEL_OPTIONS: if template_param.startswith(notification_channel): diff --git a/engine/common/api_helpers/utils.py b/engine/common/api_helpers/utils.py index 1233c4b0..0ec76200 100644 --- a/engine/common/api_helpers/utils.py +++ b/engine/common/api_helpers/utils.py @@ -14,6 +14,8 @@ from rest_framework.request import Request from apps.schedules.ical_utils import fetch_ical_file from common.api_helpers.exceptions import BadRequest +from common.jinja_templater import apply_jinja_template +from common.jinja_templater.apply_jinja_template import JinjaTemplateWarning from common.timezones import raise_exception_if_not_valid_timezone @@ -165,3 +167,12 @@ def check_phone_number_is_valid(phone_number): def serialize_datetime_as_utc_timestamp(dt: datetime.datetime) -> str: return dt.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + + +def valid_jinja_template_for_serializer_method_field(template): + for _, val in template.items(): + try: + apply_jinja_template(val, payload={}) + except JinjaTemplateWarning: + # Suppress warnings, template may be valid with payload + pass diff --git a/engine/conftest.py b/engine/conftest.py index 200f3a0d..1698befe 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -62,6 +62,7 @@ from apps.labels.tests.factories import ( AlertReceiveChannelAssociatedLabelFactory, LabelKeyFactory, LabelValueFactory, + WebhookAssociatedLabelFactory, ) from apps.mobile_app.models import MobileAppAuthToken, MobileAppVerificationToken from apps.phone_notifications.phone_backend import PhoneBackend @@ -953,7 +954,13 @@ def webhook_preset_api_setup(): @pytest.fixture def make_label_key(): - def _make_label_key(organization, **kwargs): + def _make_label_key(organization, key_id=None, key_name=None, **kwargs): + if key_id is not None: + kwargs["id"] = key_id + + if key_name is not None: + kwargs["name"] = key_name + return LabelKeyFactory(organization=organization, **kwargs) return _make_label_key @@ -961,7 +968,13 @@ def make_label_key(): @pytest.fixture def make_label_value(): - def _make_label_value(key, **kwargs): + def _make_label_value(key, value_id=None, value_name=None, **kwargs): + if value_id is not None: + kwargs["id"] = value_id + + if value_name is not None: + kwargs["name"] = value_name + return LabelValueFactory(key=key, **kwargs) return _make_label_value @@ -969,9 +982,9 @@ def make_label_value(): @pytest.fixture def make_label_key_and_value(make_label_key, make_label_value): - def _make_label_key_and_value(organization): - key = make_label_key(organization=organization) - value = make_label_value(key=key) + def _make_label_key_and_value(organization, key_id=None, key_name=None, value_id=None, value_name=None): + key = make_label_key(organization=organization, key_id=key_id, key_name=key_name) + value = make_label_value(key=key, value_id=value_id, value_name=value_name) return key, value return _make_label_key_and_value @@ -979,8 +992,12 @@ def make_label_key_and_value(make_label_key, make_label_value): @pytest.fixture def make_integration_label_association(make_label_key_and_value): - def _make_integration_label_association(organization, alert_receive_channel, **kwargs): - key, value = make_label_key_and_value(organization) + def _make_integration_label_association( + organization, alert_receive_channel, key_id=None, key_name=None, value_id=None, value_name=None, **kwargs + ): + key, value = make_label_key_and_value( + organization, key_id=key_id, key_name=key_name, value_id=value_id, value_name=value_name + ) return AlertReceiveChannelAssociatedLabelFactory( alert_receive_channel=alert_receive_channel, organization=organization, key=key, value=value, **kwargs ) @@ -994,3 +1011,12 @@ def make_alert_group_label_association(): return AlertGroupAssociatedLabelFactory(alert_group=alert_group, organization=organization, **kwargs) return _make_alert_group_label_association + + +@pytest.fixture +def make_webhook_label_association(make_label_key_and_value): + def _make_integration_label_association(organization, webhook, **kwargs): + key, value = make_label_key_and_value(organization) + return WebhookAssociatedLabelFactory(webhook=webhook, organization=organization, key=key, value=value, **kwargs) + + return _make_integration_label_association diff --git a/engine/engine/views.py b/engine/engine/views.py index 7f3e2f5d..cdb220d6 100644 --- a/engine/engine/views.py +++ b/engine/engine/views.py @@ -46,9 +46,6 @@ class StartupProbeView(View): if cache.get(AlertChannelDefiningMixin.CACHE_KEY_DB_FALLBACK) is None: AlertChannelDefiningMixin().update_alert_receive_channel_cache() - cache.set("healthcheck", "healthcheck", 30) # Checking cache connectivity - assert cache.get("healthcheck") == "healthcheck" - return HttpResponse("Ok") diff --git a/grafana-plugin/.eslintrc.js b/grafana-plugin/.eslintrc.js index 23cbc3ab..a09cde51 100644 --- a/grafana-plugin/.eslintrc.js +++ b/grafana-plugin/.eslintrc.js @@ -49,6 +49,8 @@ module.exports = { ], 'no-duplicate-imports': 'error', 'no-restricted-imports': 'warn', + // https://eslint.org/docs/latest/rules/no-redeclare#handled_by_typescript + 'no-redeclare': 0, 'react/display-name': 'warn', /** * It appears as though the react/prop-types rule has a bug in it diff --git a/grafana-plugin/package.json b/grafana-plugin/package.json index 02cd81fa..0178bc57 100644 --- a/grafana-plugin/package.json +++ b/grafana-plugin/package.json @@ -4,7 +4,7 @@ "description": "Grafana OnCall Plugin", "scripts": { "lint": "eslint --cache --ext .js,.jsx,.ts,.tsx --max-warnings=0 ./src ./e2e-tests", - "lint:fix": "eslint --fix --cache --ext .js,.jsx,.ts,.tsx --max-warnings=0 --quiet ./src ./e2e-tests", + "lint:fix": "eslint --fix --cache --ext .js,.jsx,.ts,.tsx --quiet ./src ./e2e-tests", "stylelint": "stylelint ./src/**/*.{css,scss,module.css,module.scss}", "stylelint:fix": "stylelint --fix ./src/**/*.{css,scss,module.css,module.scss}", "build": "grafana-toolkit plugin:build", @@ -115,7 +115,7 @@ "@grafana/data": "^9.2.4", "@grafana/faro-web-sdk": "^1.0.0-beta4", "@grafana/faro-web-tracing": "^1.0.0-beta4", - "@grafana/labels": "~1.2.1", + "@grafana/labels": "1.3.4", "@grafana/runtime": "9.3.0-beta1", "@grafana/ui": "^9.4.7", "@opentelemetry/api": "^1.3.0", diff --git a/grafana-plugin/playwright.config.ts b/grafana-plugin/playwright.config.ts index 689cd6cc..835639e5 100644 --- a/grafana-plugin/playwright.config.ts +++ b/grafana-plugin/playwright.config.ts @@ -1,4 +1,4 @@ -import { PlaywrightTestConfig, PlaywrightTestProject, defineConfig, devices } from '@playwright/test'; +import { PlaywrightTestProject, defineConfig, devices } from '@playwright/test'; import path from 'path'; /** diff --git a/grafana-plugin/src/components/GForm/GForm.tsx b/grafana-plugin/src/components/GForm/GForm.tsx index cbba9571..fb1c77ec 100644 --- a/grafana-plugin/src/components/GForm/GForm.tsx +++ b/grafana-plugin/src/components/GForm/GForm.tsx @@ -10,13 +10,21 @@ import { FormItem, FormItemType } from 'components/GForm/GForm.types'; import MonacoEditor from 'components/MonacoEditor/MonacoEditor'; import { MONACO_READONLY_CONFIG } from 'components/MonacoEditor/MonacoEditor.config'; import GSelect from 'containers/GSelect/GSelect'; -import { CustomFieldSectionRendererProps } from 'containers/IntegrationForm/IntegrationForm'; import RemoteSelect from 'containers/RemoteSelect/RemoteSelect'; import styles from './GForm.module.scss'; const cx = cn.bind(styles); +export interface CustomFieldSectionRendererProps { + control: any; + formItem: FormItem; + errors: any; + register: any; + setValue: (fieldName: string, fieldValue: any) => void; + getValues: (fieldName: string | string[]) => T; +} + interface GFormProps { form: { name: string; fields: FormItem[] }; data: any; @@ -211,6 +219,7 @@ class GForm extends React.Component { }} errors={errors} register={register} + getValues={getValues} /> ); } diff --git a/grafana-plugin/src/components/LabelsFilter/LabelsFilter.tsx b/grafana-plugin/src/components/LabelsFilter/LabelsFilter.tsx index 652941f6..57547385 100644 --- a/grafana-plugin/src/components/LabelsFilter/LabelsFilter.tsx +++ b/grafana-plugin/src/components/LabelsFilter/LabelsFilter.tsx @@ -21,14 +21,15 @@ const LabelsFilter: FC = (props) => { const [search, setSearch] = useState(''); const handleChange = useCallback((value) => { - onChange(value.map((v) => v.value)); + onChange(value.map((v) => v.data)); }, []); const handleLoadOptions = (search) => { return onLoadOptions(search).then((options) => options.map((v) => ({ label: `${v.key[FieldName]} : ${v.value[FieldName]}`, - value: v, + value: `${v.key[FieldName]} : ${v.value[FieldName]}`, + data: v, })) ); }; @@ -37,7 +38,8 @@ const LabelsFilter: FC = (props) => { () => propsValue.map((v) => ({ label: `${v.key[FieldName]} : ${v.value[FieldName]}`, - value: v, + value: `${v.key[FieldName]} : ${v.value[FieldName]}`, + data: v, })), [propsValue] ); diff --git a/grafana-plugin/src/components/LabelsTooltipBadge/LabelsTooltipBadge.tsx b/grafana-plugin/src/components/LabelsTooltipBadge/LabelsTooltipBadge.tsx new file mode 100644 index 00000000..a2b9262d --- /dev/null +++ b/grafana-plugin/src/components/LabelsTooltipBadge/LabelsTooltipBadge.tsx @@ -0,0 +1,40 @@ +import React, { FC } from 'react'; + +import { LabelTag } from '@grafana/labels'; +import { VerticalGroup, HorizontalGroup, Button } from '@grafana/ui'; + +import TooltipBadge from 'components/TooltipBadge/TooltipBadge'; +import { LabelKeyValue } from 'models/label/label.types'; + +interface LabelsTooltipBadgeProps { + labels: LabelKeyValue[]; + onClick: (label: LabelKeyValue) => void; +} + +const LabelsTooltipBadge: FC = ({ labels, onClick }) => + labels.length ? ( + + {labels.map((label) => ( + + + - - - - - + + +
+ + + + +
+ + + {customLabelIndexToShowTemplateEditor !== undefined && ( + setCustomLabelIndexToShowTemplateEditor(undefined)} + onUpdateTemplates={({ alert_group_labels }) => { + const newCustom = [...alertGroupLabels.custom]; + newCustom[customLabelIndexToShowTemplateEditor].value.name = alert_group_labels; + + setAlertGroupLabels({ + ...alertGroupLabels, + custom: newCustom, + }); + + setCustomLabelIndexToShowTemplateEditor(undefined); + }} + /> + )} + {showTemplateEditor && ( + setShowTemplateEditor(false)} + onUpdateTemplates={({ alert_group_labels }) => { + setAlertGroupLabels({ + ...alertGroupLabels, + template: alert_group_labels, + }); + + setShowTemplateEditor(undefined); + }} + /> + )} + ); }); +interface CustomLabelsProps { + alertGroupLabels: AlertReceiveChannel['alert_group_labels']; + onChange: (value: AlertReceiveChannel['alert_group_labels']) => void; + onShowTemplateEditor: (index: number) => void; +} + +const CustomLabels = (props: CustomLabelsProps) => { + const { alertGroupLabels, onChange, onShowTemplateEditor } = props; + + const { labelsStore } = useStore(); + + const handlePlainLabelAdd = () => { + onChange({ + ...alertGroupLabels, + custom: [ + ...alertGroupLabels.custom, + { + key: { id: undefined, name: undefined }, + value: { id: undefined, name: undefined }, + }, + ], + }); + }; + const handleTemplatedLabelAdd = () => { + onChange({ + ...alertGroupLabels, + custom: [ + ...alertGroupLabels.custom, + { + key: { id: undefined, name: undefined }, + value: { id: null, name: undefined }, // id = null means it's a templated value + }, + ], + }); + }; + + const cachedOnLoadKeys = useCallback(() => { + let result = undefined; + return async (search?: string) => { + if (!result) { + try { + result = await labelsStore.loadKeys(); + } catch (error) { + openErrorNotification('There was an error processing your request. Please try again'); + } + } + + return result.filter((k) => k.name.toLowerCase().includes(search.toLowerCase())); + }; + }, []); + + const cachedOnLoadValuesForKey = useCallback(() => { + let result = undefined; + return async (key: string, search?: string) => { + if (!result) { + try { + const { values } = await labelsStore.loadValuesForKey(key, search); + result = values; + } catch (error) { + openErrorNotification('There was an error processing your request. Please try again'); + } + } + + return result.filter((k) => k.name.toLowerCase().includes(search.toLowerCase())); + }; + }, []); + + return ( + + + + + { + if (res?.response?.status === 409) { + openErrorNotification(`Duplicate values are not allowed`); + } else { + openErrorNotification('An error has occurred. Please try again'); + } + }} + renderValue={(option, index, renderValueDefault) => { + if (option.value.id === null) { + return ( + { + onShowTemplateEditor(index); + }} + /> + } + onChange={(e: ChangeEvent) => { + const newCustom = [...alertGroupLabels.custom]; + newCustom[index].value.name = e.currentTarget.value; + + onChange({ ...alertGroupLabels, custom: newCustom }); + }} + /> + ); + } else { + return renderValueDefault(option, index); + } + }} + onDataUpdate={(value) => { + onChange({ + ...alertGroupLabels, + custom: value, + }); + }} + /> + + + + + } + > + + + + ); +}; + export default IntegrationLabelsForm; diff --git a/grafana-plugin/src/containers/IntegrationTemplate/IntegrationTemplate.tsx b/grafana-plugin/src/containers/IntegrationTemplate/IntegrationTemplate.tsx index 7376ee96..98e98b71 100644 --- a/grafana-plugin/src/containers/IntegrationTemplate/IntegrationTemplate.tsx +++ b/grafana-plugin/src/containers/IntegrationTemplate/IntegrationTemplate.tsx @@ -5,6 +5,7 @@ import cn from 'classnames/bind'; import { debounce } from 'lodash-es'; import { observer } from 'mobx-react'; +import { templateForEdit } from 'components/AlertTemplates/AlertTemplatesForm.config'; import { TemplateForEdit } from 'components/AlertTemplates/CommonAlertTemplatesForm.config'; import CheatSheet from 'components/CheatSheet/CheatSheet'; import { @@ -38,7 +39,7 @@ interface IntegrationTemplateProps { templates: AlertTemplatesDTO[]; onHide: () => void; onUpdateTemplates: (values: any) => void; - onUpdateRoute: (values: any, channelFilterId?: ChannelFilter['id']) => void; + onUpdateRoute?: (values: any, channelFilterId?: ChannelFilter['id']) => void; } const IntegrationTemplate = observer((props: IntegrationTemplateProps) => { @@ -53,11 +54,13 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => { const [isRecentAlertGroupExisting, setIsRecentAlertGroupExisting] = useState(false); useEffect(() => { - const locationParams: any = { template: template.name }; - if (template.isRoute) { - locationParams.routeId = channelFilterId; + if (templateForEdit[template.name]) { + const locationParams: any = { template: template.name }; + if (template.isRoute) { + locationParams.routeId = channelFilterId; + } + LocationHelper.update(locationParams, 'partial'); } - LocationHelper.update(locationParams, 'partial'); }, []); useEffect(() => { diff --git a/grafana-plugin/src/containers/Labels/Labels.tsx b/grafana-plugin/src/containers/Labels/Labels.tsx index f744e3f9..60fc1324 100644 --- a/grafana-plugin/src/containers/Labels/Labels.tsx +++ b/grafana-plugin/src/containers/Labels/Labels.tsx @@ -1,6 +1,6 @@ import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react'; -import ServiceLabels from '@grafana/labels'; +import { ServiceLabels, ServiceLabelsProps } from '@grafana/labels'; import { Field } from '@grafana/ui'; import cn from 'classnames/bind'; import { isEmpty } from 'lodash-es'; @@ -14,14 +14,15 @@ import styles from './Labels.module.css'; const cx = cn.bind(styles); -interface LabelsProps { +export interface LabelsProps { value: LabelKeyValue[]; errors: any; + onDataUpdate?: ServiceLabelsProps['onDataUpdate']; } const Labels = observer( forwardRef(function Labels2(props: LabelsProps, ref) { - const { value: defaultValue, errors: propsErrors } = props; + const { value: defaultValue, errors: propsErrors, onDataUpdate } = props; // propsErrors are 'external' caused by attaching/detaching labels to oncall entities, // state errors are errors caused by CRUD operations on labels storage @@ -30,6 +31,13 @@ const Labels = observer( const { labelsStore } = useStore(); + const onChange = (value: LabelKeyValue[]) => { + if (onDataUpdate) { + onDataUpdate(value); + } + setValue(value); + }; + useImperativeHandle( ref, () => { @@ -113,7 +121,7 @@ const Labels = observer( onRowItemRemoval={(_pair, _index) => {}} onUpdateError={onUpdateError} errors={isValid() ? {} : { ...propsErrors }} - onDataUpdate={setValue} + onDataUpdate={onChange} /> diff --git a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx index d299ca94..32a110c5 100644 --- a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.config.tsx @@ -8,6 +8,8 @@ import { OutgoingWebhookPreset } from 'models/outgoing_webhook/outgoing_webhook. import { KeyValuePair } from 'utils'; import { generateAssignToTeamInputDescription } from 'utils/consts'; +import { WebhookFormFieldName } from './OutgoingWebhookForm.types'; + export const WebhookTriggerType = { EscalationStep: new KeyValuePair('0', 'Escalation Step'), AlertGroupCreated: new KeyValuePair('1', 'Alert Group Created'), @@ -19,23 +21,29 @@ export const WebhookTriggerType = { Unacknowledged: new KeyValuePair('7', 'Unacknowledged'), }; -export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fields: FormItem[] } { +export function createForm( + presets: OutgoingWebhookPreset[], + hasLabelsFeature?: boolean +): { + name: string; + fields: FormItem[]; +} { return { name: 'OutgoingWebhook', fields: [ { - name: 'name', + name: WebhookFormFieldName.Name, type: FormItemType.Input, validation: { required: true }, }, { - name: 'is_webhook_enabled', + name: WebhookFormFieldName.IsWebhookEnabled, label: 'Enabled', normalize: (value) => Boolean(value), type: FormItemType.Switch, }, { - name: 'team', + name: WebhookFormFieldName.Team, label: 'Assign to Team', description: `${generateAssignToTeamInputDescription( 'Outgoing Webhooks' @@ -51,7 +59,7 @@ export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fi }, }, { - name: 'trigger_type', + name: WebhookFormFieldName.TriggerType, label: 'Trigger Type', description: 'The type of event which will cause this webhook to execute.', type: FormItemType.Select, @@ -92,13 +100,11 @@ export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fi }, ], }, - isVisible: (data) => { - return isPresetFieldVisible(data.preset, presets, 'trigger_type'); - }, + isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.TriggerType), normalize: (value) => value, }, { - name: 'http_method', + name: WebhookFormFieldName.HttpMethod, label: 'HTTP Method', type: FormItemType.Select, extra: { @@ -126,19 +132,16 @@ export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fi }, ], }, - isVisible: (data) => isPresetFieldVisible(data.preset, presets, 'http_method'), + isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.HttpMethod), normalize: (value) => value, }, { - name: 'integration_filter', + name: WebhookFormFieldName.IntegrationFilter, label: 'Integrations', type: FormItemType.MultiSelect, - isVisible: (data) => { - return ( - isPresetFieldVisible(data.preset, presets, 'integration_filter') && - data.trigger_type !== WebhookTriggerType.EscalationStep.key - ); - }, + isVisible: (data) => + isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.IntegrationFilter) && + data.trigger_type !== WebhookTriggerType.EscalationStep.key, extra: { placeholder: 'Choose (Optional)', modelName: 'alertReceiveChannelStore', @@ -151,88 +154,79 @@ export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fi 'Integrations that this webhook applies to. If this is empty the webhook will execute for all integrations', }, { - name: 'url', + name: WebhookFormFieldName.Labels, + label: 'Labels', + type: FormItemType.Other, + render: true, + }, + { + name: WebhookFormFieldName.Url, label: 'Webhook URL', type: FormItemType.Monaco, extra: { height: 30, }, - isVisible: (data) => { - return isPresetFieldVisible(data.preset, presets, 'url'); - }, + isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Url), }, { - name: 'headers', + name: WebhookFormFieldName.Headers, label: 'Webhook Headers', description: 'Request headers should be in JSON format.', type: FormItemType.Monaco, extra: { rows: 3, }, - isVisible: (data) => { - return isPresetFieldVisible(data.preset, presets, 'headers'); - }, + isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Headers), }, { - name: 'username', + name: WebhookFormFieldName.Username, type: FormItemType.Input, - isVisible: (data) => { - return isPresetFieldVisible(data.preset, presets, 'username'); - }, + isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Username), }, { - name: 'password', + name: WebhookFormFieldName.Password, type: FormItemType.Password, - isVisible: (data) => { - return isPresetFieldVisible(data.preset, presets, 'password'); - }, + isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Password), }, { - name: 'authorization_header', + name: WebhookFormFieldName.AuthorizationHeader, description: 'Value of the Authorization header, do not need to prefix with "Authorization:". For example: Bearer AbCdEf123456', type: FormItemType.Password, - isVisible: (data) => { - return isPresetFieldVisible(data.preset, presets, 'authorization_header'); - }, + isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.AuthorizationHeader), }, { - name: 'trigger_template', + name: WebhookFormFieldName.TriggerTemplate, type: FormItemType.Monaco, description: 'Trigger template is used to conditionally execute the webhook based on incoming data. The trigger template must be empty or evaluate to true or 1 for the webhook to be sent', extra: { rows: 2, }, - isVisible: (data) => { - return isPresetFieldVisible(data.preset, presets, 'trigger_template'); - }, + isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.TriggerTemplate), }, { - name: 'forward_all', + name: WebhookFormFieldName.ForwardAll, normalize: (value) => (value ? Boolean(value) : value), type: FormItemType.Switch, description: "Forwards whole payload of the alert group and context data to the webhook's url as POST/PUT data", - isVisible: (data) => { - return isPresetFieldVisible(data.preset, presets, 'forward_all'); - }, + isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.ForwardAll), }, { - name: 'data', + name: WebhookFormFieldName.Data, getDisabled: (data) => Boolean(data?.forward_all), type: FormItemType.Monaco, - description: - 'Available variables: {{ event }}, {{ user }}, {{ alert_group }}, {{ alert_group_id }}, {{ alert_payload }}, {{ integration }}, {{ notified_users }}, {{ users_to_be_notified }}, {{ responses }}', + description: `Available variables: {{ event }}, {{ user }}, {{ alert_group }}, {{ alert_group_id }}, {{ alert_payload }}, {{ integration }}, {{ notified_users }}, {{ users_to_be_notified }}, {{ responses }}${ + hasLabelsFeature ? ' {{ webhook }}' : '' + }`, extra: {}, - isVisible: (data) => { - return isPresetFieldVisible(data.preset, presets, 'data'); - }, + isVisible: (data) => isPresetFieldVisible(data.preset, presets, WebhookFormFieldName.Data), }, ], }; } -function isPresetFieldVisible(presetId: string, presets: OutgoingWebhookPreset[], fieldName: string) { +function isPresetFieldVisible(presetId: string, presets: OutgoingWebhookPreset[], fieldName: WebhookFormFieldName) { if (presetId == null) { return true; } diff --git a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx index 61bfc8fe..3c3879c5 100644 --- a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.tsx @@ -17,23 +17,28 @@ import { observer } from 'mobx-react'; import { useHistory } from 'react-router-dom'; import Block from 'components/GBlock/Block'; -import GForm from 'components/GForm/GForm'; +import GForm, { CustomFieldSectionRendererProps } from 'components/GForm/GForm'; import { FormItem, FormItemType } from 'components/GForm/GForm.types'; import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo'; import { logoCoors } from 'components/IntegrationLogo/IntegrationLogo.config'; +import RenderConditionally from 'components/RenderConditionally/RenderConditionally'; import Text from 'components/Text/Text'; +import Labels, { LabelsProps } from 'containers/Labels/Labels'; import { webhookPresetIcons } from 'containers/OutgoingWebhookForm/WebhookPresetIcons.config'; import OutgoingWebhookStatus from 'containers/OutgoingWebhookStatus/OutgoingWebhookStatus'; import WebhooksTemplateEditor from 'containers/WebhooksTemplateEditor/WebhooksTemplateEditor'; import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; +import { LabelKeyValue } from 'models/label/label.types'; import { OutgoingWebhook, OutgoingWebhookPreset } from 'models/outgoing_webhook/outgoing_webhook.types'; import { WebhookFormActionType } from 'pages/outgoing_webhooks/OutgoingWebhooks.types'; +import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; import { KeyValuePair } from 'utils'; import { UserActions } from 'utils/authorization'; import { PLUGIN_ROOT } from 'utils/consts'; import { createForm } from './OutgoingWebhookForm.config'; +import { WebhookFormFieldName } from './OutgoingWebhookForm.types'; import styles from 'containers/OutgoingWebhookForm/OutgoingWebhookForm.module.css'; @@ -52,6 +57,23 @@ export const WebhookTabs = { LastRun: new KeyValuePair('LastRun', 'Last Run'), }; +const CustomFieldSectionRenderer: React.FC = observer( + ({ errors, setValue, getValues }) => { + const { hasFeature } = useStore(); + const onDataUpdate: LabelsProps['onDataUpdate'] = (val) => setValue(WebhookFormFieldName.Labels, val); + + return ( + + (WebhookFormFieldName.Labels) || []} + errors={errors?.[WebhookFormFieldName.Labels]} + onDataUpdate={onDataUpdate} + /> + + ); + } +); + const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => { const history = useHistory(); const { id, action, onUpdate, onHide, onDelete } = props; @@ -65,10 +87,10 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => { const [selectedPreset, setSelectedPreset] = useState(undefined); const [filterValue, setFilterValue] = useState(''); - const { outgoingWebhookStore } = useStore(); + const { outgoingWebhookStore, hasFeature } = useStore(); const isNew = action === WebhookFormActionType.NEW; const isNewOrCopy = isNew || action === WebhookFormActionType.COPY; - const form = createForm(outgoingWebhookStore.outgoingWebhookPresets); + const form = createForm(outgoingWebhookStore.outgoingWebhookPresets, hasFeature(AppFeature.Labels)); const handleSubmit = useCallback( (data: Partial) => { @@ -149,7 +171,15 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => { return null; } - const formElement = ; + const formElement = ( + + ); const createWebhookParameters = ( <> @@ -279,7 +309,13 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => { return ( <>
- +
{id === 'new' ? ( @@ -339,8 +375,8 @@ const WebhookTabsContent: React.FC = ({ formElement, }) => { const [confirmationModal, setConfirmationModal] = useState(undefined); - const { outgoingWebhookStore } = useStore(); - const form = createForm(outgoingWebhookStore.outgoingWebhookPresets); + const { outgoingWebhookStore, hasFeature } = useStore(); + const form = createForm(outgoingWebhookStore.outgoingWebhookPresets, hasFeature(AppFeature.Labels)); return (
{confirmationModal && ( diff --git a/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.types.ts b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.types.ts new file mode 100644 index 00000000..044c5606 --- /dev/null +++ b/grafana-plugin/src/containers/OutgoingWebhookForm/OutgoingWebhookForm.types.ts @@ -0,0 +1,18 @@ +export const WebhookFormFieldName = { + Name: 'name', + IsWebhookEnabled: 'is_webhook_enabled', + Team: 'team', + TriggerType: 'trigger_type', + HttpMethod: 'http_method', + IntegrationFilter: 'integration_filter', + Labels: 'labels', + Url: 'url', + Headers: 'headers', + Username: 'username', + Password: 'password', + AuthorizationHeader: 'authorization_header', + TriggerTemplate: 'trigger_template', + ForwardAll: 'forward_all', + Data: 'data', +} as const; +export type WebhookFormFieldName = (typeof WebhookFormFieldName)[keyof typeof WebhookFormFieldName]; diff --git a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts index 5d71fc50..619ac98d 100644 --- a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts +++ b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts @@ -15,10 +15,11 @@ export function parseFilters( filterOptions: FilterOption[], query: { [key: string]: any } ) { - const filters = filterOptions.filter((filterOption: FilterOption) => filterOption.name in data); + const dataWithPredefinedTeams = { ...data, team: data.team || [] }; + const filters = filterOptions.filter((filterOption: FilterOption) => filterOption.name in dataWithPredefinedTeams); const values = filters.reduce((memo: any, filterOption: FilterOption) => { - const rawValue = query[filterOption.name] || data[filterOption.name]; // query takes priority over local storage + const rawValue = query[filterOption.name] || dataWithPredefinedTeams[filterOption.name]; // query takes priority over local storage let value: any = rawValue; diff --git a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx index f816c77a..769be69a 100644 --- a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx +++ b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx @@ -75,7 +75,7 @@ class RemoteFilters extends Component { const { filterOptions } = this.state; - let { filters, values } = parseFilters(query, filterOptions, query); + let { filters, values } = parseFilters({ ...query, ...filtersStore.globalValues }, filterOptions, query); this.setState({ filterOptions, filters, values }, () => this.onChange()); } @@ -103,7 +103,7 @@ class RemoteFilters extends Component { let { filters, values } = parseFilters({ ...query, ...filtersStore.globalValues }, filterOptions, query); if (isEmpty(values)) { - ({ filters, values } = parseFilters(defaultFilters || { team: [] }, filterOptions, query)); + ({ filters, values } = parseFilters(defaultFilters, filterOptions, query)); } this.setState({ filterOptions, filters, values }, () => this.onChange(true)); @@ -273,6 +273,7 @@ class RemoteFilters extends Component { value={values[filter.name]} onChange={this.getRemoteOptionsChangeHandler(filter.name)} getOptionLabel={(item: SelectableValue) => } + predefinedOptions={filter.default ? [filter.default] : undefined} /> ); diff --git a/grafana-plugin/src/containers/RemoteSelect/RemoteSelect.tsx b/grafana-plugin/src/containers/RemoteSelect/RemoteSelect.tsx index b9572040..377af14f 100644 --- a/grafana-plugin/src/containers/RemoteSelect/RemoteSelect.tsx +++ b/grafana-plugin/src/containers/RemoteSelect/RemoteSelect.tsx @@ -27,6 +27,7 @@ interface RemoteSelectProps { showError?: boolean; maxMenuHeight?: number; requiredUserAction?: UserAction; + predefinedOptions?: any[]; } const RemoteSelect = inject('store')( @@ -49,6 +50,7 @@ const RemoteSelect = inject('store')( showError, maxMenuHeight, requiredUserAction, + predefinedOptions, } = props; const [noOptionsMessage, setNoOptionsMessage] = useState('No options found'); @@ -66,7 +68,7 @@ const RemoteSelect = inject('store')( return oldOptions.concat(newOptions.filter(({ value }) => !existingValues.includes(value))); } - const [options, setOptions] = useReducer(mergeOptions, []); + const [options, setOptions] = useReducer(mergeOptions, getOptions(predefinedOptions || [])); const loadOptionsCallback = useDebouncedCallback(async (query: string, cb) => { try { diff --git a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts index ec366bf2..fd3ea806 100644 --- a/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts +++ b/grafana-plugin/src/models/alert_receive_channel/alert_receive_channel.types.ts @@ -49,7 +49,11 @@ export interface AlertReceiveChannel { allow_delete: boolean; deleted?: boolean; labels: LabelKeyValue[]; - alert_group_labels: { inheritable: Record }; + alert_group_labels: { + inheritable: Record; + custom: LabelKeyValue[]; + template: string; + }; } export interface AlertReceiveChannelChoice { diff --git a/grafana-plugin/src/models/filters/filters.ts b/grafana-plugin/src/models/filters/filters.ts index ac4a63f3..16f76283 100644 --- a/grafana-plugin/src/models/filters/filters.ts +++ b/grafana-plugin/src/models/filters/filters.ts @@ -1,8 +1,10 @@ import { action, observable } from 'mobx'; import BaseStore from 'models/base_store'; +import { LabelKeyValue } from 'models/label/label.types'; import { makeRequest } from 'network'; import { RootStore } from 'state'; +import LocationHelper from 'utils/LocationHelper'; import { PAGE } from 'utils/consts'; import { getItem, setItem } from 'utils/localStorage'; @@ -79,4 +81,21 @@ export class FiltersStore extends BaseStore { setCurrentTablePageNum(page: PAGE, currentTablePageNum: number) { this.currentTablePageNum[page] = currentTablePageNum; } + + @action + applyLabelFilter = (label: LabelKeyValue, page: PAGE) => { + const currentLabelFilterValues = this.values[page]?.label || []; + const labelToAddString = `${label.key.id}:${label.value.id}`; + const newLabelFilter = [...currentLabelFilterValues, labelToAddString]; + + if (currentLabelFilterValues?.some((label) => label === labelToAddString)) { + return; + } + + this.updateValuesForPage(page, { + label: newLabelFilter, + }); + LocationHelper.update({ label: newLabelFilter }, 'partial'); + this.setNeedToParseFilters(true); + }; } diff --git a/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.types.ts b/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.types.ts index 88d58af3..db773cf3 100644 --- a/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.types.ts +++ b/grafana-plugin/src/models/outgoing_webhook/outgoing_webhook.types.ts @@ -1,4 +1,5 @@ import { GrafanaTeam } from 'models/grafana_team/grafana_team.types'; +import { LabelKeyValue } from 'models/label/label.types'; export interface OutgoingWebhook { authorization_header: string; @@ -19,6 +20,7 @@ export interface OutgoingWebhook { is_webhook_enabled: boolean; is_legacy: boolean; preset: string; + labels: LabelKeyValue[]; } export interface OutgoingWebhookResponse { diff --git a/grafana-plugin/src/pages/incidents/Incidents.tsx b/grafana-plugin/src/pages/incidents/Incidents.tsx index 60c33cf5..f08abb31 100644 --- a/grafana-plugin/src/pages/incidents/Incidents.tsx +++ b/grafana-plugin/src/pages/incidents/Incidents.tsx @@ -1,6 +1,5 @@ import React, { SyntheticEvent } from 'react'; -import { LabelTag } from '@grafana/labels'; import { Button, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; import { observer } from 'mobx-react'; @@ -12,11 +11,11 @@ import CardButton from 'components/CardButton/CardButton'; import CursorPagination from 'components/CursorPagination/CursorPagination'; import GTable from 'components/GTable/GTable'; import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo'; +import LabelsTooltipBadge from 'components/LabelsTooltipBadge/LabelsTooltipBadge'; import ManualAlertGroup from 'components/ManualAlertGroup/ManualAlertGroup'; import PluginLink from 'components/PluginLink/PluginLink'; import Text from 'components/Text/Text'; import TextEllipsisTooltip from 'components/TextEllipsisTooltip/TextEllipsisTooltip'; -import TooltipBadge from 'components/TooltipBadge/TooltipBadge'; import Tutorial from 'components/Tutorial/Tutorial'; import { TutorialStep } from 'components/Tutorial/Tutorial.types'; import { IncidentsFiltersType } from 'containers/IncidentsFilters/IncidentFilters.types'; @@ -24,7 +23,6 @@ import RemoteFilters from 'containers/RemoteFilters/RemoteFilters'; import TeamName from 'containers/TeamName/TeamName'; import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; import { Alert, Alert as AlertType, AlertAction, IncidentStatus } from 'models/alertgroup/alertgroup.types'; -import { LabelKeyValue } from 'models/label/label.types'; import { renderRelatedUsers } from 'pages/incident/Incident.helpers'; import { AppFeature } from 'state/features'; import { PageProps, WithStoreProps } from 'state/types'; @@ -587,37 +585,6 @@ class Incidents extends React.Component ); } - renderLabels(item: AlertType) { - if (!item.labels.length) { - return null; - } - - return ( - - {item.labels.map((label) => ( - - -