From dcae98b02ad69a27742df683252870454d8ccb16 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Mon, 21 Apr 2025 14:23:37 -0300 Subject: [PATCH] feat: add support for mattermost chatops (#5321) Related to https://github.com/grafana/oncall/issues/96 --------- Co-authored-by: Ravishankar --- dev/.env.dev.example | 8 + .../sources/manage/notify/mattermost/index.md | 27 +- docs/sources/set-up/open-source/index.md | 36 +- engine/apps/alerts/constants.py | 1 + ...alter_alertgrouplogrecord_action_source.py | 18 + engine/apps/api/serializers/organization.py | 4 + engine/apps/api/tests/test_auth.py | 83 ++- engine/apps/api/tests/test_organization.py | 1 + engine/apps/api/views/auth.py | 13 +- engine/apps/api/views/features.py | 4 + engine/apps/auth_token/auth.py | 13 +- engine/apps/auth_token/constants.py | 1 + .../migrations/0008_mattermostauthtoken.py | 32 ++ engine/apps/auth_token/models/__init__.py | 1 + .../models/mattermost_auth_token.py | 38 ++ engine/apps/base/models/live_setting.py | 39 ++ .../user_notification_policy_log_record.py | 11 +- engine/apps/mattermost/__init__.py | 0 .../mattermost/alert_group_representative.py | 87 +++ engine/apps/mattermost/alert_rendering.py | 130 +++++ engine/apps/mattermost/apps.py | 8 + engine/apps/mattermost/auth.py | 29 + engine/apps/mattermost/backend.py | 65 +++ engine/apps/mattermost/client.py | 108 ++++ engine/apps/mattermost/events/__init__.py | 1 + .../events/alert_group_actions_handler.py | 81 +++ .../apps/mattermost/events/event_handler.py | 18 + .../apps/mattermost/events/event_manager.py | 37 ++ engine/apps/mattermost/events/types.py | 29 + engine/apps/mattermost/exceptions.py | 21 + .../mattermost/migrations/0001_initial.py | 68 +++ engine/apps/mattermost/migrations/__init__.py | 0 engine/apps/mattermost/models/__init__.py | 3 + engine/apps/mattermost/models/channel.py | 102 ++++ engine/apps/mattermost/models/message.py | 48 ++ engine/apps/mattermost/models/user.py | 20 + engine/apps/mattermost/serializers.py | 53 ++ engine/apps/mattermost/signals.py | 5 + engine/apps/mattermost/tasks.py | 182 +++++++ engine/apps/mattermost/tests/__init__.py | 0 engine/apps/mattermost/tests/conftest.py | 124 +++++ .../apps/mattermost/tests/events/__init__.py | 0 .../events/test_alert_group_action_handler.py | 122 +++++ engine/apps/mattermost/tests/factories.py | 35 ++ .../mattermost/tests/models/test_channel.py | 90 ++++ .../mattermost/tests/test_alert_rendering.py | 48 ++ engine/apps/mattermost/tests/test_backend.py | 94 ++++ .../tests/test_mattermost_channel.py | 383 ++++++++++++++ .../tests/test_mattermost_client.py | 161 ++++++ .../mattermost/tests/test_mattermost_event.py | 155 ++++++ .../mattermost/tests/test_representative.py | 100 ++++ engine/apps/mattermost/tests/test_tasks.py | 495 ++++++++++++++++++ engine/apps/mattermost/tests/test_utils.py | 26 + engine/apps/mattermost/urls.py | 14 + engine/apps/mattermost/utils.py | 38 ++ engine/apps/mattermost/views.py | 82 +++ engine/apps/social_auth/backends.py | 83 ++- engine/apps/social_auth/exceptions.py | 5 + .../live_setting_django_strategy.py | 6 + engine/apps/social_auth/middlewares.py | 8 +- engine/apps/social_auth/pipeline/common.py | 4 + .../apps/social_auth/pipeline/mattermost.py | 31 ++ engine/apps/social_auth/pipeline/slack.py | 4 - .../insight_log/chatops_insight_logs.py | 1 + engine/conftest.py | 9 + engine/engine/urls.py | 4 + engine/requirements-dev.txt | 2 +- engine/requirements.in | 2 +- engine/requirements.txt | 2 +- engine/settings/base.py | 24 +- engine/settings/celery_task_routes.py | 4 + engine/settings/ci_test.py | 7 + engine/settings/dev.py | 4 + .../AlertTemplatesForm.config.ts | 45 +- .../src/containers/AlertRules/AlertRules.tsx | 8 +- .../parts/connectors/MattermostConnector.tsx | 81 +++ .../IntegrationTemplatesList.config.ts | 30 +- .../MattermostIntegrationButton.tsx | 145 +++++ .../containers/UserSettings/UserSettings.tsx | 46 +- .../UserSettings/UserSettings.types.ts | 1 + .../UserSettings/parts/UserSettingsParts.tsx | 13 + .../parts/connectors/Connectors.tsx | 2 + .../parts/connectors/MattermostConnector.tsx | 62 +++ .../tabs/MattermostInfo/MattermostInfo.tsx | 56 ++ grafana-plugin/src/helpers/consts.ts | 1 + .../src/models/mattermost/mattermost.ts | 30 ++ .../src/models/mattermost/mattermost.types.ts | 7 + .../models/mattermost/mattermost_channel.ts | 101 ++++ .../models/organization/organization.types.ts | 1 + .../pages/integration/Integration.helper.ts | 3 +- .../pages/settings/tabs/ChatOps/ChatOps.tsx | 14 +- .../MattermostSettings/MattermostSettings.tsx | 223 ++++++++ grafana-plugin/src/state/features.ts | 1 + .../src/state/rootBaseStore/RootBaseStore.ts | 4 + 94 files changed, 4429 insertions(+), 37 deletions(-) create mode 100644 engine/apps/alerts/migrations/0075_alter_alertgrouplogrecord_action_source.py create mode 100644 engine/apps/auth_token/migrations/0008_mattermostauthtoken.py create mode 100644 engine/apps/auth_token/models/mattermost_auth_token.py create mode 100644 engine/apps/mattermost/__init__.py create mode 100644 engine/apps/mattermost/alert_group_representative.py create mode 100644 engine/apps/mattermost/alert_rendering.py create mode 100644 engine/apps/mattermost/apps.py create mode 100644 engine/apps/mattermost/auth.py create mode 100644 engine/apps/mattermost/backend.py create mode 100644 engine/apps/mattermost/client.py create mode 100644 engine/apps/mattermost/events/__init__.py create mode 100644 engine/apps/mattermost/events/alert_group_actions_handler.py create mode 100644 engine/apps/mattermost/events/event_handler.py create mode 100644 engine/apps/mattermost/events/event_manager.py create mode 100644 engine/apps/mattermost/events/types.py create mode 100644 engine/apps/mattermost/exceptions.py create mode 100644 engine/apps/mattermost/migrations/0001_initial.py create mode 100644 engine/apps/mattermost/migrations/__init__.py create mode 100644 engine/apps/mattermost/models/__init__.py create mode 100644 engine/apps/mattermost/models/channel.py create mode 100644 engine/apps/mattermost/models/message.py create mode 100644 engine/apps/mattermost/models/user.py create mode 100644 engine/apps/mattermost/serializers.py create mode 100644 engine/apps/mattermost/signals.py create mode 100644 engine/apps/mattermost/tasks.py create mode 100644 engine/apps/mattermost/tests/__init__.py create mode 100644 engine/apps/mattermost/tests/conftest.py create mode 100644 engine/apps/mattermost/tests/events/__init__.py create mode 100644 engine/apps/mattermost/tests/events/test_alert_group_action_handler.py create mode 100644 engine/apps/mattermost/tests/factories.py create mode 100644 engine/apps/mattermost/tests/models/test_channel.py create mode 100644 engine/apps/mattermost/tests/test_alert_rendering.py create mode 100644 engine/apps/mattermost/tests/test_backend.py create mode 100644 engine/apps/mattermost/tests/test_mattermost_channel.py create mode 100644 engine/apps/mattermost/tests/test_mattermost_client.py create mode 100644 engine/apps/mattermost/tests/test_mattermost_event.py create mode 100644 engine/apps/mattermost/tests/test_representative.py create mode 100644 engine/apps/mattermost/tests/test_tasks.py create mode 100644 engine/apps/mattermost/tests/test_utils.py create mode 100644 engine/apps/mattermost/urls.py create mode 100644 engine/apps/mattermost/utils.py create mode 100644 engine/apps/mattermost/views.py create mode 100644 engine/apps/social_auth/pipeline/mattermost.py create mode 100644 grafana-plugin/src/containers/AlertRules/parts/connectors/MattermostConnector.tsx create mode 100644 grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.tsx create mode 100644 grafana-plugin/src/containers/UserSettings/parts/connectors/MattermostConnector.tsx create mode 100644 grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.tsx create mode 100644 grafana-plugin/src/models/mattermost/mattermost.ts create mode 100644 grafana-plugin/src/models/mattermost/mattermost.types.ts create mode 100644 grafana-plugin/src/models/mattermost/mattermost_channel.ts create mode 100644 grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx diff --git a/dev/.env.dev.example b/dev/.env.dev.example index 40db0b4e..426c2cd4 100644 --- a/dev/.env.dev.example +++ b/dev/.env.dev.example @@ -13,12 +13,20 @@ TWILIO_VERIFY_SERVICE_SID= TWILIO_AUTH_TOKEN= TWILIO_NUMBER= +MATTERMOST_CLIENT_OAUTH_ID= +MATTERMOST_CLIENT_OAUTH_SECRET= +MATTERMOST_HOST= +MATTERMOST_BOT_TOKEN= +MATTERMOST_LOGIN_RETURN_REDIRECT_HOST=http://localhost:8080 +MATTERMOST_SIGNING_SECRET= + DJANGO_SETTINGS_MODULE=settings.dev SECRET_KEY=jyRnfRIeMjYfKdoFa9dKXcNaEGGc8GH1TChmYoWW BASE_URL=http://localhost:8080 FEATURE_TELEGRAM_INTEGRATION_ENABLED=True FEATURE_SLACK_INTEGRATION_ENABLED=True +FEATURE_MATTERMOST_INTEGRATION_ENABLED=True SLACK_INSTALL_RETURN_REDIRECT_HOST=http://localhost:8080 SOCIAL_AUTH_REDIRECT_IS_HTTPS=False diff --git a/docs/sources/manage/notify/mattermost/index.md b/docs/sources/manage/notify/mattermost/index.md index 7bdae427..b3403343 100644 --- a/docs/sources/manage/notify/mattermost/index.md +++ b/docs/sources/manage/notify/mattermost/index.md @@ -1,7 +1,7 @@ --- title: Mattermost menuTitle: Mattermost -description: Explains that a Mattermost integration is not implemented yet. +description: How to connect Mattermost for alert group notifications. weight: 900 keywords: - OnCall @@ -15,9 +15,26 @@ aliases: - ../../chat-options/configure-mattermost --- -# Mattermost +# Mattermost integration for Grafana OnCall -Mattermost support is not implemented yet. +The Mattermost integration for Grafana OnCall allows connecting a Mattermost channel directly +into your incident response workflow to help your team focus on alert resolution with less friction. -Please join [GitHub Issue](https://github.com/grafana/oncall/issues/96) or -check [PR](https://github.com/grafana/oncall/pull/606). +At the moment, this integration is only available for OSS installations. + +## Before you begin + +To install the Mattermost integration, you must have Admin Permissions in your Grafana setup +as well as in the Mattermost instance that you'd like to integrate with. + +Follow the steps in our [documentation](https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup). + +## Connect to a Mattermost Channel + +1. Go to the Mattermost channel you want to connect to, check its information and copy the channel id. +2. In Grafana OnCall, in the Settings section, click on the **ChatOps** tab and select Mattermost in the side menu. +3. Click the **Add Mattermost channel** button, paste the channel id from step (1) and click **Create**. +4. Set a default channel for the alerts. + +(Note: Make sure the bot in your setup is member of the team the channel belongs to and +has `read_channel` privileges [Ref](https://api.mattermost.com/#tag/channels/operation/GetChannelByNameForTeamName)) diff --git a/docs/sources/set-up/open-source/index.md b/docs/sources/set-up/open-source/index.md index 106e6d8c..70cf3cfa 100644 --- a/docs/sources/set-up/open-source/index.md +++ b/docs/sources/set-up/open-source/index.md @@ -211,11 +211,40 @@ Refer to the following steps to configure the Telegram integration: Alternatively, in case you want to connect Telegram channels to your Grafana OnCall environment, navigate to the **ChatOps** tab. -## Grafana OSS-Cloud Setup +## Mattermost Setup -The benefits of connecting to Grafana Cloud OnCall include: +The Mattermost integration of the Grafana OnCall is designed for collobarative team work and improved incident response. + +Refer to the following steps to configure the Mattermost integration: + +1. Ensure your Grafana OnCall environment is up and running. +2. Set `FEATURE_MATTERMOST_INTEGRATION_ENABLED` as "True". +3. Create a Mattermost bot account [Ref](https://developers.mattermost.com/integrate/reference/bot-accounts/#bot-account-creation) + and save the token generated (NOTE: you may need to give the bot admin permissions + to make it possible for it to update alert group notifications). +4. Add the bot to the Mattermost team(s) owning the channels you want to connect to + (via Mattermost System console -> User Management -> Teams). +5. Set the token generated in the `MATTERMOST_BOT_TOKEN` variable on the **ENV Variables** page + of your Grafana OnCall instance. +6. [Create OAuth 2.0 Application In Mattermost](https://developers.mattermost.com/integrate/apps/authentication/oauth2/#register-an-oauth-20-application). + The callback url for the OAuth application should be, + + ```text + https:///api/internal/v1/complete/mattermost-login/ + ``` + + This will allow users to connect their OnCall accounts with Mattermost. The OAuth credentials will be needed later. +7. Generate a JWT secret for authenticating the incoming event messages from Mattermost + and set it as the `MATTERMOST_SIGNING_SECRET` variable on the **ENV Variables** page of your Grafana OnCall instance. +8. Set the following environment variables too: + + ```text + MATTERMOST_CLIENT_OAUTH_ID = << Integrations -> OAuth 2.0 Applications -> Client ID >> + MATTERMOST_CLIENT_OAUTH_SECRET = << Integrations -> OAuth 2.0 Applications -> Client Secret >> + MATTERMOST_HOST = << Mattermost server URL >> + MATTERMOST_LOGIN_RETURN_REDIRECT_HOST = << OnCall external URL >> + ``` -- Grafana Cloud OnCall could monitor OSS OnCall uptime using heartbeat - SMS for user notifications - Phone calls for user notifications. @@ -354,3 +383,4 @@ To configure this feature as such: Additionally, if you prefer to disable this feature, you can set the `ESCALATION_AUDITOR_ENABLED` environment variable to `False`. +e`. diff --git a/engine/apps/alerts/constants.py b/engine/apps/alerts/constants.py index 637f02a0..553c5406 100644 --- a/engine/apps/alerts/constants.py +++ b/engine/apps/alerts/constants.py @@ -10,6 +10,7 @@ class ActionSource(IntegerChoices): TELEGRAM = 3, "Telegram" API = 4, "API" BACKSYNC = 5, "Backsync" + MATTERMOST = 6, "Mattermost" TASK_DELAY_SECONDS = 1 diff --git a/engine/apps/alerts/migrations/0075_alter_alertgrouplogrecord_action_source.py b/engine/apps/alerts/migrations/0075_alter_alertgrouplogrecord_action_source.py new file mode 100644 index 00000000..bfc2c48f --- /dev/null +++ b/engine/apps/alerts/migrations/0075_alter_alertgrouplogrecord_action_source.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-12-03 13:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('alerts', '0074_alter_escalationpolicy_step'), + ] + + operations = [ + migrations.AlterField( + model_name='alertgrouplogrecord', + name='action_source', + field=models.SmallIntegerField(default=None, null=True, verbose_name=[(0, 'Slack'), (1, 'Web'), (2, 'Phone'), (3, 'Telegram'), (4, 'API'), (5, 'Backsync'), (6, 'Mattermost')]), + ), + ] diff --git a/engine/apps/api/serializers/organization.py b/engine/apps/api/serializers/organization.py index e502e8a3..e6ae40d7 100644 --- a/engine/apps/api/serializers/organization.py +++ b/engine/apps/api/serializers/organization.py @@ -84,9 +84,13 @@ class CurrentOrganizationSerializer(OrganizationSerializer): telegram_configured = not LiveSetting.objects.filter(name__startswith="TELEGRAM", error__isnull=False).exists() phone_provider_config = get_phone_provider().flags + mattermost_configured = not LiveSetting.objects.filter( + name__startswith="MATTERMOST", error__isnull=False + ).exists() return { "telegram_configured": telegram_configured, "phone_provider": asdict(phone_provider_config), + "mattermost_configured": mattermost_configured, } diff --git a/engine/apps/api/tests/test_auth.py b/engine/apps/api/tests/test_auth.py index 4800107e..30608f42 100644 --- a/engine/apps/api/tests/test_auth.py +++ b/engine/apps/api/tests/test_auth.py @@ -8,8 +8,8 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient -from apps.auth_token.constants import SLACK_AUTH_TOKEN_NAME -from apps.social_auth.backends import SLACK_INSTALLATION_BACKEND +from apps.auth_token.constants import MATTERMOST_AUTH_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME +from apps.social_auth.backends import MATTERMOST_LOGIN_BACKEND, SLACK_INSTALLATION_BACKEND from common.constants.plugin_ids import PluginID from common.constants.slack_auth import SLACK_OAUTH_ACCESS_RESPONSE @@ -99,6 +99,85 @@ def test_start_slack_ok( assert response.json() == "https://slack_oauth_redirect.com" +@pytest.mark.django_db +@pytest.mark.parametrize( + "backend_name,expected_url", + ((MATTERMOST_LOGIN_BACKEND, "a/grafana-oncall-app/users/me"),), +) +def test_complete_mattermost_auth_redirect_ok( + make_organization, + make_user_for_organization, + make_mattermost_token_for_user, + backend_name, + expected_url, +): + organization = make_organization() + admin = make_user_for_organization(organization) + _, mattermost_token = make_mattermost_token_for_user(admin) + + client = APIClient() + url = ( + reverse("api-internal:complete-social-auth", kwargs={"backend": backend_name}) + + f"?{MATTERMOST_AUTH_TOKEN_NAME}={mattermost_token}" + ) + + with patch("apps.api.views.auth.do_complete") as mock_do_complete: + mock_do_complete.return_value = None + response = client.get(url) + + assert response.status_code == status.HTTP_302_FOUND + assert response.url == expected_url + + +@pytest.mark.django_db +def test_complete_mattermost_auth_redirect_error( + make_organization, + make_user_for_organization, + make_mattermost_token_for_user, +): + organization = make_organization() + admin = make_user_for_organization(organization) + _, mattermost_token = make_mattermost_token_for_user(admin) + + client = APIClient() + url = ( + reverse("api-internal:complete-social-auth", kwargs={"backend": MATTERMOST_LOGIN_BACKEND}) + + f"?{MATTERMOST_AUTH_TOKEN_NAME}={mattermost_token}" + ) + + def _custom_do_complete(backend, *args, **kwargs): + backend.strategy.session[REDIRECT_FIELD_NAME] = "some-url" + return HttpResponse(status=status.HTTP_400_BAD_REQUEST) + + with patch("apps.api.views.auth.do_complete", side_effect=_custom_do_complete): + response = client.get(url) + + assert response.status_code == status.HTTP_302_FOUND + assert response.url == "some-url" + + +@pytest.mark.django_db +def test_start_mattermost_ok( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, +): + """ + Covers the case when user starts Mattermost integration installation via Grafana OnCall + """ + _, user, token = make_organization_and_user_with_plugin_token() + + client = APIClient() + url = reverse("api-internal:social-auth", kwargs={"backend": MATTERMOST_LOGIN_BACKEND}) + + mock_do_auth_return = Mock() + mock_do_auth_return.url = "https://mattermost_oauth_redirect.com" + with patch("apps.api.views.auth.do_auth", return_value=mock_do_auth_return) as mock_do_auth: + response = client.get(url, **make_user_auth_headers(user, token)) + assert mock_do_auth.called + assert response.status_code == status.HTTP_200_OK + assert response.json() == "https://mattermost_oauth_redirect.com" + + @override_settings(UNIFIED_SLACK_APP_ENABLED=True) @pytest.mark.django_db def test_start_unified_slack_ok( diff --git a/engine/apps/api/tests/test_organization.py b/engine/apps/api/tests/test_organization.py index b00ef956..f4545cd6 100644 --- a/engine/apps/api/tests/test_organization.py +++ b/engine/apps/api/tests/test_organization.py @@ -20,6 +20,7 @@ mock_env_status = { "verification_call": False, "verification_sms": False, }, + "mattermost_configured": False, } diff --git a/engine/apps/api/views/auth.py b/engine/apps/api/views/auth.py index 63aafc7f..8deecaa2 100644 --- a/engine/apps/api/views/auth.py +++ b/engine/apps/api/views/auth.py @@ -13,14 +13,19 @@ from social_core.backends.google import GoogleOAuth2 from social_django.utils import psa from social_django.views import _do_login -from apps.auth_token.auth import GoogleTokenAuthentication, PluginAuthentication, SlackTokenAuthentication +from apps.auth_token.auth import ( + GoogleTokenAuthentication, + MattermostTokenAuthentication, + PluginAuthentication, + SlackTokenAuthentication, +) from apps.chatops_proxy.utils import ( get_installation_link_from_chatops_proxy, get_slack_oauth_response_from_chatops_proxy, ) from apps.grafana_plugin.ui_url_builder import UIURLBuilder from apps.slack.installation import install_slack_integration -from apps.social_auth.backends import SLACK_INSTALLATION_BACKEND, LoginSlackOAuth2V2 +from apps.social_auth.backends import SLACK_INSTALLATION_BACKEND, LoginMattermostOAuth2, LoginSlackOAuth2V2 logger = logging.getLogger(__name__) @@ -67,7 +72,7 @@ def overridden_login_social_auth(request: Request, backend: str) -> Response: @api_view(["GET"]) -@authentication_classes([GoogleTokenAuthentication, SlackTokenAuthentication]) +@authentication_classes([GoogleTokenAuthentication, SlackTokenAuthentication, MattermostTokenAuthentication]) @never_cache @csrf_exempt @psa("social:complete") @@ -98,7 +103,7 @@ def overridden_complete_social_auth(request: Request, backend: str, *args, **kwa # otherwise it pertains to the InstallSlackOAuth2V2 backend, and we should redirect to the chat-ops page return_to = ( url_builder.user_profile() - if isinstance(request.backend, (LoginSlackOAuth2V2, GoogleOAuth2)) + if isinstance(request.backend, (LoginMattermostOAuth2, LoginSlackOAuth2V2, GoogleOAuth2)) else url_builder.chatops() ) diff --git a/engine/apps/api/views/features.py b/engine/apps/api/views/features.py index b134e033..d593345c 100644 --- a/engine/apps/api/views/features.py +++ b/engine/apps/api/views/features.py @@ -28,6 +28,7 @@ class Feature(enum.StrEnum): GOOGLE_OAUTH2 = "google_oauth2" SERVICE_DEPENDENCIES = "service_dependencies" PERSONAL_WEBHOOK = "personal_webhook" + MATTERMOST = "mattermost" class FeaturesAPIView(APIView): @@ -80,4 +81,7 @@ class FeaturesAPIView(APIView): if settings.FEATURE_PERSONAL_WEBHOOK_ENABLED: enabled_features.append(Feature.PERSONAL_WEBHOOK) + if settings.FEATURE_MATTERMOST_INTEGRATION_ENABLED: + enabled_features.append(Feature.MATTERMOST) + return enabled_features diff --git a/engine/apps/auth_token/auth.py b/engine/apps/auth_token/auth.py index f96e5ef6..d84b0233 100644 --- a/engine/apps/auth_token/auth.py +++ b/engine/apps/auth_token/auth.py @@ -19,12 +19,18 @@ from apps.user_management.sync import get_or_create_user from common.utils import validate_url from settings.base import SELF_HOSTED_SETTINGS -from .constants import GOOGLE_OAUTH2_AUTH_TOKEN_NAME, SCHEDULE_EXPORT_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME +from .constants import ( + GOOGLE_OAUTH2_AUTH_TOKEN_NAME, + MATTERMOST_AUTH_TOKEN_NAME, + SCHEDULE_EXPORT_TOKEN_NAME, + SLACK_AUTH_TOKEN_NAME, +) from .exceptions import InvalidToken from .models import ( ApiAuthToken, GoogleOAuth2Token, IntegrationBacksyncAuthToken, + MattermostAuthToken, PluginAuthToken, ScheduleExportAuthToken, ServiceAccountToken, @@ -272,6 +278,11 @@ class SlackTokenAuthentication(_SocialAuthTokenAuthentication[SlackAuthToken]): model = SlackAuthToken +class MattermostTokenAuthentication(_SocialAuthTokenAuthentication[MattermostAuthToken]): + token_query_param_name = MATTERMOST_AUTH_TOKEN_NAME + model = MattermostAuthToken + + class GoogleTokenAuthentication(_SocialAuthTokenAuthentication[GoogleOAuth2Token]): token_query_param_name = GOOGLE_OAUTH2_AUTH_TOKEN_NAME model = GoogleOAuth2Token diff --git a/engine/apps/auth_token/constants.py b/engine/apps/auth_token/constants.py index 4e016932..b05db9ab 100644 --- a/engine/apps/auth_token/constants.py +++ b/engine/apps/auth_token/constants.py @@ -5,6 +5,7 @@ DIGEST_LENGTH = 128 MAX_PUBLIC_API_TOKENS_PER_USER = 5 SLACK_AUTH_TOKEN_NAME = "slack_login_token" +MATTERMOST_AUTH_TOKEN_NAME = "state" GOOGLE_OAUTH2_AUTH_TOKEN_NAME = "state" """ We must use the `state` query param, otherwise Google returns a 400 error. diff --git a/engine/apps/auth_token/migrations/0008_mattermostauthtoken.py b/engine/apps/auth_token/migrations/0008_mattermostauthtoken.py new file mode 100644 index 00000000..7d189a06 --- /dev/null +++ b/engine/apps/auth_token/migrations/0008_mattermostauthtoken.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.15 on 2024-12-03 13:13 + +import apps.auth_token.models.mattermost_auth_token +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_management', '0029_remove_organization_general_log_channel_id_db'), + ('auth_token', '0007_serviceaccounttoken'), + ] + + operations = [ + migrations.CreateModel( + name='MattermostAuthToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token_key', models.CharField(db_index=True, max_length=8)), + ('digest', models.CharField(max_length=128)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('revoked_at', models.DateTimeField(null=True)), + ('expire_date', models.DateTimeField(default=apps.auth_token.models.mattermost_auth_token.get_expire_date)), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_auth_token_set', to='user_management.organization')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_auth_token', to='user_management.user')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/engine/apps/auth_token/models/__init__.py b/engine/apps/auth_token/models/__init__.py index 42cc60c5..858763c8 100644 --- a/engine/apps/auth_token/models/__init__.py +++ b/engine/apps/auth_token/models/__init__.py @@ -2,6 +2,7 @@ from .api_auth_token import ApiAuthToken # noqa: F401 from .base_auth_token import BaseAuthToken # noqa: F401 from .google_oauth2_token import GoogleOAuth2Token # noqa: F401 from .integration_backsync_auth_token import IntegrationBacksyncAuthToken # noqa: F401 +from .mattermost_auth_token import MattermostAuthToken # noqa: F401 from .plugin_auth_token import PluginAuthToken # noqa: F401 from .schedule_export_auth_token import ScheduleExportAuthToken # noqa: F401 from .service_account_token import ServiceAccountToken # noqa: F401 diff --git a/engine/apps/auth_token/models/mattermost_auth_token.py b/engine/apps/auth_token/models/mattermost_auth_token.py new file mode 100644 index 00000000..627f87fc --- /dev/null +++ b/engine/apps/auth_token/models/mattermost_auth_token.py @@ -0,0 +1,38 @@ +from typing import Tuple + +from django.db import models +from django.utils import timezone + +from apps.auth_token import constants, crypto +from apps.auth_token.models import BaseAuthToken +from apps.user_management.models import Organization, User +from settings.base import AUTH_TOKEN_TIMEOUT_SECONDS + + +def get_expire_date(): + return timezone.now() + timezone.timedelta(seconds=AUTH_TOKEN_TIMEOUT_SECONDS) + + +class MattermostAuthToken(BaseAuthToken): + user = models.OneToOneField("user_management.User", related_name="mattermost_auth_token", on_delete=models.CASCADE) + organization = models.ForeignKey( + "user_management.Organization", related_name="mattermost_auth_token_set", on_delete=models.CASCADE + ) + expire_date = models.DateTimeField(default=get_expire_date) + + @classmethod + def create_auth_token(cls, user: User, organization: Organization) -> Tuple["MattermostAuthToken", str]: + old_token = cls.objects_with_deleted.filter(user=user) + if old_token.exists(): + old_token.delete() + + token_string = crypto.generate_token_string() + digest = crypto.hash_token_string(token_string) + + instance = cls.objects.create( + token_key=token_string[: constants.TOKEN_KEY_LENGTH], + digest=digest, + user=user, + organization=organization, + ) + return instance, token_string diff --git a/engine/apps/base/models/live_setting.py b/engine/apps/base/models/live_setting.py index 3c4f1e56..5921f4bf 100644 --- a/engine/apps/base/models/live_setting.py +++ b/engine/apps/base/models/live_setting.py @@ -80,6 +80,12 @@ class LiveSetting(models.Model): "EXOTEL_SMS_SENDER_ID", "EXOTEL_SMS_VERIFICATION_TEMPLATE", "EXOTEL_SMS_DLT_ENTITY_ID", + "MATTERMOST_CLIENT_OAUTH_ID", + "MATTERMOST_CLIENT_OAUTH_SECRET", + "MATTERMOST_HOST", + "MATTERMOST_BOT_TOKEN", + "MATTERMOST_LOGIN_RETURN_REDIRECT_HOST", + "MATTERMOST_SIGNING_SECRET", ) DESCRIPTIONS = { @@ -187,6 +193,36 @@ class LiveSetting(models.Model): "EXOTEL_SMS_SENDER_ID": "Exotel SMS Sender ID to use for verification SMS", "EXOTEL_SMS_VERIFICATION_TEMPLATE": "SMS text template to be used for sending SMS, add $verification_code as a placeholder for the verification code", "EXOTEL_SMS_DLT_ENTITY_ID": "DLT Entity ID registered with TRAI.", + "MATTERMOST_CLIENT_OAUTH_ID": ( + "Check instruction for details how to set up Mattermost. " + ), + "MATTERMOST_CLIENT_OAUTH_SECRET": ( + "Check instruction for details how to set up Mattermost. " + ), + "MATTERMOST_HOST": ( + "Check instruction for details how to set up Mattermost. " + ), + "MATTERMOST_BOT_TOKEN": ( + "Check instruction for details how to set up Mattermost. " + ), + "MATTERMOST_LOGIN_RETURN_REDIRECT_HOST": ( + "Check instruction for details how to set up Mattermost. " + ), + "MATTERMOST_SIGNING_SECRET": ( + "Check instruction for details how to set up Mattermost. " + ), } SECRET_SETTING_NAMES = ( @@ -205,6 +241,9 @@ class LiveSetting(models.Model): "ZVONOK_API_KEY", "EXOTEL_ACCOUNT_SID", "EXOTEL_API_TOKEN", + "MATTERMOST_CLIENT_OAUTH_ID", + "MATTERMOST_CLIENT_OAUTH_SECRET", + "MATTERMOST_BOT_TOKEN", ) def __str__(self): diff --git a/engine/apps/base/models/user_notification_policy_log_record.py b/engine/apps/base/models/user_notification_policy_log_record.py index 80185a84..75d7ed38 100644 --- a/engine/apps/base/models/user_notification_policy_log_record.py +++ b/engine/apps/base/models/user_notification_policy_log_record.py @@ -107,7 +107,11 @@ class UserNotificationPolicyLogRecord(models.Model): ERROR_NOTIFICATION_MOBILE_USER_HAS_NO_ACTIVE_DEVICE, ERROR_NOTIFICATION_FORMATTING_ERROR, ERROR_NOTIFICATION_IN_TELEGRAM_RATELIMIT, - ) = range(30) + ERROR_NOTIFICATION_IN_MATTERMOST_USER_NOT_IN_MATTERMOST, + ERROR_NOTIFICATION_IN_MATTERMOST_ALERT_GROUP_MESSAGE_NOT_FOUND, + ERROR_NOTIFICATION_IN_MATTERMOST_API_TOKEN_INVALID, + ERROR_NOTIFICATION_IN_MATTERMOST_API_UNAUTHORIZED, + ) = range(34) # for this errors we want to send message to general log channel ERRORS_TO_SEND_IN_SLACK_CHANNEL = [ @@ -323,6 +327,11 @@ class UserNotificationPolicyLogRecord(models.Model): result += f"failed to send push notification to {user_verbal} because user has no device set up" elif self.notification_error_code == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_FORMATTING_ERROR: result += f"failed to send message to {user_verbal} due to a formatting error" + elif ( + self.notification_error_code + == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_MATTERMOST_USER_NOT_IN_MATTERMOST + ): + result += f"failed to notify {user_verbal} in Mattermost, because {user_verbal} is not in Mattermost" else: # TODO: handle specific backend errors try: diff --git a/engine/apps/mattermost/__init__.py b/engine/apps/mattermost/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/mattermost/alert_group_representative.py b/engine/apps/mattermost/alert_group_representative.py new file mode 100644 index 00000000..b342b63b --- /dev/null +++ b/engine/apps/mattermost/alert_group_representative.py @@ -0,0 +1,87 @@ +import logging + +from rest_framework import status + +from apps.alerts.models import AlertGroup +from apps.alerts.representative import AlertGroupAbstractRepresentative +from apps.mattermost.alert_rendering import MattermostMessageRenderer +from apps.mattermost.client import MattermostClient +from apps.mattermost.exceptions import MattermostAPIException, MattermostAPITokenInvalid +from apps.mattermost.tasks import on_alert_group_action_triggered_async, on_create_alert_async + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + + +class AlertGroupMattermostRepresentative(AlertGroupAbstractRepresentative): + def __init__(self, log_record) -> None: + self.log_record = log_record + + def is_applicable(self): + from apps.mattermost.models import MattermostChannel + + organization = self.log_record.alert_group.channel.organization + handler_exists = self.log_record.type in self.get_handler_map().keys() + + mattermost_channels = MattermostChannel.objects.filter(organization=organization) + return handler_exists and mattermost_channels.exists() + + @staticmethod + def get_handler_map(): + from apps.alerts.models import AlertGroupLogRecord + + return { + AlertGroupLogRecord.TYPE_ACK: "alert_group_action", + AlertGroupLogRecord.TYPE_UN_ACK: "alert_group_action", + AlertGroupLogRecord.TYPE_AUTO_UN_ACK: "alert_group_action", + AlertGroupLogRecord.TYPE_RESOLVED: "alert_group_action", + AlertGroupLogRecord.TYPE_UN_RESOLVED: "alert_group_action", + AlertGroupLogRecord.TYPE_ACK_REMINDER_TRIGGERED: "alert_group_action", + AlertGroupLogRecord.TYPE_SILENCE: "alert_group_action", + AlertGroupLogRecord.TYPE_UN_SILENCE: "alert_group_action", + AlertGroupLogRecord.TYPE_ATTACHED: "alert_group_action", + AlertGroupLogRecord.TYPE_UNATTACHED: "alert_group_action", + } + + def on_alert_group_action(self, alert_group: AlertGroup): + logger.info(f"Update mattermost message for alert_group {alert_group.pk}") + payload = MattermostMessageRenderer(alert_group).render_alert_group_message() + mattermost_message = alert_group.mattermost_messages.order_by("created_at").first() + try: + client = MattermostClient() + client.update_post(post_id=mattermost_message.post_id, data=payload) + except MattermostAPITokenInvalid: + logger.error(f"Mattermost API token is invalid could not create post for alert {alert_group.pk}") + except MattermostAPIException as ex: + logger.error(f"Mattermost API error {ex}") + if ex.status not in [status.HTTP_401_UNAUTHORIZED]: + raise ex + + @staticmethod + def on_create_alert(**kwargs): + alert_pk = kwargs["alert"] + on_create_alert_async.apply_async((alert_pk,)) + + @staticmethod + def on_alert_group_action_triggered(**kwargs): + from apps.alerts.models import AlertGroupLogRecord + + log_record = kwargs["log_record"] + if isinstance(log_record, AlertGroupLogRecord): + log_record_id = log_record.pk + else: + log_record_id = log_record + on_alert_group_action_triggered_async.apply_async((log_record_id,)) + + def get_handler(self): + handler_name = self.get_handler_name() + logger.info(f"Using '{handler_name}' handler to process alert action in mattermost") + if hasattr(self, handler_name): + handler = getattr(self, handler_name) + else: + handler = None + + return handler + + def get_handler_name(self): + return self.HANDLER_PREFIX + self.get_handler_map()[self.log_record.type] diff --git a/engine/apps/mattermost/alert_rendering.py b/engine/apps/mattermost/alert_rendering.py new file mode 100644 index 00000000..6c99b4f1 --- /dev/null +++ b/engine/apps/mattermost/alert_rendering.py @@ -0,0 +1,130 @@ +from apps.alerts.incident_appearance.renderers.base_renderer import AlertBaseRenderer, AlertGroupBaseRenderer +from apps.alerts.incident_appearance.templaters.alert_templater import AlertTemplater +from apps.alerts.models import Alert, AlertGroup +from apps.mattermost.events.types import EventAction +from apps.mattermost.utils import MattermostEventAuthenticator +from common.api_helpers.utils import create_engine_url +from common.utils import is_string_with_visible_characters, str_or_backup + + +class MattermostMessageRenderer: + def __init__(self, alert_group: AlertGroup): + self.alert_group = alert_group + + def render_alert_group_message(self): + attachments = AlertGroupMattermostRenderer(self.alert_group).render_alert_group_attachments() + return {"props": {"attachments": attachments}} + + +class AlertMattermostTemplater(AlertTemplater): + RENDER_FOR_MATTERMOST = "mattermost" + + def _render_for(self) -> str: + return self.RENDER_FOR_MATTERMOST + + +class AlertMattermostRenderer(AlertBaseRenderer): + def __init__(self, alert: Alert): + super().__init__(alert) + self.channel = alert.group.channel + + @property + def templater_class(self): + return AlertMattermostTemplater + + def render_alert_attachments(self): + attachments = [] + title = str_or_backup(self.templated_alert.title, "Alert") + message = "" + if is_string_with_visible_characters(self.templated_alert.message): + message = self.templated_alert.message + attachments.append( + { + "fallback": "{}: {}".format(self.channel.get_integration_display(), self.alert.title), + "title": title, + "title_link": self.templated_alert.source_link, + "text": message, + "image_url": self.templated_alert.image_url, + } + ) + return attachments + + +class AlertGroupMattermostRenderer(AlertGroupBaseRenderer): + def __init__(self, alert_group: AlertGroup): + super().__init__(alert_group) + + self.alert_renderer = self.alert_renderer_class(self.alert_group.alerts.last()) + + @property + def alert_renderer_class(self): + return AlertMattermostRenderer + + def render_alert_group_attachments(self): + attachments = self.alert_renderer.render_alert_attachments() + alert_group = self.alert_group + + if alert_group.resolved: + attachments.append( + { + "fallback": "Resolved...", + "text": alert_group.get_resolve_text(), + } + ) + elif alert_group.acknowledged: + attachments.append( + { + "fallback": "Acknowledged...", + "text": alert_group.get_acknowledge_text(), + } + ) + + # append buttons to the initial attachment + attachments[0]["actions"] = self._get_buttons_attachments() + + return self._set_attachments_color(attachments) + + def _get_buttons_attachments(self): + actions = [] + + def _make_actions(id, name, token): + return { + "id": id, + "name": name, + "integration": { + "url": create_engine_url("api/internal/v1/mattermost/event/"), + "context": { + "action": id, + "alert": self.alert_group.public_primary_key, + "token": token, + }, + }, + } + + token = MattermostEventAuthenticator.create_token(organization=self.alert_group.channel.organization) + if not self.alert_group.resolved: + if self.alert_group.acknowledged: + actions.append(_make_actions(EventAction.UNACKNOWLEDGE.value, "Unacknowledge", token)) + else: + actions.append(_make_actions(EventAction.ACKNOWLEDGE.value, "Acknowledge", token)) + + if self.alert_group.resolved: + actions.append(_make_actions(EventAction.UNRESOLVE.value, "Unresolve", token)) + else: + actions.append(_make_actions(EventAction.RESOLVE.value, "Resolve", token)) + + return actions + + def _set_attachments_color(self, attachments): + color = "#a30200" # danger + if self.alert_group.silenced: + color = "#dddddd" # slack-grey + if self.alert_group.acknowledged: + color = "#daa038" # warning + if self.alert_group.resolved: + color = "#2eb886" # good + + for attachment in attachments: + attachment["color"] = color + + return attachments diff --git a/engine/apps/mattermost/apps.py b/engine/apps/mattermost/apps.py new file mode 100644 index 00000000..841c7fc8 --- /dev/null +++ b/engine/apps/mattermost/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class MattermostConfig(AppConfig): + name = "apps.mattermost" + + def ready(self) -> None: + import apps.mattermost.signals # noqa: F401 diff --git a/engine/apps/mattermost/auth.py b/engine/apps/mattermost/auth.py new file mode 100644 index 00000000..83b870f8 --- /dev/null +++ b/engine/apps/mattermost/auth.py @@ -0,0 +1,29 @@ +import logging +import typing + +from rest_framework import exceptions +from rest_framework.authentication import BaseAuthentication + +from apps.mattermost.models import MattermostUser +from apps.mattermost.utils import MattermostEventAuthenticator, MattermostEventTokenInvalid +from apps.user_management.models import User + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + + +class MattermostEventAuthentication(BaseAuthentication): + def authenticate(self, request) -> typing.Tuple[User, None]: + if "context" not in request.data or "token" not in request.data["context"]: + raise exceptions.AuthenticationFailed("Auth token is missing") + + auth = request.data["context"]["token"] + try: + MattermostEventAuthenticator.verify(auth) + mattermost_user = MattermostUser.objects.get(mattermost_user_id=request.data["user_id"]) + except MattermostEventTokenInvalid: + raise exceptions.AuthenticationFailed("Invalid auth token") + except MattermostUser.DoesNotExist: + raise exceptions.AuthenticationFailed("Mattermost user not integrated") + + return mattermost_user.user, None diff --git a/engine/apps/mattermost/backend.py b/engine/apps/mattermost/backend.py new file mode 100644 index 00000000..a43c7757 --- /dev/null +++ b/engine/apps/mattermost/backend.py @@ -0,0 +1,65 @@ +from rest_framework import serializers + +from apps.base.messaging import BaseMessagingBackend +from apps.mattermost.models import MattermostChannel +from apps.mattermost.tasks import notify_user_about_alert_async + + +class MattermostBackend(BaseMessagingBackend): + backend_id = "MATTERMOST" + label = "Mattermost" + short_label = "Mattermost" + available_for_use = True + templater = "apps.mattermost.alert_rendering.AlertMattermostTemplater" + + def unlink_user(self, user): + from apps.mattermost.models import MattermostUser + + mattermost_user = MattermostUser.objects.get(user=user) + mattermost_user.delete() + + def serialize_user(self, user): + mattermost_user = getattr(user, "mattermost_user_identity", None) + if not mattermost_user: + return None + return { + "mattermost_user_id": mattermost_user.mattermost_user_id, + "username": mattermost_user.username, + } + + def notify_user(self, user, alert_group, notification_policy): + notify_user_about_alert_async.delay( + user_pk=user.pk, + alert_group_pk=alert_group.pk, + notification_policy_pk=notification_policy.pk, + ) + + def validate_channel_filter_data(self, organization, data): + notification_data = {} + + if not data: + return notification_data + + if "enabled" in data: + notification_data["enabled"] = bool(data["enabled"]) + + if "channel" not in data: + return notification_data + + # We need to treat "channel" key and "enabled" key separately + # This condition is to handle the case when channel is cleared but the flag is enabled. + # payload example: {"channel": nil} + if not data["channel"]: + notification_data["channel"] = data["channel"] + return notification_data + + channel = MattermostChannel.objects.filter( + organization=organization, public_primary_key=data["channel"] + ).first() + + if not channel: + raise serializers.ValidationError(["Invalid mattermost channel id"]) + + notification_data["channel"] = channel.public_primary_key + + return notification_data diff --git a/engine/apps/mattermost/client.py b/engine/apps/mattermost/client.py new file mode 100644 index 00000000..ac789f50 --- /dev/null +++ b/engine/apps/mattermost/client.py @@ -0,0 +1,108 @@ +import json +from dataclasses import dataclass +from typing import Optional + +import requests +from django.conf import settings +from requests.auth import AuthBase +from requests.models import PreparedRequest + +from apps.mattermost.exceptions import MattermostAPIException, MattermostAPITokenInvalid + + +class TokenAuth(AuthBase): + def __init__(self, token: str) -> None: + self.token = token + + def __call__(self, request: PreparedRequest) -> PreparedRequest: + request.headers["Authorization"] = f"Bearer {self.token}" + return request + + +@dataclass +class MattermostUser: + user_id: str + username: str + nickname: str + + +@dataclass +class MattermostChannel: + channel_id: str + team_id: str + channel_name: str + display_name: str + + +@dataclass +class MattermostPost: + post_id: str + channel_id: str + user_id: str + + +class MattermostClient: + def __init__(self, token: Optional[str] = None) -> None: + self.token = token or settings.MATTERMOST_BOT_TOKEN + self.base_url = f"{settings.MATTERMOST_HOST}/api/v4" + self.timeout: int = 10 + + if self.token is None: + raise MattermostAPITokenInvalid + + def _check_response(self, response: requests.models.Response): + try: + response.raise_for_status() + except requests.HTTPError as ex: + raise MattermostAPIException( + status=ex.response.status_code, + url=ex.response.request.url, + msg=ex.response.json()["message"], + method=ex.response.request.method, + ) + except requests.Timeout as ex: + raise MattermostAPIException( + status=ex.response.status_code, + url=ex.response.request.url, + msg="Mattermost api call gateway timedout", + method=ex.response.request.method, + ) + except requests.exceptions.RequestException as ex: + raise MattermostAPIException( + status=ex.response.status_code, + url=ex.response.request.url, + msg="Unexpected error from mattermost server", + method=ex.response.request.method, + ) + + def get_channel_by_id(self, channel_id: str) -> MattermostChannel: + url = f"{self.base_url}/channels/{channel_id}" + response = requests.get(url=url, timeout=self.timeout, auth=TokenAuth(self.token)) + self._check_response(response) + data = response.json() + return MattermostChannel( + channel_id=data["id"], team_id=data["team_id"], channel_name=data["name"], display_name=data["display_name"] + ) + + def get_user(self, user_id: str = "me"): + url = f"{self.base_url}/users/{user_id}" + response = requests.get(url=url, timeout=self.timeout, auth=TokenAuth(self.token)) + self._check_response(response) + data = response.json() + return MattermostUser(user_id=data["id"], username=data["username"], nickname=data["nickname"]) + + def create_post(self, channel_id: str, data: dict): + url = f"{self.base_url}/posts" + data.update({"channel_id": channel_id}) + response = requests.post(url=url, data=json.dumps(data), timeout=self.timeout, auth=TokenAuth(self.token)) + self._check_response(response) + data = response.json() + return MattermostPost(post_id=data["id"], channel_id=data["channel_id"], user_id=data["user_id"]) + + def update_post(self, post_id: str, data: dict): + url = f"{self.base_url}/posts/{post_id}" + data.update({"id": post_id}) + response = requests.put(url=url, data=json.dumps(data), timeout=self.timeout, auth=TokenAuth(self.token)) + self._check_response(response) + data = response.json() + return MattermostPost(post_id=data["id"], channel_id=data["channel_id"], user_id=data["user_id"]) diff --git a/engine/apps/mattermost/events/__init__.py b/engine/apps/mattermost/events/__init__.py new file mode 100644 index 00000000..60bf9a04 --- /dev/null +++ b/engine/apps/mattermost/events/__init__.py @@ -0,0 +1 @@ +from .alert_group_actions_handler import AlertGroupActionHandler # noqa: F401 diff --git a/engine/apps/mattermost/events/alert_group_actions_handler.py b/engine/apps/mattermost/events/alert_group_actions_handler.py new file mode 100644 index 00000000..9078519e --- /dev/null +++ b/engine/apps/mattermost/events/alert_group_actions_handler.py @@ -0,0 +1,81 @@ +import logging +import typing + +from apps.alerts.constants import ActionSource +from apps.alerts.models import AlertGroup +from apps.mattermost.events.event_handler import MattermostEventHandler +from apps.mattermost.events.types import EventAction + +logger = logging.getLogger(__name__) + + +class AlertGroupActionHandler(MattermostEventHandler): + """ + Handles the alert group actions from the mattermost message buttons + """ + + def is_match(self): + action = self._get_action() + return action and action in [ + EventAction.ACKNOWLEDGE, + EventAction.UNACKNOWLEDGE, + EventAction.RESOLVE, + EventAction.UNRESOLVE, + ] + + def process(self): + alert_group = self._get_alert_group() + action = self._get_action() + + if not alert_group or not action: + return + + action_fn, fn_kwargs = self._get_action_function(alert_group, action) + action_fn(user=self.user, action_source=ActionSource.MATTERMOST, **fn_kwargs) + + def _get_action(self) -> typing.Optional[EventAction]: + if "context" not in self.event or "action" not in self.event["context"]: + return + + try: + action = self.event["context"]["action"] + return EventAction(action) + except ValueError: + logger.info(f"Mattermost event action not found {action}") + return + + def _get_alert_group(self) -> typing.Optional[AlertGroup]: + return self._get_alert_group_from_event() + + def _get_alert_group_from_event(self) -> typing.Optional[AlertGroup]: + if "context" not in self.event or "alert" not in self.event["context"]: + return + + try: + alert_group = AlertGroup.objects.get(public_primary_key=self.event["context"]["alert"]) + except AlertGroup.DoesNotExist: + return + + return alert_group + + def _get_action_function(self, alert_group: AlertGroup, action: EventAction) -> typing.Tuple[typing.Callable, dict]: + action_to_fn = { + EventAction.ACKNOWLEDGE: { + "fn_name": "acknowledge_by_user_or_backsync", + "kwargs": {}, + }, + EventAction.UNACKNOWLEDGE: { + "fn_name": "un_acknowledge_by_user_or_backsync", + "kwargs": {}, + }, + EventAction.RESOLVE: { + "fn_name": "resolve_by_user_or_backsync", + "kwargs": {}, + }, + EventAction.UNRESOLVE: {"fn_name": "un_resolve_by_user_or_backsync", "kwargs": {}}, + } + + fn_info = action_to_fn[action] + fn = getattr(alert_group, fn_info["fn_name"]) + + return fn, fn_info["kwargs"] diff --git a/engine/apps/mattermost/events/event_handler.py b/engine/apps/mattermost/events/event_handler.py new file mode 100644 index 00000000..dc6fb886 --- /dev/null +++ b/engine/apps/mattermost/events/event_handler.py @@ -0,0 +1,18 @@ +from abc import ABC, abstractmethod + +from apps.mattermost.events.types import MattermostEvent +from apps.user_management.models import User + + +class MattermostEventHandler(ABC): + def __init__(self, event: MattermostEvent, user: User): + self.event: MattermostEvent = event + self.user: User = user + + @abstractmethod + def is_match(self) -> bool: + pass + + @abstractmethod + def process(self) -> None: + pass diff --git a/engine/apps/mattermost/events/event_manager.py b/engine/apps/mattermost/events/event_manager.py new file mode 100644 index 00000000..138f04d0 --- /dev/null +++ b/engine/apps/mattermost/events/event_manager.py @@ -0,0 +1,37 @@ +import logging +import typing + +from rest_framework.request import Request + +from apps.mattermost.events.event_handler import MattermostEventHandler +from apps.mattermost.events.types import MattermostEvent +from apps.user_management.models import User + +logger = logging.getLogger(__name__) + + +class EventManager: + """ + Manager for mattermost events + """ + + @classmethod + def process_request(cls, request: Request): + user = request.user + event = request.data + handler = cls.select_event_handler(user=user, event=event) + if handler is None: + logger.info("No event handler found") + return + + logger.info(f"Processing mattermost event with handler: {handler.__class__.__name__}") + handler.process() + + @staticmethod + def select_event_handler(user: User, event: MattermostEvent) -> typing.Optional[MattermostEventHandler]: + handler_classes = MattermostEventHandler.__subclasses__() + for handler_class in handler_classes: + handler = handler_class(user=user, event=event) + if handler.is_match(): + return handler + return None diff --git a/engine/apps/mattermost/events/types.py b/engine/apps/mattermost/events/types.py new file mode 100644 index 00000000..520c2d23 --- /dev/null +++ b/engine/apps/mattermost/events/types.py @@ -0,0 +1,29 @@ +import enum +import typing + + +class MattermostAlertGroupContext(typing.TypedDict): + action: str + token: str + alert: str + + +class MattermostEvent(typing.TypedDict): + user_id: str + user_name: str + channel_id: str + channel_name: str + team_id: str + team_domain: str + post_id: str + trigger_id: str + type: str + data_source: str + context: MattermostAlertGroupContext + + +class EventAction(enum.StrEnum): + ACKNOWLEDGE = "acknowledge" + UNACKNOWLEDGE = "unacknowledge" + RESOLVE = "resolve" + UNRESOLVE = "unresolve" diff --git a/engine/apps/mattermost/exceptions.py b/engine/apps/mattermost/exceptions.py new file mode 100644 index 00000000..a96b90c9 --- /dev/null +++ b/engine/apps/mattermost/exceptions.py @@ -0,0 +1,21 @@ +class MattermostAPITokenInvalid(Exception): + pass + + +class MattermostAPIException(Exception): + def __init__(self, status, url, msg="", method="GET"): + self.url = url + self.status = status + self.method = method + self.msg = msg + + def __str__(self) -> str: + return f"MattermostAPIException: status={self.status} url={self.url} method={self.method} error={self.msg}" + + +class MattermostEventTokenInvalid(Exception): + def __init__(self, msg=""): + self.msg = msg + + def __str__(self): + return f"MattermostEventTokenInvalid message={self.msg}" diff --git a/engine/apps/mattermost/migrations/0001_initial.py b/engine/apps/mattermost/migrations/0001_initial.py new file mode 100644 index 00000000..41ad91a6 --- /dev/null +++ b/engine/apps/mattermost/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# Generated by Django 4.2.16 on 2024-11-30 16:34 + +import apps.mattermost.models.channel +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('user_management', '0026_auto_20241017_1919'), + ] + + operations = [ + migrations.CreateModel( + name='MattermostChannel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('public_primary_key', models.CharField(default=apps.mattermost.models.channel.generate_public_primary_key_for_mattermost_channel, max_length=20, unique=True, validators=[django.core.validators.MinLengthValidator(13)])), + ('mattermost_team_id', models.CharField(max_length=100)), + ('channel_id', models.CharField(max_length=100)), + ('channel_name', models.CharField(default=None, max_length=100)), + ('display_name', models.CharField(default=None, max_length=100)), + ('is_default_channel', models.BooleanField(default=False, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_channels', to='user_management.organization')), + ], + ), + migrations.CreateModel( + name='MattermostUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('mattermost_user_id', models.CharField(max_length=100)), + ('username', models.CharField(max_length=100)), + ('nickname', models.CharField(blank=True, default=None, max_length=100, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_user_identity', to='user_management.user')), + ], + options={ + 'indexes': [models.Index(fields=['mattermost_user_id'], name='mattermost__matterm_55d2a0_idx')], + }, + ), + migrations.CreateModel( + name='MattermostMessage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('post_id', models.CharField(max_length=100)), + ('channel_id', models.CharField(max_length=100)), + ('message_type', models.IntegerField(choices=[(0, 'Alert group message'), (1, 'Log message')])), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('alert_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_messages', to='alerts.alertgroup')), + ], + options={ + 'indexes': [models.Index(fields=['channel_id', 'post_id'], name='mattermost__channel_1fbf8b_idx')], + }, + ), + migrations.AddConstraint( + model_name='mattermostmessage', + constraint=models.UniqueConstraint(fields=('alert_group', 'message_type', 'channel_id'), name='unique_alert_group_message_type_channel_id'), + ), + migrations.AlterUniqueTogether( + name='mattermostchannel', + unique_together={('organization', 'channel_id')}, + ), + ] diff --git a/engine/apps/mattermost/migrations/__init__.py b/engine/apps/mattermost/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/mattermost/models/__init__.py b/engine/apps/mattermost/models/__init__.py new file mode 100644 index 00000000..cada7d9f --- /dev/null +++ b/engine/apps/mattermost/models/__init__.py @@ -0,0 +1,3 @@ +from .channel import MattermostChannel # noqa: F401 +from .message import MattermostMessage # noqa F401 +from .user import MattermostUser # noqa F401 diff --git a/engine/apps/mattermost/models/channel.py b/engine/apps/mattermost/models/channel.py new file mode 100644 index 00000000..b1d86e23 --- /dev/null +++ b/engine/apps/mattermost/models/channel.py @@ -0,0 +1,102 @@ +import typing + +from django.conf import settings +from django.core.validators import MinLengthValidator +from django.db import models, transaction + +from apps.alerts.models import AlertGroup +from common.insight_log.chatops_insight_logs import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log +from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length + + +def generate_public_primary_key_for_mattermost_channel(): + prefix = "MT" + new_public_primary_key = generate_public_primary_key(prefix) + + failure_counter = 0 + while MattermostChannel.objects.filter(public_primary_key=new_public_primary_key).exists(): + new_public_primary_key = increase_public_primary_key_length( + failure_counter=failure_counter, prefix=prefix, model_name="MattermostChannel" + ) + failure_counter += 1 + + return new_public_primary_key + + +class MattermostChannel(models.Model): + organization = models.ForeignKey( + "user_management.Organization", + on_delete=models.CASCADE, + related_name="mattermost_channels", + ) + + public_primary_key = models.CharField( + max_length=20, + validators=[MinLengthValidator(settings.PUBLIC_PRIMARY_KEY_MIN_LENGTH + 1)], + unique=True, + default=generate_public_primary_key_for_mattermost_channel, + ) + + mattermost_team_id = models.CharField(max_length=100) + channel_id = models.CharField(max_length=100) + channel_name = models.CharField(max_length=100, default=None) + display_name = models.CharField(max_length=100, default=None) + is_default_channel = models.BooleanField(null=True, default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("organization", "channel_id") + + @classmethod + def get_channel_for_alert_group(cls, alert_group: AlertGroup) -> typing.Optional["MattermostChannel"]: + from apps.mattermost.backend import MattermostBackend # To avoid circular import + + default_channel = cls.objects.filter( + organization=alert_group.channel.organization, is_default_channel=True + ).first() + + if ( + alert_group.channel_filter is None + or not alert_group.channel_filter.notification_backends + or not alert_group.channel_filter.notification_backends.get(MattermostBackend.backend_id) + ): + return default_channel + + channel_id = alert_group.channel_filter.notification_backends[MattermostBackend.backend_id].get("channel") + enabled = alert_group.channel_filter.notification_backends[MattermostBackend.backend_id].get("enabled") + + if not enabled: + return None + + if not channel_id: + return default_channel + + channel = cls.objects.filter( + organization=alert_group.channel.organization, public_primary_key=channel_id + ).first() + + if not channel: + return default_channel + + return channel + + def make_channel_default(self, author): + try: + old_default_channel = MattermostChannel.objects.get(organization=self.organization, is_default_channel=True) + old_default_channel.is_default_channel = False + except MattermostChannel.DoesNotExist: + old_default_channel = None + self.is_default_channel = True + self.save(update_fields=["is_default_channel"]) + else: + self.is_default_channel = True + with transaction.atomic(): + old_default_channel.save(update_fields=["is_default_channel"]) + self.save(update_fields=["is_default_channel"]) + write_chatops_insight_log( + author=author, + event_name=ChatOpsEvent.DEFAULT_CHANNEL_CHANGED, + chatops_type=ChatOpsTypePlug.MATTERMOST.value, + prev_channel=old_default_channel.channel_name if old_default_channel else None, + new_channel=self.channel_name, + ) diff --git a/engine/apps/mattermost/models/message.py b/engine/apps/mattermost/models/message.py new file mode 100644 index 00000000..4e9d3bd7 --- /dev/null +++ b/engine/apps/mattermost/models/message.py @@ -0,0 +1,48 @@ +from django.db import models + +from apps.alerts.models import AlertGroup +from apps.mattermost.client import MattermostPost + + +class MattermostMessage(models.Model): + ( + ALERT_GROUP_MESSAGE, + LOG_MESSAGE, + ) = range(2) + + MATTERMOST_MESSAGE_CHOICES = ( + (ALERT_GROUP_MESSAGE, "Alert group message"), + (LOG_MESSAGE, "Log message"), + ) + + post_id = models.CharField(max_length=100) + + channel_id = models.CharField(max_length=100) + + message_type = models.IntegerField(choices=MATTERMOST_MESSAGE_CHOICES) + + alert_group = models.ForeignKey( + "alerts.AlertGroup", + on_delete=models.CASCADE, + related_name="mattermost_messages", + ) + + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["alert_group", "message_type", "channel_id"], + name="unique_alert_group_message_type_channel_id", + ) + ] + + indexes = [ + models.Index(fields=["channel_id", "post_id"]), + ] + + @staticmethod + def create_message(alert_group: AlertGroup, post: MattermostPost, message_type: int): + return MattermostMessage.objects.create( + alert_group=alert_group, post_id=post.post_id, channel_id=post.channel_id, message_type=message_type + ) diff --git a/engine/apps/mattermost/models/user.py b/engine/apps/mattermost/models/user.py new file mode 100644 index 00000000..6a35f3e1 --- /dev/null +++ b/engine/apps/mattermost/models/user.py @@ -0,0 +1,20 @@ +from django.db import models + + +class MattermostUser(models.Model): + user = models.OneToOneField( + "user_management.User", on_delete=models.CASCADE, related_name="mattermost_user_identity" + ) + mattermost_user_id = models.CharField(max_length=100) + username = models.CharField(max_length=100) + nickname = models.CharField(max_length=100, null=True, blank=True, default=None) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + indexes = [ + models.Index(fields=["mattermost_user_id"]), + ] + + @property + def mention_username(self): + return f"@{self.username}" diff --git a/engine/apps/mattermost/serializers.py b/engine/apps/mattermost/serializers.py new file mode 100644 index 00000000..cb4d7a8c --- /dev/null +++ b/engine/apps/mattermost/serializers.py @@ -0,0 +1,53 @@ +from rest_framework import serializers + +from apps.mattermost.client import MattermostClient +from apps.mattermost.exceptions import MattermostAPIException, MattermostAPITokenInvalid +from apps.mattermost.models import MattermostChannel +from common.api_helpers.exceptions import BadRequest +from common.api_helpers.utils import CurrentOrganizationDefault + + +class MattermostChannelSerializer(serializers.ModelSerializer): + id = serializers.CharField(read_only=True, source="public_primary_key") + organization = serializers.HiddenField(default=CurrentOrganizationDefault()) + + class Meta: + model = MattermostChannel + fields = [ + "id", + "organization", + "mattermost_team_id", + "channel_id", + "channel_name", + "display_name", + "is_default_channel", + ] + extra_kwargs = { + "mattermost_team_id": {"required": True, "write_only": True}, + "channel_id": {"required": True}, + } + + def create(self, validated_data): + return MattermostChannel.objects.create(**validated_data) + + def to_internal_value(self, data): + channel_id = data.get("channel_id") + + if not channel_id: + raise serializers.ValidationError({"channel_id": "This field is required."}) + + try: + response = MattermostClient().get_channel_by_id(channel_id=channel_id) + except MattermostAPIException as ex: + raise BadRequest(detail=ex.msg) + except MattermostAPITokenInvalid: + raise BadRequest(detail="Mattermost API token is invalid.") + + return super().to_internal_value( + { + "channel_id": response.channel_id, + "mattermost_team_id": response.team_id, + "channel_name": response.channel_name, + "display_name": response.display_name, + } + ) diff --git a/engine/apps/mattermost/signals.py b/engine/apps/mattermost/signals.py new file mode 100644 index 00000000..f18b28bd --- /dev/null +++ b/engine/apps/mattermost/signals.py @@ -0,0 +1,5 @@ +from apps.alerts.signals import alert_create_signal, alert_group_action_triggered_signal +from apps.mattermost.alert_group_representative import AlertGroupMattermostRepresentative + +alert_create_signal.connect(AlertGroupMattermostRepresentative.on_create_alert) +alert_group_action_triggered_signal.connect(AlertGroupMattermostRepresentative.on_alert_group_action_triggered) diff --git a/engine/apps/mattermost/tasks.py b/engine/apps/mattermost/tasks.py new file mode 100644 index 00000000..b4e1aaa1 --- /dev/null +++ b/engine/apps/mattermost/tasks.py @@ -0,0 +1,182 @@ +import logging + +from celery.utils.log import get_task_logger +from django.conf import settings +from rest_framework import status + +from apps.alerts.models import Alert, AlertGroup +from apps.mattermost.alert_rendering import AlertGroupMattermostRenderer, MattermostMessageRenderer +from apps.mattermost.client import MattermostClient +from apps.mattermost.exceptions import MattermostAPIException, MattermostAPITokenInvalid +from apps.mattermost.models import MattermostChannel, MattermostMessage +from apps.user_management.models import User +from common.custom_celery_tasks import shared_dedicated_queue_retry_task +from common.utils import OkToRetry + +logger = get_task_logger(__name__) +logger.setLevel(logging.DEBUG) + + +@shared_dedicated_queue_retry_task( + bind=True, autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None +) +def on_create_alert_async(self, alert_pk): + """ + It's async in order to prevent Mattermost downtime or formatting issues causing delay with SMS and other destinations. + """ + try: + alert = Alert.objects.get(pk=alert_pk) + except Alert.DoesNotExist as e: + if on_create_alert_async.request.retries >= 10: + logger.error(f"Alert {alert_pk} was not found. Probably it was deleted. Stop retrying") + return + else: + raise e + + alert_group = alert.group + mattermost_channel = MattermostChannel.get_channel_for_alert_group(alert_group=alert_group) + if not mattermost_channel: + logger.error(f"Mattermost channel not found for alert {alert_pk}. Probably it was deleted. Stop retrying") + return + + message = alert_group.mattermost_messages.filter(message_type=MattermostMessage.ALERT_GROUP_MESSAGE).first() + if message: + logger.error(f"Mattermost message exist with post id {message.post_id} hence skipping") + return + + payload = MattermostMessageRenderer(alert_group).render_alert_group_message() + + with OkToRetry(task=self, exc=(MattermostAPIException,), num_retries=3): + try: + client = MattermostClient() + mattermost_post = client.create_post(channel_id=mattermost_channel.channel_id, data=payload) + except MattermostAPITokenInvalid: + logger.error(f"Mattermost API token is invalid could not create post for alert {alert_pk}") + except MattermostAPIException as ex: + logger.error(f"Mattermost API error {ex}") + if ex.status not in [status.HTTP_401_UNAUTHORIZED]: + raise ex + else: + MattermostMessage.create_message( + alert_group=alert_group, post=mattermost_post, message_type=MattermostMessage.ALERT_GROUP_MESSAGE + ) + + +@shared_dedicated_queue_retry_task( + autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None +) +def on_alert_group_action_triggered_async(log_record_id): + from apps.alerts.models import AlertGroupLogRecord + from apps.mattermost.alert_group_representative import AlertGroupMattermostRepresentative + + try: + log_record = AlertGroupLogRecord.objects.get(pk=log_record_id) + except AlertGroupLogRecord.DoesNotExist as e: + logger.warning(f"Mattermost representative: log record {log_record_id} never created or has been deleted") + raise e + + alert_group_id = log_record.alert_group_id + + try: + log_record.alert_group.mattermost_messages.get(message_type=MattermostMessage.ALERT_GROUP_MESSAGE) + except MattermostMessage.DoesNotExist as e: + if on_alert_group_action_triggered_async.request.retries >= 10: + logger.error(f"Mattermost message not created for {alert_group_id}. Stop retrying") + return + else: + raise e + + logger.info( + f"Start mattermost on_alert_group_action_triggered for alert_group {alert_group_id}, log record {log_record_id}" + ) + representative = AlertGroupMattermostRepresentative(log_record) + if representative.is_applicable(): + handler = representative.get_handler() + handler(log_record.alert_group) + + +@shared_dedicated_queue_retry_task( + autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None +) +def notify_user_about_alert_async(user_pk, alert_group_pk, notification_policy_pk): + from apps.base.models import UserNotificationPolicy, UserNotificationPolicyLogRecord + + def _create_error_log_record(notification_error_code=None): + UserNotificationPolicyLogRecord.objects.create( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + reason="Error during mattermost notification", + notification_step=notification_policy.step, + notification_channel=notification_policy.notify_by, + notification_error_code=notification_error_code, + ) + + try: + user = User.objects.get(pk=user_pk) + alert_group = AlertGroup.objects.get(pk=alert_group_pk) + notification_policy = UserNotificationPolicy.objects.get(pk=notification_policy_pk) + mattermost_messsage = alert_group.mattermost_messages.get(message_type=MattermostMessage.ALERT_GROUP_MESSAGE) + except User.DoesNotExist: + logger.warning(f"User {user_pk} is not found") + return + except AlertGroup.DoesNotExist: + logger.warning(f"Alert group {alert_group_pk} is not found") + return + except UserNotificationPolicy.DoesNotExist: + logger.warning(f"UserNotificationPolicy {notification_policy_pk} is not found") + return + except MattermostMessage.DoesNotExist as e: + if notify_user_about_alert_async.request.retries >= 10: + logger.error( + f"Alert group mattermost message is not created {alert_group_pk}. Hence stopped retrying for user notification" + ) + _create_error_log_record( + UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_MATTERMOST_ALERT_GROUP_MESSAGE_NOT_FOUND + ) + return + else: + raise e + + mattermost_channel = MattermostChannel.get_channel_for_alert_group(alert_group=alert_group) + if not mattermost_channel: + logger.error(f"Mattermost channel not found for user notification {user_pk}") + return + + templated_alert = AlertGroupMattermostRenderer(alert_group).alert_renderer.templated_alert + + if not hasattr(user, "mattermost_user_identity"): + message = "{}\nTried to invite {} to look at the alert group. Unfortunately {} is not in mattermost.".format( + templated_alert.title, user.username, user.username + ) + _create_error_log_record( + UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_MATTERMOST_USER_NOT_IN_MATTERMOST + ) + else: + message = "{}\nInviting {} to look at the alert group.".format( + templated_alert.title, user.mattermost_user_identity.mention_username + ) + + payload = {"root_id": mattermost_messsage.post_id, "message": message} + + try: + client = MattermostClient() + client.create_post(channel_id=mattermost_channel.channel_id, data=payload) + except MattermostAPITokenInvalid: + logger.error(f"Mattermost API token is invalid could not create post for alert {alert_group_pk}") + _create_error_log_record(UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_MATTERMOST_API_TOKEN_INVALID) + except MattermostAPIException as ex: + logger.error(f"Mattermost API error {ex}") + if ex.status != status.HTTP_401_UNAUTHORIZED: + raise ex + _create_error_log_record(UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_MATTERMOST_API_UNAUTHORIZED) + else: + UserNotificationPolicyLogRecord.objects.create( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_SUCCESS, + notification_policy=notification_policy, + alert_group=alert_group, + notification_step=notification_policy.step, + notification_channel=notification_policy.notify_by, + ) diff --git a/engine/apps/mattermost/tests/__init__.py b/engine/apps/mattermost/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/mattermost/tests/conftest.py b/engine/apps/mattermost/tests/conftest.py new file mode 100644 index 00000000..aef00804 --- /dev/null +++ b/engine/apps/mattermost/tests/conftest.py @@ -0,0 +1,124 @@ +import pytest +from django.conf import settings + +if not settings.FEATURE_MATTERMOST_INTEGRATION_ENABLED: + pytest.skip("Mattermost integration is not enabled", allow_module_level=True) +else: + from apps.mattermost.tests.factories import ( + MattermostChannelFactory, + MattermostMessageFactory, + MattermostUserFactory, + ) + + +@pytest.fixture() +def make_mattermost_channel(): + def _make_mattermost_channel(organization, **kwargs): + return MattermostChannelFactory(organization=organization, **kwargs) + + return _make_mattermost_channel + + +@pytest.fixture() +def make_mattermost_get_channel_response(): + def _make_mattermost_get_channel_response(): + return { + "id": "pbg5piuc5bgniftrserb88575h", + "team_id": "oxfug4kgx3fx7jzow49cpxkmgo", + "display_name": "Town Square", + "name": "town-square", + } + + return _make_mattermost_get_channel_response + + +@pytest.fixture() +def make_mattermost_get_user_response(): + def _make_mattermost_get_user_response(): + return { + "id": "bew5wsjnctbt78mkq9z6ci9sme", + "username": "fuzz", + "nickname": "buzz", + } + + return _make_mattermost_get_user_response + + +@pytest.fixture() +def make_mattermost_post_response(): + def _make_mattermost_post_response(**kwargs): + return { + "id": kwargs["id"] if "id" in kwargs else "bew5wsjnctbt78mkq9z6ci9sme", + "channel_id": kwargs["channel_id"] if "channel_id" in kwargs else "cew5wstyetbt78mkq9z6ci9spq", + "user_id": kwargs["user_id"] if "user_id" in kwargs else "uew5wsjnctbz78mkq9z6ci9sos", + } + + return _make_mattermost_post_response + + +@pytest.fixture() +def make_mattermost_post_response_failure(): + def _make_mattermost_post_response(**kwargs): + return { + "status_code": kwargs["status_code"] if "status_code" in kwargs else 400, + "id": kwargs["id"] if "id" in kwargs else "itre5wsjnctbz78mkq9z6ci9itue", + "message": kwargs["message"] if "message" in kwargs else "API Error", + "request_id": kwargs["request_id"] if "request_id" in kwargs else "reqe5wsjnctbz78mkq9z6ci9iqer", + } + + return _make_mattermost_post_response + + +@pytest.fixture() +def make_mattermost_message(): + def _make_mattermost_message(alert_group, message_type, **kwargs): + return MattermostMessageFactory(alert_group=alert_group, message_type=message_type, **kwargs) + + return _make_mattermost_message + + +@pytest.fixture() +def make_mattermost_user(): + def _make_mattermost_user(user, **kwargs): + return MattermostUserFactory(user=user, **kwargs) + + return _make_mattermost_user + + +@pytest.fixture +def set_random_mattermost_sigining_secret(settings): + def _set_random_mattermost_sigining_secret(): + settings.MATTERMOST_SIGNING_SECRET = "n0cb4954bec053e6e616febf2c2392ff60bd02c453a52ab53d9a8b0d0d6284a6" + + return _set_random_mattermost_sigining_secret + + +@pytest.fixture() +def make_mattermost_event(): + def _make_mattermost_event(action, token, **kwargs): + return { + "user_id": kwargs["user_id"] if "user_id" in kwargs else "k8y8fccx57ygpq18oxp8pp3ntr", + "user_name": kwargs["user_name"] if "user_name" in kwargs else "hbx80530", + "channel_id": kwargs["channel_id"] if "channel_id" in kwargs else "gug81e7stfy8md747sewpeeqga", + "channel_name": kwargs["channel_name"] if "channel_name" in kwargs else "camelcase", + "team_id": kwargs["team_id"] if "team_id" in kwargs else "kjywdxcbjiyyupdgqst8bj8zrw", + "team_domain": kwargs["team_domain"] if "team_domain" in kwargs else "local", + "post_id": kwargs["post_id"] if "post_id" in kwargs else "cfsogqc61fbj3yssz78b1tarbw", + "trigger_id": kwargs["trigger_id"] + if "trigger_id" in kwargs + else ( + "cXJhd2Zwc2V3aW5nanBjY2I2YzdxdTc5NmE6azh5OGZjY3" + "g1N3lncHExOG94cDhwcDNudHI6MTcyODgyMzQxODU4NzpNRVFDSUgv" + "bURORjQrWFB1R1QzWHdTWGhDZG9rdEpNb3cydFNJL3l5QktLMkZrVj" + "dBaUFaMjdybFB3c21EWUlyMHFIeVpKVnIyR1gwa2N6RzY5YkpuSDdrOEpuVXhnPT0=" + ), + "type": kwargs["type"] if "type" in kwargs else "", + "data_source": kwargs["data_source"] if "data_source" in kwargs else "", + "context": { + "action": action, + "token": token, + "alert": kwargs["alert"] if "alert" in kwargs else "", + }, + } + + return _make_mattermost_event diff --git a/engine/apps/mattermost/tests/events/__init__.py b/engine/apps/mattermost/tests/events/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/mattermost/tests/events/test_alert_group_action_handler.py b/engine/apps/mattermost/tests/events/test_alert_group_action_handler.py new file mode 100644 index 00000000..0bbc23e6 --- /dev/null +++ b/engine/apps/mattermost/tests/events/test_alert_group_action_handler.py @@ -0,0 +1,122 @@ +import pytest +from django.utils import timezone + +from apps.alerts.constants import ActionSource, AlertGroupState +from apps.alerts.models import AlertReceiveChannel +from apps.mattermost.events.alert_group_actions_handler import AlertGroupActionHandler +from apps.mattermost.events.types import EventAction +from apps.mattermost.models import MattermostMessage +from apps.mattermost.utils import MattermostEventAuthenticator + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "event_action,expected_state", + [ + (EventAction.ACKNOWLEDGE, AlertGroupState.ACKNOWLEDGED), + (EventAction.RESOLVE, AlertGroupState.RESOLVED), + (EventAction.UNACKNOWLEDGE, AlertGroupState.FIRING), + (EventAction.UNRESOLVE, AlertGroupState.FIRING), + ], +) +def test_alert_group_action_success( + make_organization_and_user, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_mattermost_event, + make_mattermost_message, + make_mattermost_user, + event_action, + expected_state, +): + organization, user = make_organization_and_user() + + alert_receive_channel = make_alert_receive_channel( + organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA + ) + + if event_action in [EventAction.ACKNOWLEDGE, EventAction.RESOLVE]: + alert_group = make_alert_group(alert_receive_channel) + elif event_action == EventAction.UNACKNOWLEDGE: + alert_group = make_alert_group( + alert_receive_channel=alert_receive_channel, + acknowledged_at=timezone.now(), + acknowledged=True, + ) + elif event_action == EventAction.UNRESOLVE: + alert_group = make_alert_group(alert_receive_channel, resolved=True) + + make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + + mattermost_message = make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE) + mattermost_user = make_mattermost_user(user=user) + + token = MattermostEventAuthenticator.create_token(organization=organization) + event = make_mattermost_event( + event_action, + token, + post_id=mattermost_message.post_id, + channel_id=mattermost_message.channel_id, + user_id=mattermost_user.mattermost_user_id, + alert=alert_group.public_primary_key, + ) + handler = AlertGroupActionHandler(event=event, user=user) + handler.process() + alert_group.refresh_from_db() + assert alert_group.state == expected_state + assert alert_group.log_records.last().action_source == ActionSource.MATTERMOST + + +@pytest.mark.django_db +def test_alert_group_not_found( + make_organization_and_user, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_mattermost_event, + make_mattermost_user, +): + organization, user = make_organization_and_user() + + alert_receive_channel = make_alert_receive_channel( + organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA + ) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + mattermost_user = make_mattermost_user(user=user) + + token = MattermostEventAuthenticator.create_token(organization=organization) + event = make_mattermost_event( + EventAction.ACKNOWLEDGE, token, user_id=mattermost_user.mattermost_user_id, alert="ABC" + ) + handler = AlertGroupActionHandler(event=event, user=user) + handler.process() + alert_group.refresh_from_db() + assert not alert_group.acknowledged + + +@pytest.mark.django_db +def test_alert_group_action_not_found( + make_organization_and_user, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_mattermost_event, + make_mattermost_user, +): + organization, user = make_organization_and_user() + + alert_receive_channel = make_alert_receive_channel( + organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA + ) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + mattermost_user = make_mattermost_user(user=user) + + token = MattermostEventAuthenticator.create_token(organization=organization) + event = make_mattermost_event("", token, user_id=mattermost_user.mattermost_user_id, alert="ABC") + handler = AlertGroupActionHandler(event=event, user=user) + handler.process() + alert_group.refresh_from_db() + assert not alert_group.acknowledged diff --git a/engine/apps/mattermost/tests/factories.py b/engine/apps/mattermost/tests/factories.py new file mode 100644 index 00000000..8feb05dc --- /dev/null +++ b/engine/apps/mattermost/tests/factories.py @@ -0,0 +1,35 @@ +import factory + +from apps.mattermost.models import MattermostChannel, MattermostMessage, MattermostUser +from common.utils import UniqueFaker + + +class MattermostChannelFactory(factory.DjangoModelFactory): + mattermost_team_id = factory.LazyAttribute( + lambda v: str(UniqueFaker("pystr", min_chars=5, max_chars=26).generate()) + ) + channel_id = factory.LazyAttribute(lambda v: str(UniqueFaker("pystr", min_chars=5, max_chars=26).generate())) + channel_name = factory.Faker("word") + display_name = factory.Faker("word") + + class Meta: + model = MattermostChannel + + +class MattermostMessageFactory(factory.DjangoModelFactory): + post_id = factory.LazyAttribute(lambda v: str(UniqueFaker("pystr", min_chars=5, max_chars=26).generate())) + channel_id = factory.LazyAttribute(lambda v: str(UniqueFaker("pystr", min_chars=5, max_chars=26).generate())) + + class Meta: + model = MattermostMessage + + +class MattermostUserFactory(factory.DjangoModelFactory): + mattermost_user_id = factory.LazyAttribute( + lambda v: str(UniqueFaker("pystr", min_chars=5, max_chars=26).generate()) + ) + username = factory.Faker("word") + nickname = factory.Faker("word") + + class Meta: + model = MattermostUser diff --git a/engine/apps/mattermost/tests/models/test_channel.py b/engine/apps/mattermost/tests/models/test_channel.py new file mode 100644 index 00000000..65151d5b --- /dev/null +++ b/engine/apps/mattermost/tests/models/test_channel.py @@ -0,0 +1,90 @@ +import pytest + +from apps.mattermost.models import MattermostChannel + + +@pytest.mark.django_db +def test_get_channel_for_alert_group( + make_organization, + make_alert_receive_channel, + make_channel_filter, + make_alert_group, + make_alert, + make_mattermost_channel, +): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + make_mattermost_channel(organization=organization, is_default_channel=True) + channel = make_mattermost_channel(organization=organization) + channel_filter = make_channel_filter( + alert_receive_channel, + notification_backends={"MATTERMOST": {"channel": channel.public_primary_key, "enabled": True}}, + ) + + alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter) + make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + + ch = MattermostChannel.get_channel_for_alert_group(alert_group) + assert ch.public_primary_key == channel.public_primary_key + + +@pytest.mark.django_db +def test_get_mattermost_channel_disabled_for_route( + make_organization, + make_alert_receive_channel, + make_channel_filter, + make_alert_group, + make_alert, + make_mattermost_channel, +): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + channel = make_mattermost_channel(organization=organization) + channel_filter = make_channel_filter( + alert_receive_channel, + notification_backends={"MATTERMOST": {"channel": channel.public_primary_key, "enabled": False}}, + ) + + alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter) + make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + + ch = MattermostChannel.get_channel_for_alert_group(alert_group) + assert ch is None + + +@pytest.mark.django_db +def test_get_mattermost_channel_invalid_route_channel( + make_organization, + make_alert_receive_channel, + make_channel_filter, + make_alert_group, + make_alert, + make_mattermost_channel, +): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + default_channel = make_mattermost_channel(organization=organization, is_default_channel=True) + channel_filter = make_channel_filter( + alert_receive_channel, notification_backends={"MATTERMOST": {"channel": "invalid_id", "enabled": True}} + ) + + alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter) + make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + + ch = MattermostChannel.get_channel_for_alert_group(alert_group) + assert ch.public_primary_key == default_channel.public_primary_key + + +@pytest.mark.django_db +def test_get_mattermost_channel_channel_filter_not_configured( + make_organization, make_alert_receive_channel, make_alert_group, make_alert, make_mattermost_channel +): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + default_channel = make_mattermost_channel(organization=organization, is_default_channel=True) + + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + + ch = MattermostChannel.get_channel_for_alert_group(alert_group) + assert ch.public_primary_key == default_channel.public_primary_key diff --git a/engine/apps/mattermost/tests/test_alert_rendering.py b/engine/apps/mattermost/tests/test_alert_rendering.py new file mode 100644 index 00000000..da9964a9 --- /dev/null +++ b/engine/apps/mattermost/tests/test_alert_rendering.py @@ -0,0 +1,48 @@ +import pytest +from django.utils import timezone + +from apps.mattermost.alert_rendering import MattermostMessageRenderer + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "expected_button_ids,expected_button_names,color_code,alert_type", + [ + (["acknowledge", "resolve"], ["Acknowledge", "Resolve"], "#a30200", "unack"), + (["unacknowledge", "resolve"], ["Unacknowledge", "Resolve"], "#daa038", "ack"), + (["unresolve"], ["Unresolve"], "#2eb886", "resolved"), + ], +) +def test_alert_group_message_renderer( + make_organization, + make_alert_receive_channel, + make_alert_group, + make_alert, + expected_button_ids, + expected_button_names, + color_code, + alert_type, +): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel(organization) + + if alert_type == "unack": + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + elif alert_type == "ack": + alert_group = make_alert_group(alert_receive_channel, acknowledged_at=timezone.now(), acknowledged=True) + make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + elif alert_type == "resolved": + alert_group = make_alert_group(alert_receive_channel, resolved_at=timezone.now(), resolved=True) + make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + + message = MattermostMessageRenderer(alert_group=alert_group).render_alert_group_message() + actions = message["props"]["attachments"][0]["actions"] + color = message["props"]["attachments"][0]["color"] + assert color == color_code + ids = [a["id"] for a in actions] + for id in ids: + assert id in expected_button_ids + names = [a["name"] for a in actions] + for name in names: + assert name in expected_button_names diff --git a/engine/apps/mattermost/tests/test_backend.py b/engine/apps/mattermost/tests/test_backend.py new file mode 100644 index 00000000..67db285f --- /dev/null +++ b/engine/apps/mattermost/tests/test_backend.py @@ -0,0 +1,94 @@ +import pytest +from rest_framework import serializers + +from apps.mattermost.backend import MattermostBackend +from apps.user_management.models import User + + +@pytest.mark.django_db +def test_unlink_user(make_organization_and_user, make_mattermost_user): + _, user = make_organization_and_user() + make_mattermost_user(user=user) + backend = MattermostBackend() + backend.unlink_user(user) + user.refresh_from_db() + + with pytest.raises(User.mattermost_user_identity.RelatedObjectDoesNotExist): + user.mattermost_user_identity + + +@pytest.mark.django_db +def test_serialize_user(make_organization_and_user, make_mattermost_user): + _, user = make_organization_and_user() + mattermost_user = make_mattermost_user(user=user) + data = MattermostBackend().serialize_user(user) + assert data["mattermost_user_id"] == mattermost_user.mattermost_user_id + assert data["username"] == mattermost_user.username + + +@pytest.mark.django_db +def test_serialize_user_not_found( + make_organization_and_user, +): + _, user = make_organization_and_user() + data = MattermostBackend().serialize_user(user) + assert data is None + + +@pytest.mark.django_db +def test_validate_channel_filter_data( + make_organization, + make_mattermost_channel, +): + organization = make_organization() + channel = make_mattermost_channel(organization=organization, is_default_channel=True) + input_data = {"channel": channel.public_primary_key, "enabled": True} + data = MattermostBackend().validate_channel_filter_data(organization=organization, data=input_data) + assert data["channel"] == channel.public_primary_key + assert data["enabled"] + + +@pytest.mark.django_db +def test_validate_channel_filter_data_update_only_channel( + make_organization, + make_mattermost_channel, +): + organization = make_organization() + channel = make_mattermost_channel(organization=organization, is_default_channel=True) + input_data = {"channel": channel.public_primary_key} + data = MattermostBackend().validate_channel_filter_data(organization=organization, data=input_data) + assert data["channel"] == channel.public_primary_key + assert "enabled" not in data + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "input_data,expected_data", + [ + ({}, {}), + ({"enabled": True}, {"enabled": True}), + ({"enabled": False}, {"enabled": False}), + ({"enabled": 1}, {"enabled": True}), + ({"enabled": 0}, {"enabled": False}), + ({"channel": None, "enabled": True}, {"channel": None, "enabled": True}), + ({"channel": None}, {"channel": None}), + ], +) +def test_validate_channel_filter_data_toggle_flag( + make_organization, + input_data, + expected_data, +): + organization = make_organization() + data = MattermostBackend().validate_channel_filter_data(organization=organization, data=input_data) + assert data == expected_data + + +@pytest.mark.django_db +def test_validate_channel_filter_data_invalid_channel( + make_organization, +): + organization = make_organization() + input_data = {"channel": "abcd", "enabled": True} + with pytest.raises(serializers.ValidationError): + MattermostBackend().validate_channel_filter_data(organization=organization, data=input_data) diff --git a/engine/apps/mattermost/tests/test_mattermost_channel.py b/engine/apps/mattermost/tests/test_mattermost_channel.py new file mode 100644 index 00000000..ba11fbe6 --- /dev/null +++ b/engine/apps/mattermost/tests/test_mattermost_channel.py @@ -0,0 +1,383 @@ +import json +from unittest.mock import Mock, patch + +import pytest +import requests +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from apps.api.permissions import LegacyAccessControlRole + + +@pytest.mark.django_db +def test_not_authorized(make_organization_and_user_with_plugin_token, make_mattermost_channel): + client = APIClient() + + organization, _, _ = make_organization_and_user_with_plugin_token() + mattermost_channel = make_mattermost_channel(organization=organization) + + url = reverse("mattermost:channel-list") + response = client.post(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + url = reverse("mattermost:channel-list") + response = client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + url = reverse("mattermost:channel-detail", kwargs={"pk": mattermost_channel.public_primary_key}) + response = client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + url = reverse("mattermost:channel-detail", kwargs={"pk": mattermost_channel.public_primary_key}) + response = client.delete(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected_status", + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN), + ], +) +def test_list_mattermost_channels_permissions( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + role, + expected_status, +): + client = APIClient() + _, user, token = make_organization_and_user_with_plugin_token(role) + + url = reverse("mattermost:channel-list") + response = client.get(url, **make_user_auth_headers(user, token)) + + assert response.status_code == expected_status + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected_status", + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_200_OK), + (LegacyAccessControlRole.VIEWER, status.HTTP_200_OK), + (LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN), + ], +) +def test_get_mattermost_channels_permissions( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_mattermost_channel, + role, + expected_status, +): + client = APIClient() + organization, user, token = make_organization_and_user_with_plugin_token(role) + mattermost_channel = make_mattermost_channel(organization=organization) + + url = reverse("mattermost:channel-detail", kwargs={"pk": mattermost_channel.public_primary_key}) + response = client.get(url, **make_user_auth_headers(user, token)) + + assert response.status_code == expected_status + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected_status", + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_204_NO_CONTENT), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN), + ], +) +def test_delete_mattermost_channels_permissions( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_mattermost_channel, + role, + expected_status, +): + client = APIClient() + organization, user, token = make_organization_and_user_with_plugin_token(role) + mattermost_channel = make_mattermost_channel(organization=organization) + + url = reverse("mattermost:channel-detail", kwargs={"pk": mattermost_channel.public_primary_key}) + response = client.delete(url, **make_user_auth_headers(user, token)) + + assert response.status_code == expected_status + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected_status", + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_201_CREATED), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN), + ], +) +def test_post_mattermost_channels_permissions( + make_organization_and_user_with_plugin_token, + make_mattermost_get_channel_response, + make_user_auth_headers, + role, + expected_status, +): + client = APIClient() + _, user, token = make_organization_and_user_with_plugin_token(role) + + data = make_mattermost_get_channel_response() + channel_response = requests.Response() + channel_response.status_code = status.HTTP_200_OK + channel_response._content = json.dumps(data).encode() + + url = reverse("mattermost:channel-list") + with patch("apps.mattermost.client.requests.get", return_value=channel_response) as mock_request: + response = client.post( + url, + data={"channel_id": "fuzzchannel"}, + format="json", + **make_user_auth_headers(user, token), + ) + assert response.status_code == expected_status + if expected_status == status.HTTP_201_CREATED: + res = response.json() + mock_request.assert_called_once() + assert res["channel_id"] == data["id"] + assert res["channel_name"] == data["name"] + assert res["display_name"] == data["display_name"] + assert res["is_default_channel"] is False + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "request_body,expected_status", + [ + ({"channel_id": "fuzzchannel"}, status.HTTP_201_CREATED), + ({}, status.HTTP_400_BAD_REQUEST), + ], +) +def test_post_mattermost_channels( + make_organization_and_user_with_plugin_token, + make_mattermost_get_channel_response, + make_user_auth_headers, + request_body, + expected_status, +): + client = APIClient() + _, user, token = make_organization_and_user_with_plugin_token() + + data = make_mattermost_get_channel_response() + channel_response = requests.Response() + channel_response.status_code = status.HTTP_200_OK + channel_response._content = json.dumps(data).encode() + + url = reverse("mattermost:channel-list") + with patch("apps.mattermost.client.requests.get", return_value=channel_response) as mock_request: + response = client.post(url, data=request_body, format="json", **make_user_auth_headers(user, token)) + + if expected_status == status.HTTP_201_CREATED: + mock_request.assert_called_once() + else: + mock_request.assert_not_called() + assert response.status_code == expected_status + + +@pytest.mark.django_db +def test_post_mattermost_channels_mattermost_api_call_failure( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, +): + client = APIClient() + _, user, token = make_organization_and_user_with_plugin_token() + + # Timeout Error + mock_response = Mock() + mock_response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + mock_response.request = requests.Request( + url="https://example.com", + method="GET", + ) + mock_response.raise_for_status.side_effect = requests.Timeout(response=mock_response) + + url = reverse("mattermost:channel-list") + with patch("apps.mattermost.client.requests.get", return_value=mock_response) as mock_request: + response = client.post( + url, + data={"channel_id": "fuzzchannel"}, + format="json", + **make_user_auth_headers(user, token), + ) + mock_request.assert_called_once() + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["detail"] == "Mattermost api call gateway timedout" + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "role,expected_status", + [ + (LegacyAccessControlRole.ADMIN, status.HTTP_200_OK), + (LegacyAccessControlRole.EDITOR, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.VIEWER, status.HTTP_403_FORBIDDEN), + (LegacyAccessControlRole.NONE, status.HTTP_403_FORBIDDEN), + ], +) +def test_set_default_mattermost_channels_permissions( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, + make_mattermost_channel, + role, + expected_status, +): + client = APIClient() + + organization, user, token = make_organization_and_user_with_plugin_token(role) + mattermost_channel = make_mattermost_channel(organization=organization) + + url = reverse("mattermost:channel-set-default", kwargs={"pk": mattermost_channel.public_primary_key}) + response = client.post(url, **make_user_auth_headers(user, token)) + + assert response.status_code == expected_status + + +@pytest.mark.django_db +def test_list_mattermost_channels( + make_organization_and_user_with_plugin_token, make_user_auth_headers, make_mattermost_channel +): + client = APIClient() + + organization, user, token = make_organization_and_user_with_plugin_token() + + first_mattermost_channel = make_mattermost_channel(organization=organization) + second_mattermost_channel = make_mattermost_channel(organization=organization) + + expected_payload = [ + { + "id": first_mattermost_channel.public_primary_key, + "channel_id": first_mattermost_channel.channel_id, + "channel_name": first_mattermost_channel.channel_name, + "display_name": first_mattermost_channel.display_name, + "is_default_channel": first_mattermost_channel.is_default_channel, + }, + { + "id": second_mattermost_channel.public_primary_key, + "channel_id": second_mattermost_channel.channel_id, + "channel_name": second_mattermost_channel.channel_name, + "display_name": second_mattermost_channel.display_name, + "is_default_channel": second_mattermost_channel.is_default_channel, + }, + ] + + url = reverse("mattermost:channel-list") + response = client.get(url, **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + response_data = response.json() + assert len(response_data) == 2 + for channel_data in expected_payload: + assert channel_data in response_data + + +@pytest.mark.django_db +def test_get_mattermost_channel( + make_organization_and_user_with_plugin_token, make_user_auth_headers, make_mattermost_channel +): + client = APIClient() + + organization, user, token = make_organization_and_user_with_plugin_token() + mattermost_channel = make_mattermost_channel(organization=organization) + + expected_payload = { + "id": mattermost_channel.public_primary_key, + "channel_id": mattermost_channel.channel_id, + "channel_name": mattermost_channel.channel_name, + "display_name": mattermost_channel.display_name, + "is_default_channel": mattermost_channel.is_default_channel, + } + + url = reverse("mattermost:channel-detail", kwargs={"pk": mattermost_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() == expected_payload + + +@pytest.mark.django_db +def test_delete_mattermost_channel( + make_organization_and_user_with_plugin_token, make_user_auth_headers, make_mattermost_channel +): + client = APIClient() + + organization, user, token = make_organization_and_user_with_plugin_token() + mattermost_channel = make_mattermost_channel(organization=organization) + + url = reverse("mattermost:channel-detail", kwargs={"pk": mattermost_channel.public_primary_key}) + response = client.delete(url, **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_204_NO_CONTENT + + url = reverse("mattermost:channel-detail", kwargs={"pk": mattermost_channel.public_primary_key}) + response = client.get(url, **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_access_other_organization_mattermost_channels( + make_organization_and_user_with_plugin_token, make_user_auth_headers, make_mattermost_channel +): + client = APIClient() + + organization, _, _ = make_organization_and_user_with_plugin_token() + mattermost_channel = make_mattermost_channel(organization=organization) + + _, other_user, other_token = make_organization_and_user_with_plugin_token() + + url = reverse("mattermost:channel-detail", kwargs={"pk": mattermost_channel.public_primary_key}) + response = client.get(url, **make_user_auth_headers(other_user, other_token)) + assert response.status_code == status.HTTP_404_NOT_FOUND + + url = reverse("mattermost:channel-detail", kwargs={"pk": mattermost_channel.public_primary_key}) + response = client.delete(url, **make_user_auth_headers(other_user, other_token)) + assert response.status_code == status.HTTP_404_NOT_FOUND + + url = reverse("mattermost:channel-list") + response = client.get(url, **make_user_auth_headers(other_user, other_token)) + assert response.status_code == status.HTTP_200_OK + assert response.json() == [] + + url = reverse("mattermost:channel-set-default", kwargs={"pk": mattermost_channel.public_primary_key}) + response = client.post(url, **make_user_auth_headers(other_user, other_token)) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_set_default(make_organization_and_user_with_plugin_token, make_user_auth_headers, make_mattermost_channel): + client = APIClient() + + organization, user, token = make_organization_and_user_with_plugin_token() + + first_mattermost_channel = make_mattermost_channel(organization=organization) + second_mattermost_channel = make_mattermost_channel(organization=organization) + + # If no channel is default + url = reverse("mattermost:channel-set-default", kwargs={"pk": first_mattermost_channel.public_primary_key}) + response = client.post(url, **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + first_mattermost_channel.refresh_from_db() + second_mattermost_channel.refresh_from_db() + assert first_mattermost_channel.is_default_channel is True + assert second_mattermost_channel.is_default_channel is False + + # If there is an existing default channel + url = reverse("mattermost:channel-set-default", kwargs={"pk": second_mattermost_channel.public_primary_key}) + response = client.post(url, **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + first_mattermost_channel.refresh_from_db() + second_mattermost_channel.refresh_from_db() + assert first_mattermost_channel.is_default_channel is False + assert second_mattermost_channel.is_default_channel is True diff --git a/engine/apps/mattermost/tests/test_mattermost_client.py b/engine/apps/mattermost/tests/test_mattermost_client.py new file mode 100644 index 00000000..5c0a88ae --- /dev/null +++ b/engine/apps/mattermost/tests/test_mattermost_client.py @@ -0,0 +1,161 @@ +import json +from unittest.mock import Mock, patch + +import httpretty +import pytest +import requests +from django.conf import settings +from rest_framework import status + +from apps.mattermost.client import MattermostAPIException, MattermostAPITokenInvalid, MattermostClient + + +@pytest.mark.django_db +def test_mattermost_client_initialization(): + settings.MATTERMOST_BOT_TOKEN = None + with pytest.raises(MattermostAPITokenInvalid) as exc: + MattermostClient() + assert type(exc) is MattermostAPITokenInvalid + + +@pytest.mark.django_db +@httpretty.activate(verbose=True, allow_net_connect=False) +def test_get_channel_by_id_ok(make_mattermost_get_channel_response): + client = MattermostClient("abcd") + data = make_mattermost_get_channel_response() + url = "{}/api/v4/channels/{}".format(settings.MATTERMOST_HOST, data["id"]) + + mock_response = httpretty.Response(json.dumps(data), status=status.HTTP_200_OK) + httpretty.register_uri(httpretty.GET, url, responses=[mock_response]) + + channel_response = client.get_channel_by_id(data["id"]) + + last_request = httpretty.last_request() + assert last_request.method == "GET" + assert last_request.url == url + assert channel_response.channel_id == data["id"] + assert channel_response.team_id == data["team_id"] + assert channel_response.channel_name == data["name"] + assert channel_response.display_name == data["display_name"] + + +@pytest.mark.django_db +@httpretty.activate(verbose=True, allow_net_connect=False) +def test_get_user_ok(make_mattermost_get_user_response): + client = MattermostClient("abcd") + data = make_mattermost_get_user_response() + url = "{}/api/v4/users/{}".format(settings.MATTERMOST_HOST, data["id"]) + + mock_response = httpretty.Response(json.dumps(data), status=status.HTTP_200_OK) + httpretty.register_uri(httpretty.GET, url, responses=[mock_response]) + + mattermost_user = client.get_user(data["id"]) + + last_request = httpretty.last_request() + assert last_request.method == "GET" + assert last_request.url == url + assert mattermost_user.user_id == data["id"] + assert mattermost_user.username == data["username"] + assert mattermost_user.nickname == data["nickname"] + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "client_method,params,method", + [ + ("get_channel_by_id", ["fuzz"], "GET"), + ("get_user", ["fuzz"], "GET"), + ("create_post", ["fuzz", {}], "POST"), + ("update_post", ["fuzz", {}], "PUT"), + ], +) +def test_check_response_failures(client_method, params, method): + client = MattermostClient("abcd") + data = { + "status_code": status.HTTP_400_BAD_REQUEST, + "id": "fuzzbuzz", + "message": "Client Error", + "request_id": "foobar", + } + + # HTTP Error + mock_response = Mock() + mock_response.status_code = status.HTTP_400_BAD_REQUEST + mock_response.json.return_value = data + mock_response.request = requests.Request( + url="https://example.com", + method=method, + ) + mock_response.raise_for_status.side_effect = requests.HTTPError(response=mock_response) + with patch(f"apps.mattermost.client.requests.{method.lower()}", return_value=mock_response) as mock_request: + with pytest.raises(MattermostAPIException) as exc: + getattr(client, client_method)(*params) + mock_request.assert_called_once() + + # Timeout Error + mock_response = Mock() + mock_response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + mock_response.request = requests.Request( + url="https://example.com", + method=method, + ) + mock_response.raise_for_status.side_effect = requests.Timeout(response=mock_response) + with patch(f"apps.mattermost.client.requests.{method.lower()}", return_value=mock_response) as mock_request: + with pytest.raises(MattermostAPIException) as exc: + getattr(client, client_method)(*params) + assert exc.value.msg == "Mattermost api call gateway timedout" + mock_request.assert_called_once() + + # RequestException Error + mock_response = Mock() + mock_response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + mock_response.request = requests.Request( + url="https://example.com", + method=method, + ) + mock_response.raise_for_status.side_effect = requests.exceptions.RequestException(response=mock_response) + with patch(f"apps.mattermost.client.requests.{method.lower()}", return_value=mock_response) as mock_request: + with pytest.raises(MattermostAPIException) as exc: + getattr(client, client_method)(*params) + assert exc.value.msg == "Unexpected error from mattermost server" + mock_request.assert_called_once() + + +@pytest.mark.django_db +@httpretty.activate(verbose=True, allow_net_connect=False) +def test_create_post_ok(make_mattermost_post_response): + client = MattermostClient("abcd") + data = make_mattermost_post_response() + url = "{}/api/v4/posts".format(settings.MATTERMOST_HOST) + + mock_response = httpretty.Response(json.dumps(data), status=status.HTTP_200_OK) + httpretty.register_uri(httpretty.POST, url, responses=[mock_response]) + + mattermost_post = client.create_post(data["id"], {}) + + last_request = httpretty.last_request() + assert last_request.method == "POST" + assert last_request.url == url + assert mattermost_post.post_id == data["id"] + assert mattermost_post.channel_id == data["channel_id"] + assert mattermost_post.user_id == data["user_id"] + + +@pytest.mark.django_db +@httpretty.activate(verbose=True, allow_net_connect=False) +def test_update_post_ok(make_mattermost_post_response): + client = MattermostClient("abcd") + data = make_mattermost_post_response() + url = "{}/api/v4/posts/{}".format(settings.MATTERMOST_HOST, data["id"]) + + mock_response = httpretty.Response(json.dumps(data), status=status.HTTP_200_OK) + httpretty.register_uri(httpretty.PUT, url, responses=[mock_response]) + + mattermost_post = client.update_post(data["id"], {}) + + last_request = httpretty.last_request() + assert last_request.method == "PUT" + assert last_request.url == url + assert mattermost_post.post_id == data["id"] + assert mattermost_post.channel_id == data["channel_id"] + assert mattermost_post.user_id == data["user_id"] diff --git a/engine/apps/mattermost/tests/test_mattermost_event.py b/engine/apps/mattermost/tests/test_mattermost_event.py new file mode 100644 index 00000000..0344b284 --- /dev/null +++ b/engine/apps/mattermost/tests/test_mattermost_event.py @@ -0,0 +1,155 @@ +import pytest +from django.urls import reverse +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APIClient + +from apps.alerts.constants import ActionSource, AlertGroupState +from apps.alerts.models import AlertReceiveChannel +from apps.api.permissions import LegacyAccessControlRole +from apps.mattermost.events.types import EventAction +from apps.mattermost.models import MattermostMessage +from apps.mattermost.utils import MattermostEventAuthenticator + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "event_action,expected_state", + [ + (EventAction.ACKNOWLEDGE, AlertGroupState.ACKNOWLEDGED), + (EventAction.RESOLVE, AlertGroupState.RESOLVED), + (EventAction.UNACKNOWLEDGE, AlertGroupState.FIRING), + (EventAction.UNRESOLVE, AlertGroupState.FIRING), + ], +) +def test_mattermost_alert_group_event_success( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_mattermost_channel, + make_alert_group, + make_alert, + make_mattermost_event, + make_mattermost_message, + make_mattermost_user, + event_action, + expected_state, +): + organization, user, _ = make_organization_and_user_with_plugin_token() + + alert_receive_channel = make_alert_receive_channel( + organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA + ) + + if event_action in [EventAction.ACKNOWLEDGE, EventAction.RESOLVE]: + alert_group = make_alert_group(alert_receive_channel) + elif event_action == EventAction.UNACKNOWLEDGE: + alert_group = make_alert_group( + alert_receive_channel=alert_receive_channel, + acknowledged_at=timezone.now(), + acknowledged=True, + ) + elif event_action == EventAction.UNRESOLVE: + alert_group = make_alert_group(alert_receive_channel, resolved=True) + + make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + + make_mattermost_channel(organization=organization, is_default_channel=True) + mattermost_message = make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE) + mattermost_user = make_mattermost_user(user=user) + + token = MattermostEventAuthenticator.create_token(organization=organization) + event = make_mattermost_event( + event_action, + token, + post_id=mattermost_message.post_id, + channel_id=mattermost_message.channel_id, + user_id=mattermost_user.mattermost_user_id, + alert=alert_group.public_primary_key, + ) + + url = reverse("mattermost:incoming_mattermost_event") + client = APIClient() + response = client.post(url, event, format="json") + alert_group.refresh_from_db() + assert alert_group.state == expected_state + assert alert_group.log_records.last().action_source == ActionSource.MATTERMOST + assert response.status_code == status.HTTP_200_OK + + +@pytest.mark.django_db +def test_mattermost_alert_group_event_incorrect_token( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_mattermost_channel, + make_alert_group, + make_alert, + make_mattermost_event, + make_mattermost_message, + make_mattermost_user, +): + organization, user, _ = make_organization_and_user_with_plugin_token() + + alert_receive_channel = make_alert_receive_channel( + organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA + ) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + + make_mattermost_channel(organization=organization, is_default_channel=True) + mattermost_message = make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE) + mattermost_user = make_mattermost_user(user=user) + + token = MattermostEventAuthenticator.create_token(organization=organization) + token += "abx" + event = make_mattermost_event( + EventAction.ACKNOWLEDGE, + token, + post_id=mattermost_message.post_id, + channel_id=mattermost_message.channel_id, + user_id=mattermost_user.mattermost_user_id, + alert=alert_group.public_primary_key, + ) + + url = reverse("mattermost:incoming_mattermost_event") + client = APIClient() + response = client.post(url, event, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_mattermost_alert_group_event_insufficient_permission( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_mattermost_channel, + make_alert_group, + make_alert, + make_mattermost_event, + make_mattermost_message, + make_mattermost_user, +): + organization, user, _ = make_organization_and_user_with_plugin_token(LegacyAccessControlRole.VIEWER) + + alert_receive_channel = make_alert_receive_channel( + organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA + ) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + + make_mattermost_channel(organization=organization, is_default_channel=True) + mattermost_message = make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE) + mattermost_user = make_mattermost_user(user=user) + + token = MattermostEventAuthenticator.create_token(organization=organization) + event = make_mattermost_event( + EventAction.ACKNOWLEDGE, + token, + post_id=mattermost_message.post_id, + channel_id=mattermost_message.channel_id, + user_id=mattermost_user.mattermost_user_id, + alert=alert_group.public_primary_key, + ) + + url = reverse("mattermost:incoming_mattermost_event") + client = APIClient() + response = client.post(url, event, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/engine/apps/mattermost/tests/test_representative.py b/engine/apps/mattermost/tests/test_representative.py new file mode 100644 index 00000000..b8082ded --- /dev/null +++ b/engine/apps/mattermost/tests/test_representative.py @@ -0,0 +1,100 @@ +import pytest +from django.utils import timezone + +from apps.alerts.models import AlertGroupLogRecord, AlertReceiveChannel +from apps.mattermost.alert_group_representative import AlertGroupMattermostRepresentative + + +@pytest.mark.django_db +def test_get_handler( + make_organization, + make_alert_receive_channel, + make_mattermost_channel, + make_alert_group, + make_alert, + make_alert_group_log_record, +): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel( + organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA + ) + make_mattermost_channel(organization=organization, is_default_channel=True) + ack_alert_group = make_alert_group( + alert_receive_channel=alert_receive_channel, + acknowledged_at=timezone.now(), + acknowledged=True, + ) + make_alert(alert_group=ack_alert_group, raw_request_data=alert_receive_channel.config.example_payload) + log_record = make_alert_group_log_record(ack_alert_group, type=AlertGroupLogRecord.TYPE_ACK, author=None) + handler = AlertGroupMattermostRepresentative(log_record=log_record).get_handler() + assert handler.__name__ == "on_alert_group_action" + + +@pytest.mark.django_db +def test_is_applicable_success( + make_organization, + make_alert_receive_channel, + make_mattermost_channel, + make_alert_group, + make_alert, + make_alert_group_log_record, +): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel( + organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA + ) + make_mattermost_channel(organization=organization, is_default_channel=True) + ack_alert_group = make_alert_group( + alert_receive_channel=alert_receive_channel, + acknowledged_at=timezone.now(), + acknowledged=True, + ) + make_alert(alert_group=ack_alert_group, raw_request_data=alert_receive_channel.config.example_payload) + log_record = make_alert_group_log_record(ack_alert_group, type=AlertGroupLogRecord.TYPE_ACK, author=None) + assert AlertGroupMattermostRepresentative(log_record=log_record).is_applicable() + + +@pytest.mark.django_db +def test_is_applicable_without_channels( + make_organization, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_alert_group_log_record, +): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel( + organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA + ) + ack_alert_group = make_alert_group( + alert_receive_channel=alert_receive_channel, + acknowledged_at=timezone.now(), + acknowledged=True, + ) + make_alert(alert_group=ack_alert_group, raw_request_data=alert_receive_channel.config.example_payload) + log_record = make_alert_group_log_record(ack_alert_group, type=AlertGroupLogRecord.TYPE_ACK, author=None) + assert not AlertGroupMattermostRepresentative(log_record=log_record).is_applicable() + + +@pytest.mark.django_db +def test_is_applicable_invalid_type( + make_organization, + make_alert_receive_channel, + make_mattermost_channel, + make_alert_group, + make_alert, + make_alert_group_log_record, +): + organization = make_organization() + alert_receive_channel = make_alert_receive_channel( + organization, integration=AlertReceiveChannel.INTEGRATION_GRAFANA + ) + make_mattermost_channel(organization=organization, is_default_channel=True) + ack_alert_group = make_alert_group( + alert_receive_channel=alert_receive_channel, + acknowledged_at=timezone.now(), + acknowledged=True, + ) + make_alert(alert_group=ack_alert_group, raw_request_data=alert_receive_channel.config.example_payload) + log_record = make_alert_group_log_record(ack_alert_group, type=AlertGroupLogRecord.TYPE_RE_INVITE, author=None) + assert not AlertGroupMattermostRepresentative(log_record=log_record).is_applicable() diff --git a/engine/apps/mattermost/tests/test_tasks.py b/engine/apps/mattermost/tests/test_tasks.py new file mode 100644 index 00000000..c5ea6a21 --- /dev/null +++ b/engine/apps/mattermost/tests/test_tasks.py @@ -0,0 +1,495 @@ +import json +from unittest.mock import patch + +import httpretty +import pytest +from django.conf import settings +from django.utils import timezone +from rest_framework import status + +from apps.alerts.models import AlertGroupLogRecord +from apps.base.models import UserNotificationPolicyLogRecord +from apps.base.models.user_notification_policy import UserNotificationPolicy +from apps.mattermost.client import MattermostAPIException +from apps.mattermost.models import MattermostMessage +from apps.mattermost.tasks import ( + notify_user_about_alert_async, + on_alert_group_action_triggered_async, + on_create_alert_async, +) + + +@pytest.mark.django_db +@httpretty.activate(verbose=True, allow_net_connect=False) +def test_on_create_alert_async_success( + make_organization_and_user, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_mattermost_channel, + make_mattermost_post_response, +): + organization, _ = make_organization_and_user() + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + alert = make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + make_mattermost_channel(organization=organization, is_default_channel=True) + + url = "{}/api/v4/posts".format(settings.MATTERMOST_HOST) + data = make_mattermost_post_response() + mock_response = httpretty.Response(json.dumps(data), status=status.HTTP_200_OK) + httpretty.register_uri(httpretty.POST, url, responses=[mock_response]) + + on_create_alert_async(alert_pk=alert.pk) + + mattermost_message = alert_group.mattermost_messages.order_by("created_at").first() + assert mattermost_message.post_id == data["id"] + assert mattermost_message.channel_id == data["channel_id"] + assert mattermost_message.message_type == MattermostMessage.ALERT_GROUP_MESSAGE + + +@pytest.mark.django_db +def test_on_create_alert_async_skip_post_for_duplicate( + make_organization_and_user, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_mattermost_channel, + make_mattermost_message, +): + organization, _ = make_organization_and_user() + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + alert = make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + make_mattermost_channel(organization=organization, is_default_channel=True) + make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE) + + with patch("apps.mattermost.client.MattermostClient.create_post") as mock_post_call: + on_create_alert_async(alert_pk=alert.pk) + + mock_post_call.assert_not_called() + + +@pytest.mark.django_db +def test_on_create_alert_async_skip_post_for_no_channel( + make_organization_and_user, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_mattermost_message, +): + organization, _ = make_organization_and_user() + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + alert = make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE) + + with patch("apps.mattermost.client.MattermostClient.create_post") as mock_post_call: + on_create_alert_async(alert_pk=alert.pk) + + mock_post_call.assert_not_called() + + +@pytest.mark.django_db +@httpretty.activate(verbose=True, allow_net_connect=False) +@pytest.mark.parametrize("status_code", [400, 401]) +def test_on_create_alert_async_mattermost_api_failure( + make_organization_and_user, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_mattermost_channel, + make_mattermost_post_response_failure, + status_code, +): + organization, _ = make_organization_and_user() + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + alert = make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + make_mattermost_channel(organization=organization, is_default_channel=True) + + url = "{}/api/v4/posts".format(settings.MATTERMOST_HOST) + data = make_mattermost_post_response_failure(status_code=status_code) + mock_response = httpretty.Response(json.dumps(data), status=status_code) + httpretty.register_uri(httpretty.POST, url, status=status_code, responses=[mock_response]) + + on_create_alert_async(alert_pk=alert.pk) + + mattermost_message = alert_group.mattermost_messages.order_by("created_at").first() + assert mattermost_message is None + + +@pytest.mark.django_db +@httpretty.activate(verbose=True, allow_net_connect=False) +def test_on_alert_group_action_triggered_async_success( + make_organization_and_user, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_mattermost_channel, + make_mattermost_post_response, + make_alert_group_log_record, + make_mattermost_message, +): + organization, _ = make_organization_and_user() + alert_receive_channel = make_alert_receive_channel(organization) + ack_alert_group = make_alert_group(alert_receive_channel, acknowledged_at=timezone.now(), acknowledged=True) + make_alert(alert_group=ack_alert_group, raw_request_data=alert_receive_channel.config.example_payload) + make_mattermost_channel(organization=organization, is_default_channel=True) + ack_log_record = make_alert_group_log_record(ack_alert_group, type=AlertGroupLogRecord.TYPE_ACK, author=None) + mattermost_message = make_mattermost_message(ack_alert_group, MattermostMessage.ALERT_GROUP_MESSAGE) + expected_button_ids = ["unacknowledge", "resolve"] + + url = "{}/api/v4/posts/{}".format(settings.MATTERMOST_HOST, mattermost_message.post_id) + data = make_mattermost_post_response() + mock_response = httpretty.Response(json.dumps(data), status=status.HTTP_200_OK) + httpretty.register_uri(httpretty.PUT, url, responses=[mock_response]) + + on_alert_group_action_triggered_async(ack_log_record.pk) + + last_request = httpretty.last_request() + assert last_request.method == "PUT" + assert last_request.url == url + + request_body = json.loads(last_request.body) + ids = [a["id"] for a in request_body["props"]["attachments"][0]["actions"]] + for id in ids: + assert id in expected_button_ids + + +@pytest.mark.django_db +@httpretty.activate(verbose=True, allow_net_connect=False) +def test_on_alert_group_action_triggered_async_fails_without_alert_group_message( + make_organization_and_user, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_mattermost_channel, + make_alert_group_log_record, +): + organization, _ = make_organization_and_user() + alert_receive_channel = make_alert_receive_channel(organization) + ack_alert_group = make_alert_group(alert_receive_channel, acknowledged_at=timezone.now(), acknowledged=True) + make_alert(alert_group=ack_alert_group, raw_request_data=alert_receive_channel.config.example_payload) + make_mattermost_channel(organization=organization, is_default_channel=True) + ack_log_record = make_alert_group_log_record(ack_alert_group, type=AlertGroupLogRecord.TYPE_ACK, author=None) + + with pytest.raises(MattermostMessage.DoesNotExist): + on_alert_group_action_triggered_async(ack_log_record.pk) + + +@pytest.mark.django_db +@httpretty.activate(verbose=True, allow_net_connect=False) +@pytest.mark.parametrize("status_code", [400, 401]) +def test_on_alert_group_action_triggered_async_failure( + make_organization_and_user, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_mattermost_channel, + make_alert_group_log_record, + make_mattermost_message, + make_mattermost_post_response_failure, + status_code, +): + organization, _ = make_organization_and_user() + alert_receive_channel = make_alert_receive_channel(organization) + ack_alert_group = make_alert_group(alert_receive_channel, acknowledged_at=timezone.now(), acknowledged=True) + make_alert(alert_group=ack_alert_group, raw_request_data=alert_receive_channel.config.example_payload) + make_mattermost_channel(organization=organization, is_default_channel=True) + ack_log_record = make_alert_group_log_record(ack_alert_group, type=AlertGroupLogRecord.TYPE_ACK, author=None) + mattermost_message = make_mattermost_message(ack_alert_group, MattermostMessage.ALERT_GROUP_MESSAGE) + + url = "{}/api/v4/posts/{}".format(settings.MATTERMOST_HOST, mattermost_message.post_id) + data = make_mattermost_post_response_failure(status_code=status_code) + mock_response = httpretty.Response(json.dumps(data), status=status_code) + httpretty.register_uri(httpretty.PUT, url, status=status_code, responses=[mock_response]) + + if status_code != 401: + with pytest.raises(MattermostAPIException): + on_alert_group_action_triggered_async(ack_log_record.pk) + else: + on_alert_group_action_triggered_async(ack_log_record.pk) + + last_request = httpretty.last_request() + assert last_request.method == "PUT" + assert last_request.url == url + + +@pytest.mark.django_db +@httpretty.activate(verbose=True, allow_net_connect=False) +def test_notify_user_about_alert_async_success( + make_organization_and_user, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_mattermost_channel, + make_mattermost_post_response, + make_mattermost_message, + make_user_notification_policy, + make_mattermost_user, +): + organization, user = make_organization_and_user() + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + + make_mattermost_channel(organization=organization, is_default_channel=True) + make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE) + + make_mattermost_user(user=user) + user_notification_policy = make_user_notification_policy( + user=user, + step=UserNotificationPolicy.Step.NOTIFY, + notify_by=UserNotificationPolicy.NotificationChannel.TESTONLY, + ) + + url = "{}/api/v4/posts".format(settings.MATTERMOST_HOST) + data = make_mattermost_post_response() + mock_response = httpretty.Response(json.dumps(data), status=status.HTTP_200_OK) + httpretty.register_uri(httpretty.POST, url, responses=[mock_response]) + + notify_user_about_alert_async( + user_pk=user.pk, alert_group_pk=alert_group.pk, notification_policy_pk=user_notification_policy.pk + ) + + log_record = alert_group.personal_log_records.last() + assert log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_SUCCESS + assert log_record.alert_group.pk == alert_group.pk + + +@pytest.mark.django_db +def test_notify_user_about_alert_async_user_does_not_exist( + make_organization_and_user, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_mattermost_channel, + make_mattermost_message, + make_mattermost_user, + make_user_notification_policy, +): + organization, user = make_organization_and_user() + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + make_mattermost_channel(organization=organization, is_default_channel=True) + make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE) + make_mattermost_user(user=user) + user_notification_policy = make_user_notification_policy( + user=user, + step=UserNotificationPolicy.Step.NOTIFY, + notify_by=UserNotificationPolicy.NotificationChannel.TESTONLY, + ) + + with patch("apps.mattermost.client.MattermostClient.create_post") as mock_post_call: + notify_user_about_alert_async( + user_pk=123, alert_group_pk=alert_group.pk, notification_policy_pk=user_notification_policy.pk + ) + + mock_post_call.assert_not_called() + + +@pytest.mark.django_db +def test_notify_user_about_alert_async_alert_does_not_exist( + make_organization_and_user, + make_mattermost_channel, + make_mattermost_user, + make_user_notification_policy, +): + organization, user = make_organization_and_user() + make_mattermost_channel(organization=organization, is_default_channel=True) + make_mattermost_user(user=user) + user_notification_policy = make_user_notification_policy( + user=user, + step=UserNotificationPolicy.Step.NOTIFY, + notify_by=UserNotificationPolicy.NotificationChannel.TESTONLY, + ) + + with patch("apps.mattermost.client.MattermostClient.create_post") as mock_post_call: + notify_user_about_alert_async( + user_pk=user.pk, alert_group_pk=123, notification_policy_pk=user_notification_policy.pk + ) + + mock_post_call.assert_not_called() + + +@pytest.mark.django_db +def test_notify_user_about_alert_async_notification_policy_does_not_exist( + make_organization_and_user, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_mattermost_channel, + make_mattermost_message, + make_mattermost_user, +): + organization, user = make_organization_and_user() + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + make_mattermost_channel(organization=organization, is_default_channel=True) + make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE) + make_mattermost_user(user=user) + + with patch("apps.mattermost.client.MattermostClient.create_post") as mock_post_call: + notify_user_about_alert_async(user_pk=user.pk, alert_group_pk=alert_group.pk, notification_policy_pk=123) + + mock_post_call.assert_not_called() + + +@pytest.mark.django_db +def test_notify_user_about_alert_async_mattermost_message_does_not_exist( + make_organization_and_user, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_mattermost_channel, + make_mattermost_user, + make_user_notification_policy, +): + organization, user = make_organization_and_user() + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + make_mattermost_channel(organization=organization, is_default_channel=True) + make_mattermost_user(user=user) + user_notification_policy = make_user_notification_policy( + user=user, + step=UserNotificationPolicy.Step.NOTIFY, + notify_by=UserNotificationPolicy.NotificationChannel.TESTONLY, + ) + + with pytest.raises(MattermostMessage.DoesNotExist): + notify_user_about_alert_async( + user_pk=user.pk, alert_group_pk=alert_group.pk, notification_policy_pk=user_notification_policy.pk + ) + + +@pytest.mark.django_db +@httpretty.activate(verbose=True, allow_net_connect=False) +def test_notify_user_about_alert_async_mattermost_user_does_not_exist( + make_organization_and_user, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_mattermost_channel, + make_mattermost_message, + make_user_notification_policy, + make_mattermost_post_response, +): + organization, user = make_organization_and_user() + + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + make_mattermost_channel(organization=organization, is_default_channel=True) + make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE) + user_notification_policy = make_user_notification_policy( + user=user, + step=UserNotificationPolicy.Step.NOTIFY, + notify_by=UserNotificationPolicy.NotificationChannel.TESTONLY, + ) + + url = "{}/api/v4/posts".format(settings.MATTERMOST_HOST) + data = make_mattermost_post_response() + mock_response = httpretty.Response(json.dumps(data), status=status.HTTP_200_OK) + httpretty.register_uri(httpretty.POST, url, responses=[mock_response]) + + notify_user_about_alert_async( + user_pk=user.pk, alert_group_pk=alert_group.pk, notification_policy_pk=user_notification_policy.pk + ) + + assert alert_group.personal_log_records.filter( + notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_MATTERMOST_USER_NOT_IN_MATTERMOST + ).exists() + log_record = alert_group.personal_log_records.last() + assert log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_SUCCESS + assert log_record.alert_group.pk == alert_group.pk + + +@pytest.mark.django_db +@httpretty.activate(verbose=True, allow_net_connect=False) +@pytest.mark.parametrize("status_code", [400, 401]) +def test_notify_user_about_alert_async_api_failure( + make_organization_and_user, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_mattermost_channel, + make_mattermost_message, + make_mattermost_post_response_failure, + make_user_notification_policy, + make_mattermost_user, + status_code, +): + organization, user = make_organization_and_user() + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + make_mattermost_channel(organization=organization, is_default_channel=True) + make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE) + make_mattermost_user(user=user) + user_notification_policy = make_user_notification_policy( + user=user, + step=UserNotificationPolicy.Step.NOTIFY, + notify_by=UserNotificationPolicy.NotificationChannel.TESTONLY, + ) + + url = "{}/api/v4/posts".format(settings.MATTERMOST_HOST) + data = make_mattermost_post_response_failure(status_code=status_code) + mock_response = httpretty.Response(json.dumps(data), status=status_code) + httpretty.register_uri(httpretty.POST, url, status=status_code, responses=[mock_response]) + + if status_code != 401: + with pytest.raises(MattermostAPIException): + notify_user_about_alert_async( + user_pk=user.pk, alert_group_pk=alert_group.pk, notification_policy_pk=user_notification_policy.pk + ) + else: + notify_user_about_alert_async( + user_pk=user.pk, alert_group_pk=alert_group.pk, notification_policy_pk=user_notification_policy.pk + ) + log_record = alert_group.personal_log_records.last() + assert ( + log_record.notification_error_code + == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_MATTERMOST_API_UNAUTHORIZED + ) + + last_request = httpretty.last_request() + assert last_request.method == "POST" + assert last_request.url == url + + +@pytest.mark.django_db +def test_notify_user_about_alert_async_skip_post_for_no_channel( + make_organization_and_user, + make_alert_receive_channel, + make_alert_group, + make_alert, + make_mattermost_message, + make_mattermost_user, + make_user_notification_policy, +): + organization, user = make_organization_and_user() + alert_receive_channel = make_alert_receive_channel(organization) + alert_group = make_alert_group(alert_receive_channel) + make_alert(alert_group=alert_group, raw_request_data=alert_receive_channel.config.example_payload) + make_mattermost_message(alert_group, MattermostMessage.ALERT_GROUP_MESSAGE) + make_mattermost_user(user=user) + user_notification_policy = make_user_notification_policy( + user=user, + step=UserNotificationPolicy.Step.NOTIFY, + notify_by=UserNotificationPolicy.NotificationChannel.TESTONLY, + ) + + with patch("apps.mattermost.client.MattermostClient.create_post") as mock_post_call: + notify_user_about_alert_async( + user_pk=user.pk, alert_group_pk=alert_group.pk, notification_policy_pk=user_notification_policy.pk + ) + + mock_post_call.assert_not_called() diff --git a/engine/apps/mattermost/tests/test_utils.py b/engine/apps/mattermost/tests/test_utils.py new file mode 100644 index 00000000..761d2104 --- /dev/null +++ b/engine/apps/mattermost/tests/test_utils.py @@ -0,0 +1,26 @@ +import pytest + +from apps.mattermost.exceptions import MattermostEventTokenInvalid +from apps.mattermost.utils import MattermostEventAuthenticator + + +@pytest.mark.django_db +def test_jwt_token_validation_success( + make_organization, +): + organization = make_organization() + token = MattermostEventAuthenticator.create_token(organization=organization) + payload = MattermostEventAuthenticator.verify(token) + assert payload["organization_id"] == organization.public_primary_key + + +@pytest.mark.django_db +def test_jwt_token_validation_failure( + make_organization, + set_random_mattermost_sigining_secret, +): + organization = make_organization() + token = MattermostEventAuthenticator.create_token(organization=organization) + set_random_mattermost_sigining_secret() + with pytest.raises(MattermostEventTokenInvalid): + MattermostEventAuthenticator.verify(token) diff --git a/engine/apps/mattermost/urls.py b/engine/apps/mattermost/urls.py new file mode 100644 index 00000000..6743c7b1 --- /dev/null +++ b/engine/apps/mattermost/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path + +from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path + +from .views import MattermostChannelViewSet, MattermostEventView + +app_name = "mattermost" +router = OptionalSlashRouter() +router.register(r"channels", MattermostChannelViewSet, basename="channel") + +urlpatterns = [ + path("", include(router.urls)), + optional_slash_path("event", MattermostEventView.as_view(), name="incoming_mattermost_event"), +] diff --git a/engine/apps/mattermost/utils.py b/engine/apps/mattermost/utils.py new file mode 100644 index 00000000..72e6aa26 --- /dev/null +++ b/engine/apps/mattermost/utils.py @@ -0,0 +1,38 @@ +import datetime +import logging +import typing + +import jwt +from django.conf import settings +from django.utils import timezone + +from apps.mattermost.exceptions import MattermostEventTokenInvalid + +if typing.TYPE_CHECKING: + from apps.user_management.models import Organization + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + + +class MattermostEventAuthenticator: + @staticmethod + def create_token(organization: typing.Optional["Organization"]): + secret = settings.MATTERMOST_SIGNING_SECRET + expiration = timezone.now() + datetime.timedelta(days=30) + payload = { + "organization_id": organization.public_primary_key, + "exp": expiration, + } + token = jwt.encode(payload, secret, algorithm="HS256") + return token + + @staticmethod + def verify(token: str): + secret = settings.MATTERMOST_SIGNING_SECRET + try: + payload = jwt.decode(token, secret, algorithms="HS256") + return payload + except jwt.InvalidTokenError as e: + logger.error(f"Error while verifying mattermost token {e}") + raise MattermostEventTokenInvalid(msg="Invalid token from mattermost server") diff --git a/engine/apps/mattermost/views.py b/engine/apps/mattermost/views.py new file mode 100644 index 00000000..2a8fe650 --- /dev/null +++ b/engine/apps/mattermost/views.py @@ -0,0 +1,82 @@ +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.api.permissions import RBACPermission +from apps.auth_token.auth import PluginAuthentication +from apps.mattermost.auth import MattermostEventAuthentication +from apps.mattermost.events.event_manager import EventManager +from apps.mattermost.models import MattermostChannel +from apps.mattermost.serializers import MattermostChannelSerializer +from common.api_helpers.mixins import PublicPrimaryKeyMixin +from common.insight_log.chatops_insight_logs import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log + + +class MattermostChannelViewSet( + PublicPrimaryKeyMixin[MattermostChannel], + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + authentication_classes = (PluginAuthentication,) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "list": [RBACPermission.Permissions.CHATOPS_READ], + "retrieve": [RBACPermission.Permissions.CHATOPS_READ], + "create": [RBACPermission.Permissions.CHATOPS_UPDATE_SETTINGS], + "destroy": [RBACPermission.Permissions.CHATOPS_UPDATE_SETTINGS], + "set_default": [RBACPermission.Permissions.CHATOPS_UPDATE_SETTINGS], + } + + serializer_class = MattermostChannelSerializer + + def get_queryset(self): + return MattermostChannel.objects.filter(organization=self.request.user.organization) + + @action(detail=True, methods=["post"]) + def set_default(self, request, pk): + mattermost_channel = self.get_object() + mattermost_channel.make_channel_default(request.user) + + return Response(status=status.HTTP_200_OK) + + def perform_create(self, serializer): + serializer.save() + instance = serializer.instance + write_chatops_insight_log( + author=self.request.user, + event_name=ChatOpsEvent.CHANNEL_CONNECTED, + chatops_type=ChatOpsTypePlug.MATTERMOST.value, + channel_name=instance.channel_name, + ) + + def perform_destroy(self, instance): + write_chatops_insight_log( + author=self.request.user, + event_name=ChatOpsEvent.CHANNEL_DISCONNECTED, + chatops_type=ChatOpsTypePlug.MATTERMOST.value, + channel_name=instance.channel_name, + channel_id=instance.channel_id, + ) + instance.delete() + + +class MattermostEventView(APIView): + authentication_classes = (MattermostEventAuthentication,) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "post": [RBACPermission.Permissions.ALERT_GROUPS_WRITE], + } + + def get(self, request, format=None): + return Response("hello") + + def post(self, request): + EventManager.process_request(request=request) + return Response(status=200) diff --git a/engine/apps/social_auth/backends.py b/engine/apps/social_auth/backends.py index 2021f2a9..33824536 100644 --- a/engine/apps/social_auth/backends.py +++ b/engine/apps/social_auth/backends.py @@ -1,11 +1,17 @@ from urllib.parse import urljoin +from django.conf import settings from social_core.backends.google import GoogleOAuth2 as BaseGoogleOAuth2 +from social_core.backends.oauth import BaseOAuth2 from social_core.backends.slack import SlackOAuth2 from social_core.utils import handle_http_errors -from apps.auth_token.constants import SLACK_AUTH_TOKEN_NAME -from apps.auth_token.models import GoogleOAuth2Token, SlackAuthToken +from apps.auth_token.constants import MATTERMOST_AUTH_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME +from apps.auth_token.models import GoogleOAuth2Token, MattermostAuthToken, SlackAuthToken +from apps.mattermost.client import MattermostClient +from apps.mattermost.exceptions import MattermostAPIException, MattermostAPITokenInvalid + +from .exceptions import UserLoginOAuth2MattermostException # Scopes for slack user token. # It is main purpose - retrieve user data in SlackOAuth2V2 but we are using it in legacy code or weird Slack api cases. @@ -201,3 +207,76 @@ class InstallSlackOAuth2V2(SlackOAuth2V2): def get_scope(self): return {"user_scope": USER_SCOPE, "scope": BOT_SCOPE} + + +MATTERMOST_LOGIN_BACKEND = "mattermost-login" + + +class LoginMattermostOAuth2(BaseOAuth2): + name = MATTERMOST_LOGIN_BACKEND + + REDIRECT_STATE = False + """ + Remove redirect state because we lose session during redirects + """ + + STATE_PARAMETER = False + """ + keep `False` to avoid having `BaseOAuth2` check the `state` query param against a session value + """ + + ACCESS_TOKEN_METHOD = "POST" + AUTH_TOKEN_NAME = MATTERMOST_AUTH_TOKEN_NAME + + def authorization_url(self): + return f"{settings.MATTERMOST_HOST}/oauth/authorize" + + def access_token_url(self): + return f"{settings.MATTERMOST_HOST}/oauth/access_token" + + def get_user_details(self, response): + """ + Return user details from Mattermost Account + + Sample response + { + "access_token":"opoj5nbi6tyipdkjry8gc6tkqr", + "token_type":"bearer", + "expires_in":2553990, + "scope":"", + "refresh_token":"8gacxj3rwtr5mxczwred9xbmoh", + "id_token":"" + } + + """ + return response + + def user_data(self, access_token, *args, **kwargs): + try: + client = MattermostClient(token=access_token) + user = client.get_user() + except (MattermostAPITokenInvalid, MattermostAPIException) as ex: + raise UserLoginOAuth2MattermostException( + f"Error while trying to fetch mattermost user: {ex.msg} status: {ex.status}" + ) + response = {} + response["user"] = {} + response["user"]["user_id"] = user.user_id + response["user"]["username"] = user.username + response["user"]["nickname"] = user.nickname + return response + + def auth_params(self, state=None): + """ + Override to generate `MattermostOAuth2Token` token to include as `state` query parameter. + + https://developers.google.com/identity/protocols/oauth2/web-server#:~:text=Specifies%20any%20string%20value%20that%20your%20application%20uses%20to%20maintain%20state%20between%20your%20authorization%20request%20and%20the%20authorization%20server%27s%20response + """ + + params = super().auth_params(state) + + _, token_string = MattermostAuthToken.create_auth_token( + self.strategy.request.user, self.strategy.request.auth.organization + ) + params["state"] = token_string + return params diff --git a/engine/apps/social_auth/exceptions.py b/engine/apps/social_auth/exceptions.py index 8912c9a5..dd68101f 100644 --- a/engine/apps/social_auth/exceptions.py +++ b/engine/apps/social_auth/exceptions.py @@ -2,4 +2,9 @@ class InstallMultiRegionSlackException(Exception): pass +class UserLoginOAuth2MattermostException(Exception): + pass + + GOOGLE_AUTH_MISSING_GRANTED_SCOPE_ERROR = "missing_granted_scope" +MATTERMOST_AUTH_FETCH_USER_ERROR = "failed_to_fetch_user" diff --git a/engine/apps/social_auth/live_setting_django_strategy.py b/engine/apps/social_auth/live_setting_django_strategy.py index 6e103bfb..7e4f295f 100644 --- a/engine/apps/social_auth/live_setting_django_strategy.py +++ b/engine/apps/social_auth/live_setting_django_strategy.py @@ -37,6 +37,12 @@ class LiveSettingDjangoStrategy(DjangoStrategy): """ Overridden DjangoStrategy's method to substitute and force the host value from ENV """ + if ( + settings.MATTERMOST_LOGIN_RETURN_REDIRECT_HOST is not None + and path is not None + and path == "/api/internal/v1/complete/mattermost-login/" + ): + return create_engine_url(path, override_base=settings.MATTERMOST_LOGIN_RETURN_REDIRECT_HOST) if live_settings.SLACK_INSTALL_RETURN_REDIRECT_HOST is not None and path is not None: return create_engine_url(path, override_base=live_settings.SLACK_INSTALL_RETURN_REDIRECT_HOST) if self.request: diff --git a/engine/apps/social_auth/middlewares.py b/engine/apps/social_auth/middlewares.py index 09583dcd..7be391ad 100644 --- a/engine/apps/social_auth/middlewares.py +++ b/engine/apps/social_auth/middlewares.py @@ -8,7 +8,11 @@ from social_django.middleware import SocialAuthExceptionMiddleware from apps.grafana_plugin.ui_url_builder import UIURLBuilder from apps.social_auth.backends import LoginSlackOAuth2V2 -from apps.social_auth.exceptions import InstallMultiRegionSlackException +from apps.social_auth.exceptions import ( + MATTERMOST_AUTH_FETCH_USER_ERROR, + InstallMultiRegionSlackException, + UserLoginOAuth2MattermostException, +) from common.constants.slack_auth import REDIRECT_AFTER_SLACK_INSTALL, SLACK_AUTH_FAILED, SLACK_REGION_ERROR logger = logging.getLogger(__name__) @@ -43,3 +47,5 @@ class SocialAuthAuthCanceledExceptionMiddleware(SocialAuthExceptionMiddleware): return HttpResponse(status=status.HTTP_401_UNAUTHORIZED) elif isinstance(exception, InstallMultiRegionSlackException): return redirect(url_builder_function(f"?tab=Slack&slack_error={SLACK_REGION_ERROR}")) + elif isinstance(exception, UserLoginOAuth2MattermostException): + return redirect(url_builder_function(f"?mattermost_error={MATTERMOST_AUTH_FETCH_USER_ERROR}")) diff --git a/engine/apps/social_auth/pipeline/common.py b/engine/apps/social_auth/pipeline/common.py index bf33377b..6b84a935 100644 --- a/engine/apps/social_auth/pipeline/common.py +++ b/engine/apps/social_auth/pipeline/common.py @@ -25,3 +25,7 @@ def set_user_and_organization_from_request( "user": user, "organization": organization, } + + +def delete_auth_token(strategy, *args, **kwargs): + strategy.request.auth.delete() diff --git a/engine/apps/social_auth/pipeline/mattermost.py b/engine/apps/social_auth/pipeline/mattermost.py new file mode 100644 index 00000000..702e8abc --- /dev/null +++ b/engine/apps/social_auth/pipeline/mattermost.py @@ -0,0 +1,31 @@ +from apps.social_auth.backends import MATTERMOST_LOGIN_BACKEND +from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log + + +def connect_user_to_mattermost(response, backend, strategy, user, organization, *args, **kwargs): + from apps.mattermost.models import MattermostUser + + if backend.name != MATTERMOST_LOGIN_BACKEND: + return + + # at this point everything is correct and we can create the MattermostUser + # be sure to clear any pre-existing sessions, in case the user previously enecountered errors we want + # to be sure to clear these so they do not see them again + strategy.session.flush() + + MattermostUser.objects.get_or_create( + user=user, + mattermost_user_id=response["user"]["user_id"], + defaults={ + "username": response["user"]["username"], + "nickname": response["user"]["nickname"], + }, + ) + + write_chatops_insight_log( + author=user, + event_name=ChatOpsEvent.USER_LINKED, + chatops_type=ChatOpsTypePlug.MATTERMOST.value, + linked_user=user.username, + linked_user_id=user.public_primary_key, + ) diff --git a/engine/apps/social_auth/pipeline/slack.py b/engine/apps/social_auth/pipeline/slack.py index 1564c5a2..00cacc46 100644 --- a/engine/apps/social_auth/pipeline/slack.py +++ b/engine/apps/social_auth/pipeline/slack.py @@ -88,7 +88,3 @@ def populate_slack_identities(response, backend, user, organization, **kwargs): return HttpResponse(status=status.HTTP_400_BAD_REQUEST) if settings.FEATURE_MULTIREGION_ENABLED and not settings.UNIFIED_SLACK_APP_ENABLED: link_slack_team(str(organization.uuid), slack_team_id) - - -def delete_slack_auth_token(strategy, *args, **kwargs): - strategy.request.auth.delete() diff --git a/engine/common/insight_log/chatops_insight_logs.py b/engine/common/insight_log/chatops_insight_logs.py index f6de200b..34aa4d1e 100644 --- a/engine/common/insight_log/chatops_insight_logs.py +++ b/engine/common/insight_log/chatops_insight_logs.py @@ -26,6 +26,7 @@ class ChatOpsTypePlug(enum.Enum): # ChatOpsTypePlug provides backend_id string for chatops integration not supporting messaging_backends. SLACK = "slack" TELEGRAM = "telegram" + MATTERMOST = "mattermost" def write_chatops_insight_log(author: "User", event_name: ChatOpsEvent, chatops_type: str, **kwargs): diff --git a/engine/conftest.py b/engine/conftest.py index e3440ba0..6176241e 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -52,6 +52,7 @@ from apps.auth_token.models import ( ApiAuthToken, GoogleOAuth2Token, IntegrationBacksyncAuthToken, + MattermostAuthToken, PluginAuthToken, ServiceAccountToken, SlackAuthToken, @@ -329,6 +330,14 @@ def make_slack_token_for_user(): return _make_slack_token_for_user +@pytest.fixture +def make_mattermost_token_for_user(): + def _make_mattermost_token_for_user(user): + return MattermostAuthToken.create_auth_token(organization=user.organization, user=user) + + return _make_mattermost_token_for_user + + @pytest.fixture def make_google_oauth2_token_for_user(): def _make_google_oauth2_token_for_user(user): diff --git a/engine/engine/urls.py b/engine/engine/urls.py index 1a2f9339..635c7ba8 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -63,6 +63,10 @@ if settings.FEATURE_SLACK_INTEGRATION_ENABLED: path("slack/", include("apps.slack.urls")), ] +if settings.FEATURE_MATTERMOST_INTEGRATION_ENABLED: + urlpatterns += [ + path("api/internal/v1/mattermost/", include("apps.mattermost.urls")), + ] if settings.IS_OPEN_SOURCE: urlpatterns += [ diff --git a/engine/requirements-dev.txt b/engine/requirements-dev.txt index 6057770c..8718b1d5 100644 --- a/engine/requirements-dev.txt +++ b/engine/requirements-dev.txt @@ -18,7 +18,7 @@ charset-normalizer==3.3.2 # requests distlib==0.3.8 # via virtualenv -django==4.2.19 +django==4.2.20 # via # -c requirements.txt # django-stubs diff --git a/engine/requirements.in b/engine/requirements.in index 0714f90c..07d92131 100644 --- a/engine/requirements.in +++ b/engine/requirements.in @@ -2,7 +2,7 @@ babel==2.12.1 beautifulsoup4==4.12.2 celery[redis]==5.3.6 cryptography==43.0.1 -django==4.2.19 +django==4.2.20 django-add-default-value==0.10.0 django-anymail[amazon-ses]==12.0 django-cors-headers==3.7.0 diff --git a/engine/requirements.txt b/engine/requirements.txt index cc7d3641..922ff66a 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -75,7 +75,7 @@ deprecated==1.2.14 # opentelemetry-api # opentelemetry-exporter-otlp-proto-grpc # opentelemetry-semantic-conventions -django==4.2.19 +django==4.2.20 # via # -r requirements.in # django-add-default-value diff --git a/engine/settings/base.py b/engine/settings/base.py index fe8e8189..e419af29 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -691,6 +691,7 @@ SOCIAL_AUTH_STRATEGY = "apps.social_auth.live_setting_django_strategy.LiveSettin # https://python-social-auth.readthedocs.io/en/latest/configuration/settings.html AUTHENTICATION_BACKENDS = [ + "apps.social_auth.backends.LoginMattermostOAuth2", "apps.social_auth.backends.InstallSlackOAuth2V2", "apps.social_auth.backends.LoginSlackOAuth2V2", "django.contrib.auth.backends.ModelBackend", @@ -729,14 +730,30 @@ SLACK_IRM_ROOT_COMMAND = os.environ.get("SLACK_IRM_ROOT_COMMAND", "/grafana").ls # Controls if slack integration can be installed/uninstalled. SLACK_INTEGRATION_MAINTENANCE_ENABLED = os.environ.get("SLACK_INTEGRATION_MAINTENANCE_ENABLED", False) +# Mattermost +FEATURE_MATTERMOST_INTEGRATION_ENABLED = getenv_boolean("FEATURE_MATTERMOST_INTEGRATION_ENABLED", default=False) +MATTERMOST_CLIENT_OAUTH_ID = os.environ.get("MATTERMOST_CLIENT_OAUTH_ID") +MATTERMOST_CLIENT_OAUTH_SECRET = os.environ.get("MATTERMOST_CLIENT_OAUTH_SECRET") +MATTERMOST_HOST = os.environ.get("MATTERMOST_HOST") +MATTERMOST_BOT_TOKEN = os.environ.get("MATTERMOST_BOT_TOKEN") +MATTERMOST_LOGIN_RETURN_REDIRECT_HOST = os.environ.get("MATTERMOST_LOGIN_RETURN_REDIRECT_HOST", None) +MATTERMOST_SIGNING_SECRET = os.environ.get("MATTERMOST_SIGNING_SECRET", None) + +if FEATURE_MATTERMOST_INTEGRATION_ENABLED: + INSTALLED_APPS += ["apps.mattermost"] + SOCIAL_AUTH_SLACK_LOGIN_KEY = SLACK_CLIENT_OAUTH_ID SOCIAL_AUTH_SLACK_LOGIN_SECRET = SLACK_CLIENT_OAUTH_SECRET +SOCIAL_AUTH_MATTERMOST_LOGIN_KEY = MATTERMOST_CLIENT_OAUTH_ID +SOCIAL_AUTH_MATTERMOST_LOGIN_SECRET = MATTERMOST_CLIENT_OAUTH_SECRET SOCIAL_AUTH_SETTING_NAME_TO_LIVE_SETTING_NAME = { "SOCIAL_AUTH_SLACK_LOGIN_KEY": "SLACK_CLIENT_OAUTH_ID", "SOCIAL_AUTH_SLACK_LOGIN_SECRET": "SLACK_CLIENT_OAUTH_SECRET", "SOCIAL_AUTH_SLACK_INSTALL_FREE_KEY": "SLACK_CLIENT_OAUTH_ID", "SOCIAL_AUTH_SLACK_INSTALL_FREE_SECRET": "SLACK_CLIENT_OAUTH_SECRET", + "SOCIAL_AUTH_MATTERMOST_LOGIN_KEY": "MATTERMOST_CLIENT_OAUTH_ID", + "SOCIAL_AUTH_MATTERMOST_LOGIN_SECRET": "MATTERMOST_CLIENT_OAUTH_SECRET", } SOCIAL_AUTH_SLACK_INSTALL_FREE_CUSTOM_SCOPE = [ "bot", @@ -752,7 +769,8 @@ SOCIAL_AUTH_PIPELINE = ( "social_core.pipeline.social_auth.social_details", "apps.social_auth.pipeline.slack.connect_user_to_slack", "apps.social_auth.pipeline.slack.populate_slack_identities", - "apps.social_auth.pipeline.slack.delete_slack_auth_token", + "apps.social_auth.pipeline.mattermost.connect_user_to_mattermost", + "apps.social_auth.pipeline.common.delete_auth_token", ) SOCIAL_AUTH_GOOGLE_OAUTH2_PIPELINE = ( @@ -864,6 +882,10 @@ EMAIL_BACKEND_INTERNAL_ID = 8 if FEATURE_EMAIL_INTEGRATION_ENABLED: EXTRA_MESSAGING_BACKENDS += [("apps.email.backend.EmailBackend", EMAIL_BACKEND_INTERNAL_ID)] +MATTERMOST_BACKEND_INTERNAL_ID = 9 +if FEATURE_MATTERMOST_INTEGRATION_ENABLED: + EXTRA_MESSAGING_BACKENDS += [("apps.mattermost.backend.MattermostBackend", MATTERMOST_BACKEND_INTERNAL_ID)] + # Inbound email settings INBOUND_EMAIL_ESP = os.getenv("INBOUND_EMAIL_ESP") INBOUND_EMAIL_DOMAIN = os.getenv("INBOUND_EMAIL_DOMAIN") diff --git a/engine/settings/celery_task_routes.py b/engine/settings/celery_task_routes.py index e3b9fdcf..47c64498 100644 --- a/engine/settings/celery_task_routes.py +++ b/engine/settings/celery_task_routes.py @@ -189,4 +189,8 @@ CELERY_TASK_ROUTES = { "apps.webhooks.tasks.alert_group_status.alert_group_created": {"queue": "webhook"}, "apps.webhooks.tasks.alert_group_status.alert_group_status_change": {"queue": "webhook"}, "apps.webhooks.tasks.notify_user.notify_user_async": {"queue": "webhook"}, + # MATTERMOST + "apps.mattermost.tasks.on_create_alert_async": {"queue": "mattermost"}, + "apps.mattermost.tasks.on_alert_group_action_triggered_async": {"queue": "mattermost"}, + "apps.mattermost.tasks.notify_user_about_alert_async": {"queue": "mattermost"}, } diff --git a/engine/settings/ci_test.py b/engine/settings/ci_test.py index 9ac6dca1..753c1296 100644 --- a/engine/settings/ci_test.py +++ b/engine/settings/ci_test.py @@ -57,3 +57,10 @@ EXTRA_MESSAGING_BACKENDS = [("apps.base.tests.messaging_backend.TestOnlyBackend" # File "/usr/local/lib/python3.12/site-packages/silk/model_factory.py", line 243, in construct_request_model # request_model = models.Request.objects.create( SILK_PROFILER_ENABLED = False + +FEATURE_MATTERMOST_INTEGRATION_ENABLED = True +if FEATURE_MATTERMOST_INTEGRATION_ENABLED: + INSTALLED_APPS += ["apps.mattermost"] + MATTERMOST_HOST = "http://localhost:8065" + MATTERMOST_BOT_TOKEN = "0000000000:XXXXXXXXXXXXXXXXXXXXXXXXXXXX-XXXXXX" + MATTERMOST_SIGNING_SECRET = "f0cb4953bec053e6e616febf2c2392ff60bd02c453a52ab53d9a8b0d0d7284a6" diff --git a/engine/settings/dev.py b/engine/settings/dev.py index 078b0735..0366fa09 100644 --- a/engine/settings/dev.py +++ b/engine/settings/dev.py @@ -64,6 +64,10 @@ SWAGGER_SETTINGS = { if TESTING: EXTRA_MESSAGING_BACKENDS = [("apps.base.tests.messaging_backend.TestOnlyBackend", 42)] TELEGRAM_TOKEN = "0000000000:XXXXXXXXXXXXXXXXXXXXXXXXXXXX-XXXXXX" + MATTERMOST_HOST = "http://localhost:8065" + MATTERMOST_BOT_TOKEN = "0000000000:XXXXXXXXXXXXXXXXXXXXXXXXXXXX-XXXXXX" + MATTERMOST_SIGNING_SECRET = "f0cb4953bec053e6e616febf2c2392ff60bd02c453a52ab53d9a8b0d0d7284a6" + TWILIO_AUTH_TOKEN = "twilio_auth_token" # charset/collation related tests don't work without this diff --git a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.config.ts b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.config.ts index b8c5efb0..1b0d6c4b 100644 --- a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.config.ts +++ b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.config.ts @@ -1,15 +1,21 @@ +import { merge } from 'lodash-es' + import { AppFeature } from 'state/features'; import { TemplateForEdit, commonTemplateForEdit } from './CommonAlertTemplatesForm.config'; export const getTemplatesForEdit = (features: Record) => { + const templatesForEdit = {...commonTemplateForEdit} if (features?.[AppFeature.MsTeams]) { - return { ...commonTemplateForEdit, ...additionalTemplateForEdit }; + merge(templatesForEdit, msteamsTemplateForEdit) } - return commonTemplateForEdit; + if (features?.[AppFeature.Mattermost]) { + merge(templatesForEdit, mattermostTemplateForEdit) + } + return templatesForEdit; }; -const additionalTemplateForEdit: { [id: string]: TemplateForEdit } = { +const msteamsTemplateForEdit: { [id: string]: TemplateForEdit } = { msteams_title_template: { name: 'msteams_title_template', displayName: 'MS Teams title', @@ -42,4 +48,37 @@ const additionalTemplateForEdit: { [id: string]: TemplateForEdit } = { }, }; +const mattermostTemplateForEdit: { [id: string]: TemplateForEdit } = { + mattermost_title_template: { + name: 'mattermost_title_template', + displayName: 'Mattermost title', + description: '', + additionalData: { + chatOpsName: 'mattermost', + chatOpsDisplayName: 'Mattermost', + }, + type: 'plain', + }, + mattermost_message_template: { + name: 'mattermost_message_template', + displayName: 'Mattermost message', + description: '', + additionalData: { + chatOpsName: 'mattermost', + chatOpsDisplayName: 'Mattermost', + }, + type: 'plain', + }, + mattermost_image_url_template: { + name: 'mattermost_image_url_template', + displayName: 'Mattermost image url', + description: '', + additionalData: { + chatOpsName: 'mattermost', + chatOpsDisplayName: 'Mattermost', + }, + type: 'plain', + }, +}; + export const FORM_NAME = 'AlertTemplates'; diff --git a/grafana-plugin/src/containers/AlertRules/AlertRules.tsx b/grafana-plugin/src/containers/AlertRules/AlertRules.tsx index 517d49e8..d5d6285e 100644 --- a/grafana-plugin/src/containers/AlertRules/AlertRules.tsx +++ b/grafana-plugin/src/containers/AlertRules/AlertRules.tsx @@ -4,6 +4,7 @@ import { Stack, useTheme2 } from '@grafana/ui'; import { Timeline } from 'components/Timeline/Timeline'; import { MSTeamsConnector } from 'containers/AlertRules/parts/connectors/MSTeamsConnector'; +import { MattermostConnector } from 'containers/AlertRules/parts/connectors/MattermostConnector'; import { SlackConnector } from 'containers/AlertRules/parts/connectors/SlackConnector'; import { TelegramConnector } from 'containers/AlertRules/parts/connectors/TelegramConnector'; import { ChannelFilter } from 'models/channel_filter/channel_filter.types'; @@ -20,7 +21,7 @@ export const ChatOpsConnectors = (props: ChatOpsConnectorsProps) => { const store = useStore(); const theme = useTheme2(); - const { organizationStore, telegramChannelStore, msteamsChannelStore } = store; + const { organizationStore, telegramChannelStore, msteamsChannelStore, mattermostChannelStore } = store; const isSlackInstalled = Boolean(organizationStore.currentOrganization?.slack_team_identity); const isTelegramInstalled = @@ -28,11 +29,13 @@ export const ChatOpsConnectors = (props: ChatOpsConnectorsProps) => { useEffect(() => { msteamsChannelStore.updateMSTeamsChannels(); + mattermostChannelStore.updateMattermostChannels(); }, []); const isMSTeamsInstalled = msteamsChannelStore.currentTeamToMSTeamsChannel?.length > 0; + const isMattermostInstalled = store.hasFeature(AppFeature.Mattermost) && Object.keys(mattermostChannelStore.items).length > 0; - if (!isSlackInstalled && !isTelegramInstalled && !isMSTeamsInstalled) { + if (!isSlackInstalled && !isTelegramInstalled && !isMSTeamsInstalled && !isMattermostInstalled) { return null; } @@ -42,6 +45,7 @@ export const ChatOpsConnectors = (props: ChatOpsConnectorsProps) => { {isSlackInstalled && } {isTelegramInstalled && } {isMSTeamsInstalled && } + {isMattermostInstalled && } ); diff --git a/grafana-plugin/src/containers/AlertRules/parts/connectors/MattermostConnector.tsx b/grafana-plugin/src/containers/AlertRules/parts/connectors/MattermostConnector.tsx new file mode 100644 index 00000000..7316619e --- /dev/null +++ b/grafana-plugin/src/containers/AlertRules/parts/connectors/MattermostConnector.tsx @@ -0,0 +1,81 @@ +import React, { useCallback } from 'react'; + +import { cx } from '@emotion/css'; +import { InlineSwitch, Stack, useStyles2 } from '@grafana/ui'; +import { UserActions } from 'helpers/authorization/authorization'; +import { StackSize } from 'helpers/consts'; +import { observer } from 'mobx-react'; + +import { GSelect } from 'containers/GSelect/GSelect'; +import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; +import { ChannelFilter } from 'models/channel_filter/channel_filter.types'; +import { MattermostChannel } from 'models/mattermost/mattermost.types'; +import { useStore } from 'state/useStore'; + +import { getConnectorsStyles } from './Connectors.styles'; + +interface MattermostConnectorProps { + channelFilterId: ChannelFilter['id']; +} + +export const MattermostConnector = observer((props: MattermostConnectorProps) => { + const { channelFilterId } = props; + + const store = useStore(); + const styles = useStyles2(getConnectorsStyles); + + const { + alertReceiveChannelStore, + mattermostChannelStore, + // dereferencing items is needed to rerender GSelect + mattermostChannelStore: { items: mattermostChannelItems }, + } = store; + + const channelFilter = alertReceiveChannelStore.channelFilters[channelFilterId]; + + const handleMattermostChannelChange = useCallback((_value: MattermostChannel['id'], mattermostChannel: MattermostChannel) => { + alertReceiveChannelStore.saveChannelFilter(channelFilterId, { + notification_backends: { + MATTERMOST: { channel: mattermostChannel?.id || null }, + }, + }); + }, []); + + const handleChannelFilterNotifyInMattermostChange = useCallback((event: React.ChangeEvent) => { + alertReceiveChannelStore.saveChannelFilter(channelFilterId, { + notification_backends: { MATTERMOST: { enabled: event.target.checked } }, + }); + }, []); + + return ( +
+ +
+ + + +
+ Post to Mattermost channel + + + allowClear + className={cx('select', 'control')} + items={mattermostChannelItems} + fetchItemsFn={mattermostChannelStore.updateItems} + fetchItemFn={mattermostChannelStore.updateById} + getSearchResult={mattermostChannelStore.getSearchResult} + displayField="display_name" + valueField="id" + placeholder="Select Mattermost Channel" + value={channelFilter.notification_backends?.MATTERMOST?.channel} + onChange={handleMattermostChannelChange} + /> + +
+
+ ); +}); diff --git a/grafana-plugin/src/containers/IntegrationContainers/IntegrationTemplatesList.config.ts b/grafana-plugin/src/containers/IntegrationContainers/IntegrationTemplatesList.config.ts index 59b79a54..616d2ad4 100644 --- a/grafana-plugin/src/containers/IntegrationContainers/IntegrationTemplatesList.config.ts +++ b/grafana-plugin/src/containers/IntegrationContainers/IntegrationTemplatesList.config.ts @@ -1,3 +1,5 @@ +import { clone } from 'lodash-es' + import { MONACO_INPUT_HEIGHT_SMALL, MONACO_INPUT_HEIGHT_TALL } from 'pages/integration/IntegrationCommon.config'; import { AppFeature } from 'state/features'; @@ -24,11 +26,35 @@ const additionalTemplatesToRender: TemplateBlock[] = [ }, ], }, + { + name: 'Mattermost', + contents: [ + { + name: 'mattermost_title_template', + label: 'Title', + height: MONACO_INPUT_HEIGHT_SMALL, + }, + { + name: 'mattermost_message_template', + label: 'Message', + height: MONACO_INPUT_HEIGHT_TALL, + }, + { + name: 'mattermost_image_url_template', + label: 'Image', + height: MONACO_INPUT_HEIGHT_SMALL, + }, + ], + } ]; export const getTemplatesToRender = (features?: Record) => { + const templatesToRender = clone(commonTemplatesToRender) if (features?.[AppFeature.MsTeams]) { - return commonTemplatesToRender.concat(additionalTemplatesToRender); + templatesToRender.push(additionalTemplatesToRender[0]); } - return commonTemplatesToRender; + if (features?.[AppFeature.Mattermost]) { + templatesToRender.push(additionalTemplatesToRender[1]) + } + return templatesToRender; }; diff --git a/grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.tsx b/grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.tsx new file mode 100644 index 00000000..0fa58356 --- /dev/null +++ b/grafana-plugin/src/containers/MattermostIntegrationButton/MattermostIntegrationButton.tsx @@ -0,0 +1,145 @@ +import React, { useCallback, useState } from 'react'; + +import { css } from '@emotion/css'; +import { Button, Modal, Field, Input, Stack, useStyles2 } from '@grafana/ui'; +import { UserActions } from 'helpers/authorization/authorization'; +import { openErrorNotification } from 'helpers/helpers'; +import { get } from 'lodash-es'; +import { observer } from 'mobx-react'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; + +import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip'; +import { useStore } from 'state/useStore'; + +interface MattermostIntegrationProps { + disabled?: boolean; + size?: 'md' | 'lg'; + onUpdate: () => void; +} +export const MattermostIntegrationButton = observer((props: MattermostIntegrationProps) => { + const { disabled, size = 'md', onUpdate } = props; + + const [showModal, setShowModal] = useState(false); + + const onModalCreateCallback = useCallback(() => { + setShowModal(true); + }, []); + + const onModalCancelCallback = useCallback(() => { + setShowModal(false); + }, []); + + const onModalUpdateCallback = useCallback(() => { + setShowModal(false); + + onUpdate(); + }, [onUpdate]); + + return ( + <> + + + + {showModal && } + + ); +}); + +interface MattermostCreationModalProps { + onHide: () => void; + onUpdate: () => void; +} + +interface FormFields { + channelId: string; +} + +const MattermostChannelForm = (props: MattermostCreationModalProps) => { + const { onHide, onUpdate } = props; + const store = useStore(); + + const formMethods = useForm({ + mode: 'onChange', + }); + + const { + control, + watch, + formState: { errors }, + handleSubmit, + } = formMethods; + + const channelId = watch('channelId'); + + const styles = useStyles2(getStyles); + + return ( + + +
+ + {renderChannelIdInput()} + + + + + +
+
+
+ ); + + function renderChannelIdInput() { + return ( + ( + + + + )} + /> + ); + } + + async function onCreateChannelCallback() { + try { + await store.mattermostChannelStore.create({ channel_id: channelId }, true); + onUpdate(); + } catch (error) { + openErrorNotification(get(error, 'response.data.detail', 'error creating channel')); + } + } +}; + +const getStyles = () => { + return { + channelFormFieldInput: css ` + border-top-right-radius: 0; + border-bottom-right-radius: 0; + `, + + field: css ` + flex-grow: 1; + ` + } +} diff --git a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx index c56cab83..3aa12f13 100644 --- a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx @@ -20,6 +20,10 @@ enum GoogleError { MISSING_GRANTED_SCOPE = 'missing_granted_scope', } +enum MattermostError { + MATTERMOST_AUTH_FETCH_USER_ERROR = 'failed_to_fetch_user', +} + interface UserFormProps { onHide: () => void; id: ApiSchemas['User']['pk'] | 'new'; @@ -41,9 +45,23 @@ function getGoogleMessage(googleError: GoogleError) { return <>Couldn't connect your Google account.; } +function getMattermostErrorMessage(mattermostError: MattermostError) { + if (mattermostError === MattermostError.MATTERMOST_AUTH_FETCH_USER_ERROR) { + return ( + <> + Couldn't connect your Mattermost account. Failed to fetch user information from your mattermost server. Please + check your mattermost ENV variable values and retry. + + ); + } + + return <>Couldn't connect your Mattermost account.; +} + const UserAlerts: React.FC = () => { const queryParams = useQueryParams(); const [showGoogleConnectAlert, setShowGoogleConnectAlert] = useState(); + const [showMattermostConnectAlert, setshowMattermostConnectAlert] = useState(); const styles = useStyles2(getStyles); @@ -51,18 +69,41 @@ const UserAlerts: React.FC = () => { setShowGoogleConnectAlert(undefined); }, []); + const handleCloseMattermostAlert = useCallback(() => { + setshowMattermostConnectAlert(undefined); + }, []); + useEffect(() => { if (queryParams.get('google_error')) { setShowGoogleConnectAlert(queryParams.get('google_error') as GoogleError); LocationHelper.update({ google_error: undefined }, 'partial'); + } else if (queryParams.get('mattermost_error')) { + setshowMattermostConnectAlert(queryParams.get('mattermost_error') as MattermostError); + + LocationHelper.update({ mattermost_error: undefined }, 'partial'); } }, []); - if (!showGoogleConnectAlert) { + if (!showGoogleConnectAlert && !showMattermostConnectAlert) { return null; } + if (showMattermostConnectAlert) { + return ( +
+ + {getMattermostErrorMessage(showMattermostConnectAlert)} + +
+ ); + } + return (
diff --git a/grafana-plugin/src/containers/UserSettings/UserSettings.types.ts b/grafana-plugin/src/containers/UserSettings/UserSettings.types.ts index 52095b4f..8821679c 100644 --- a/grafana-plugin/src/containers/UserSettings/UserSettings.types.ts +++ b/grafana-plugin/src/containers/UserSettings/UserSettings.types.ts @@ -8,4 +8,5 @@ export enum UserSettingsTab { PersonalWebhookInfo, MSTeamsInfo, MobileAppConnection, + MattermostInfo, } diff --git a/grafana-plugin/src/containers/UserSettings/parts/UserSettingsParts.tsx b/grafana-plugin/src/containers/UserSettings/parts/UserSettingsParts.tsx index 29fbafd0..b2476f57 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/UserSettingsParts.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/UserSettingsParts.tsx @@ -13,6 +13,7 @@ import { SlackTab } from 'containers/UserSettings/parts/tabs//SlackTab/SlackTab' import { CloudPhoneSettings } from 'containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings'; import { GoogleCalendar } from 'containers/UserSettings/parts/tabs/GoogleCalendar/GoogleCalendar'; import { MSTeamsInfo } from 'containers/UserSettings/parts/tabs/MSTeamsInfo/MSTeamsInfo'; +import { MattermostInfo } from 'containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo'; import { NotificationSettingsTab } from 'containers/UserSettings/parts/tabs/NotificationSettingsTab'; import { PersonalWebhookInfo } from 'containers/UserSettings/parts/tabs/PersonalWebhookInfo/PersonalWebhookInfo'; import { PhoneVerification } from 'containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification'; @@ -32,6 +33,7 @@ interface TabsProps { showTelegramConnectionTab: boolean; showPersonalWebhookConnectionTab: boolean; showMsTeamsConnectionTab: boolean; + showMattermostConnectionTab: boolean; } export const Tabs = ({ @@ -44,6 +46,7 @@ export const Tabs = ({ showTelegramConnectionTab, showPersonalWebhookConnectionTab, showMsTeamsConnectionTab, + showMattermostConnectionTab, }: TabsProps) => { const getTabClickHandler = useCallback( (tab: UserSettingsTab) => { @@ -133,6 +136,15 @@ export const Tabs = ({ data-testid="tab-msteams" /> )} + {showMattermostConnectionTab && ( + + )} ); }; @@ -180,6 +192,7 @@ export const TabsContent = observer(({ id, activeTab, onTabChange, isDesktopOrLa {activeTab === UserSettingsTab.TelegramInfo && } {activeTab === UserSettingsTab.PersonalWebhookInfo && } {activeTab === UserSettingsTab.MSTeamsInfo && } + {activeTab === UserSettingsTab.MattermostInfo && } ); diff --git a/grafana-plugin/src/containers/UserSettings/parts/connectors/Connectors.tsx b/grafana-plugin/src/containers/UserSettings/parts/connectors/Connectors.tsx index de5a44a3..6d575bf6 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/connectors/Connectors.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/connectors/Connectors.tsx @@ -10,6 +10,7 @@ import { useStore } from 'state/useStore'; import { ICalConnector } from './ICalConnector'; import { MSTeamsConnector } from './MSTeamsConnector'; +import { MattermostConnector } from './MattermostConnector'; import { MobileAppConnector } from './MobileAppConnector'; import { PersonalWebhookConnector } from './PersonalWebhookConnector'; import { PhoneConnector } from './PhoneConnector'; @@ -30,6 +31,7 @@ export const Connectors: FC = observer((props) => { {store.hasFeature(AppFeature.Telegram) && } {store.hasFeature(AppFeature.PersonalWebhook) && } + {store.hasFeature(AppFeature.Mattermost) && } {store.hasFeature(AppFeature.MsTeams) && } Calendar export diff --git a/grafana-plugin/src/containers/UserSettings/parts/connectors/MattermostConnector.tsx b/grafana-plugin/src/containers/UserSettings/parts/connectors/MattermostConnector.tsx new file mode 100644 index 00000000..7b3ec934 --- /dev/null +++ b/grafana-plugin/src/containers/UserSettings/parts/connectors/MattermostConnector.tsx @@ -0,0 +1,62 @@ +import React, { useCallback } from 'react'; + +import { Button, InlineField, Input, Stack } from '@grafana/ui'; +import { StackSize } from 'helpers/consts'; +import { observer } from 'mobx-react'; + +import { WithConfirm } from 'components/WithConfirm/WithConfirm'; +import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types'; +import { ApiSchemas } from 'network/oncall-api/api.types'; +import { useStore } from 'state/useStore'; + +interface MattermostConnectorProps { + id: ApiSchemas['User']['pk']; + onTabChange: (tab: UserSettingsTab) => void; +} +export const MattermostConnector = observer((props: MattermostConnectorProps) => { + const { id, onTabChange } = props; + + const store = useStore(); + const { userStore } = store; + + const storeUser = userStore.items[id]; + + const isCurrentUser = id === store.userStore.currentUserPk; + + const handleConnectButtonClick = useCallback(() => { + onTabChange(UserSettingsTab.MattermostInfo); + }, []); + + const handleUnlinkMattermostAccount = useCallback(() => { + userStore.unlinkBackend(id, 'MATTERMOST'); + }, []); + + const mattermostConfigured = storeUser.messaging_backends['MATTERMOST']; + + return ( +
+ {storeUser.messaging_backends.MATTERMOST ? ( + + + + + + +
+ )} + + ); +}); diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.tsx new file mode 100644 index 00000000..71b6852d --- /dev/null +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/MattermostInfo/MattermostInfo.tsx @@ -0,0 +1,56 @@ +import React, { useCallback } from 'react'; + +import { css } from '@emotion/css'; +import { Button, Stack, useStyles2 } from '@grafana/ui'; +import { UserActions } from 'helpers/authorization/authorization'; +import { DOCS_MATTERMOST_SETUP, StackSize } from 'helpers/consts'; + +import { Block } from 'components/GBlock/Block'; +import { Text } from 'components/Text/Text'; +import { WithPermissionControlDisplay } from 'containers/WithPermissionControl/WithPermissionControlDisplay'; +import { useStore } from 'state/useStore'; + +export const MattermostInfo = () => { + const styles = useStyles2(getStyles); + + const { mattermostStore } = useStore(); + + const handleClickConnectMattermostAccount = useCallback(() => { + mattermostStore.mattermostLogin(); + }, [mattermostStore]); + + return ( + + + + + + Personal Mattermost connection will allow you to manage alert groups in your connected Mattermost channel + + To link your Mattermost account, click the button below and login to your server + + + More details in{' '} + + our documentation + + + + + + + + + + ); +}; + +const getStyles = () => { + return { + mattermostInfoblock: css` + text-align: center; + `, + }; +}; diff --git a/grafana-plugin/src/helpers/consts.ts b/grafana-plugin/src/helpers/consts.ts index 0bfa08c0..de2fa3ef 100644 --- a/grafana-plugin/src/helpers/consts.ts +++ b/grafana-plugin/src/helpers/consts.ts @@ -99,6 +99,7 @@ export const DOCS_TELEGRAM_SETUP = 'https://grafana.com/docs/oncall/latest/notif export const DOCS_SERVICE_ACCOUNTS = 'https://grafana.com/docs/grafana/latest/administration/service-accounts/'; export const DOCS_ONCALL_OSS_INSTALL = 'https://grafana.com/docs/oncall/latest/set-up/open-source/#install-grafana-oncall-oss'; +export const DOCS_MATTERMOST_SETUP = 'https://grafana.com/docs/oncall/latest/manage/notify/mattermost/'; export const generateAssignToTeamInputDescription = (objectName: string): string => `Assigning to a team allows you to filter ${objectName} and configure their visibility. Go to OnCall -> Settings -> Team and Access Settings for more details.`; diff --git a/grafana-plugin/src/models/mattermost/mattermost.ts b/grafana-plugin/src/models/mattermost/mattermost.ts new file mode 100644 index 00000000..478b80fb --- /dev/null +++ b/grafana-plugin/src/models/mattermost/mattermost.ts @@ -0,0 +1,30 @@ +import { GENERIC_ERROR } from 'helpers/consts'; +import { openErrorNotification } from 'helpers/helpers'; +import { makeObservable } from 'mobx'; + +import { BaseStore } from 'models/base_store'; +import { makeRequestRaw } from 'network/network'; +import { RootStore } from 'state/rootStore'; + +export class MattermostStore extends BaseStore { + constructor(rootStore: RootStore) { + super(rootStore); + makeObservable(this); + } + + async mattermostLogin() { + try { + const response = await makeRequestRaw('/login/mattermost-login/', {}); + + if (response.status === 201) { + this.rootStore.organizationStore.loadCurrentOrganization(); + } else if (response.status === 200) { + window.location = response.data; + } + } catch (ex) { + if (ex.response?.status === 500) { + openErrorNotification(GENERIC_ERROR); + } + } + } +} diff --git a/grafana-plugin/src/models/mattermost/mattermost.types.ts b/grafana-plugin/src/models/mattermost/mattermost.types.ts new file mode 100644 index 00000000..2c84f06f --- /dev/null +++ b/grafana-plugin/src/models/mattermost/mattermost.types.ts @@ -0,0 +1,7 @@ +export interface MattermostChannel { + id: string; + channel_id: string; + channel_name: string; + display_name: string; + is_default_channel: false; +} diff --git a/grafana-plugin/src/models/mattermost/mattermost_channel.ts b/grafana-plugin/src/models/mattermost/mattermost_channel.ts new file mode 100644 index 00000000..7df8d892 --- /dev/null +++ b/grafana-plugin/src/models/mattermost/mattermost_channel.ts @@ -0,0 +1,101 @@ +import { action, observable, makeObservable, runInAction } from 'mobx'; + +import { BaseStore } from 'models/base_store'; +import { makeRequest } from 'network/network'; +import { RootStore } from 'state/rootStore'; + +import { MattermostChannel } from './mattermost.types'; + +export class MattermostChannelStore extends BaseStore { + @observable.shallow + items: { [id: string]: MattermostChannel } = {}; + + @observable.shallow + searchResult: { [key: string]: Array } = {}; + + private autoUpdateTimer?: ReturnType; + + constructor(rootStore: RootStore) { + super(rootStore); + + makeObservable(this); + + this.path = '/mattermost/channels/'; + } + + @action.bound + async updateMattermostChannels() { + const response = await makeRequest(this.path, {}); + + const items = response.reduce( + (acc: any, mattermostChannel: MattermostChannel) => ({ + ...acc, + [mattermostChannel.id]: mattermostChannel, + }), + {} + ); + + runInAction(() => { + this.items = { + ...this.items, + ...items, + }; + }); + } + + @action.bound + async updateById(id: MattermostChannel['id']) { + const response = await this.getById(id); + + runInAction(() => { + this.items = { + ...this.items, + [id]: response, + }; + }); + } + + @action.bound + async updateItems(query = '') { + const result = await this.getAll(); + + runInAction(() => { + this.items = { + ...this.items, + ...result.reduce( + (acc: { [key: number]: MattermostChannel }, item: MattermostChannel) => ({ + ...acc, + [item.id]: item, + }), + {} + ), + }; + + this.searchResult = { + ...this.searchResult, + [query]: result.map((item: MattermostChannel) => item.id), + }; + }); + } + + getSearchResult = (query = '') => { + if (!this.searchResult[query]) { + return undefined; + } + return this.searchResult[query].map( + (mattermostChannelId: MattermostChannel['id']) => this.items[mattermostChannelId] + ); + }; + + @action.bound + async makeMattermostChannelDefault(id: MattermostChannel['id']) { + return makeRequest(`/mattermost/channels/${id}/set_default`, { + method: 'POST', + }); + } + + async deleteMattermostChannel(id: MattermostChannel['id']) { + return super.delete(id); + } + +} diff --git a/grafana-plugin/src/models/organization/organization.types.ts b/grafana-plugin/src/models/organization/organization.types.ts index 2c01dc4b..336b5f05 100644 --- a/grafana-plugin/src/models/organization/organization.types.ts +++ b/grafana-plugin/src/models/organization/organization.types.ts @@ -29,5 +29,6 @@ export interface Organization { verification_call: boolean; verification_sms: boolean; }; + mattermost_configured: boolean; }; } diff --git a/grafana-plugin/src/pages/integration/Integration.helper.ts b/grafana-plugin/src/pages/integration/Integration.helper.ts index 0812cc8f..dd2cfa56 100644 --- a/grafana-plugin/src/pages/integration/Integration.helper.ts +++ b/grafana-plugin/src/pages/integration/Integration.helper.ts @@ -68,8 +68,9 @@ export const IntegrationHelper = { const hasTelegram = store.hasFeature(AppFeature.Telegram) && store.telegramChannelStore.currentTeamToTelegramChannel?.length > 0; const isMSTeamsInstalled = Boolean(store.msteamsChannelStore.currentTeamToMSTeamsChannel?.length > 0); + const isMattermostInstalled = Object.keys(store.mattermostChannelStore.items).length > 0; - return hasSlack || hasTelegram || isMSTeamsInstalled; + return hasSlack || hasTelegram || isMSTeamsInstalled || isMattermostInstalled; }, getChatOpsChannels(channelFilter: ChannelFilter, store: RootStore): Array<{ name: string; icon: IconName }> { diff --git a/grafana-plugin/src/pages/settings/tabs/ChatOps/ChatOps.tsx b/grafana-plugin/src/pages/settings/tabs/ChatOps/ChatOps.tsx index ea768c1e..5f030fe2 100644 --- a/grafana-plugin/src/pages/settings/tabs/ChatOps/ChatOps.tsx +++ b/grafana-plugin/src/pages/settings/tabs/ChatOps/ChatOps.tsx @@ -9,6 +9,7 @@ import { observer } from 'mobx-react'; import { VerticalTabsBar, VerticalTab } from 'components/VerticalTabsBar/VerticalTabsBar'; import { MSTeamsSettings } from 'pages/settings/tabs/ChatOps/tabs/MSTeamsSettings/MSTeamsSettings'; +import { MattermostSettings } from 'pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings'; import { SlackSettings } from 'pages/settings/tabs/ChatOps/tabs/SlackSettings/SlackSettings'; import { TelegramSettings } from 'pages/settings/tabs/ChatOps/tabs/TelegramSettings/TelegramSettings'; import { AppFeature } from 'state/features'; @@ -22,6 +23,7 @@ export enum ChatOpsTab { Slack = 'Slack', Telegram = 'Telegram', MSTeams = 'MSTeams', + Mattermost = 'Mattermost', } interface ChatOpsProps extends AppRootProps, WithStoreProps, Themeable2 {} interface ChatOpsState { @@ -92,7 +94,8 @@ export class _ChatOpsPage extends React.Component { return ( store.hasFeature(AppFeature.Slack) || store.hasFeature(AppFeature.Telegram) || - store.hasFeature(AppFeature.MsTeams) + store.hasFeature(AppFeature.MsTeams) || + store.hasFeature(AppFeature.Mattermost) ); } @@ -140,6 +143,14 @@ const Tabs = (props: TabsProps) => { )} + {store.hasFeature(AppFeature.Mattermost) && ( + + + + Mattermost + + + )} ); }; @@ -157,6 +168,7 @@ const TabsContent = (props: TabsContentProps) => { {store.hasFeature(AppFeature.Slack) && activeTab === ChatOpsTab.Slack && } {store.hasFeature(AppFeature.Telegram) && activeTab === ChatOpsTab.Telegram && } {store.hasFeature(AppFeature.MsTeams) && activeTab === ChatOpsTab.MSTeams && } + {store.hasFeature(AppFeature.Mattermost) && activeTab === ChatOpsTab.Mattermost && } ); }; diff --git a/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx new file mode 100644 index 00000000..3ec492d3 --- /dev/null +++ b/grafana-plugin/src/pages/settings/tabs/ChatOps/tabs/MattermostSettings/MattermostSettings.tsx @@ -0,0 +1,223 @@ +import React, { Component } from 'react'; + +import { css } from '@emotion/css'; +import { Badge, Button, LoadingPlaceholder, Stack } from '@grafana/ui'; +import { DOCS_MATTERMOST_SETUP, StackSize } from 'helpers/consts'; +import { observer } from 'mobx-react'; + +import { Block } from 'components/GBlock/Block'; +import { GTable } from 'components/GTable/GTable'; +import { PluginLink } from 'components/PluginLink/PluginLink'; +import { Text } from 'components/Text/Text'; +import { WithConfirm } from 'components/WithConfirm/WithConfirm'; +import { MattermostIntegrationButton } from 'containers/MattermostIntegrationButton/MattermostIntegrationButton'; +import { MattermostChannel } from 'models/mattermost/mattermost.types'; +import { AppFeature } from 'state/features'; +import { WithStoreProps } from 'state/types'; +import { withMobXProviderContext } from 'state/withStore'; + +interface MattermostProps extends WithStoreProps {} + +interface MattermostState {} + +@observer +class _MattermostSettings extends Component { + state: MattermostState = {}; + + componentDidMount() { + this.update(); + } + + update = () => { + const { store } = this.props; + + store.mattermostChannelStore.updateItems(); + }; + + render() { + const { store } = this.props; + const { mattermostChannelStore, organizationStore } = store; + const connectedChannels = mattermostChannelStore.getSearchResult(); + const styles = getStyles(); + + const mattermostConfigured = organizationStore.currentOrganization?.env_status.mattermost_configured; + + if (!mattermostConfigured && store.hasFeature(AppFeature.LiveSettings)) { + return ( + + Connect Mattermost workspace + + + + Connecting Mattermost App will allow you to manage alert groups in your team Mattermost workspace. + + + + After a basic workspace connection your team members need to connect their personal Mattermost accounts + in order to be allowed to manage alert groups. + + + More details in{' '} + + our documentation + + + + + + + + + ); + } + + if (!connectedChannels) { + return ; + } + + if (!connectedChannels.length) { + return ( + + Connect Mattermost workspace + + + + Connecting Mattermost App will allow you to manage alert groups in your team Mattermost workspace. + + + + After a basic workspace connection your team members need to connect their personal Mattermost accounts + in order to be allowed to manage alert groups. + + + More details in{' '} + + our documentation + + + + + + + {store.hasFeature(AppFeature.LiveSettings) && ( + + + + )} + + + ); + } + + const columns = [ + { + width: '35%', + title: 'Channel Name', + key: 'name', + render: this.renderChannelName, + }, + { + width: '35%', + title: 'Channel ID', + render: this.renderChannelId, + }, + { + width: '30%', + key: 'action', + render: this.renderActionButtons, + }, + ]; + + return ( +
+ {connectedChannels && ( +
+ ( +
+ Mattermost Channels + +
+ )} + emptyText={connectedChannels ? 'No Mattermost channels connected' : 'Loading...'} + rowKey="id" + columns={columns} + data={connectedChannels} + /> +
+ )} +
+ ); + } + + renderChannelName = (record: MattermostChannel) => { + return ( + <> + {record.display_name} {record.is_default_channel && } + + ); + }; + + renderChannelId = (record: MattermostChannel) => { + return <>{record.channel_id}; + }; + + renderActionButtons = (record: MattermostChannel) => { + return ( + + + + + + + ); + }; + + makeMattermostChannelDefault = async (id: MattermostChannel['id']) => { + const { store } = this.props; + const { mattermostChannelStore } = store; + + await mattermostChannelStore.makeMattermostChannelDefault(id); + mattermostChannelStore.updateItems(); + }; + + disconnectMattermostChannel = async (id: MattermostChannel['id']) => { + const { store } = this.props; + const { mattermostChannelStore } = store; + + await mattermostChannelStore.deleteMattermostChannel(id); + mattermostChannelStore.updateItems(); + }; +} + +export const MattermostSettings = withMobXProviderContext(_MattermostSettings); + +const getStyles = () => { + return { + root: css` + display: block; + `, + header: css` + display: flex; + justify-content: space-between; + `, + mattermostInfoBlock: css` + text-align: center; + width: 725px; + `, + + infoBlockText: css` + margin-left: 48px; + margin-right: 48px; + margin-top: 24px; + `, + }; +}; diff --git a/grafana-plugin/src/state/features.ts b/grafana-plugin/src/state/features.ts index 28792a09..59d04e48 100644 --- a/grafana-plugin/src/state/features.ts +++ b/grafana-plugin/src/state/features.ts @@ -9,4 +9,5 @@ export enum AppFeature { MsTeams = 'msteams', GoogleOauth2 = 'google_oauth2', PersonalWebhook = 'personal_webhook', + Mattermost = 'mattermost', } diff --git a/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts b/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts index c2308082..1e6fc98d 100644 --- a/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts +++ b/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts @@ -20,6 +20,8 @@ import { GrafanaTeamStore } from 'models/grafana_team/grafana_team'; import { HeartbeatStore } from 'models/heartbeat/heartbeat'; import { LabelStore } from 'models/label/label'; import { LoaderStore } from 'models/loader/loader'; +import { MattermostStore } from 'models/mattermost/mattermost'; +import { MattermostChannelStore } from 'models/mattermost/mattermost_channel'; import { MSTeamsChannelStore } from 'models/msteams_channel/msteams_channel'; import { OrganizationStore } from 'models/organization/organization'; import { OutgoingWebhookStore } from 'models/outgoing_webhook/outgoing_webhook'; @@ -82,6 +84,8 @@ export class RootBaseStore { telegramChannelStore = new TelegramChannelStore(this); slackStore = new SlackStore(this); slackChannelStore = new SlackChannelStore(this); + mattermostStore = new MattermostStore(this); + mattermostChannelStore = new MattermostChannelStore(this); heartbeatStore = new HeartbeatStore(this); scheduleStore = new ScheduleStore(this); userGroupStore = new UserGroupStore(this);