From d58d17d4926ffce5d276ccc0a637b05fdd2b351d Mon Sep 17 00:00:00 2001 From: Ravishankar Date: Tue, 11 Jun 2024 07:38:57 +0530 Subject: [PATCH] Mattermost Installation Flow Mattermost install flow remove utils Fix jwt error Add install flow details Fix formatting Fix auth token --- engine/apps/api/urls.py | 4 ++ engine/apps/api/views/features.py | 4 ++ engine/apps/api/views/organization.py | 23 +++++++ engine/apps/mattermost/__init__.py | 0 engine/apps/mattermost/auth.py | 34 ++++++++++ .../mattermost/migrations/0001_initial.py | 31 +++++++++ engine/apps/mattermost/migrations/__init__.py | 0 engine/apps/mattermost/models/__init__.py | 1 + .../models/mattermost_auth_token.py | 34 ++++++++++ engine/apps/mattermost/urls.py | 9 +++ engine/apps/mattermost/views.py | 64 +++++++++++++++++++ engine/engine/urls.py | 5 ++ engine/settings/base.py | 2 + 13 files changed, 211 insertions(+) create mode 100644 engine/apps/mattermost/__init__.py create mode 100644 engine/apps/mattermost/auth.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/mattermost_auth_token.py create mode 100644 engine/apps/mattermost/urls.py create mode 100644 engine/apps/mattermost/views.py diff --git a/engine/apps/api/urls.py b/engine/apps/api/urls.py index 639d109434..fdcb193853 100644 --- a/engine/apps/api/urls.py +++ b/engine/apps/api/urls.py @@ -19,6 +19,7 @@ from .views.organization import ( CurrentOrganizationView, GetChannelVerificationCode, + GetMattermostSetupDetails, GetTelegramVerificationCode, OrganizationConfigChecksView, SetGeneralChannel, @@ -90,6 +91,9 @@ GetChannelVerificationCode.as_view(), name="api-get-channel-verification-code", ), + optional_slash_path( + "mattermost/setup", GetMattermostSetupDetails.as_view(), name="api-get-mattermost-setup-details" + ), optional_slash_path("slack_settings", SlackTeamSettingsAPIView.as_view(), name="slack-settings"), optional_slash_path( "slack_settings/acknowledge_remind_options", diff --git a/engine/apps/api/views/features.py b/engine/apps/api/views/features.py index acd98de81d..6a3a67385d 100644 --- a/engine/apps/api/views/features.py +++ b/engine/apps/api/views/features.py @@ -25,6 +25,7 @@ class Feature(enum.StrEnum): GRAFANA_ALERTING_V2 = "grafana_alerting_v2" LABELS = "labels" GOOGLE_OAUTH2 = "google_oauth2" + MATTERMOST = "mattermost" class FeaturesAPIView(APIView): @@ -68,4 +69,7 @@ def _get_enabled_features(self, request): if settings.GOOGLE_OAUTH2_ENABLED: enabled_features.append(Feature.GOOGLE_OAUTH2) + if settings.FEATURE_MATTERMOST_INTEGRATION_ENABLED: + enabled_features.append(Feature.MATTERMOST) + return enabled_features diff --git a/engine/apps/api/views/organization.py b/engine/apps/api/views/organization.py index 8b6f5d70b1..4c7f855422 100644 --- a/engine/apps/api/views/organization.py +++ b/engine/apps/api/views/organization.py @@ -1,5 +1,6 @@ from contextlib import suppress +from django.conf import settings from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -108,6 +109,28 @@ def get(self, request): return Response(code) +class GetMattermostSetupDetails(APIView): + authentication_classes = (PluginAuthentication,) + permission_classes = (IsAuthenticated, RBACPermission) + + rbac_permissions = { + "get": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + } + + def get(self, request): + organization = request.auth.organization + user = request.user + from apps.mattermost.models import MattermostAuthToken + + with suppress(MattermostAuthToken.DoesNotExist): + existing_auth_token = organization.mattermost_auth_token + existing_auth_token.delete() + _, auth_token = MattermostAuthToken.create_auth_token(user=user, organization=organization) + manifest_link = f"{settings.BASE_URL}/mattermost/manifest?auth_token={auth_token}" + + return Response({"manifest_link": manifest_link}) + + class SetGeneralChannel(APIView): authentication_classes = (PluginAuthentication,) permission_classes = (IsAuthenticated, RBACPermission) diff --git a/engine/apps/mattermost/__init__.py b/engine/apps/mattermost/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/engine/apps/mattermost/auth.py b/engine/apps/mattermost/auth.py new file mode 100644 index 0000000000..22c8e0b641 --- /dev/null +++ b/engine/apps/mattermost/auth.py @@ -0,0 +1,34 @@ +from typing import Tuple + +from rest_framework import exceptions +from rest_framework.authentication import BaseAuthentication + +from apps.auth_token.exceptions import InvalidToken +from apps.user_management.models import Organization + +from .models import MattermostAuthToken + + +class MattermostAuthTokenAuthentication(BaseAuthentication): + model = MattermostAuthToken + + def extract_auth_token(self, request) -> str: + return request.query_params.get("auth_token") + + def authenticate(self, request) -> Tuple[Organization, MattermostAuthToken]: + auth = self.extract_auth_token(request=request) + organization, auth_token = self.authenticate_credentials(auth) + return organization, auth_token + + def authenticate_credentials(self, token_string: str) -> Tuple[Organization, MattermostAuthToken]: + try: + auth_token = self.model.validate_token_string(token_string) + except InvalidToken: + raise exceptions.AuthenticationFailed("Invalid auth token") + + return auth_token.organization, auth_token + + +class MattermostWebhookAuthTokenAuthentication(MattermostAuthTokenAuthentication): + def extract_auth_token(self, request) -> str: + return request.data.get("state", {}).get("auth_token", "") diff --git a/engine/apps/mattermost/migrations/0001_initial.py b/engine/apps/mattermost/migrations/0001_initial.py new file mode 100644 index 0000000000..06b1fe1f7f --- /dev/null +++ b/engine/apps/mattermost/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.11 on 2024-06-13 04:24 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('user_management', '0022_alter_team_unique_together'), + ] + + 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)), + ('organization', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_auth_token', to='user_management.organization')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_auth_token_set', to='user_management.user')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/engine/apps/mattermost/migrations/__init__.py b/engine/apps/mattermost/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/engine/apps/mattermost/models/__init__.py b/engine/apps/mattermost/models/__init__.py new file mode 100644 index 0000000000..ba99c7b087 --- /dev/null +++ b/engine/apps/mattermost/models/__init__.py @@ -0,0 +1 @@ +from .mattermost_auth_token import MattermostAuthToken # noqa: F401 diff --git a/engine/apps/mattermost/models/mattermost_auth_token.py b/engine/apps/mattermost/models/mattermost_auth_token.py new file mode 100644 index 0000000000..aa9c672e5f --- /dev/null +++ b/engine/apps/mattermost/models/mattermost_auth_token.py @@ -0,0 +1,34 @@ +from typing import Tuple + +from django.db import models + +from apps.auth_token import constants, crypto +from apps.auth_token.models import BaseAuthToken +from apps.user_management.models import Organization, User + + +class MattermostAuthToken(BaseAuthToken): + objects: models.Manager["MattermostAuthToken"] + + user = models.ForeignKey( + "user_management.User", + related_name="mattermost_auth_token_set", + on_delete=models.CASCADE, + ) + + organization = models.OneToOneField( + "user_management.Organization", on_delete=models.CASCADE, related_name="mattermost_auth_token" + ) + + @classmethod + def create_auth_token(cls, user: User, organization: Organization) -> Tuple["MattermostAuthToken", str]: + 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/mattermost/urls.py b/engine/apps/mattermost/urls.py new file mode 100644 index 0000000000..44a75a8f9c --- /dev/null +++ b/engine/apps/mattermost/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import GetMattermostManifest, MattermostBindings, MattermostInstall + +urlpatterns = [ + path("manifest", GetMattermostManifest.as_view()), + path("install", MattermostInstall.as_view()), + path("bindings", MattermostBindings.as_view()), +] diff --git a/engine/apps/mattermost/views.py b/engine/apps/mattermost/views.py new file mode 100644 index 0000000000..f0fa04f1ef --- /dev/null +++ b/engine/apps/mattermost/views.py @@ -0,0 +1,64 @@ +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.mattermost.auth import MattermostAuthTokenAuthentication, MattermostWebhookAuthTokenAuthentication + +MATTERMOST_CONNECTED_TEXT = ( + "Done! {app_id} Successfully Installed and Linked with organization {organization_title} 🎉" +) + + +class GetMattermostManifest(APIView): + authentication_classes = (MattermostAuthTokenAuthentication,) + + def get(self, request): + auth_token = request.query_params.get("auth_token") + manifest = self._build_manifest(auth_token) + return Response(manifest, status=status.HTTP_200_OK) + + def _build_on_install_callback(self, auth_token: str) -> dict: + return { + "path": "/mattermost/install", + "expand": {"app": "summary", "acting_user": "summary"}, + "state": {"auth_token": auth_token}, + } + + def _build_bindings_callback(self, auth_token: str) -> dict: + return {"path": "/mattermost/bindings", "state": {"auth_token": auth_token}} + + def _build_manifest(self, auth_token: str) -> dict: + return { + "app_id": "Grafana-Oncall", + "version": "1.0.0", + "display_name": "Grafana Oncall", + "description": "Grafana Oncall app for sending and receiving events from mattermost", + "homepage_url": "https://grafana.com/docs/oncall/latest/", + "requested_permissions": ["act_as_bot"], + "requested_locations": ["/in_post", "/post_menu", "/command"], + "on_install": self._build_on_install_callback(auth_token=auth_token), + "bindings": self._build_bindings_callback(auth_token=auth_token), + "http": {"root_url": "http://host.docker.internal:8080"}, + } + + +class MattermostInstall(APIView): + authentication_classes = (MattermostWebhookAuthTokenAuthentication,) + + def post(self, request): + app_id = request.data.get("context").get("app").get("app_id") + view_text = MATTERMOST_CONNECTED_TEXT.format( + app_id=app_id, organization_title=request.auth.organization.org_title + ) + # TODO: Create and save bot user and access token and also link org and user with mattermost + response = {"type": "ok", "text": view_text} + return Response(response, status=status.HTTP_200_OK) + + +class MattermostBindings(APIView): + authentication_classes = (MattermostWebhookAuthTokenAuthentication,) + + def post(self, request): + # TODO: Implement bindings or slash commands + response = {"type": "ok"} + return Response(response, status=status.HTTP_200_OK) diff --git a/engine/engine/urls.py b/engine/engine/urls.py index 5869b7c39b..9659740395 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -55,6 +55,11 @@ path("telegram/", include("apps.telegram.urls")), ] +if settings.FEATURE_MATTERMOST_INTEGRATION_ENABLED: + urlpatterns += [ + path("mattermost/", include("apps.mattermost.urls")), + ] + if settings.FEATURE_SLACK_INTEGRATION_ENABLED: urlpatterns += [ path("api/internal/v1/slack/", include("apps.slack.urls")), diff --git a/engine/settings/base.py b/engine/settings/base.py index ecdac1a1a2..e42fc02150 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -59,6 +59,7 @@ FEATURE_LIVE_SETTINGS_ENABLED = getenv_boolean("FEATURE_LIVE_SETTINGS_ENABLED", default=True) FEATURE_TELEGRAM_INTEGRATION_ENABLED = getenv_boolean("FEATURE_TELEGRAM_INTEGRATION_ENABLED", default=True) FEATURE_TELEGRAM_LONG_POLLING_ENABLED = getenv_boolean("FEATURE_TELEGRAM_LONG_POLLING_ENABLED", default=False) +FEATURE_MATTERMOST_INTEGRATION_ENABLED = getenv_boolean("FEATURE_MATTERMOST_INTEGRATION_ENABLED", default=False) FEATURE_EMAIL_INTEGRATION_ENABLED = getenv_boolean("FEATURE_EMAIL_INTEGRATION_ENABLED", default=True) FEATURE_SLACK_INTEGRATION_ENABLED = getenv_boolean("FEATURE_SLACK_INTEGRATION_ENABLED", default=True) FEATURE_MULTIREGION_ENABLED = getenv_boolean("FEATURE_MULTIREGION_ENABLED", default=False) @@ -281,6 +282,7 @@ class DatabaseTypes: "apps.phone_notifications", "drf_spectacular", "apps.google", + "apps.mattermost", ] REST_FRAMEWORK = {