diff --git a/dev/kind-config.yaml b/dev/kind-config.yaml index 840b9e6ee5..efaebb6e81 100644 --- a/dev/kind-config.yaml +++ b/dev/kind-config.yaml @@ -6,4 +6,4 @@ registry: ctlptl-registry kindV1Alpha4Cluster: nodes: - role: control-plane - image: kindest/node:v1.24.7 + image: kindest/node:v1.27.3 diff --git a/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py b/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py index 7f7455a635..4930746649 100644 --- a/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py +++ b/engine/apps/alerts/incident_appearance/renderers/slack_renderer.py @@ -5,6 +5,7 @@ from apps.alerts.incident_appearance.renderers.base_renderer import AlertBaseRenderer, AlertGroupBaseRenderer from apps.alerts.incident_appearance.templaters import AlertSlackTemplater +from apps.slack.chatops_proxy_routing import make_value from apps.slack.constants import BLOCK_SECTION_TEXT_MAX_SIZE from apps.slack.scenarios.scenario_step import ScenarioStep from apps.slack.types import Block @@ -436,9 +437,8 @@ def _alert_group_action_value(self, **kwargs): data = { "organization_id": self.alert_group.channel.organization_id, - "alert_group_pk": self.alert_group.pk, - # eventually replace using alert_group_pk with alert_group_public_pk in slack payload "alert_group_ppk": self.alert_group.public_primary_key, **kwargs, } - return json.dumps(data) # Slack block elements allow to pass value as string only (max 2000 chars) + + return make_value(data, self.alert_group.channel.organization) diff --git a/engine/apps/api/tests/test_auth.py b/engine/apps/api/tests/test_auth.py index 20e8c6552d..7ba330f9e2 100644 --- a/engine/apps/api/tests/test_auth.py +++ b/engine/apps/api/tests/test_auth.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest from django.contrib.auth import REDIRECT_FIELD_NAME @@ -9,6 +9,8 @@ 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 common.constants.slack_auth import SLACK_OAUTH_ACCESS_RESPONSE @pytest.mark.django_db @@ -16,7 +18,7 @@ "backend_name,expected_url", ( ("slack-login", "/a/grafana-oncall-app/users/me"), - ("slack-install-free", "/a/grafana-oncall-app/chat-ops"), + (SLACK_INSTALLATION_BACKEND, "/a/grafana-oncall-app/chat-ops"), ), ) def test_complete_slack_auth_redirect_ok( @@ -71,6 +73,77 @@ def _custom_do_complete(backend, *args, **kwargs): assert response.url == "some-url" +@override_settings(UNIFIED_SLACK_APP_ENABLED=False) +@pytest.mark.django_db +def test_start_slack_ok( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, +): + """ + Covers the case when user starts Slack integration installation via Grafana OnCall + """ + _, user, token = make_organization_and_user_with_plugin_token() + + client = APIClient() + url = reverse("api-internal:social-auth", kwargs={"backend": SLACK_INSTALLATION_BACKEND}) + + mock_do_auth_return = Mock() + mock_do_auth_return.url = "https://slack_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://slack_oauth_redirect.com" + + +@override_settings(UNIFIED_SLACK_APP_ENABLED=True) +@pytest.mark.django_db +def test_start_unified_slack_ok( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, +): + """ + Covers the case when user starts Unified Slack integration installation + """ + _, user, token = make_organization_and_user_with_plugin_token() + + client = APIClient() + url = reverse("api-internal:social-auth", kwargs={"backend": SLACK_INSTALLATION_BACKEND}) + + with patch( + "apps.api.views.auth.get_installation_link_from_chatops_proxy", return_value="https://slack_oauth_redirect.com" + ): + response = client.get(url, **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + assert response.json() == "https://slack_oauth_redirect.com" + + +@override_settings(UNIFIED_SLACK_APP_ENABLED=True) +@patch("apps.api.views.auth.get_installation_link_from_chatops_proxy", return_value=None) +@patch("apps.api.views.auth.get_slack_oauth_response_from_chatops_proxy", return_value=SLACK_OAUTH_ACCESS_RESPONSE) +@patch("apps.api.views.auth.install_slack_integration", return_value=None) +@pytest.mark.django_db +def test_start_slack_ok_via_chatops_proxy_when_already_installed( + mock_install_slack_integration, + mock_get_slack_oauth_response_from_chatops_proxy, + mock_get_installation_link_from_chatops_proxy, + make_organization_and_user_with_plugin_token, + make_user_auth_headers, +): + """ + Covers the case when user starts Unified Slack integration installation, but it's already installed. + It might happen if integration was installed from Incident side, but OnCall missed the corresponding event + """ + org, user, token = make_organization_and_user_with_plugin_token() + + client = APIClient() + url = reverse("api-internal:social-auth", kwargs={"backend": SLACK_INSTALLATION_BACKEND}) + + response = client.get(url, **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_201_CREATED + assert mock_install_slack_integration.call_args.args == (org, user, SLACK_OAUTH_ACCESS_RESPONSE) + + @pytest.mark.django_db @patch("apps.social_auth.backends.GoogleOAuth2.get_redirect_uri") @patch("apps.social_auth.backends.GoogleOAuth2Token.create_auth_token", return_value=("something", "token_string")) diff --git a/engine/apps/api/views/auth.py b/engine/apps/api/views/auth.py index 7ae7d2e381..5a6e7cbb96 100644 --- a/engine/apps/api/views/auth.py +++ b/engine/apps/api/views/auth.py @@ -15,7 +15,12 @@ from social_django.views import _do_login from apps.auth_token.auth import GoogleTokenAuthentication, PluginAuthentication, SlackTokenAuthentication -from apps.social_auth.backends import LoginSlackOAuth2V2 +from apps.chatops_proxy.utils import ( + get_installation_link_from_chatops_proxy, + get_slack_oauth_response_from_chatops_proxy, +) +from apps.slack.installation import install_slack_integration +from apps.social_auth.backends import SLACK_INSTALLATION_BACKEND, LoginSlackOAuth2V2 logger = logging.getLogger(__name__) @@ -25,6 +30,10 @@ @never_cache @psa("social:complete") def overridden_login_social_auth(request: Request, backend: str) -> Response: + """ + overridden_login_social_auth starts the installation of integration which uses OAuth flow. + """ + # We can't just redirect frontend here because we need to make a API call and pass tokens to this view from JS. # So frontend can't follow our redirect. # So wrapping and returning URL to redirect as a string. @@ -34,7 +43,26 @@ def overridden_login_social_auth(request: Request, backend: str) -> Response: status=400, ) - url_to_redirect_to = do_auth(request.backend, redirect_name=REDIRECT_FIELD_NAME).url + if backend == SLACK_INSTALLATION_BACKEND and settings.UNIFIED_SLACK_APP_ENABLED: + """ + Install unified slack integration via chatops-proxy. + 1. Get installation link from chatops-proxy + 2. If link is not None – slack installation already exists on Chatops-Proxy - install using it's oauth response. + """ + try: + link = get_installation_link_from_chatops_proxy(request.user) + if link is not None: + return Response(link, 200) + else: + slack_oauth_response = get_slack_oauth_response_from_chatops_proxy(request.user.organization.stack_id) + install_slack_integration(request.user.organization, request.user, slack_oauth_response) + return Response("slack integration installed", 201) + except Exception as e: + logger.exception("overridden_login_social_auth: Failed to install slack via chatops-proxy: %s", e) + return Response({"error": "something went wrong, try again later"}, 500) + else: + # Otherwise use social-auth. + url_to_redirect_to = do_auth(request.backend, redirect_name=REDIRECT_FIELD_NAME).url return Response(url_to_redirect_to, 200) diff --git a/engine/apps/chatops_proxy/__init__.py b/engine/apps/chatops_proxy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/engine/common/oncall_gateway/client.py b/engine/apps/chatops_proxy/client.py similarity index 80% rename from engine/common/oncall_gateway/client.py rename to engine/apps/chatops_proxy/client.py index f2d11a2cb2..baa382d5b4 100644 --- a/engine/common/oncall_gateway/client.py +++ b/engine/apps/chatops_proxy/client.py @@ -7,6 +7,7 @@ from django.conf import settings SERVICE_TYPE_ONCALL = "oncall" +PROVIDER_TYPE_SLACK = "slack" @dataclass @@ -32,6 +33,15 @@ class Tenant: msteams_links: List[MSTeamsLink] = field(default_factory=list) +@dataclass +class OAuthInstallation: + id: str + oauth_response: dict + stack_id: int + provider_type: str + provider_id: str + + class ChatopsProxyAPIException(Exception): """A generic 400 or 500 level exception from the Chatops Proxy API""" @@ -55,7 +65,7 @@ def __init__(self, url: str, token: str): # OnCall Tenant def register_tenant( - self, service_tenant_id: str, cluster_slug: str, service_type: str + self, service_tenant_id: str, cluster_slug: str, service_type: str, stack_id: int ) -> tuple[Tenant, requests.models.Response]: url = f"{self.api_base_url}/tenants/register" d = { @@ -63,6 +73,7 @@ def register_tenant( "service_tenant_id": service_tenant_id, "cluster_slug": cluster_slug, "service_type": service_type, + "stack_id": stack_id, } } response = requests.post(url=url, json=d, headers=self._headers) @@ -131,6 +142,34 @@ def unlink_slack_team( self._check_response(response) return response.json()["removed"], response + def get_slack_oauth_link( + self, stack_id: int, grafana_user_id: int, app_redirect: str, app_type: str + ) -> tuple[str, requests.models.Response]: + url = f"{self.api_base_url}/oauth2/start" + d = { + "stack_id": stack_id, + "grafana_user_id": grafana_user_id, + "app_redirect": app_redirect, + "app_type": app_type, + } + response = requests.post(url=url, json=d, headers=self._headers) + self._check_response(response) + return response.json()["install_link"], response + + def get_oauth_installation( + self, + stack_id: int, + provider_type: str, + ) -> tuple[OAuthInstallation, requests.models.Response]: + url = f"{self.api_base_url}/oauth_installations/get" + d = { + "stack_id": stack_id, + "provider_type": provider_type, + } + response = requests.post(url=url, json=d, headers=self._headers) + self._check_response(response) + return OAuthInstallation(**response.json()["oauth_installation"]), response + def _check_response(self, response: requests.models.Response): """ Wraps an exceptional response to ChatopsProxyAPIException diff --git a/engine/apps/chatops_proxy/events/__init__.py b/engine/apps/chatops_proxy/events/__init__.py new file mode 100644 index 0000000000..5f92abe73d --- /dev/null +++ b/engine/apps/chatops_proxy/events/__init__.py @@ -0,0 +1 @@ +from .root_handler import ChatopsEventsHandler # noqa diff --git a/engine/apps/chatops_proxy/events/handlers.py b/engine/apps/chatops_proxy/events/handlers.py new file mode 100644 index 0000000000..967be6ee13 --- /dev/null +++ b/engine/apps/chatops_proxy/events/handlers.py @@ -0,0 +1,50 @@ +import logging +import typing +from abc import ABC, abstractmethod + +from apps.chatops_proxy.client import PROVIDER_TYPE_SLACK +from apps.slack.installation import SlackInstallationExc, install_slack_integration +from apps.user_management.models import Organization + +from .types import INTEGRATION_INSTALLED_EVENT_TYPE, Event, IntegrationInstalledData + +logger = logging.getLogger(__name__) + + +class Handler(ABC): + @classmethod + @abstractmethod + def match(cls, event: Event) -> bool: + pass + + @classmethod + @abstractmethod + def handle(cls, event_data: dict) -> None: + pass + + +class SlackInstallationHandler(Handler): + @classmethod + def match(cls, event: Event) -> bool: + return ( + event.get("event_type") == INTEGRATION_INSTALLED_EVENT_TYPE + and event.get("data", {}).get("provider_type") == PROVIDER_TYPE_SLACK + ) + + @classmethod + def handle(cls, data: dict) -> None: + data = typing.cast(IntegrationInstalledData, data) + + stack_id = data.get("stack_id") + user_id = data.get("grafana_user_id") + payload = data.get("payload") + + organization = Organization.objects.get(stack_id=stack_id) + user = organization.users.get(user_id=user_id) + try: + install_slack_integration(organization, user, payload) + except SlackInstallationExc as e: + logger.exception( + f'msg="SlackInstallationHandler: Failed to install Slack integration: %s" org_id={organization.id} stack_id={stack_id}', + e, + ) diff --git a/engine/apps/chatops_proxy/events/root_handler.py b/engine/apps/chatops_proxy/events/root_handler.py new file mode 100644 index 0000000000..d9a8c6f38e --- /dev/null +++ b/engine/apps/chatops_proxy/events/root_handler.py @@ -0,0 +1,37 @@ +import logging +import typing + +from .handlers import Handler, SlackInstallationHandler +from .types import Event + +logger = logging.getLogger(__name__) + + +class ChatopsEventsHandler: + """ + ChatopsEventsHandler is a root handler which receives event from Chatops-Proxy and chooses the handler to process it. + """ + + HANDLERS: typing.List[typing.Type[Handler]] = [SlackInstallationHandler] + + def handle(self, event_data: Event) -> bool: + """ + handle iterates over all handlers and chooses the first one that matches the event. + Returns True if a handler was found and False otherwise. + """ + logger.info(f"msg=\"ChatopsEventsHandler: Handling\" event_type={event_data.get('event_type')}") + for h in self.HANDLERS: + if h.match(event_data): + logger.info( + f"msg=\"ChatopsEventsHandler: Found matching handler {h.__name__}\" event_type={event_data.get('event_type')}" + ) + self._exec(h.handle, event_data.get("data", {})) + return True + logger.error(f"msg=\"ChatopsEventsHandler: No handler found\" event_type={event_data.get('event_type')}") + return False + + def _exec(self, handlefunc: typing.Callable[[dict], None], data: dict): + """ + _exec is a helper method to execute a handler's handle method. + """ + return handlefunc(data) diff --git a/engine/apps/chatops_proxy/events/signature.py b/engine/apps/chatops_proxy/events/signature.py new file mode 100644 index 0000000000..bd9cddd47a --- /dev/null +++ b/engine/apps/chatops_proxy/events/signature.py @@ -0,0 +1,36 @@ +import base64 +import binascii +import hashlib +import hmac + + +def hash(data): + hasher = hashlib.sha256() + hasher.update(data) + return base64.b64encode(hasher.digest()).decode() + + +def generate_signature(data, secret): + h = hmac.new(secret.encode(), data.encode(), hashlib.sha256) + return binascii.hexlify(h.digest()).decode() + + +def verify_signature(request, secret) -> bool: + header = request.META.get("HTTP_X_CHATOPS_SIGNATURE") + if not header: + return False + + signatures = header.split(",") + s = dict(pair.split("=") for pair in signatures) + t = s.get("t") + v1 = s.get("v1") + + payload = request.body + body_hash = hash(payload) + string_to_sign = f"{body_hash}:{t}:v1" + expected = generate_signature(string_to_sign, secret) + + if expected != v1: + return False + + return True diff --git a/engine/apps/chatops_proxy/events/types.py b/engine/apps/chatops_proxy/events/types.py new file mode 100644 index 0000000000..22ce2310d3 --- /dev/null +++ b/engine/apps/chatops_proxy/events/types.py @@ -0,0 +1,17 @@ +import typing + +INTEGRATION_INSTALLED_EVENT_TYPE = "integration_installed" +INTEGRATION_UNINSTALLED_EVENT_TYPE = "integration_uninstalled" + + +class Event(typing.TypedDict): + event_type: str + data: dict + + +class IntegrationInstalledData(typing.TypedDict): + oauth_installation_id: int + provider_type: str + stack_id: int + grafana_user_id: int + payload: dict diff --git a/engine/apps/chatops_proxy/tasks.py b/engine/apps/chatops_proxy/tasks.py new file mode 100644 index 0000000000..bbe3dcd0bc --- /dev/null +++ b/engine/apps/chatops_proxy/tasks.py @@ -0,0 +1,122 @@ +from celery.utils.log import get_task_logger +from django.conf import settings + +from common.custom_celery_tasks import shared_dedicated_queue_retry_task + +from .client import ChatopsProxyAPIClient, ChatopsProxyAPIException + +task_logger = get_task_logger(__name__) + + +@shared_dedicated_queue_retry_task( + autoretry_for=(Exception,), + retry_backoff=True, + max_retries=100, +) +def register_oncall_tenant_async(**kwargs): + service_tenant_id = kwargs.get("service_tenant_id") + cluster_slug = kwargs.get("cluster_slug") + service_type = kwargs.get("service_type") + stack_id = kwargs.get("stack_id") + + client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) + try: + client.register_tenant(service_tenant_id, cluster_slug, service_type, stack_id) + except ChatopsProxyAPIException as api_exc: + task_logger.error( + f'msg="Failed to register OnCall tenant: {api_exc.msg}" service_tenant_id={service_tenant_id} cluster_slug={cluster_slug}' + ) + if api_exc.status == 409: + # 409 Indicates that it's impossible to register tenant, because tenant already registered. + # Not retrying in this case, because manual conflict-resolution needed. + return + else: + # Otherwise keep retrying task + raise api_exc + except Exception as e: + # Keep retrying task for any other exceptions too + task_logger.error( + f"Failed to register OnCall tenant: {e} service_tenant_id={service_tenant_id} cluster_slug={cluster_slug}" + ) + raise e + + +@shared_dedicated_queue_retry_task( + autoretry_for=(Exception,), + retry_backoff=True, + max_retries=100, +) +def unregister_oncall_tenant_async(**kwargs): + service_tenant_id = kwargs.get("service_tenant_id") + cluster_slug = kwargs.get("cluster_slug") + service_type = kwargs.get("service_type") + + client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) + try: + client.unregister_tenant(service_tenant_id, cluster_slug, service_type) + except ChatopsProxyAPIException as api_exc: + if api_exc.status == 400: + # 400 Indicates that tenant is already deleted + return + else: + # Otherwise keep retrying task + raise api_exc + except Exception as e: + task_logger.error(f"Failed to delete OnCallTenant: {e} service_tenant_id={service_tenant_id}") + raise e + + +@shared_dedicated_queue_retry_task( + autoretry_for=(Exception,), + retry_backoff=True, + max_retries=100, +) +def link_slack_team_async(**kwargs): + service_tenant_id = kwargs.get("service_tenant_id") + service_type = kwargs.get("service_type") + slack_team_id = kwargs.get("slack_team_id") + client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) + try: + client.link_slack_team(service_tenant_id, slack_team_id, service_type) + except ChatopsProxyAPIException as api_exc: + task_logger.error( + f'msg="Failed to link slack team: {api_exc.msg}" service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}' + ) + if api_exc.status == 409: + # Impossible to register tenant, slack workspace already connected to another cluster. + # Not retrying in this case, because manual conflict-resolution needed. + return + else: + raise api_exc + except Exception as e: + task_logger.error( + f'msg="Failed to link slack team: {e}" service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}' + ) + raise e + + +@shared_dedicated_queue_retry_task( + autoretry_for=(Exception,), + retry_backoff=True, + max_retries=100, +) +def unlink_slack_team_async(**kwargs): + service_tenant_id = kwargs.get("service_tenant_id") + service_type = kwargs.get("service_type") + slack_team_id = kwargs.get("slack_team_id") + + client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) + try: + client.unlink_slack_team(service_tenant_id, slack_team_id, service_type) + except ChatopsProxyAPIException as api_exc: + if api_exc.status == 400: + # 400 Indicates that tenant is already deleted + return + else: + # Otherwise keep retrying task + raise api_exc + except Exception as e: + task_logger.error( + f'msg="Failed to unlink slack_team: {e}" service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}' + ) + raise e diff --git a/engine/apps/chatops_proxy/tests/__init__.py b/engine/apps/chatops_proxy/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/engine/apps/chatops_proxy/tests/test_events.py b/engine/apps/chatops_proxy/tests/test_events.py new file mode 100644 index 0000000000..01891bdb9d --- /dev/null +++ b/engine/apps/chatops_proxy/tests/test_events.py @@ -0,0 +1,66 @@ +from unittest.mock import patch + +import pytest +from django.test import override_settings + +from apps.chatops_proxy.events import ChatopsEventsHandler +from apps.chatops_proxy.events.handlers import SlackInstallationHandler +from common.constants.slack_auth import SLACK_OAUTH_ACCESS_RESPONSE + +installation_event = { + "event_type": "integration_installed", + "data": { + "provider_type": "slack", + "stack_id": "stack_id", + "grafana_user_id": "grafana_user_id", + "payload": SLACK_OAUTH_ACCESS_RESPONSE, + }, +} + +unknown_event = { + "event_type": "unknown_event", + "data": { + "provider_type": "slack", + "stack_id": "stack_id", + "grafana_user_id": "grafana_user_id", + "payload": {}, + }, +} + +invalid_schema_event = { + "a": "b", + "c": "d", +} + + +@patch.object(ChatopsEventsHandler, "_exec", return_value=None) +@pytest.mark.parametrize( + "payload,is_handled", + [ + (installation_event, True), + (unknown_event, False), + (invalid_schema_event, False), + ], +) +@pytest.mark.django_db +@override_settings(UNIFIED_SLACK_APP_ENABLED=True) +def test_root_event_handler(mock_exec, payload, is_handled): + h = ChatopsEventsHandler() + assert h.handle(payload) is is_handled + + +@patch("apps.chatops_proxy.events.handlers.install_slack_integration", return_value=None) +@pytest.mark.django_db +def test_slack_installation_handler(mock_install_slack_integration, make_organization_and_user): + organization, user = make_organization_and_user() + + installation_event["data"].update({"stack_id": organization.stack_id, "grafana_user_id": user.user_id}) + + h = SlackInstallationHandler() + + assert h.match(unknown_event) is False + assert h.match(invalid_schema_event) is False + + assert h.match(installation_event) is True + h.handle(installation_event["data"]) + assert mock_install_slack_integration.call_args.args == (organization, user, installation_event["data"]["payload"]) diff --git a/engine/apps/chatops_proxy/urls.py b/engine/apps/chatops_proxy/urls.py new file mode 100644 index 0000000000..10e2d53253 --- /dev/null +++ b/engine/apps/chatops_proxy/urls.py @@ -0,0 +1,9 @@ +from common.api_helpers.optional_slash_router import optional_slash_path + +from .views import ChatopsEventsView + +app_name = "chatops-proxy" + +urlpatterns = [ + optional_slash_path("events", ChatopsEventsView.as_view(), name="events"), +] diff --git a/engine/apps/chatops_proxy/utils.py b/engine/apps/chatops_proxy/utils.py new file mode 100644 index 0000000000..60d7039116 --- /dev/null +++ b/engine/apps/chatops_proxy/utils.py @@ -0,0 +1,143 @@ +""" +Set of utils to handle oncall and chatops-proxy interaction. +""" +import logging +import typing + +from django.conf import settings + +from .client import PROVIDER_TYPE_SLACK, SERVICE_TYPE_ONCALL, ChatopsProxyAPIClient, ChatopsProxyAPIException +from .tasks import ( + link_slack_team_async, + register_oncall_tenant_async, + unlink_slack_team_async, + unregister_oncall_tenant_async, +) + +logger = logging.getLogger(__name__) + + +def get_installation_link_from_chatops_proxy(user) -> typing.Optional[str]: + """ + get_installation_link_from_chatops_proxy fetches slack installation link from chatops proxy. + If there is no existing slack installation - if returns link, If slack already installed, it returns None. + """ + client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) + org = user.organization + try: + link, _ = client.get_slack_oauth_link( + org.stack_id, + user.user_id, + org.web_link, + SERVICE_TYPE_ONCALL, + ) + return link + except ChatopsProxyAPIException as api_exc: + if api_exc.status == 409: + return None + logger.exception( + "Error while getting installation link from chatops proxy: " "error=%s", + api_exc, + ) + raise api_exc + + +def get_slack_oauth_response_from_chatops_proxy(stack_id) -> dict: + client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) + slack_installation, _ = client.get_oauth_installation(stack_id, PROVIDER_TYPE_SLACK) + return slack_installation.oauth_response + + +def register_oncall_tenant(service_tenant_id: str, cluster_slug: str, stack_id: int): + """ + register_oncall_tenant tries to register oncall tenant synchronously and fall back to task in case of any exceptions + to make sure that tenant is registered. + First attempt is synchronous to register tenant ASAP to not miss any chatops requests. + """ + client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) + try: + client.register_tenant( + service_tenant_id, + cluster_slug, + SERVICE_TYPE_ONCALL, + stack_id, + ) + except Exception as e: + logger.error( + f"create_oncall_connector: failed " + f"oncall_org_id={service_tenant_id} backend={cluster_slug} stack_id={stack_id} exc={e}" + ) + register_oncall_tenant_async.apply_async( + kwargs={ + "service_tenant_id": service_tenant_id, + "cluster_slug": cluster_slug, + "service_type": SERVICE_TYPE_ONCALL, + "stack_id": stack_id, + }, + countdown=2, + ) + + +def unregister_oncall_tenant(service_tenant_id: str, cluster_slug: str): + """ + unregister_oncall_tenant unregisters tenant asynchronously. + """ + unregister_oncall_tenant_async.apply_async( + kwargs={ + "service_tenant_id": service_tenant_id, + "cluster_slug": cluster_slug, + "service_type": SERVICE_TYPE_ONCALL, + }, + countdown=2, + ) + + +def can_link_slack_team( + service_tenant_id: str, + slack_team_id: str, + cluster_slug: str, +) -> bool: + """ + can_link_slack_team checks if it's possible to link slack workspace to oncall tenant located in cluster. + All oncall tenants linked to same slack team should have same cluster. + """ + client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) + try: + response = client.can_slack_link(service_tenant_id, cluster_slug, slack_team_id, SERVICE_TYPE_ONCALL) + return response.status_code == 200 + except Exception as e: + logger.error( + f"can_link_slack_team: slack installation impossible: {e} " + f"service_tenant_id={service_tenant_id} slack_team_id={slack_team_id} cluster_slug={cluster_slug}" + ) + + return False + + +def link_slack_team(service_tenant_id: str, slack_team_id: str): + client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) + try: + client.link_slack_team(service_tenant_id, slack_team_id, SERVICE_TYPE_ONCALL) + except Exception as e: + logger.error( + f'msg="Failed to link slack team: {e}"' + f"service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}" + ) + link_slack_team_async.apply_async( + kwargs={ + "service_tenant_id": service_tenant_id, + "slack_team_id": slack_team_id, + "service_type": SERVICE_TYPE_ONCALL, + }, + countdown=2, + ) + + +def unlink_slack_team(service_tenant_id: str, slack_team_id: str): + unlink_slack_team_async.apply_async( + kwargs={ + "service_tenant_id": service_tenant_id, + "slack_team_id": slack_team_id, + "service_type": SERVICE_TYPE_ONCALL, + } + ) diff --git a/engine/apps/chatops_proxy/views.py b/engine/apps/chatops_proxy/views.py new file mode 100644 index 0000000000..7430427254 --- /dev/null +++ b/engine/apps/chatops_proxy/views.py @@ -0,0 +1,24 @@ +import logging + +from django.conf import settings +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.chatops_proxy.events import ChatopsEventsHandler +from apps.chatops_proxy.events.signature import verify_signature + +logger = logging.getLogger(__name__) + +handler = ChatopsEventsHandler() + + +class ChatopsEventsView(APIView): + def post(self, request): + verified = verify_signature(request, settings.CHATOPS_SIGNING_SECRET) + if not verified: + logger.error("ChatopsEventsView: Invalid signature") + return Response(status=401) + found = handler.handle(request.data) + if not found: + return Response(status=400) + return Response(status=200) diff --git a/engine/apps/slack/chatops_proxy_routing.py b/engine/apps/slack/chatops_proxy_routing.py new file mode 100644 index 0000000000..310af19415 --- /dev/null +++ b/engine/apps/slack/chatops_proxy_routing.py @@ -0,0 +1,18 @@ +import json +import typing + + +# ProxyMeta is a data injected into various Slack payloads to route them to the correct cluster via Chatops-Proxy +# Short keys are used to satisfy slack limit for 155 chars in values +class ProxyMeta(typing.TypedDict): + s: str # s is a service name + tid: str # tid is a tenant_id + + +def make_value(data: dict, organization) -> str: + # Slack block elements allow to pass value as string only (max 2000 chars) + return json.dumps({**data, "s": "oncall", "tid": str(organization.uuid)}) + + +def make_private_metadata(data: dict, organization) -> str: + return json.dumps({**data, "s": "oncall", "tid": str(organization.uuid)}) diff --git a/engine/apps/slack/installation.py b/engine/apps/slack/installation.py new file mode 100644 index 0000000000..a48784bc2f --- /dev/null +++ b/engine/apps/slack/installation.py @@ -0,0 +1,74 @@ +import logging + +from django.conf import settings + +from apps.chatops_proxy.utils import unlink_slack_team +from apps.slack.tasks import ( + clean_slack_integration_leftovers, + populate_slack_channels_for_team, + populate_slack_usergroups_for_team, + unpopulate_slack_user_identities, +) +from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log + +logger = logging.getLogger(__name__) + + +class SlackInstallationExc(Exception): + """ + SlackInstallationExc represents some exception happened while managing Slack integration. + """ + + def __init__(self, error_message=None): + # error message is a user-visible error message + self.error_message = error_message + + +def install_slack_integration(organization, user, oauth_response): + """ + Installs Slack integration for the organization. + Raises: + SlackInstallationExc if organization already has Slack integration. + """ + from apps.slack.models import SlackTeamIdentity + + if organization.slack_team_identity is not None: + raise SlackInstallationExc("Organization already has Slack integration") + + slack_team_id = oauth_response["team"]["id"] + slack_team_identity, is_slack_team_identity_created = SlackTeamIdentity.objects.get_or_create( + slack_id=slack_team_id, + ) + # update slack oauth fields by data from response + slack_team_identity.update_oauth_fields(user, organization, oauth_response) + write_chatops_insight_log( + author=user, event_name=ChatOpsEvent.WORKSPACE_CONNECTED, chatops_type=ChatOpsTypePlug.SLACK.value + ) + populate_slack_channels_for_team.apply_async((slack_team_identity.pk,)) + user.slack_user_identity.update_profile_info() + # todo slack: do we need update info for all existing slack users in slack team? + # 24.03.2024 - this todo here for a while. populate_slack_user_identities automatically links users to slack. + # Should be useful if we want to unify with Incident. + # populate_slack_user_identities.apply_async((organization.pk,)) + populate_slack_usergroups_for_team.apply_async((slack_team_identity.pk,), countdown=10) + + +def uninstall_slack_integration(organization, user): + """ + Uninstalls Slack integration for the organization. + Raises: + SlackInstallationExc if organization has no Slack integration. + """ + slack_team_identity = organization.slack_team_identity + if slack_team_identity is not None: + clean_slack_integration_leftovers.apply_async((organization.pk,)) + if settings.FEATURE_MULTIREGION_ENABLED and not settings.UNIFIED_SLACK_APP_ENABLED: + unlink_slack_team(str(organization.uuid), slack_team_identity.slack_id) + write_chatops_insight_log( + author=user, + event_name=ChatOpsEvent.WORKSPACE_DISCONNECTED, + chatops_type=ChatOpsTypePlug.SLACK.value, + ) + unpopulate_slack_user_identities(organization.pk, True) + else: + raise SlackInstallationExc("Organization has no Slack integration.") diff --git a/engine/apps/slack/models/slack_team_identity.py b/engine/apps/slack/models/slack_team_identity.py index 6fe09320c3..a5e24c12f9 100644 --- a/engine/apps/slack/models/slack_team_identity.py +++ b/engine/apps/slack/models/slack_team_identity.py @@ -14,7 +14,6 @@ SlackAPITokenError, ) from apps.user_management.models.user import User -from common.insight_log.chatops_insight_logs import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log if typing.TYPE_CHECKING: from django.db.models.manager import RelatedManager @@ -55,30 +54,27 @@ class Meta: def __str__(self): return f"{self.pk}: {self.name}" - def update_oauth_fields(self, user, organization, reinstall_data): + def update_oauth_fields(self, user, organization, oauth_response): logger.info(f"updated oauth_fields for sti {self.pk}") from apps.slack.models import SlackUserIdentity organization.slack_team_identity = self organization.save(update_fields=["slack_team_identity"]) slack_user_identity, _ = SlackUserIdentity.objects.get_or_create( - slack_id=reinstall_data["authed_user"]["id"], + slack_id=oauth_response["authed_user"]["id"], slack_team_identity=self, ) user.slack_user_identity = slack_user_identity user.save(update_fields=["slack_user_identity"]) - self.bot_access_token = reinstall_data["access_token"] - self.bot_user_id = reinstall_data["bot_user_id"] - self.oauth_scope = reinstall_data["scope"] - self.cached_name = reinstall_data["team"]["name"] - self.access_token = reinstall_data["authed_user"]["access_token"] + self.bot_access_token = oauth_response["access_token"] + self.bot_user_id = oauth_response["bot_user_id"] + self.oauth_scope = oauth_response["scope"] + self.cached_name = oauth_response["team"]["name"] + self.access_token = oauth_response["authed_user"]["access_token"] self.installed_by = slack_user_identity self.cached_reinstall_data = None self.installed_via_granular_permissions = True self.save() - write_chatops_insight_log( - author=user, event_name=ChatOpsEvent.WORKSPACE_CONNECTED, chatops_type=ChatOpsTypePlug.SLACK.value - ) def get_cached_channels(self, search_term=None, slack_id=None): queryset = self.cached_channels diff --git a/engine/apps/slack/scenarios/alertgroup_appearance.py b/engine/apps/slack/scenarios/alertgroup_appearance.py index 7cad3553a3..431e1a211b 100644 --- a/engine/apps/slack/scenarios/alertgroup_appearance.py +++ b/engine/apps/slack/scenarios/alertgroup_appearance.py @@ -2,6 +2,7 @@ import typing from apps.api.permissions import RBACPermission +from apps.slack.chatops_proxy_routing import make_private_metadata from apps.slack.scenarios import scenario_step from apps.slack.types import ( Block, @@ -63,7 +64,7 @@ def process_scenario( "type": "plain_text", "text": "Refresh alert group", }, - "private_metadata": json.dumps(private_metadata), + "private_metadata": make_private_metadata(private_metadata, alert_receive_channel.organization), } self._slack_client.views_open(trigger_id=payload["trigger_id"], view=view) diff --git a/engine/apps/slack/scenarios/distribute_alerts.py b/engine/apps/slack/scenarios/distribute_alerts.py index a67c1a5fe7..6eb5586067 100644 --- a/engine/apps/slack/scenarios/distribute_alerts.py +++ b/engine/apps/slack/scenarios/distribute_alerts.py @@ -11,6 +11,7 @@ from apps.alerts.incident_appearance.renderers.slack_renderer import AlertSlackRenderer from apps.alerts.models import Alert, AlertGroup, AlertGroupLogRecord, AlertReceiveChannel, Invitation from apps.api.permissions import RBACPermission +from apps.slack.chatops_proxy_routing import make_private_metadata, make_value from apps.slack.constants import CACHE_UPDATE_INCIDENT_SLACK_MESSAGE_LIFETIME from apps.slack.errors import ( SlackAPICantUpdateMessageError, @@ -339,11 +340,12 @@ def process_scenario( "type": "plain_text", "text": "Attach to Alert Group", }, - "private_metadata": json.dumps( + "private_metadata": make_private_metadata( { "organization_id": self.organization.pk if self.organization else alert_group.organization.pk, "alert_group_pk": alert_group.pk, - } + }, + self.organization, ), "close": {"type": "plain_text", "text": "Cancel", "emoji": True}, } @@ -398,9 +400,8 @@ def get_select_incidents_blocks(self, alert_group: AlertGroup) -> Block.AnyBlock collected_options: typing.List[CompositionObjectOption] = [] blocks: Block.AnyBlocks = [] - alert_receive_channel_ids = AlertReceiveChannel.objects.filter( - organization=alert_group.channel.organization - ).values_list("id", flat=True) + org = alert_group.channel.organization + alert_receive_channel_ids = AlertReceiveChannel.objects.filter(organization=org).values_list("id", flat=True) alert_groups_queryset = ( AlertGroup.objects.prefetch_related( @@ -412,6 +413,7 @@ def get_select_incidents_blocks(self, alert_group: AlertGroup) -> Block.AnyBlock .order_by("-pk") ) + sf = SlackFormatter(org) for alert_group_to_attach in alert_groups_queryset[:ATTACH_TO_ALERT_GROUPS_LIMIT]: # long_verbose_name_without_formatting was removed from here because it increases queries count due to # alerts.first(). @@ -419,7 +421,6 @@ def get_select_incidents_blocks(self, alert_group: AlertGroup) -> Block.AnyBlock # prefetch_related. first_alert = alert_group_to_attach.alerts.all()[0] templated_alert = AlertSlackRenderer(first_alert).templated_alert - sf = SlackFormatter(alert_group_to_attach.channel.organization) if is_string_with_visible_characters(templated_alert.title): alert_name = templated_alert.title alert_name = sf.format(alert_name) @@ -434,7 +435,7 @@ def get_select_incidents_blocks(self, alert_group: AlertGroup) -> Block.AnyBlock collected_options.append( { "text": {"type": "plain_text", "text": f"{alert_name}", "emoji": True}, - "value": str(alert_group_to_attach.pk), + "value": make_value({root_ag_id_value_key: alert_group_to_attach.pk}, org), } ) if len(collected_options) > 0: @@ -495,16 +496,22 @@ def process_scenario( if payload["type"] == PayloadType.VIEW_SUBMISSION: alert_group_pk = json.loads(payload["view"]["private_metadata"])["alert_group_pk"] alert_group = AlertGroup.objects.get(pk=alert_group_pk) - root_alert_group_pk = payload["view"]["state"]["values"][SelectAttachGroupStep.routing_uid()][ - AttachGroupStep.routing_uid() - ]["selected_option"]["value"] + root_alert_group_pk = _get_root_alert_group_id_from_value( + payload["view"]["state"]["values"][SelectAttachGroupStep.routing_uid()][AttachGroupStep.routing_uid()][ + "selected_option" + ]["value"] + ) root_alert_group = AlertGroup.objects.get(pk=root_alert_group_pk) # old version of attach selection by dropdown else: try: - root_alert_group_pk = int(payload["actions"][0]["selected_options"][0]["value"]) + root_alert_group_pk = int( + _get_root_alert_group_id_from_value(payload["actions"][0]["selected_options"][0]["value"]) + ) except KeyError: - root_alert_group_pk = int(payload["actions"][0]["selected_option"]["value"]) + root_alert_group_pk = int( + _get_root_alert_group_id_from_value(payload["actions"][0]["selected_option"]["value"]) + ) root_alert_group = AlertGroup.objects.get(pk=root_alert_group_pk) alert_group = self.get_alert_group(slack_team_identity, payload) @@ -512,6 +519,22 @@ def process_scenario( alert_group.attach_by_user(self.user, root_alert_group, action_source=ActionSource.SLACK) +root_ag_id_value_key = "ag_id" + + +def _get_root_alert_group_id_from_value(value: str) -> str: + """ + Extract ag ID from value string. + It might be either JSON-encoded object or just a user ID. + Json encoded object introduced for Chatops-Proxy routing, plain string with user ID is legacy. + """ + try: + data = json.loads(value) + return data[root_ag_id_value_key] + except json.JSONDecodeError: + return value + + class UnAttachGroupStep(AlertGroupActionsMixin, scenario_step.ScenarioStep): REQUIRED_PERMISSIONS = [RBACPermission.Permissions.ALERT_GROUPS_WRITE] @@ -717,8 +740,14 @@ def process_scenario( ) -> None: from apps.alerts.models import AlertGroup - alert_group_id = payload["actions"][0]["value"].split("_")[1] - alert_group = AlertGroup.objects.get(pk=alert_group_id) + value = payload["actions"][0]["value"] + try: + alert_group_pk = json.loads(value)["alert_group_pk"] + except json.JSONDecodeError: + # Deprecated and kept for backward compatibility (so older Slack messages can still be processed) + alert_group_pk = value.split("_")[1] + + alert_group = AlertGroup.objects.get(pk=alert_group_pk) channel = payload["channel"]["id"] message_ts = payload["message_ts"] @@ -776,10 +805,7 @@ def process_signal(self, log_record: AlertGroupLogRecord) -> None: "text": "Confirm", "type": "button", "style": "primary", - "value": scenario_step.ScenarioStep.get_step( - "distribute_alerts", "AcknowledgeConfirmationStep" - ).routing_uid() - + ("_" + str(alert_group.pk)), + "value": make_value({"alert_group_pk": alert_group.pk}, alert_group.channel.organization), }, ], } diff --git a/engine/apps/slack/scenarios/manage_responders.py b/engine/apps/slack/scenarios/manage_responders.py index 87a71ea510..63b8bd7fe1 100644 --- a/engine/apps/slack/scenarios/manage_responders.py +++ b/engine/apps/slack/scenarios/manage_responders.py @@ -2,6 +2,7 @@ import typing from apps.alerts.paging import DirectPagingAlertGroupResolvedError, direct_paging, unpage_user, user_is_oncall +from apps.slack.chatops_proxy_routing import make_private_metadata, make_value from apps.slack.constants import DIVIDER from apps.slack.scenarios import scenario_step from apps.slack.scenarios.paging import ( @@ -61,7 +62,9 @@ def process_scenario( # check if user is on-call if not user_is_oncall(selected_user): # display additional confirmation modal - private_metadata = json.dumps({USER_DATA_KEY: selected_user.id, ALERT_GROUP_DATA_KEY: alert_group.pk}) + private_metadata = make_private_metadata( + {USER_DATA_KEY: selected_user.id, ALERT_GROUP_DATA_KEY: alert_group.pk}, organization + ) view = _display_confirm_participant_invitation_view( ManageRespondersConfirmUserChange.routing_uid(), private_metadata ) @@ -163,7 +166,7 @@ def render_dialog(alert_group: "AlertGroup", alert_group_resolved_warning=False) "type": "button", "text": {"type": "plain_text", "text": "Remove", "emoji": True}, "action_id": ManageRespondersRemoveUser.routing_uid(), - "value": str(user["id"]), + "value": make_value({"id": str(user["id"])}, alert_group.channel.organization), }, }, ), @@ -205,7 +208,7 @@ def _get_selected_user_from_payload(payload: EventPayload) -> "User": from apps.user_management.models import User try: - selected_user_id = payload["actions"][0]["value"] # "remove" button + selected_user_id = json.loads(payload["actions"][0]["value"])["id"] # "remove" button except KeyError: try: # "confirm" button on availability warnings modal diff --git a/engine/apps/slack/scenarios/paging.py b/engine/apps/slack/scenarios/paging.py index 6cacd73a45..ecce5b5d76 100644 --- a/engine/apps/slack/scenarios/paging.py +++ b/engine/apps/slack/scenarios/paging.py @@ -11,6 +11,7 @@ from apps.alerts.paging import DirectPagingUserTeamValidationError, UserNotifications, direct_paging, user_is_oncall from apps.api.permissions import RBACPermission, user_is_authorized from apps.schedules.ical_utils import get_cached_oncall_users_for_multiple_schedules +from apps.slack.chatops_proxy_routing import make_private_metadata, make_value from apps.slack.constants import DIVIDER, PRIVATE_METADATA_MAX_LENGTH from apps.slack.errors import SlackAPIChannelNotFoundError from apps.slack.scenarios import scenario_step @@ -292,15 +293,16 @@ def process_scenario( # check if user is on-call if not user_is_oncall(selected_user): # display additional confirmation modal - metadata = metadata = json.loads(payload["view"]["private_metadata"]) - private_metadata = json.dumps( + metadata = json.loads(payload["view"]["private_metadata"]) + private_metadata = make_private_metadata( { "state": payload["view"]["state"], "input_id_prefix": metadata["input_id_prefix"], "channel_id": metadata["channel_id"], "submit_routing_uid": metadata["submit_routing_uid"], DataKey.USERS: metadata[DataKey.USERS], - } + }, + selected_user.organization, ) view = _display_confirm_participant_invitation_view( @@ -326,9 +328,9 @@ def process_scenario( class OnPagingItemActionChange(scenario_step.ScenarioStep): """Reload form with updated user details.""" - def _parse_action(self, payload: EventPayload) -> typing.Tuple[Policy, str, str]: - value = payload["actions"][0]["selected_option"]["value"] - return value.split("|") + def _parse_action(self, payload: EventPayload) -> typing.Tuple[Policy, DataKey, str]: + value = json.loads(payload["actions"][0]["selected_option"]["value"]) + return value["action"], value["key"], value["id"] def process_scenario( self, @@ -464,7 +466,9 @@ def render_dialog( } ) - return _get_form_view(submit_routing_uid, blocks, json.dumps(new_private_metadata)) + return _get_form_view( + submit_routing_uid, blocks, make_private_metadata(new_private_metadata, selected_organization) + ) def _get_unauthorized_warning(error=False): @@ -518,7 +522,7 @@ def _get_organization_select( "text": f"{org.org_title} ({org.stack_slug})", "emoji": True, }, - "value": f"{org.pk}", + "value": make_value({"id": org.pk}, org), } ) @@ -547,7 +551,7 @@ def _get_select_field_value(payload: EventPayload, prefix_id: str, routing_uid: field = payload["view"]["state"]["values"][prefix_id + field_id][routing_uid]["selected_option"] except KeyError: return None - return field["value"] if field else None + return json.loads(field["value"])["id"] if field else None def _get_selected_org_from_payload( @@ -616,7 +620,7 @@ def _get_team_select_blocks( "text": team_name, "emoji": True, }, - "value": str(team_pk), + "value": make_value({"id": team_pk}, organization), } ) @@ -668,7 +672,7 @@ def _get_team_select_blocks( def _create_user_option_groups( - users: "RelatedManager['User']", max_options_per_group: int, option_group_label_text_prefix: str + organization, users: "RelatedManager['User']", max_options_per_group: int, option_group_label_text_prefix: str ) -> typing.List[CompositionObjectOptionGroup]: user_options: typing.List[CompositionObjectOption] = [ { @@ -677,7 +681,7 @@ def _create_user_option_groups( "text": f"{user.name or user.username}", "emoji": True, }, - "value": f"{user.pk}", + "value": make_value({"id": user.pk}, organization), } for user in users ] @@ -733,7 +737,7 @@ def _get_user_select_blocks( # selected items if selected_users := get_current_items(payload, DataKey.USERS, organization.users): blocks += [DIVIDER] - blocks += _get_selected_entries_list(input_id_prefix, DataKey.USERS, selected_users) + blocks += _get_selected_entries_list(organization, input_id_prefix, DataKey.USERS, selected_users) blocks += [DIVIDER] return blocks @@ -746,10 +750,10 @@ def _get_users_select( oncall_user_pks = {user.pk for _, users in schedules.items() for user in users} oncall_user_option_groups = _create_user_option_groups( - organization.users.filter(pk__in=oncall_user_pks), max_options_per_group, "On-call now" + organization, organization.users.filter(pk__in=oncall_user_pks), max_options_per_group, "On-call now" ) not_oncall_user_option_groups = _create_user_option_groups( - organization.users.exclude(pk__in=oncall_user_pks), max_options_per_group, "Not on-call" + organization, organization.users.exclude(pk__in=oncall_user_pks), max_options_per_group, "Not on-call" ) if not oncall_user_option_groups and not not_oncall_user_option_groups: @@ -773,7 +777,7 @@ def _get_users_select( def _get_selected_entries_list( - input_id_prefix: str, key: DataKey, entries: typing.List[typing.Tuple[Model, Policy]] + organization: "Organization", input_id_prefix: str, key: DataKey, entries: typing.List[typing.Tuple[Model, Policy]] ) -> typing.List[Block.Section]: current_entries: typing.List[Block.Section] = [] for entry, policy in entries: @@ -793,7 +797,10 @@ def _get_selected_entries_list( "accessory": { "type": "overflow", "options": [ - {"text": {"type": "plain_text", "text": f"{label}"}, "value": f"{action}|{key}|{entry.pk}"} + { + "text": {"type": "plain_text", "text": f"{label}"}, + "value": make_value({"action": action, "key": key, "id": str(entry.pk)}, organization), + } for (action, label) in ITEM_ACTIONS ], "action_id": OnPagingItemActionChange.routing_uid(), @@ -900,7 +907,7 @@ def _get_available_organizations( def _generate_input_id_prefix() -> str: """ - returns unique string to not to preserve input's values between view update + returns unique string to not preserve input's values between view update https://api.slack.com/methods/views.update#markdown """ diff --git a/engine/apps/slack/scenarios/resolution_note.py b/engine/apps/slack/scenarios/resolution_note.py index df45602f49..d67e525796 100644 --- a/engine/apps/slack/scenarios/resolution_note.py +++ b/engine/apps/slack/scenarios/resolution_note.py @@ -7,6 +7,7 @@ from django.utils.text import Truncator from apps.api.permissions import RBACPermission +from apps.slack.chatops_proxy_routing import make_value from apps.slack.constants import BLOCK_SECTION_TEXT_MAX_SIZE, DIVIDER from apps.slack.errors import ( SlackAPICantUpdateMessageError, @@ -483,14 +484,15 @@ def get_resolution_notes_blocks( "emoji": True, }, "action_id": AddRemoveThreadMessageStep.routing_uid(), - "value": json.dumps( + "value": make_value( { "resolution_note_window_action": "edit", "msg_value": "add" if not message.added_to_resolution_note else "remove", "message_pk": message.pk, "resolution_note_pk": None, "alert_group_pk": alert_group.pk, - } + }, + alert_group.channel.organization, ), }, } @@ -542,7 +544,7 @@ def get_resolution_notes_blocks( "emoji": True, }, "action_id": AddRemoveThreadMessageStep.routing_uid(), - "value": json.dumps( + "value": make_value( { "resolution_note_window_action": "edit", "msg_value": "remove", @@ -551,7 +553,8 @@ def get_resolution_notes_blocks( else resolution_note_slack_message.pk, "resolution_note_pk": resolution_note.pk, "alert_group_pk": alert_group.pk, - } + }, + alert_group.channel.organization, ), "confirm": { "title": {"type": "plain_text", "text": "Are you sure?"}, diff --git a/engine/apps/slack/scenarios/schedules.py b/engine/apps/slack/scenarios/schedules.py index d9c58290d9..4762d90ce9 100644 --- a/engine/apps/slack/scenarios/schedules.py +++ b/engine/apps/slack/scenarios/schedules.py @@ -4,6 +4,7 @@ import pytz from apps.schedules.models import OnCallSchedule +from apps.slack.chatops_proxy_routing import make_value from apps.slack.scenarios import scenario_step from apps.slack.types import ( Block, @@ -33,22 +34,26 @@ def process_scenario( slack_team_identity: "SlackTeamIdentity", payload: EventPayload, ) -> None: - if payload["actions"][0].get("value", None) and payload["actions"][0]["value"].startswith("edit"): + action_type = payload["actions"][0]["type"] + if action_type == BlockActionType.BUTTON: self.open_settings_modal(payload) - elif payload["actions"][0].get("type", None) and payload["actions"][0]["type"] == "static_select": + elif action_type == BlockActionType.STATIC_SELECT: self.set_selected_value(slack_user_identity, payload) def open_settings_modal(self, payload: EventPayload) -> None: - schedule_id = payload["actions"][0]["value"].split("_")[1] + value = payload["actions"][0]["value"] try: - _ = OnCallSchedule.objects.get(pk=schedule_id) # noqa + schedule_id = json.loads(value)["schedule_id"] + except json.JSONDecodeError: + # Deprecated and kept for backward compatibility (so older Slack messages can still be processed) + schedule_id = value.split("_")[1] + + try: + schedule = OnCallSchedule.objects.get(pk=schedule_id) # noqa except OnCallSchedule.DoesNotExist: blocks = [{"type": "section", "text": {"type": "plain_text", "text": "Schedule was removed"}}] else: - blocks = self.get_modal_blocks(schedule_id) - - private_metadata = {} - private_metadata["schedule_id"] = schedule_id + blocks = self.get_modal_blocks(schedule) view: ModalView = { "callback_id": EditScheduleShiftNotifyStep.routing_uid(), @@ -58,7 +63,7 @@ def open_settings_modal(self, payload: EventPayload) -> None: "type": "plain_text", "text": "Notification preferences", }, - "private_metadata": json.dumps(private_metadata), + "private_metadata": json.dumps({"schedule_id": schedule_id}), } self._slack_client.views_open(trigger_id=payload["trigger_id"], view=view) @@ -69,7 +74,7 @@ def set_selected_value(self, slack_user_identity: "SlackUserIdentity", payload: schedule_id = private_metadata["schedule_id"] schedule = OnCallSchedule.objects.get(pk=schedule_id) prev_state = schedule.insight_logs_serialized - setattr(schedule, action["block_id"], int(action["selected_option"]["value"])) + setattr(schedule, action["block_id"], json.loads(action["selected_option"]["value"])["option"]) schedule.save() new_state = schedule.insight_logs_serialized write_resource_insight_log( @@ -80,7 +85,7 @@ def set_selected_value(self, slack_user_identity: "SlackUserIdentity", payload: new_state=new_state, ) - def get_modal_blocks(self, schedule_id: str) -> typing.List[Block.Section]: + def get_modal_blocks(self, schedule: OnCallSchedule) -> typing.List[Block.Section]: blocks: typing.List[Block.Section] = [ { "type": "section", @@ -90,8 +95,8 @@ def get_modal_blocks(self, schedule_id: str) -> typing.List[Block.Section]: "type": "static_select", "placeholder": {"type": "plain_text", "text": "----"}, "action_id": EditScheduleShiftNotifyStep.routing_uid(), - "options": self.get_options("notify_oncall_shift_freq"), - "initial_option": self.get_initial_option(schedule_id, "notify_oncall_shift_freq"), + "options": self.get_options(schedule, "notify_oncall_shift_freq"), + "initial_option": self.get_initial_option(schedule, "notify_oncall_shift_freq"), }, }, { @@ -102,8 +107,8 @@ def get_modal_blocks(self, schedule_id: str) -> typing.List[Block.Section]: "type": "static_select", "placeholder": {"type": "plain_text", "text": "----"}, "action_id": EditScheduleShiftNotifyStep.routing_uid(), - "options": self.get_options("mention_oncall_start"), - "initial_option": self.get_initial_option(schedule_id, "mention_oncall_start"), + "options": self.get_options(schedule, "mention_oncall_start"), + "initial_option": self.get_initial_option(schedule, "mention_oncall_start"), }, }, { @@ -114,8 +119,8 @@ def get_modal_blocks(self, schedule_id: str) -> typing.List[Block.Section]: "type": "static_select", "placeholder": {"type": "plain_text", "text": "----"}, "action_id": EditScheduleShiftNotifyStep.routing_uid(), - "options": self.get_options("mention_oncall_next"), - "initial_option": self.get_initial_option(schedule_id, "mention_oncall_next"), + "options": self.get_options(schedule, "mention_oncall_next"), + "initial_option": self.get_initial_option(schedule, "mention_oncall_next"), }, }, { @@ -126,24 +131,25 @@ def get_modal_blocks(self, schedule_id: str) -> typing.List[Block.Section]: "type": "static_select", "placeholder": {"type": "plain_text", "text": "----"}, "action_id": EditScheduleShiftNotifyStep.routing_uid(), - "options": self.get_options("notify_empty_oncall"), - "initial_option": self.get_initial_option(schedule_id, "notify_empty_oncall"), + "options": self.get_options(schedule, "notify_empty_oncall"), + "initial_option": self.get_initial_option(schedule, "notify_empty_oncall"), }, }, ] return blocks - def get_options(self, select_name: str) -> typing.List[CompositionObjectOption]: + def get_options(self, schedule: OnCallSchedule, select_name: str) -> typing.List[CompositionObjectOption]: select_options = getattr(self, f"{select_name}_options") return [ - {"text": {"type": "plain_text", "text": select_options[option]}, "value": str(option)} + { + "text": {"type": "plain_text", "text": select_options[option]}, + "value": make_value({"option": option}, schedule.organization), + } for option in select_options ] - def get_initial_option(self, schedule_id: str, select_name: str) -> CompositionObjectOption: - schedule = OnCallSchedule.objects.get(pk=schedule_id) - + def get_initial_option(self, schedule: OnCallSchedule, select_name: str) -> CompositionObjectOption: current_value = getattr(schedule, select_name) text = getattr(self, f"{select_name}_options")[current_value] @@ -152,7 +158,7 @@ def get_initial_option(self, schedule_id: str, select_name: str) -> CompositionO "type": "plain_text", "text": f"{text}", }, - "value": str(int(current_value)), + "value": make_value({"option": int(current_value)}, schedule.organization), } return initial_option @@ -243,7 +249,7 @@ def get_report_blocks_ical(cls, new_shifts, next_shifts, schedule: OnCallSchedul "type": "button", "action_id": f"{cls.routing_uid()}", "text": {"type": "plain_text", "text": ":gear:", "emoji": True}, - "value": f"edit_{schedule.pk}", + "value": make_value({"schedule_id": schedule.pk}, schedule.organization), }, ], }, diff --git a/engine/apps/slack/scenarios/shift_swap_requests.py b/engine/apps/slack/scenarios/shift_swap_requests.py index 6e676ef2b4..d9c54bf198 100644 --- a/engine/apps/slack/scenarios/shift_swap_requests.py +++ b/engine/apps/slack/scenarios/shift_swap_requests.py @@ -6,6 +6,7 @@ from django.utils import timezone from apps.api.permissions import RBACPermission +from apps.slack.chatops_proxy_routing import make_value from apps.slack.models import SlackMessage from apps.slack.scenarios import scenario_step from apps.slack.types import Block, BlockActionType, EventPayload, PayloadType, ScenarioRoute @@ -143,7 +144,7 @@ def _generate_blocks(self, shift_swap_request: "ShiftSwapRequest") -> Block.AnyB "text": "Accept", "emoji": True, }, - "value": json.dumps(value), + "value": make_value(value, shift_swap_request.organization), "action_id": AcceptShiftSwapRequestStep.routing_uid(), }, ], diff --git a/engine/apps/slack/tests/test_installation.py b/engine/apps/slack/tests/test_installation.py new file mode 100644 index 0000000000..7075c276e8 --- /dev/null +++ b/engine/apps/slack/tests/test_installation.py @@ -0,0 +1,135 @@ +# Response example from Slack docs https://api.slack.com/methods/oauth.v2.access#examples +from unittest.mock import patch + +import pytest + +from apps.slack.client import SlackClient +from apps.slack.installation import SlackInstallationExc, install_slack_integration, uninstall_slack_integration +from common.constants.slack_auth import SLACK_OAUTH_ACCESS_RESPONSE + +users_profile_get_response = { + "ok": True, + "user": { + "id": "W012A3CDE", + "team_id": "T012AB3C4", + "name": "spengler", + "deleted": False, + "color": "9f69e7", + "real_name": "Egon Spengler", + "tz": "America/Los_Angeles", + "tz_label": "Pacific Daylight Time", + "tz_offset": -25200, + "profile": { + "avatar_hash": "ge3b51ca72de", + "status_text": "Print is dead", + "status_emoji": ":books:", + "real_name": "Egon Spengler", + "display_name": "spengler", + "real_name_normalized": "Egon Spengler", + "display_name_normalized": "spengler", + "email": "spengler@ghostbusters.example.com", + "image_original": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg", + "image_24": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg", + "image_32": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg", + "image_48": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg", + "image_72": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg", + "image_192": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg", + "image_512": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg", + "team": "T012AB3C4", + }, + "is_admin": True, + "is_owner": False, + "is_primary_owner": False, + "is_restricted": False, + "is_ultra_restricted": False, + "is_bot": False, + "updated": 1502138686, + "is_app_user": False, + "has_2fa": False, + }, +} + + +@patch("apps.slack.tasks.populate_slack_channels_for_team.apply_async", return_value=None) +@patch("apps.slack.tasks.populate_slack_usergroups_for_team.apply_async", return_value=None) +@patch.object(SlackClient, "users_info", return_value=users_profile_get_response) +@pytest.mark.django_db +def test_install_slack_integration( + mock_populate_slack_channels_for_team, + mock_populate_slack_usergroups_for_team, + mock_users_info, + make_organization_and_user, +): + organization, user = make_organization_and_user() + install_slack_integration(organization, user, SLACK_OAUTH_ACCESS_RESPONSE) + + assert organization.slack_team_identity is not None + # test that two most important fields are set: id of slack workspace and api acess token + assert organization.slack_team_identity.slack_id == SLACK_OAUTH_ACCESS_RESPONSE["team"]["id"] + assert organization.slack_team_identity.bot_access_token == SLACK_OAUTH_ACCESS_RESPONSE["access_token"] + + # install_slack_integration links instgallers's slack profile to OnCall + assert user.slack_user_identity is not None + + # assert that installer slack profile is linked to OnCall user + assert user.slack_user_identity.slack_id == SLACK_OAUTH_ACCESS_RESPONSE["authed_user"]["id"] + + # assert that we populated user's profile info + assert user.slack_user_identity.cached_slack_login == users_profile_get_response["user"]["name"] + + # assert that we ran task for fetching data from slack + assert mock_populate_slack_channels_for_team.called + assert mock_populate_slack_usergroups_for_team.called + + +@pytest.mark.django_db +def test_install_slack_integration_raises_exception_for_existing_integration( + make_organization_and_user, make_slack_team_identity +): + slack_team_identity = make_slack_team_identity() + organization, user = make_organization_and_user() + organization.slack_team_identity = slack_team_identity + organization.save() + + with pytest.raises(SlackInstallationExc): + install_slack_integration(organization, user, SLACK_OAUTH_ACCESS_RESPONSE) + + +@patch("apps.slack.tasks.clean_slack_integration_leftovers.apply_async", return_value=None) +@pytest.mark.django_db +def test_uninstall_slack_integration( + mock_clean_slack_integration_leftovers, + make_organization_and_user, + make_slack_team_identity, + make_slack_user_identity, +): + slack_team_identity = make_slack_team_identity() + organization, user = make_organization_and_user() + organization.slack_team_identity = slack_team_identity + organization.save() + organization.refresh_from_db() + + slack_user_identity = make_slack_user_identity(slack_team_identity=slack_team_identity) + user.slack_user_identity = slack_user_identity + user.save() + user.refresh_from_db() + + uninstall_slack_integration(organization, user) + + organization.refresh_from_db() + user.refresh_from_db() + assert organization.slack_team_identity is None + assert user.slack_user_identity is None + + # assert that we ran task for fetching data from slack + assert mock_clean_slack_integration_leftovers.called + + +@pytest.mark.django_db +def test_uninstall_slack_integration_raises_exception_for_non_existing_integration( + make_organization_and_user, +): + organization, user = make_organization_and_user() + + with pytest.raises(SlackInstallationExc): + uninstall_slack_integration(organization, user) diff --git a/engine/apps/slack/tests/test_scenario_steps/test_manage_responders.py b/engine/apps/slack/tests/test_scenario_steps/test_manage_responders.py index 682acc22a7..5238aea6d2 100644 --- a/engine/apps/slack/tests/test_scenario_steps/test_manage_responders.py +++ b/engine/apps/slack/tests/test_scenario_steps/test_manage_responders.py @@ -6,6 +6,7 @@ from apps.base.models import UserNotificationPolicy from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb +from apps.slack.chatops_proxy_routing import make_private_metadata, make_value from apps.slack.scenarios.manage_responders import ( ALERT_GROUP_DATA_KEY, DIRECT_PAGING_USER_SELECT_ID, @@ -23,17 +24,19 @@ MESSAGE_TS = "67" -def make_slack_payload(user=None, actions=None): +def make_slack_payload(organization, user=None, actions=None): payload = { "trigger_id": TRIGGER_ID, "view": { "id": "view-id", - "private_metadata": json.dumps({"input_id_prefix": "", ALERT_GROUP_DATA_KEY: ALERT_GROUP_ID}), + "private_metadata": make_private_metadata( + {"input_id_prefix": "", ALERT_GROUP_DATA_KEY: ALERT_GROUP_ID}, organization + ), "state": { "values": { DIRECT_PAGING_USER_SELECT_ID: { ManageRespondersUserChange.routing_uid(): { - "selected_option": {"value": user.pk} if user else None + "selected_option": {"value": make_value({"id": user.pk}, organization)} if user else None } }, } @@ -118,21 +121,23 @@ def test_add_user_no_warning(manage_responders_setup, make_schedule, make_on_cal notify_by=UserNotificationPolicy.NotificationChannel.SMS, ) - payload = make_slack_payload(user=user) + payload = make_slack_payload(organization, user=user) step = ManageRespondersUserChange(slack_team_identity, organization, user) with patch.object(step._slack_client, "views_update") as mock_slack_api_call: step.process_scenario(slack_user_identity, slack_team_identity, payload) # check there's a delete button for the user - assert mock_slack_api_call.call_args.kwargs["view"]["blocks"][0]["accessory"]["value"] == str(user.pk) + assert mock_slack_api_call.call_args.kwargs["view"]["blocks"][0]["accessory"]["value"] == make_value( + {"id": str(user.pk)}, organization + ) @pytest.mark.django_db def test_add_user_raise_warning(manage_responders_setup): organization, user, slack_team_identity, slack_user_identity = manage_responders_setup # user is not on call - payload = make_slack_payload(user=user) + payload = make_slack_payload(organization, user=user) step = ManageRespondersUserChange(slack_team_identity, organization, user) with patch.object(step._slack_client, "views_push") as mock_slack_api_call: @@ -154,7 +159,7 @@ def test_add_user_raise_warning(manage_responders_setup): def test_remove_user(manage_responders_setup): organization, user, slack_team_identity, slack_user_identity = manage_responders_setup - payload = make_slack_payload(actions=[{"value": user.pk}]) + payload = make_slack_payload(organization, actions=[{"value": make_value({"id": user.pk}, organization)}]) step = ManageRespondersRemoveUser(slack_team_identity, organization, user) with patch.object(step._slack_client, "views_update") as mock_slack_api_call: step.process_scenario(slack_user_identity, slack_team_identity, payload) diff --git a/engine/apps/slack/tests/test_scenario_steps/test_paging.py b/engine/apps/slack/tests/test_scenario_steps/test_paging.py index 0d4d114d5c..f58c2b678b 100644 --- a/engine/apps/slack/tests/test_scenario_steps/test_paging.py +++ b/engine/apps/slack/tests/test_scenario_steps/test_paging.py @@ -7,6 +7,7 @@ from apps.alerts.models import AlertReceiveChannel from apps.api.permissions import LegacyAccessControlRole from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb +from apps.slack.chatops_proxy_routing import make_private_metadata, make_value from apps.slack.scenarios.paging import ( DIRECT_PAGING_MESSAGE_INPUT_ID, DIRECT_PAGING_ORG_SELECT_ID, @@ -32,24 +33,31 @@ def make_slack_payload(organization, team=None, user=None, current_users=None, a "trigger_id": "111", "view": { "id": "view-id", - "private_metadata": json.dumps( + "private_metadata": make_private_metadata( { "input_id_prefix": "", "channel_id": "123", "submit_routing_uid": "FinishStepUID", DataKey.USERS: current_users or {}, - } + }, + organization, ), "state": { "values": { DIRECT_PAGING_ORG_SELECT_ID: { - OnPagingOrgChange.routing_uid(): {"selected_option": {"value": organization.pk}} + OnPagingOrgChange.routing_uid(): { + "selected_option": {"value": make_value({"id": organization.pk}, organization)} + } }, DIRECT_PAGING_TEAM_SELECT_ID: { - OnPagingTeamChange.routing_uid(): {"selected_option": {"value": team.pk if team else None}} + OnPagingTeamChange.routing_uid(): { + "selected_option": {"value": make_value({"id": team.pk if team else None}, organization)} + } }, DIRECT_PAGING_USER_SELECT_ID: { - OnPagingUserChange.routing_uid(): {"selected_option": {"value": user.pk} if user else None} + OnPagingUserChange.routing_uid(): { + "selected_option": {"value": make_value({"id": user.pk}, organization)} if user else None + } }, DIRECT_PAGING_MESSAGE_INPUT_ID: {FinishDirectPaging.routing_uid(): {"value": "The Message"}}, } @@ -203,7 +211,13 @@ def test_change_user_policy(make_organization_and_user_with_slack_identities): organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() payload = make_slack_payload( organization=organization, - actions=[{"selected_option": {"value": f"{Policy.IMPORTANT}|{DataKey.USERS}|{user.pk}"}}], + actions=[ + { + "selected_option": { + "value": make_value({"action": Policy.IMPORTANT, "key": DataKey.USERS, "id": user.pk}, organization) + } + } + ], ) step = OnPagingItemActionChange(slack_team_identity) @@ -219,7 +233,15 @@ def test_remove_user(make_organization_and_user_with_slack_identities): organization, user, slack_team_identity, slack_user_identity = make_organization_and_user_with_slack_identities() payload = make_slack_payload( organization=organization, - actions=[{"selected_option": {"value": f"{Policy.REMOVE_ACTION}|{DataKey.USERS}|{user.pk}"}}], + actions=[ + { + "selected_option": { + "value": make_value( + {"action": Policy.REMOVE_ACTION, "key": DataKey.USERS, "id": user.pk}, organization + ) + } + } + ], ) step = OnPagingItemActionChange(slack_team_identity) @@ -302,7 +324,9 @@ def test_get_organization_select(make_organization): select = _get_organization_select(Organization.objects.filter(pk=organization.pk), organization, "test") assert len(select["element"]["options"]) == 1 - assert select["element"]["options"][0]["value"] == str(organization.pk) + assert json.loads(select["element"]["options"][0]["value"]) == json.loads( + make_value({"id": organization.pk}, organization) + ) assert select["element"]["options"][0]["text"]["text"] == "Organization (stack_slug)" @@ -322,7 +346,10 @@ def test_get_team_select_blocks( input_id_prefix = "nmxcnvmnxv" def _contstruct_team_option(team): - return {"text": {"emoji": True, "text": team.name, "type": "plain_text"}, "value": str(team.pk)} + return { + "text": {"emoji": True, "text": team.name, "type": "plain_text"}, + "value": make_value({"id": team.pk}, organization), + } # no team selected - no team direct paging integrations available organization, _, _, slack_user_identity = make_organization_and_user_with_slack_identities() @@ -408,5 +435,7 @@ def _sort_team_options(options): assert input_block["type"] == "input" assert len(input_block["element"]["options"]) == 1 - assert input_block["element"]["options"] == [_contstruct_team_option(team)] + assert json.loads(input_block["element"]["options"][0]["value"]) == json.loads( + _contstruct_team_option(team)["value"] + ) assert context_block["elements"][0]["text"] == info_msg diff --git a/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py b/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py index 1b4a87e60d..9bc0b2b69b 100644 --- a/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py +++ b/engine/apps/slack/tests/test_scenario_steps/test_resolution_note.py @@ -3,6 +3,7 @@ import pytest +from apps.slack.chatops_proxy_routing import make_value from apps.slack.client import SlackClient from apps.slack.constants import BLOCK_SECTION_TEXT_MAX_SIZE from apps.slack.errors import SlackAPIViewNotFoundError @@ -91,14 +92,15 @@ def test_get_resolution_notes_blocks_non_empty( "emoji": True, }, "action_id": "AddRemoveThreadMessageStep", - "value": json.dumps( + "value": make_value( { "resolution_note_window_action": "edit", "msg_value": "add", "message_pk": resolution_note.pk, "resolution_note_pk": None, "alert_group_pk": alert_group.pk, - } + }, + organization, ), }, }, @@ -281,14 +283,15 @@ def test_get_resolution_notes_blocks_latest_limit( "emoji": True, }, "action_id": "AddRemoveThreadMessageStep", - "value": json.dumps( + "value": make_value( { "resolution_note_window_action": "edit", "msg_value": "add", "message_pk": m.pk, "resolution_note_pk": None, "alert_group_pk": alert_group.pk, - } + }, + organization, ), }, }, diff --git a/engine/apps/slack/tests/test_slack_renderer.py b/engine/apps/slack/tests/test_slack_renderer.py index 2d60685d52..5d78a09efa 100644 --- a/engine/apps/slack/tests/test_slack_renderer.py +++ b/engine/apps/slack/tests/test_slack_renderer.py @@ -4,6 +4,7 @@ from apps.alerts.incident_appearance.renderers.slack_renderer import AlertGroupSlackRenderer from apps.alerts.models import AlertGroup +from apps.slack.chatops_proxy_routing import make_value @pytest.mark.django_db @@ -17,11 +18,15 @@ def test_slack_renderer_acknowledge_button(make_organization, make_alert_receive button = elements[0] assert button["text"]["text"] == "Acknowledge" - assert json.loads(button["value"]) == { - "organization_id": organization.pk, - "alert_group_pk": alert_group.pk, - "alert_group_ppk": alert_group.public_primary_key, - } + assert json.loads(button["value"]) == json.loads( + make_value( + { + "organization_id": organization.pk, + "alert_group_ppk": alert_group.public_primary_key, + }, + organization, + ) + ) @pytest.mark.django_db @@ -37,11 +42,15 @@ def test_slack_renderer_unacknowledge_button( button = elements[0] assert button["text"]["text"] == "Unacknowledge" - assert json.loads(button["value"]) == { - "organization_id": organization.pk, - "alert_group_pk": alert_group.pk, - "alert_group_ppk": alert_group.public_primary_key, - } + assert json.loads(button["value"]) == json.loads( + make_value( + { + "organization_id": organization.pk, + "alert_group_ppk": alert_group.public_primary_key, + }, + organization, + ) + ) @pytest.mark.django_db @@ -55,11 +64,15 @@ def test_slack_renderer_resolve_button(make_organization, make_alert_receive_cha button = elements[1] assert button["text"]["text"] == "Resolve" - assert json.loads(button["value"]) == { - "organization_id": organization.pk, - "alert_group_pk": alert_group.pk, - "alert_group_ppk": alert_group.public_primary_key, - } + assert json.loads(button["value"]) == json.loads( + make_value( + { + "organization_id": organization.pk, + "alert_group_ppk": alert_group.public_primary_key, + }, + organization, + ) + ) @pytest.mark.django_db @@ -73,11 +86,15 @@ def test_slack_renderer_unresolve_button(make_organization, make_alert_receive_c button = elements[0] assert button["text"]["text"] == "Unresolve" - assert json.loads(button["value"]) == { - "organization_id": organization.pk, - "alert_group_pk": alert_group.pk, - "alert_group_ppk": alert_group.public_primary_key, - } + assert json.loads(button["value"]) == json.loads( + make_value( + { + "organization_id": organization.pk, + "alert_group_ppk": alert_group.public_primary_key, + }, + organization, + ) + ) @pytest.mark.django_db @@ -109,12 +126,16 @@ def test_slack_renderer_stop_invite_button( action = AlertGroupSlackRenderer(alert_group).render_alert_group_attachments()[1]["actions"][0] assert action["text"] == f"Stop inviting {user.username}" - assert json.loads(action["value"]) == { - "organization_id": organization.pk, - "alert_group_pk": alert_group.pk, - "alert_group_ppk": alert_group.public_primary_key, - "invitation_id": invitation.pk, - } + assert json.loads(action["value"]) == json.loads( + make_value( + { + "organization_id": organization.pk, + "alert_group_ppk": alert_group.public_primary_key, + "invitation_id": invitation.pk, + }, + organization, + ) + ) @pytest.mark.django_db @@ -131,12 +152,16 @@ def test_slack_renderer_silence_button(make_organization, make_alert_receive_cha values = [json.loads(option["value"]) for option in button["options"]] assert values == [ - { - "organization_id": organization.pk, - "alert_group_pk": alert_group.pk, - "alert_group_ppk": alert_group.public_primary_key, - "delay": delay, - } + json.loads( + make_value( + { + "organization_id": organization.pk, + "alert_group_ppk": alert_group.public_primary_key, + "delay": delay, + }, + organization, + ) + ) for delay, _ in AlertGroup.SILENCE_DELAY_OPTIONS ] @@ -152,11 +177,15 @@ def test_slack_renderer_unsilence_button(make_organization, make_alert_receive_c button = elements[2] assert button["text"]["text"] == "Unsilence" - assert json.loads(button["value"]) == { - "organization_id": organization.pk, - "alert_group_pk": alert_group.pk, - "alert_group_ppk": alert_group.public_primary_key, - } + assert json.loads(button["value"]) == json.loads( + make_value( + { + "organization_id": organization.pk, + "alert_group_ppk": alert_group.public_primary_key, + }, + organization, + ) + ) @pytest.mark.django_db @@ -170,11 +199,15 @@ def test_slack_renderer_attach_button(make_organization, make_alert_receive_chan button = elements[4] assert button["text"]["text"] == "Attach to ..." - assert json.loads(button["value"]) == { - "organization_id": organization.pk, - "alert_group_pk": alert_group.pk, - "alert_group_ppk": alert_group.public_primary_key, - } + assert json.loads(button["value"]) == json.loads( + make_value( + { + "organization_id": organization.pk, + "alert_group_ppk": alert_group.public_primary_key, + }, + organization, + ) + ) @pytest.mark.django_db @@ -191,11 +224,15 @@ def test_slack_renderer_unattach_button(make_organization, make_alert_receive_ch action = AlertGroupSlackRenderer(alert_group).render_alert_group_attachments()[0]["actions"][0] assert action["text"] == "Unattach" - assert json.loads(action["value"]) == { - "organization_id": organization.pk, - "alert_group_pk": alert_group.pk, - "alert_group_ppk": alert_group.public_primary_key, - } + assert json.loads(action["value"]) == json.loads( + make_value( + { + "organization_id": organization.pk, + "alert_group_ppk": alert_group.public_primary_key, + }, + organization, + ) + ) @pytest.mark.django_db @@ -211,11 +248,15 @@ def test_slack_renderer_format_alert_button( button = elements[5] assert button["text"]["text"] == ":mag: Format Alert" - assert json.loads(button["value"]) == { - "organization_id": organization.pk, - "alert_group_pk": alert_group.pk, - "alert_group_ppk": alert_group.public_primary_key, - } + assert json.loads(button["value"]) == json.loads( + make_value( + { + "organization_id": organization.pk, + "alert_group_ppk": alert_group.public_primary_key, + }, + organization, + ) + ) @pytest.mark.django_db @@ -231,9 +272,13 @@ def test_slack_renderer_resolution_notes_button( button = elements[6] assert button["text"]["text"] == "Add Resolution notes" - assert json.loads(button["value"]) == { - "organization_id": organization.pk, - "alert_group_pk": alert_group.pk, - "alert_group_ppk": alert_group.public_primary_key, - "resolution_note_window_action": "edit", - } + assert json.loads(button["value"]) == json.loads( + make_value( + { + "organization_id": organization.pk, + "alert_group_ppk": alert_group.public_primary_key, + "resolution_note_window_action": "edit", + }, + organization, + ) + ) diff --git a/engine/apps/slack/urls.py b/engine/apps/slack/urls.py index f4e36ad5f4..c17e11156d 100644 --- a/engine/apps/slack/urls.py +++ b/engine/apps/slack/urls.py @@ -9,17 +9,19 @@ ) urlpatterns = [ + # Old urls for handling slack events and interactive messages. Currently used in OSS path("event_api_endpoint/", SlackEventApiEndpointView.as_view()), path("interactive_api_endpoint/", SlackEventApiEndpointView.as_view()), + # New urls used in cloud via chatops-proxy v3. + path("events/", SlackEventApiEndpointView.as_view()), + path("interactive/", SlackEventApiEndpointView.as_view()), + # Trailing / is missing here on purpose. QA the feature if you want to add it. No idea why doesn't it work with it. + path("reset_slack", ResetSlackView.as_view(), name="reset-slack"), + # Deprecated. path("oauth/", OAuthSlackView.as_view()), path("oauth///", OAuthSlackView.as_view()), path("install_redirect/", InstallLinkRedirectView.as_view()), path("install_redirect///", InstallLinkRedirectView.as_view()), path("signup_redirect/", SignupRedirectView.as_view()), path("signup_redirect///", SignupRedirectView.as_view()), - # Trailing / is missing here on purpose. QA the feature if you want to add it. No idea why doesn't it work with it. - path("reset_slack", ResetSlackView.as_view(), name="reset-slack"), - # urls for chatops-proxy v3. Currently, they are experimental. - path("events/", SlackEventApiEndpointView.as_view()), - path("interactive/", SlackEventApiEndpointView.as_view()), ] diff --git a/engine/apps/slack/views.py b/engine/apps/slack/views.py index 9ed339ac3d..d1948f89e4 100644 --- a/engine/apps/slack/views.py +++ b/engine/apps/slack/views.py @@ -35,13 +35,11 @@ from apps.slack.scenarios.slack_channel import STEPS_ROUTING as CHANNEL_ROUTING from apps.slack.scenarios.slack_channel_integration import STEPS_ROUTING as SLACK_CHANNEL_INTEGRATION_ROUTING from apps.slack.scenarios.slack_usergroup import STEPS_ROUTING as SLACK_USERGROUP_UPDATE_ROUTING -from apps.slack.tasks import clean_slack_integration_leftovers, unpopulate_slack_user_identities from apps.slack.types import EventPayload, EventType, MessageEventSubtype, PayloadType, ScenarioRoute from apps.user_management.models import Organization -from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log -from common.oncall_gateway import unlink_slack_team_wrapper from .errors import SlackAPITokenError +from .installation import SlackInstallationExc, uninstall_slack_integration from .models import SlackMessage, SlackTeamIdentity, SlackUserIdentity SCENARIOS_ROUTES: ScenarioRoute.RoutingSteps = [] @@ -565,25 +563,14 @@ class ResetSlackView(APIView): } def post(self, request): + # TODO: this check should be removed once Unified Slack App is release if settings.SLACK_INTEGRATION_MAINTENANCE_ENABLED: - response = Response( + return Response( "Grafana OnCall is temporary unable to connect your slack account or install OnCall to your slack workspace", status=400, ) - else: - organization = request.auth.organization - slack_team_identity = organization.slack_team_identity - if slack_team_identity is not None: - clean_slack_integration_leftovers.apply_async((organization.pk,)) - if settings.FEATURE_MULTIREGION_ENABLED: - unlink_slack_team_wrapper(str(organization.uuid), slack_team_identity.slack_id) - write_chatops_insight_log( - author=request.user, - event_name=ChatOpsEvent.WORKSPACE_DISCONNECTED, - chatops_type=ChatOpsTypePlug.SLACK.value, - ) - unpopulate_slack_user_identities(organization.pk, True) - response = Response(status=200) - else: - response = Response(status=400) - return response + try: + uninstall_slack_integration(request.user.organization, request.user) + except SlackInstallationExc as e: + return Response({"error": e.error_message}, status=400) + return Response(status=200) diff --git a/engine/apps/social_auth/backends.py b/engine/apps/social_auth/backends.py index 9043db50dc..997a787f40 100644 --- a/engine/apps/social_auth/backends.py +++ b/engine/apps/social_auth/backends.py @@ -91,7 +91,10 @@ class SlackOAuth2V2(SlackOAuth2): @handle_http_errors def auth_complete(self, *args, **kwargs): - """Completes login process, must return user instance""" + """ + Override original method to include auth token in redirect uri and adjust response shape to slack Oauth2.0 V2. + Access token is in the ["authed_user"]["access_token"] field, not in the root of the response. + """ self.process_error(self.data) state = self.validate_state() # add auth token to redirect uri, because it must be the same in all slack auth requests @@ -113,6 +116,7 @@ def auth_complete(self, *args, **kwargs): method=self.ACCESS_TOKEN_METHOD, ) self.process_error(response) + # Take access token from the authed_user field, not from the root access_token = response["authed_user"]["access_token"] kwargs.update(response=response) return self.do_auth(access_token, *args, **kwargs) @@ -187,8 +191,13 @@ def get_scope(self): return {"user_scope": USER_SCOPE} +# it's named slack-install-free because it was used to install free version of Slack App. +# There is no free/paid version of Slack App anymore, so it's just a name. +SLACK_INSTALLATION_BACKEND = "slack-install-free" + + class InstallSlackOAuth2V2(SlackOAuth2V2): - name = "slack-install-free" + name = SLACK_INSTALLATION_BACKEND def get_scope(self): return {"user_scope": USER_SCOPE, "scope": BOT_SCOPE} diff --git a/engine/apps/social_auth/pipeline/slack.py b/engine/apps/social_auth/pipeline/slack.py index 3be18bfaa1..4a1f606bbc 100644 --- a/engine/apps/social_auth/pipeline/slack.py +++ b/engine/apps/social_auth/pipeline/slack.py @@ -6,11 +6,12 @@ from django.http import HttpResponse from rest_framework import status -from apps.slack.tasks import populate_slack_channels_for_team, populate_slack_usergroups_for_team +from apps.chatops_proxy.utils import can_link_slack_team, link_slack_team +from apps.slack.installation import SlackInstallationExc, install_slack_integration +from apps.social_auth.backends import SLACK_INSTALLATION_BACKEND from apps.social_auth.exceptions import InstallMultiRegionSlackException from common.constants.slack_auth import SLACK_AUTH_SLACK_USER_ALREADY_CONNECTED_ERROR, SLACK_AUTH_WRONG_WORKSPACE_ERROR from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log -from common.oncall_gateway import can_link_slack_team_wrapper, link_slack_team_wrapper logger = logging.getLogger(__name__) @@ -72,34 +73,21 @@ def connect_user_to_slack(response, backend, strategy, user, organization, *args def populate_slack_identities(response, backend, user, organization, **kwargs): - from apps.slack.models import SlackTeamIdentity - # Continue pipeline step only if it was installation - if backend.name != "slack-install-free": + if backend.name != SLACK_INSTALLATION_BACKEND: return - if organization.slack_team_identity is not None: - # means that organization already has Slack integration - return HttpResponse(status=status.HTTP_400_BAD_REQUEST) - slack_team_id = response["team"]["id"] - if settings.LICENSE == settings.CLOUD_LICENSE_NAME: - can_link = can_link_slack_team_wrapper(str(organization.uuid), slack_team_id, settings.ONCALL_BACKEND_REGION) - if settings.FEATURE_MULTIREGION_ENABLED and not can_link: + if settings.FEATURE_MULTIREGION_ENABLED and not settings.UNIFIED_SLACK_APP_ENABLED: + can_link = can_link_slack_team(str(organization.uuid), slack_team_id, settings.ONCALL_BACKEND_REGION) + if not can_link: raise InstallMultiRegionSlackException - - slack_team_identity, is_slack_team_identity_created = SlackTeamIdentity.objects.get_or_create( - slack_id=slack_team_id, - ) - # update slack oauth fields by data from response - slack_team_identity.update_oauth_fields(user, organization, response) - if settings.FEATURE_MULTIREGION_ENABLED: - link_slack_team_wrapper(str(organization.uuid), slack_team_id) - populate_slack_channels_for_team.apply_async((slack_team_identity.pk,)) - user.slack_user_identity.update_profile_info() - # todo slack: do we need update info for all existing slack users in slack team? - # populate_slack_user_identities.apply_async((organization.pk,)) - populate_slack_usergroups_for_team.apply_async((slack_team_identity.pk,), countdown=10) + try: + install_slack_integration(organization, user, response) + except SlackInstallationExc: + 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): diff --git a/engine/apps/telegram/client.py b/engine/apps/telegram/client.py index 81180c9f7b..e469e5e9fb 100644 --- a/engine/apps/telegram/client.py +++ b/engine/apps/telegram/client.py @@ -39,14 +39,13 @@ def is_chat_member(self, chat_id: Union[int, str]) -> bool: return False def register_webhook(self, webhook_url: Optional[str] = None) -> None: - # Hack to test chatops-proxy v3, remove once v3 is release. - if settings.CHATOPS_V3: + if settings.IS_OPEN_SOURCE: webhook_url = webhook_url or create_engine_url( - "api/v3/webhook/telegram/", override_base=live_settings.TELEGRAM_WEBHOOK_HOST + "/telegram/", override_base=live_settings.TELEGRAM_WEBHOOK_HOST ) else: webhook_url = webhook_url or create_engine_url( - "/telegram/", override_base=live_settings.TELEGRAM_WEBHOOK_HOST + "api/v3/webhook/telegram/", override_base=live_settings.TELEGRAM_WEBHOOK_HOST ) # avoid unnecessary set_webhook calls to make sure Telegram rate limits are not exceeded webhook_info = self.api_client.get_webhook_info() diff --git a/engine/apps/user_management/models/organization.py b/engine/apps/user_management/models/organization.py index 8564a19527..e82b8aceab 100644 --- a/engine/apps/user_management/models/organization.py +++ b/engine/apps/user_management/models/organization.py @@ -11,14 +11,10 @@ from mirage import fields as mirage_fields from apps.alerts.models import MaintainableObject +from apps.chatops_proxy.utils import register_oncall_tenant, unlink_slack_team, unregister_oncall_tenant from apps.user_management.subscription_strategy import FreePublicBetaSubscriptionStrategy from apps.user_management.types import AlertGroupTableColumn from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log -from common.oncall_gateway import ( - register_oncall_tenant_wrapper, - unlink_slack_team_wrapper, - unregister_oncall_tenant_wrapper, -) from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length if typing.TYPE_CHECKING: @@ -65,7 +61,7 @@ class OrganizationQuerySet(models.QuerySet): def create(self, **kwargs): instance = super().create(**kwargs) if settings.FEATURE_MULTIREGION_ENABLED: - register_oncall_tenant_wrapper(str(instance.uuid), settings.ONCALL_BACKEND_REGION) + register_oncall_tenant(str(instance.uuid), settings.ONCALL_BACKEND_REGION, instance.stack_id) return instance def delete(self): @@ -108,9 +104,9 @@ def __init__(self, *args, **kwargs): def delete(self): if settings.FEATURE_MULTIREGION_ENABLED: - unregister_oncall_tenant_wrapper(str(self.uuid), settings.ONCALL_BACKEND_REGION) - if self.slack_team_identity: - unlink_slack_team_wrapper(str(self.uuid), self.slack_team_identity.slack_id) + unregister_oncall_tenant(str(self.uuid), settings.ONCALL_BACKEND_REGION) + if self.slack_team_identity and not settings.UNIFIED_SLACK_APP_ENABLED: + unlink_slack_team(str(self.uuid), self.slack_team_identity.slack_id) self.deleted_at = timezone.now() self.save(update_fields=["deleted_at"]) diff --git a/engine/common/constants/slack_auth.py b/engine/common/constants/slack_auth.py index 12a8b319c2..b693711350 100644 --- a/engine/common/constants/slack_auth.py +++ b/engine/common/constants/slack_auth.py @@ -3,3 +3,19 @@ SLACK_AUTH_WRONG_WORKSPACE_ERROR = "wrong_workspace" SLACK_AUTH_SLACK_USER_ALREADY_CONNECTED_ERROR = "user_already_connected" SLACK_AUTH_FAILED = "auth_failed" + + +# Example of a slack oauth response to be used in tests. +# It contains NO actual tokens, got it from slack docs. +# https://api.slack.com/authentication/oauth-v2 +SLACK_OAUTH_ACCESS_RESPONSE = { + "ok": True, + "access_token": "xoxb-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy", + "token_type": "bot", + "scope": "commands,incoming-webhook", + "bot_user_id": "U0KRQLJ9H", + "app_id": "A0KRD7HC3", + "team": {"name": "Slack Softball Team", "id": "T9TK3CUKW"}, + "enterprise": {"name": "slack-sports", "id": "E12345678"}, + "authed_user": {"id": "U1234", "scope": "chat:write", "access_token": "xoxp-1234", "token_type": "user"}, +} diff --git a/engine/common/oncall_gateway/__init__.py b/engine/common/oncall_gateway/__init__.py deleted file mode 100644 index 0fc5ac56cb..0000000000 --- a/engine/common/oncall_gateway/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -This package is for interaction with OnCall-Gateway, service to provide multiregional chatops. -""" - -from .utils import ( # noqa: F401 - can_link_slack_team_wrapper, - link_slack_team_wrapper, - register_oncall_tenant_wrapper, - unlink_slack_team_wrapper, - unregister_oncall_tenant_wrapper, -) diff --git a/engine/common/oncall_gateway/legacy_client.py b/engine/common/oncall_gateway/legacy_client.py deleted file mode 100644 index cf7c1c852e..0000000000 --- a/engine/common/oncall_gateway/legacy_client.py +++ /dev/null @@ -1,138 +0,0 @@ -import json -from dataclasses import dataclass -from urllib.parse import urljoin - -import requests -from django.conf import settings - - -@dataclass -class OnCallConnector: - """ - OnCallConnector represents connection between oncall org and oncall-gateway - """ - - oncall_org_id: str - backend: str - - -@dataclass -class SlackConnector: - """ - SlackConnector represents connection between slack team with installed oncall app and oncall-gateway - """ - - oncall_org_id: str - slack_team_id: str - backend: str - - -DEFAULT_TIMEOUT = 5 - - -class OnCallGatewayAPIClient: - """ - It's a legacy api client, which should be removed after chatops proxy v3 release. - """ - - def __init__(self, url: str, token: str): - self.base_url = url - self.api_base_url = urljoin(self.base_url, "api/v1/") - self.api_token = token - - # OnCall Connector - @property - def _oncall_connectors_url(self) -> str: - return urljoin(self.api_base_url, "oncall_org_connectors") - - def post_oncall_connector( - self, oncall_org_id: str, backend: str - ) -> tuple[OnCallConnector, requests.models.Response]: - d = {"oncall_org_id": oncall_org_id, "backend": backend} - response = self._post(url=self._oncall_connectors_url, json=d) - response_data = response.json() - - return OnCallConnector(oncall_org_id=response_data["oncall_org_id"], backend=response_data["backend"]), response - - def delete_oncall_connector(self, oncall_org_id: str) -> requests.models.Response: - url = urljoin(f"{self._oncall_connectors_url}/", oncall_org_id) - response = self._delete(url=url) - return response - - # Slack Connector - @property - def _slack_connectors_url(self) -> str: - return urljoin(self.api_base_url, "slack_team_connectors") - - def post_slack_connector( - self, oncall_org_id: str, slack_id: str, backend: str - ) -> tuple[SlackConnector, requests.models.Response]: - d = {"oncall_org_id": oncall_org_id, "slack_team_id": slack_id, "backend": backend} - response = self._post(url=self._slack_connectors_url, json=d) - response_data = response.json() - return ( - SlackConnector( - response_data["oncall_org_id"], - response_data["slack_team_id"], - response_data["backend"], - ), - response, - ) - - def delete_slack_connector(self, oncall_org_id: str) -> requests.models.Response: - url = urljoin(f"{self._slack_connectors_url}/", oncall_org_id) - response = self._delete(url=url) - return response - - def check_slack_installation_possible(self, oncall_org_id, backend, slack_id: str) -> requests.models.Response: - url = urljoin(f"{self._slack_connectors_url}/", "check_installation_possible") - url += f"?slack_team_id={slack_id}&oncall_org_id={oncall_org_id}&backend={backend}" - return self._get(url=url) - - def _get(self, url, params=None, **kwargs) -> requests.models.Response: - kwargs["params"] = params - response = self._call_api(method=requests.get, url=url, **kwargs) - return response - - def _post(self, url, data=None, json=None, **kwargs) -> requests.models.Response: - kwargs["data"] = data - kwargs["json"] = json - response = self._call_api(method=requests.post, url=url, **kwargs) - return response - - def _delete(self, url, **kwargs) -> requests.models.Response: - response = self._call_api(method=requests.delete, url=url, **kwargs) - return response - - def _call_api(self, method, url, **kwargs) -> requests.models.Response: - kwargs["headers"] = self._headers | kwargs.get("headers", {}) - response = method(url, **kwargs) - self._check_response(response) - return response - - @property - def _headers(self) -> dict: - return { - "User-Agent": settings.GRAFANA_COM_USER_AGENT, - "Authorization": f"Bearer {self.api_token}", - "Content-Type": "application/json", - } - - @classmethod - def _check_response(cls, response: requests.models.Response): - if response.status_code not in [200, 201, 202, 204]: - err_msg = cls._get_error_msg_from_response(response) - if 400 <= response.status_code < 500: - err_msg = "%s Client Error: %s for url: %s" % (response.status_code, err_msg, response.url) - elif 500 <= response.status_code < 600: - err_msg = "%s Server Error: %s for url: %s" % (response.status_code, err_msg, response.url) - raise requests.exceptions.HTTPError(err_msg, response=response) - - @classmethod - def _get_error_msg_from_response(cls, response: requests.models.Response) -> str: - error_msg = "" - try: - error_msg = response.json()["message"] - except (json.JSONDecodeError, KeyError): - error_msg = response.text if response.text else response.reason - return error_msg diff --git a/engine/common/oncall_gateway/tasks.py b/engine/common/oncall_gateway/tasks.py index 0d108df8f5..f0133bddc6 100644 --- a/engine/common/oncall_gateway/tasks.py +++ b/engine/common/oncall_gateway/tasks.py @@ -1,137 +1,16 @@ -import requests -from celery.utils.log import get_task_logger -from django.conf import settings - +from apps.chatops_proxy import tasks as new_tasks from common.custom_celery_tasks import shared_dedicated_queue_retry_task -from .client import ChatopsProxyAPIClient, ChatopsProxyAPIException -from .legacy_client import OnCallGatewayAPIClient - -task_logger = get_task_logger(__name__) - - -@shared_dedicated_queue_retry_task( - autoretry_for=(Exception,), - retry_backoff=True, - max_retries=100, -) -def create_oncall_connector_async(oncall_org_id, backend): - client = OnCallGatewayAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) - try: - client.post_oncall_connector(oncall_org_id, backend) - except requests.exceptions.HTTPError as http_exc: - if http_exc.response.status_code == 409: - # 409 Indicates that it's impossible to create such connector. - # More likely because it already exists. - task_logger.error( - f"Failed to create OnCallConnector oncall_org_id={oncall_org_id} backend={backend} exc={http_exc}" - ) - else: - raise http_exc - except Exception as e: - task_logger.error(f"Failed to create OnCallConnector oncall_org_id={oncall_org_id} backend={backend} exc={e}") - raise e - - -@shared_dedicated_queue_retry_task( - autoretry_for=(Exception,), - retry_backoff=True, - max_retries=100, -) -def delete_oncall_connector_async(oncall_org_id): - client = OnCallGatewayAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) - try: - client.delete_oncall_connector(oncall_org_id) - except requests.exceptions.HTTPError as http_exc: - if http_exc.response.status_code == 404: - # 404 indicates that connector was deleted already - return - else: - task_logger.error(f"Failed to delete OnCallConnector oncall_org_id={oncall_org_id} exc={http_exc}") - raise http_exc - except Exception as e: - task_logger.error(f"Failed to delete OnCallConnector oncall_org_id={oncall_org_id} exc={e}") - raise e - -@shared_dedicated_queue_retry_task( - autoretry_for=(Exception,), - retry_backoff=True, - max_retries=100, -) -def create_slack_connector_async_v2(**kwargs): - oncall_org_id = kwargs.get("oncall_org_id") - slack_team_id = kwargs.get("slack_team_id") - backend = kwargs.get("backend") - client = OnCallGatewayAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) - try: - client.post_slack_connector(oncall_org_id, slack_team_id, backend) - except requests.exceptions.HTTPError as http_exc: - if http_exc.response.status_code == 409: - # 409 Indicates that it's impossible to create such connector. - # More likely because it already exists. - task_logger.error( - f"Failed to create SlackConnector oncall_org_id={oncall_org_id} backend={backend} exc={http_exc}" - ) - else: - raise http_exc - except Exception as e: - task_logger.error(f"Failed to create SlackConnector slack_id={oncall_org_id} backend={backend} exc={e}") - raise e - - -@shared_dedicated_queue_retry_task( - autoretry_for=(Exception,), - retry_backoff=True, - max_retries=100, -) -def delete_slack_connector_async_v2(**kwargs): - oncall_org_id = kwargs.get("oncall_org_id") - client = OnCallGatewayAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) - try: - client.delete_slack_connector(oncall_org_id) - except requests.exceptions.HTTPError as http_exc: - if http_exc.response.status_code == 404: - # 404 indicates that connector was deleted already - return - else: - raise http_exc - except Exception as e: - task_logger.error(f"Failed to delete SlackConnectorV2 oncall_org_id={oncall_org_id} exc={e}") - raise e - - -# New tasks to use once chatops v3 is landed @shared_dedicated_queue_retry_task( autoretry_for=(Exception,), retry_backoff=True, max_retries=100, ) def register_oncall_tenant_async(**kwargs): - service_tenant_id = kwargs.get("service_tenant_id") - cluster_slug = kwargs.get("cluster_slug") - service_type = kwargs.get("service_type") - - client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) - try: - client.register_tenant(service_tenant_id, cluster_slug, service_type) - except ChatopsProxyAPIException as api_exc: - task_logger.error( - f'msg="Failed to register OnCall tenant: {api_exc.msg}" service_tenant_id={service_tenant_id} cluster_slug={cluster_slug}' - ) - if api_exc.status == 409: - # 409 Indicates that it's impossible to register tenant, because tenant already registered. - # Not retrying in this case, because manual conflict-resolution needed. - return - else: - # Otherwise keep retrying task - raise api_exc - except Exception as e: - # Keep retrying task for any other exceptions too - task_logger.error( - f"Failed to register OnCall tenant: {e} service_tenant_id={service_tenant_id} cluster_slug={cluster_slug}" - ) - raise e + new_tasks.register_oncall_tenant_async.apply_async( + kwargs=kwargs, + ) @shared_dedicated_queue_retry_task( @@ -140,23 +19,9 @@ def register_oncall_tenant_async(**kwargs): max_retries=100, ) def unregister_oncall_tenant_async(**kwargs): - service_tenant_id = kwargs.get("service_tenant_id") - cluster_slug = kwargs.get("cluster_slug") - service_type = kwargs.get("service_type") - - client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) - try: - client.unregister_tenant(service_tenant_id, cluster_slug, service_type) - except ChatopsProxyAPIException as api_exc: - if api_exc.status == 400: - # 400 Indicates that tenant is already deleted - return - else: - # Otherwise keep retrying task - raise api_exc - except Exception as e: - task_logger.error(f"Failed to delete OnCallTenant: {e} service_tenant_id={service_tenant_id}") - raise e + new_tasks.unregister_oncall_tenant_async.apply_async( + kwargs=kwargs, + ) @shared_dedicated_queue_retry_task( @@ -165,27 +30,9 @@ def unregister_oncall_tenant_async(**kwargs): max_retries=100, ) def link_slack_team_async(**kwargs): - service_tenant_id = kwargs.get("service_tenant_id") - service_type = kwargs.get("service_type") - slack_team_id = kwargs.get("slack_team_id") - client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) - try: - client.link_slack_team(service_tenant_id, slack_team_id, service_type) - except ChatopsProxyAPIException as api_exc: - task_logger.error( - f'msg="Failed to link slack team: {api_exc.msg}" service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}' - ) - if api_exc.status == 409: - # Impossible to register tenant, slack workspace already connected to another cluster. - # Not retrying in this case, because manual conflict-resolution needed. - return - else: - raise api_exc - except Exception as e: - task_logger.error( - f'msg="Failed to link slack team: {e}" service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}' - ) - raise e + new_tasks.link_slack_team_async.apply_async( + kwargs=kwargs, + ) @shared_dedicated_queue_retry_task( @@ -194,22 +41,6 @@ def link_slack_team_async(**kwargs): max_retries=100, ) def unlink_slack_team_async(**kwargs): - service_tenant_id = kwargs.get("service_tenant_id") - service_type = kwargs.get("service_type") - slack_team_id = kwargs.get("slack_team_id") - - client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) - try: - client.unlink_slack_team(service_tenant_id, slack_team_id, service_type) - except ChatopsProxyAPIException as api_exc: - if api_exc.status == 400: - # 400 Indicates that tenant is already deleted - return - else: - # Otherwise keep retrying task - raise api_exc - except Exception as e: - task_logger.error( - f'msg="Failed to unlink slack_team: {e}" service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}' - ) - raise e + new_tasks.unlink_slack_team_async.apply_async( + kwargs=kwargs, + ) diff --git a/engine/common/oncall_gateway/utils.py b/engine/common/oncall_gateway/utils.py deleted file mode 100644 index a11bab3fb5..0000000000 --- a/engine/common/oncall_gateway/utils.py +++ /dev/null @@ -1,196 +0,0 @@ -""" -Set of utils to handle oncall and chatops-proxy interaction. -TODO: Once chatops v3 will be released, remove legacy and wrapper functions -""" -import logging - -import requests -from django.conf import settings - -from .client import SERVICE_TYPE_ONCALL, ChatopsProxyAPIClient -from .legacy_client import OnCallGatewayAPIClient -from .tasks import ( - create_oncall_connector_async, - create_slack_connector_async_v2, - delete_oncall_connector_async, - delete_slack_connector_async_v2, - link_slack_team_async, - register_oncall_tenant_async, - unlink_slack_team_async, - unregister_oncall_tenant_async, -) - -logger = logging.getLogger(__name__) - - -# Legacy to work with chatops-proxy v1. -def create_oncall_connector(oncall_org_id: str, backend: str): - client = OnCallGatewayAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) - try: - client.post_oncall_connector(oncall_org_id, backend) - except Exception as e: - logger.error(f"create_oncall_connector: failed " f"oncall_org_id={oncall_org_id} backend={backend} exc={e}") - create_oncall_connector_async.apply_async((oncall_org_id, backend), countdown=2) - - -def delete_oncall_connector(oncall_org_id: str): - delete_oncall_connector_async.delay(oncall_org_id) - - -def check_slack_installation_possible(oncall_org_id: str, slack_id: str, backend: str) -> bool: - client = OnCallGatewayAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) - try: - response = client.check_slack_installation_possible( - oncall_org_id=oncall_org_id, slack_id=slack_id, backend=backend - ) - return response.status_code == 200 - except requests.exceptions.HTTPError as http_exc: - logger.error( - f"check_slack_installation_backend: slack installation impossible " - f"oncall_org_id={oncall_org_id} slack_id={slack_id} backend={backend} exc={http_exc}" - ) - - return False - - -def create_slack_connector(oncall_org_id: str, slack_id: str, backend: str): - client = OnCallGatewayAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) - try: - client.post_slack_connector(oncall_org_id, slack_id, backend) - except Exception as e: - logger.error( - f"create_slack_connector: failed " - f"oncall_org_id={oncall_org_id} slack_id={slack_id} backend={backend} exc={e}" - ) - create_slack_connector_async_v2.apply_async( - kwargs={"oncall_org_id": oncall_org_id, "slack_id": slack_id, "backend": backend}, countdown=2 - ) - - -def delete_slack_connector(oncall_org_id: str): - delete_slack_connector_async_v2.delay(oncall_org_id=oncall_org_id) - - -# utils to work with v3 version -def register_oncall_tenant(service_tenant_id: str, cluster_slug: str): - """ - register_oncall_tenant tries to register oncall tenant synchronously and fall back to task in case of any exceptions - to make sure that tenant is registered. - First attempt is synchronous to register tenant ASAP to not miss any chatops requests. - """ - client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) - try: - client.register_tenant(service_tenant_id, cluster_slug, SERVICE_TYPE_ONCALL) - except Exception as e: - logger.error( - f"create_oncall_connector: failed " f"oncall_org_id={service_tenant_id} backend={cluster_slug} exc={e}" - ) - register_oncall_tenant_async.apply_async( - kwargs={ - "service_tenant_id": service_tenant_id, - "cluster_slug": cluster_slug, - "service_type": SERVICE_TYPE_ONCALL, - }, - countdown=2, - ) - - -def unregister_oncall_tenant(service_tenant_id: str, cluster_slug: str): - """ - unregister_oncall_tenant unregisters tenant asynchronously. - """ - unregister_oncall_tenant_async.apply_async( - kwargs={ - "service_tenant_id": service_tenant_id, - "cluster_slug": cluster_slug, - "service_type": SERVICE_TYPE_ONCALL, - }, - countdown=2, - ) - - -def can_link_slack_team( - service_tenant_id: str, - slack_team_id: str, - cluster_slug: str, -) -> bool: - """ - can_link_slack_team checks if it's possible to link slack workspace to oncall tenant located in cluster. - All oncall tenants linked to same slack team should have same cluster. - """ - client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) - try: - response = client.can_slack_link(service_tenant_id, cluster_slug, slack_team_id, SERVICE_TYPE_ONCALL) - return response.status_code == 200 - except Exception as e: - logger.error( - f"can_link_slack_team: slack installation impossible: {e} " - f"service_tenant_id={service_tenant_id} slack_team_id={slack_team_id} cluster_slug={cluster_slug}" - ) - - return False - - -def link_slack_team(service_tenant_id: str, slack_team_id: str): - client = ChatopsProxyAPIClient(settings.ONCALL_GATEWAY_URL, settings.ONCALL_GATEWAY_API_TOKEN) - try: - client.link_slack_team(service_tenant_id, slack_team_id, SERVICE_TYPE_ONCALL) - except Exception as e: - logger.error( - f'msg="Failed to link slack team: {e}"' - f"service_tenant_id={service_tenant_id} slack_team_id={slack_team_id}" - ) - link_slack_team_async.apply_async( - kwargs={ - "service_tenant_id": service_tenant_id, - "slack_team_id": slack_team_id, - "service_type": SERVICE_TYPE_ONCALL, - }, - countdown=2, - ) - - -def unlink_slack_team(service_tenant_id: str, slack_team_id: str): - unlink_slack_team_async.apply_async( - kwargs={ - "service_tenant_id": service_tenant_id, - "slack_team_id": slack_team_id, - "service_type": SERVICE_TYPE_ONCALL, - } - ) - - -# Wrappers to choose whether legacy or v3 function should be call, depending on CHATOPS_V3 env var. -def register_oncall_tenant_wrapper(service_tenant_id: str, cluster_slug: str): - if settings.CHATOPS_V3: - register_oncall_tenant(service_tenant_id, cluster_slug) - else: - create_oncall_connector(service_tenant_id, cluster_slug) - - -def unregister_oncall_tenant_wrapper(service_tenant_id: str, cluster_slug: str): - if settings.CHATOPS_V3: - unregister_oncall_tenant(service_tenant_id, cluster_slug) - else: - delete_oncall_connector(service_tenant_id) - - -def can_link_slack_team_wrapper(service_tenant_id: str, slack_team_id, cluster_slug: str) -> bool: - if settings.CHATOPS_V3: - return can_link_slack_team(service_tenant_id, slack_team_id, cluster_slug) - else: - return check_slack_installation_possible(service_tenant_id, slack_team_id, cluster_slug) - - -def link_slack_team_wrapper(service_tenant_id: str, slack_team_id: str): - if settings.CHATOPS_V3: - link_slack_team(service_tenant_id, slack_team_id) - else: - create_slack_connector(service_tenant_id, slack_team_id, settings.ONCALL_BACKEND_REGION) - - -def unlink_slack_team_wrapper(service_tenant_id: str, slack_team_id: str): - if settings.CHATOPS_V3: - unlink_slack_team(service_tenant_id, slack_team_id) - else: - delete_slack_connector(service_tenant_id) diff --git a/engine/engine/urls.py b/engine/engine/urls.py index 5869b7c39b..ddcfb51e32 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -58,9 +58,11 @@ if settings.FEATURE_SLACK_INTEGRATION_ENABLED: urlpatterns += [ path("api/internal/v1/slack/", include("apps.slack.urls")), + path("api/v3/webhook/slack/", include("apps.slack.urls")), path("slack/", include("apps.slack.urls")), ] + if settings.IS_OPEN_SOURCE: urlpatterns += [ path("api/internal/v1/", include("apps.oss_installation.urls", namespace="oss_installation")), @@ -88,3 +90,8 @@ path("internal/schema/", SpectacularYAMLAPIView.as_view(api_version="internal/v1"), name="schema"), path("internal/schema/swagger-ui/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), ] + +if settings.UNIFIED_SLACK_APP_ENABLED: + urlpatterns += [ + path("api/chatops/", include("apps.chatops_proxy.urls")), + ] diff --git a/engine/settings/base.py b/engine/settings/base.py index ecdac1a1a2..48d3fbdda9 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -98,7 +98,9 @@ ONCALL_GATEWAY_URL = os.environ.get("ONCALL_GATEWAY_URL", "") ONCALL_GATEWAY_API_TOKEN = os.environ.get("ONCALL_GATEWAY_API_TOKEN", "") ONCALL_BACKEND_REGION = os.environ.get("ONCALL_BACKEND_REGION") -CHATOPS_V3 = getenv_boolean("CHATOPS_V3", False) +UNIFIED_SLACK_APP_ENABLED = getenv_boolean("UNIFIED_SLACK_APP_ENABLED", default=False) +# secret to verify the incoming requests from the chatops-proxy +CHATOPS_SIGNING_SECRET = os.environ.get("CHATOPS_SIGNING_SECRET", None) # Prometheus exporter metrics endpoint auth PROMETHEUS_EXPORTER_SECRET = os.environ.get("PROMETHEUS_EXPORTER_SECRET") @@ -281,6 +283,7 @@ class DatabaseTypes: "apps.phone_notifications", "drf_spectacular", "apps.google", + "apps.chatops_proxy", ] REST_FRAMEWORK = { @@ -779,7 +782,7 @@ class BrokerTypes: GRAFANA_CLOUD_AUTH_API_SYSTEM_TOKEN = os.environ.get("GRAFANA_CLOUD_AUTH_API_SYSTEM_TOKEN", None) SELF_HOSTED_SETTINGS = { - "STACK_ID": 5, + "STACK_ID": getenv_integer("SELF_HOSTED_STACK_ID", 5), "STACK_SLUG": os.environ.get("SELF_HOSTED_STACK_SLUG", "self_hosted_stack"), "ORG_ID": 100, "ORG_SLUG": os.environ.get("SELF_HOSTED_ORG_SLUG", "self_hosted_org"), diff --git a/engine/settings/celery_task_routes.py b/engine/settings/celery_task_routes.py index 7b154feac3..c4ffa5ae71 100644 --- a/engine/settings/celery_task_routes.py +++ b/engine/settings/celery_task_routes.py @@ -87,6 +87,10 @@ "common.oncall_gateway.tasks.unlink_slack_team_async": {"queue": "default"}, "common.oncall_gateway.tasks.register_oncall_tenant_async": {"queue": "default"}, "common.oncall_gateway.tasks.unregister_oncall_tenant_async": {"queue": "default"}, + "apps.chatops_proxy.tasks.link_slack_team_async": {"queue": "default"}, + "apps.chatops_proxy.tasks.unlink_slack_team_async": {"queue": "default"}, + "apps.chatops_proxy.tasks.register_oncall_tenant_async": {"queue": "default"}, + "apps.chatops_proxy.tasks.unregister_oncall_tenant_async": {"queue": "default"}, # CRITICAL "apps.alerts.tasks.acknowledge_reminder.acknowledge_reminder_task": {"queue": "critical"}, "apps.alerts.tasks.acknowledge_reminder.unacknowledge_timeout_task": {"queue": "critical"}, diff --git a/grafana-plugin/src/models/slack/slack.ts b/grafana-plugin/src/models/slack/slack.ts index 09f0384e46..fb665b9767 100644 --- a/grafana-plugin/src/models/slack/slack.ts +++ b/grafana-plugin/src/models/slack/slack.ts @@ -2,8 +2,10 @@ import { action, observable, makeObservable, runInAction } from 'mobx'; import { BaseStore } from 'models/base_store'; import { SlackChannel } from 'models/slack_channel/slack_channel.types'; -import { makeRequest } from 'network/network'; +import { makeRequest, makeRequestRaw } from 'network/network'; import { RootStore } from 'state/rootStore'; +import { GENERIC_ERROR } from 'utils/consts'; +import { openErrorNotification } from 'utils/utils'; import { SlackSettings } from './slack.types'; @@ -81,8 +83,19 @@ export class SlackStore extends BaseStore { } async installSlackIntegration() { - const url_for_redirect = await makeRequest('/login/slack-install-free/', {}); - window.location = url_for_redirect; + try { + const response = await makeRequestRaw('/login/slack-install-free/', {}); + + 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); + } + } } async removeSlackIntegration() { diff --git a/grafana-plugin/src/network/network.ts b/grafana-plugin/src/network/network.ts index 9b73933d8d..2604a44939 100644 --- a/grafana-plugin/src/network/network.ts +++ b/grafana-plugin/src/network/network.ts @@ -39,7 +39,7 @@ interface RequestConfig { export const isNetworkError = axios.isAxiosError; -export const makeRequest = async (path: string, config: RequestConfig) => { +export const makeRequestRaw = async (path: string, config: RequestConfig) => { const { method = 'GET', params, data, validateStatus, headers } = config; const url = `${API_PROXY_PREFIX}${API_PATH_PREFIX}${path}`; @@ -56,7 +56,7 @@ export const makeRequest = async (path: string, config: RequestConfig) }); FaroHelper.pushAxiosNetworkResponseEvent({ name: 'Request succeeded', res: response }); - return response.data as RT; + return response; } catch (ex) { const error = ex as AxiosError; FaroHelper.pushAxiosNetworkResponseEvent({ name: 'Request failed', res: error.response }); @@ -64,3 +64,12 @@ export const makeRequest = async (path: string, config: RequestConfig) throw ex; } }; + +export const makeRequest = async (path: string, config: RequestConfig) => { + try { + const result = await makeRequestRaw(path, config); + return result.data as RT; + } catch (ex) { + throw ex; + } +};