From 5771cda5e651614ccffdc6162ddd9fa6f59b273f Mon Sep 17 00:00:00 2001 From: Ryan Skonnord Date: Mon, 26 Aug 2024 14:01:56 -0700 Subject: [PATCH] ref(integration): Extract base class for identity linking (#74744) Extract common code for the "link identity" and "unlink identity" operations on messaging integrations. --- pyproject.toml | 1 - .../integrations/discord/views/__init__.py | 2 - .../discord/views/link_identity.py | 64 +--- .../integrations/discord/views/linkage.py | 34 ++ .../discord/views/unlink_identity.py | 70 +--- src/sentry/integrations/messaging.py | 313 ++++++++++++++++++ .../integrations/msteams/link_identity.py | 79 ++--- src/sentry/integrations/msteams/linkage.py | 34 ++ .../integrations/msteams/unlink_identity.py | 69 ++-- .../integrations/slack/utils/notifications.py | 54 +-- .../integrations/slack/views/__init__.py | 2 +- .../integrations/slack/views/link_identity.py | 155 ++------- .../integrations/slack/views/linkage.py | 57 ++++ src/sentry/integrations/slack/views/types.py | 14 - .../slack/views/unlink_identity.py | 131 ++------ .../integrations/slack/webhooks/action.py | 6 +- .../integrations/slack/webhooks/base.py | 6 +- .../sentry/integrations/generic-error.html | 14 + .../sentry/integrations/discord/test_views.py | 4 +- .../msteams/test_link_identity.py | 4 +- .../integrations/slack/test_link_identity.py | 4 +- .../integrations/slack/test_link_team.py | 2 +- 22 files changed, 621 insertions(+), 498 deletions(-) create mode 100644 src/sentry/integrations/discord/views/linkage.py create mode 100644 src/sentry/integrations/msteams/linkage.py create mode 100644 src/sentry/integrations/slack/views/linkage.py create mode 100644 src/sentry/templates/sentry/integrations/generic-error.html diff --git a/pyproject.toml b/pyproject.toml index 7950a94553518..d6c100845bf4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -266,7 +266,6 @@ module = [ "sentry.integrations.msteams.actions.form", "sentry.integrations.msteams.client", "sentry.integrations.msteams.integration", - "sentry.integrations.msteams.link_identity", "sentry.integrations.msteams.notifications", "sentry.integrations.msteams.webhook", "sentry.integrations.notifications", diff --git a/src/sentry/integrations/discord/views/__init__.py b/src/sentry/integrations/discord/views/__init__.py index bd536d345d157..e69de29bb2d1d 100644 --- a/src/sentry/integrations/discord/views/__init__.py +++ b/src/sentry/integrations/discord/views/__init__.py @@ -1,2 +0,0 @@ -from .link_identity import * # noqa: F401,F403 -from .unlink_identity import * # noqa: F401,F403 diff --git a/src/sentry/integrations/discord/views/link_identity.py b/src/sentry/integrations/discord/views/link_identity.py index 3bb1bdf4a2b18..b05d21614340f 100644 --- a/src/sentry/integrations/discord/views/link_identity.py +++ b/src/sentry/integrations/discord/views/link_identity.py @@ -1,19 +1,14 @@ -from django.core.signing import BadSignature, SignatureExpired -from django.http import HttpRequest, HttpResponse +from collections.abc import Mapping +from typing import Any + from django.urls import reverse -from django.utils.decorators import method_decorator -from django.views.decorators.cache import never_cache -from sentry import analytics +from sentry.integrations.discord.views.linkage import DiscordIdentityLinkageView +from sentry.integrations.messaging import LinkIdentityView +from sentry.integrations.models.integration import Integration from sentry.integrations.services.integration.model import RpcIntegration -from sentry.integrations.types import ExternalProviders -from sentry.integrations.utils.identities import get_identity_or_404 -from sentry.types.actor import ActorType -from sentry.users.models.identity import Identity from sentry.utils.http import absolute_uri -from sentry.utils.signing import sign, unsign -from sentry.web.frontend.base import BaseView, control_silo_view -from sentry.web.helpers import render_to_response +from sentry.utils.signing import sign from .constants import SALT @@ -27,41 +22,12 @@ def build_linking_url(integration: RpcIntegration, discord_id: str) -> str: return absolute_uri(reverse(endpoint, kwargs={"signed_params": sign(salt=SALT, **kwargs)})) -@control_silo_view -class DiscordLinkIdentityView(BaseView): - """ - Django view for linking user to Discord account. - """ - - @method_decorator(never_cache) - def handle(self, request: HttpRequest, signed_params: str) -> HttpResponse: - try: - params = unsign(signed_params, salt=SALT) - except (SignatureExpired, BadSignature): - return render_to_response("sentry/integrations/discord/expired-link.html") - - organization, integration, idp = get_identity_or_404( - ExternalProviders.DISCORD, - request.user, - integration_id=params["integration_id"], - ) - - if request.method != "POST": - return render_to_response( - "sentry/auth-link-identity.html", - request=request, - context={"organization": organization, "provider": integration.get_provider()}, - ) - - Identity.objects.link_identity(user=request.user, idp=idp, external_id=params["discord_id"]) # type: ignore[arg-type] +class DiscordLinkIdentityView(DiscordIdentityLinkageView, LinkIdentityView): + def get_success_template_and_context( + self, params: Mapping[str, Any], integration: Integration | None + ) -> tuple[str, dict[str, Any]]: + return "sentry/integrations/discord/linked.html", {} - analytics.record( - "integrations.discord.identity_linked", - provider="discord", - actor_id=request.user.id, - actor_type=ActorType.USER, - ) - return render_to_response( - "sentry/integrations/discord/linked.html", - request=request, - ) + @property + def analytics_operation_key(self) -> str | None: + return "identity_linked" diff --git a/src/sentry/integrations/discord/views/linkage.py b/src/sentry/integrations/discord/views/linkage.py new file mode 100644 index 0000000000000..1f08a2751cd54 --- /dev/null +++ b/src/sentry/integrations/discord/views/linkage.py @@ -0,0 +1,34 @@ +from abc import ABC + +from sentry.integrations.messaging import IdentityLinkageView, MessagingIntegrationSpec +from sentry.integrations.types import ExternalProviderEnum, ExternalProviders + +from .constants import SALT + + +class DiscordIdentityLinkageView(IdentityLinkageView, ABC): + @property + def parent_messaging_spec(self) -> MessagingIntegrationSpec: + from sentry.integrations.discord.spec import DiscordMessagingSpec + + return DiscordMessagingSpec() + + @property + def provider(self) -> ExternalProviders: + return ExternalProviders.DISCORD + + @property + def external_provider_enum(self) -> ExternalProviderEnum: + return ExternalProviderEnum.DISCORD + + @property + def salt(self) -> str: + return SALT + + @property + def external_id_parameter(self) -> str: + return "discord_id" + + @property + def expired_link_template(self) -> str: + return "sentry/integrations/discord/expired-link.html" diff --git a/src/sentry/integrations/discord/views/unlink_identity.py b/src/sentry/integrations/discord/views/unlink_identity.py index 00dc3c375ccfa..8daa6fd941656 100644 --- a/src/sentry/integrations/discord/views/unlink_identity.py +++ b/src/sentry/integrations/discord/views/unlink_identity.py @@ -1,22 +1,15 @@ -from django.core.signing import BadSignature, SignatureExpired -from django.db import IntegrityError -from django.http import Http404, HttpRequest, HttpResponse +from collections.abc import Mapping +from typing import Any + from django.urls import reverse -from django.utils.decorators import method_decorator -from django.views.decorators.cache import never_cache -from sentry import analytics +from sentry.integrations.discord.views.linkage import DiscordIdentityLinkageView +from sentry.integrations.messaging import UnlinkIdentityView +from sentry.integrations.models.integration import Integration from sentry.integrations.services.integration.model import RpcIntegration -from sentry.integrations.types import ExternalProviders -from sentry.integrations.utils.identities import get_identity_or_404 -from sentry.types.actor import ActorType -from sentry.users.models.identity import Identity from sentry.utils.http import absolute_uri -from sentry.utils.signing import sign, unsign -from sentry.web.frontend.base import BaseView, control_silo_view -from sentry.web.helpers import render_to_response +from sentry.utils.signing import sign -from ..utils import logger from .constants import SALT @@ -29,45 +22,12 @@ def build_unlinking_url(integration: RpcIntegration, discord_id: str) -> str: return absolute_uri(reverse(endpoint, kwargs={"signed_params": sign(salt=SALT, **kwargs)})) -@control_silo_view -class DiscordUnlinkIdentityView(BaseView): - """ - Django view for unlinking user from Discord account. - """ - - @method_decorator(never_cache) - def handle(self, request: HttpRequest, signed_params: str) -> HttpResponse: - try: - params = unsign(signed_params, salt=SALT) - except (SignatureExpired, BadSignature): - return render_to_response("sentry/integrations/discord/expired-link.html") - - organization, integration, idp = get_identity_or_404( - ExternalProviders.DISCORD, - request.user, - integration_id=params["integration_id"], - ) - - if request.method != "POST": - return render_to_response( - "sentry/auth-unlink-identity.html", - request=request, - context={"organization": organization, "provider": integration.get_provider()}, - ) - - try: - Identity.objects.filter(idp_id=idp.id, external_id=params["discord_id"]).delete() - except IntegrityError: - logger.exception("discord.unlink.integrity-error") - raise Http404 +class DiscordUnlinkIdentityView(DiscordIdentityLinkageView, UnlinkIdentityView): + def get_success_template_and_context( + self, params: Mapping[str, Any], integration: Integration | None + ) -> tuple[str, dict[str, Any]]: + return "sentry/integrations/discord/unlinked.html", {} - analytics.record( - "integrations.discord.identity_unlinked", - provider="discord", - actor_id=request.user.id, - actor_type=ActorType.USER, - ) - return render_to_response( - "sentry/integrations/discord/unlinked.html", - request=request, - ) + @property + def analytics_operation_key(self) -> str | None: + return "identity_unlinked" diff --git a/src/sentry/integrations/messaging.py b/src/sentry/integrations/messaging.py index c5f2ca4268899..6806bb1bd1d71 100644 --- a/src/sentry/integrations/messaging.py +++ b/src/sentry/integrations/messaging.py @@ -1,19 +1,45 @@ +import logging from abc import ABC, abstractmethod +from collections.abc import Mapping from dataclasses import dataclass +from typing import Any +from django.contrib.auth.models import AnonymousUser +from django.core.signing import BadSignature, SignatureExpired +from django.db import IntegrityError +from django.http import Http404, HttpRequest, HttpResponse +from django.http.response import HttpResponseBase from django.urls import re_path from django.urls.resolvers import URLPattern +from django.utils.decorators import method_decorator +from django.views.decorators.cache import never_cache from django.views.generic import View +from rest_framework.request import Request from sentry import analytics from sentry.incidents.action_handlers import ActionHandler, DefaultActionHandler from sentry.incidents.models.alert_rule import ActionHandlerFactory, AlertRuleTriggerAction from sentry.incidents.models.incident import Incident, IncidentStatus from sentry.integrations.base import IntegrationProvider +from sentry.integrations.models.integration import Integration +from sentry.integrations.types import ExternalProviderEnum, ExternalProviders +from sentry.integrations.utils.identities import get_identity_or_404 +from sentry.models.identity import Identity, IdentityProvider from sentry.models.notificationaction import ActionService, ActionTarget from sentry.models.project import Project +from sentry.models.user import User +from sentry.notifications.notificationcontroller import NotificationController +from sentry.notifications.notifications.integration_nudge import IntegrationNudgeNotification +from sentry.organizations.services.organization import RpcOrganization from sentry.rules import rules from sentry.rules.actions import IntegrationEventAction +from sentry.types.actor import ActorType +from sentry.utils import metrics +from sentry.utils.signing import unsign +from sentry.web.frontend.base import BaseView, control_silo_view +from sentry.web.helpers import render_to_response + +logger = logging.getLogger("sentry.integrations.messaging") @dataclass(frozen=True) @@ -199,3 +225,290 @@ def build_handler( self, action: AlertRuleTriggerAction, incident: Incident, project: Project ) -> ActionHandler: return MessagingActionHandler(action, incident, project, self.spec) + + +@control_silo_view +class IdentityLinkageView(BaseView, ABC): + """ "Linkage" includes both linking and unlinking.""" + + @property + @abstractmethod + def parent_messaging_spec(self) -> MessagingIntegrationSpec: + raise NotImplementedError + + @property + @abstractmethod + def provider(self) -> ExternalProviders: + raise NotImplementedError + + @property + @abstractmethod + def external_provider_enum(self) -> ExternalProviderEnum: + raise NotImplementedError + + @property + def provider_slug(self) -> str: + return self.parent_messaging_spec.provider_slug + + @property + @abstractmethod + def salt(self) -> str: + raise NotImplementedError + + @property + @abstractmethod + def external_id_parameter(self) -> str: + raise NotImplementedError + + # TODO: Replace thw two template properties below with base templates for all + # integrations to use. Add service-specific parts to the context as needed. + + @property + @abstractmethod + def confirmation_template(self) -> str: + """Path to the HTML template to render for a non-POST request.""" + raise NotImplementedError + + @property + @abstractmethod + def expired_link_template(self) -> str: + """Path to the HTML template to show when a link is expired.""" + raise NotImplementedError + + @abstractmethod + def get_success_template_and_context( + self, params: Mapping[str, Any], integration: Integration | None + ) -> tuple[str, dict[str, Any]]: + """HTML content to show when the operation has been completed.""" + raise NotImplementedError + + @property + @abstractmethod + def metrics_operation_key(self) -> str: + raise NotImplementedError + + def capture_metric(self, event_tag: str, tags: dict[str, str] | None = None) -> str: + event = ".".join( + ("sentry.integrations", self.provider_slug, self.metrics_operation_key, event_tag) + ) + metrics.incr(event, tags=(tags or {}), sample_rate=1.0) + return event + + @property + def analytics_operation_key(self) -> str | None: + """Operation description to use in analytics. Return None to skip.""" + return None + + def record_analytic(self, actor_id: int) -> None: + if self.analytics_operation_key is None: + # This preserves legacy differences between messaging integrations, + # in that some record analytics and some don't. + # TODO: Make consistent across all messaging integrations. + return + + event = ".".join(("integrations", self.provider_slug, self.analytics_operation_key)) + analytics.record( + event, provider=self.provider_slug, actor_id=actor_id, actor_type=ActorType.USER + ) + + @staticmethod + def _render_error_page( + request: Request | HttpRequest, status: int, body_text: str + ) -> HttpResponse: + template = "sentry/integrations/generic-error.html" + context = {"body_text": body_text} + return render_to_response(template, request=request, status=status, context=context) + + @method_decorator(never_cache) + def dispatch(self, request: HttpRequest, signed_params: str) -> HttpResponseBase: + try: + params = unsign(signed_params, salt=self.salt) + except (SignatureExpired, BadSignature) as e: + logger.warning("dispatch.signature_error", exc_info=e) + self.capture_metric("failure", tags={"error": str(e)}) + return render_to_response( + self.expired_link_template, + request=request, + ) + + organization: RpcOrganization | None = None + integration: Integration | None = None + idp: IdentityProvider | None = None + integration_id = params.get("integration_id") + try: + if integration_id: + organization, integration, idp = get_identity_or_404( + self.provider, request.user, integration_id=integration_id + ) + except Http404: + logger.exception("get_identity_error", extra={"integration_id": integration_id}) + self.capture_metric("failure.get_identity") + return self._render_error_page( + request, + status=404, + body_text="HTTP 404: Could not find the identity.", + ) + + logger.info( + "get_identity_success", + extra={"integration_id": integration_id, "provider": self.provider_slug}, + ) + self.capture_metric("success.get_identity") + params.update({"organization": organization, "integration": integration, "idp": idp}) + + dispatch_kwargs = dict( + organization=organization, integration=integration, idp=idp, params=params + ) + dispatch_kwargs = {k: v for (k, v) in dispatch_kwargs.items() if v is not None} + return super().dispatch(request, **dispatch_kwargs) + + def get(self, request: Request, *args, **kwargs) -> HttpResponse: + params = kwargs["params"] + context = {"organization": params["organization"]} + integration = params.get("integration") + if integration: + context["provider"] = integration.get_provider() + return render_to_response(self.confirmation_template, request=request, context=context) + + def post(self, request: Request, *args: Any, **kwargs: Any) -> HttpResponse: + if isinstance(request.user, AnonymousUser): + return HttpResponse(status=401) + + try: + organization: RpcOrganization | None = kwargs.get("organization") + integration: Integration | None = kwargs.get("integration") + idp: IdentityProvider | None = kwargs.get("idp") + + params_dict: Mapping[str, Any] = kwargs["params"] + external_id: str = params_dict[self.external_id_parameter] + except KeyError as e: + event = self.capture_metric("failure.post.missing_params", tags={"error": str(e)}) + logger.exception(event) + return self._render_error_page( + request, + status=400, + body_text="HTTP 400: Missing required parameters.", + ) + + exc_response = self.persist_identity(idp, external_id, request) + if exc_response is not None: + return exc_response + + self.notify_on_success(external_id, params_dict, integration) + self.capture_metric("success.post") + self.record_analytic(request.user.id) + + if organization is not None: + self._send_nudge_notification(organization, request) + + success_template, success_context = self.get_success_template_and_context( + params_dict, integration + ) + return render_to_response(success_template, request=request, context=success_context) + + def _send_nudge_notification(self, organization: RpcOrganization, request: Request): + # TODO: Delete this if no longer needed + + user: User = request.user # type: ignore[assignment] + controller = NotificationController( + recipients=[user], + organization_id=organization.id, + provider=self.external_provider_enum, + ) + has_provider_settings = controller.user_has_any_provider_settings( + self.external_provider_enum + ) + if not has_provider_settings: + # Expects Organization, not RpcOrganization. Suspect this to be a bug + # that isn't being hit because these notifications aren't being sent. + nudge_notification = IntegrationNudgeNotification(organization, user, self.provider) # type: ignore[arg-type] + + nudge_notification.send() + + @abstractmethod + def persist_identity( + self, idp: IdentityProvider | None, external_id: str, request: HttpRequest + ) -> HttpResponse | None: + """Execute the operation on the Identity table. + + Return a response to trigger an early halt under exceptional conditions. + Return None if everything is normal. + """ + raise NotImplementedError + + def notify_on_success( + self, external_id: str, params: Mapping[str, Any], integration: Integration | None + ) -> None: + """On success, notify the user through the messaging client. + + No-op by default. + + :param external_id: the `Identity.external_id` value (the messaging service's ID) + :param params: raw params from the incoming request + :param integration: affected Integration entity, if any + """ + + +class LinkIdentityView(IdentityLinkageView, ABC): + @property + def confirmation_template(self) -> str: + return "sentry/auth-link-identity.html" + + @property + def metrics_operation_key(self) -> str: + return "link_identity_view" + + def persist_identity( + self, idp: IdentityProvider | None, external_id: str, request: HttpRequest + ) -> None: + if idp is None: + raise ValueError('idp is required for linking (params must include "integration_id")') + + user = request.user + if isinstance(user, AnonymousUser): + raise TypeError("Cannot link identity without a logged-in user") + + try: + Identity.objects.link_identity(user=user, idp=idp, external_id=external_id) + except IntegrityError: + event = self.capture_metric("failure.integrity_error") + logger.exception(event) + raise Http404 + + +class UnlinkIdentityView(IdentityLinkageView, ABC): + @property + def confirmation_template(self) -> str: + return "sentry/auth-unlink-identity.html" + + @property + def no_identity_template(self) -> str | None: + """Optional page to show if identities were not found.""" + return None + + @property + def filter_by_user_id(self) -> bool: + # TODO: Is it okay to just make this True everywhere? + return False + + @property + def metrics_operation_key(self) -> str: + return "unlink_identity_view" + + def persist_identity( + self, idp: IdentityProvider | None, external_id: str, request: HttpRequest + ) -> HttpResponse | None: + try: + identities = Identity.objects.filter(external_id=external_id) + if idp is not None: + identities = identities.filter(idp=idp) + if self.filter_by_user_id: + identities = identities.filter(user_id=request.user.id) + if self.no_identity_template and not identities: + return render_to_response(self.no_identity_template, request=request, context={}) + identities.delete() + except IntegrityError: + tag = f"{self.provider_slug}.unlink.integrity-error" + logger.exception(tag) + raise Http404 + return None diff --git a/src/sentry/integrations/msteams/link_identity.py b/src/sentry/integrations/msteams/link_identity.py index b5fbfce02fee4..13dfabc8eb8cc 100644 --- a/src/sentry/integrations/msteams/link_identity.py +++ b/src/sentry/integrations/msteams/link_identity.py @@ -1,24 +1,28 @@ -from django.core.signing import BadSignature, SignatureExpired -from django.http import HttpResponse +from collections.abc import Mapping +from typing import Any + from django.urls import reverse -from django.utils.decorators import method_decorator -from django.views.decorators.cache import never_cache -from rest_framework.request import Request -from sentry.integrations.types import ExternalProviders -from sentry.integrations.utils import get_identity_or_404 -from sentry.users.models.identity import Identity +from sentry.integrations.messaging import LinkIdentityView +from sentry.integrations.models.integration import Integration +from sentry.integrations.msteams.linkage import MsTeamsIdentityLinkageView +from sentry.models.organization import Organization from sentry.utils.http import absolute_uri -from sentry.utils.signing import sign, unsign -from sentry.web.frontend.base import BaseView, control_silo_view -from sentry.web.helpers import render_to_response +from sentry.utils.signing import sign from .card_builder.identity import build_linked_card from .client import MsTeamsClient -from .constants import SALT -def build_linking_url(integration, organization, teams_user_id, team_id, tenant_id): +def build_linking_url( + integration: Integration, + organization: Organization, + teams_user_id: str, + team_id: str, + tenant_id: str, +) -> str: + from sentry.integrations.msteams.constants import SALT + signed_params = sign( salt=SALT, integration_id=integration.id, @@ -33,45 +37,20 @@ def build_linking_url(integration, organization, teams_user_id, team_id, tenant_ ) -@control_silo_view -class MsTeamsLinkIdentityView(BaseView): - @method_decorator(never_cache) - def handle(self, request: Request, signed_params) -> HttpResponse: - try: - params = unsign(signed_params, salt=SALT) - except (SignatureExpired, BadSignature): - return render_to_response( - "sentry/integrations/msteams/expired-link.html", - request=request, - ) - - organization, integration, idp = get_identity_or_404( - ExternalProviders.MSTEAMS, - request.user, - integration_id=params["integration_id"], - organization_id=params["organization_id"], - ) +class MsTeamsLinkIdentityView(MsTeamsIdentityLinkageView, LinkIdentityView): + def get_success_template_and_context( + self, params: Mapping[str, Any], integration: Integration | None + ) -> tuple[str, dict[str, Any]]: + return "sentry/integrations/msteams/linked.html", {} - if request.method != "POST": - return render_to_response( - "sentry/auth-link-identity.html", - request=request, - context={"organization": organization, "provider": integration.get_provider()}, + def notify_on_success( + self, external_id: str, params: Mapping[str, Any], integration: Integration | None + ) -> None: + if integration is None: + raise ValueError( + 'Integration is required for linking (params must include "integration_id")' ) - - Identity.objects.link_identity( - user=request.user, idp=idp, external_id=params["teams_user_id"] - ) - card = build_linked_card() client = MsTeamsClient(integration) - user_conversation_id = client.get_user_conversation_id( - params["teams_user_id"], params["tenant_id"] - ) + user_conversation_id = client.get_user_conversation_id(external_id, params["tenant_id"]) client.send_card(user_conversation_id, card) - - return render_to_response( - "sentry/integrations/msteams/linked.html", - request=request, - context={"team_id": params["team_id"]}, - ) diff --git a/src/sentry/integrations/msteams/linkage.py b/src/sentry/integrations/msteams/linkage.py new file mode 100644 index 0000000000000..1a479babf1217 --- /dev/null +++ b/src/sentry/integrations/msteams/linkage.py @@ -0,0 +1,34 @@ +from abc import ABC + +from sentry.integrations.messaging import IdentityLinkageView, MessagingIntegrationSpec +from sentry.integrations.types import ExternalProviderEnum, ExternalProviders + + +class MsTeamsIdentityLinkageView(IdentityLinkageView, ABC): + @property + def parent_messaging_spec(self) -> MessagingIntegrationSpec: + from sentry.integrations.msteams.spec import MsTeamsMessagingSpec + + return MsTeamsMessagingSpec() + + @property + def provider(self) -> ExternalProviders: + return ExternalProviders.MSTEAMS + + @property + def external_provider_enum(self) -> ExternalProviderEnum: + return ExternalProviderEnum.MSTEAMS + + @property + def salt(self) -> str: + from .constants import SALT + + return SALT + + @property + def external_id_parameter(self) -> str: + return "teams_user_id" + + @property + def expired_link_template(self) -> str: + return "sentry/integrations/msteams/expired-link.html" diff --git a/src/sentry/integrations/msteams/unlink_identity.py b/src/sentry/integrations/msteams/unlink_identity.py index b70ced69f9a9a..bd24ea1097a79 100644 --- a/src/sentry/integrations/msteams/unlink_identity.py +++ b/src/sentry/integrations/msteams/unlink_identity.py @@ -1,14 +1,13 @@ -from django.core.signing import BadSignature, SignatureExpired -from django.http import HttpRequest, HttpResponse +from collections.abc import Mapping +from typing import Any + from django.urls import reverse -from django.utils.decorators import method_decorator -from django.views.decorators.cache import never_cache -from sentry.users.models.identity import Identity +from sentry.integrations.messaging import UnlinkIdentityView +from sentry.integrations.models.integration import Integration +from sentry.integrations.msteams.linkage import MsTeamsIdentityLinkageView from sentry.utils.http import absolute_uri -from sentry.utils.signing import sign, unsign -from sentry.web.frontend.base import BaseView, control_silo_view -from sentry.web.helpers import render_to_response +from sentry.utils.signing import sign from .card_builder.identity import build_unlinked_card from .constants import SALT @@ -30,45 +29,27 @@ def build_unlinking_url(conversation_id, service_url, teams_user_id): ) -@control_silo_view -class MsTeamsUnlinkIdentityView(BaseView): - @method_decorator(never_cache) - def handle(self, request: HttpRequest, signed_params) -> HttpResponse: - try: - params = unsign(signed_params, salt=SALT) - except (SignatureExpired, BadSignature): - return render_to_response( - "sentry/integrations/msteams/expired-link.html", - request=request, - ) +class MsTeamsUnlinkIdentityView(MsTeamsIdentityLinkageView, UnlinkIdentityView): + def get_success_template_and_context( + self, params: Mapping[str, Any], integration: Integration | None + ) -> tuple[str, dict[str, Any]]: + return "sentry/integrations/msteams/unlinked.html", {} - if request.method != "POST": - return render_to_response( - "sentry/integrations/msteams/unlink-identity.html", - request=request, - context={}, - ) + @property + def confirmation_template(self) -> str: + return "sentry/integrations/msteams/unlink-identity.html" - # find the identities linked to this team user and sentry user - identity_list = Identity.objects.filter( - external_id=params["teams_user_id"], user_id=request.user.id - ) - # if no identities, tell the user that - if not identity_list: - return render_to_response( - "sentry/integrations/msteams/no-identity.html", - request=request, - context={}, - ) + @property + def no_identity_template(self) -> str | None: + return "sentry/integrations/msteams/no-identity.html" + + @property + def filter_by_user_id(self) -> bool: + return True - # otherwise, delete the identities, send message to the user, and render a success screen - identity_list.delete() + def notify_on_success( + self, external_id: str, params: Mapping[str, Any], integration: Integration | None + ) -> None: client = get_preinstall_client(params["service_url"]) card = build_unlinked_card() client.send_card(params["conversation_id"], card) - - return render_to_response( - "sentry/integrations/msteams/unlinked.html", - request=request, - context={}, - ) diff --git a/src/sentry/integrations/slack/utils/notifications.py b/src/sentry/integrations/slack/utils/notifications.py index 79dae51edddcf..95c37c3f612c2 100644 --- a/src/sentry/integrations/slack/utils/notifications.py +++ b/src/sentry/integrations/slack/utils/notifications.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from dataclasses import dataclass from typing import Any import orjson @@ -13,6 +14,7 @@ from sentry.incidents.charts import build_metric_alert_chart from sentry.incidents.models.alert_rule import AlertRuleTriggerAction from sentry.incidents.models.incident import Incident, IncidentStatus +from sentry.integrations.models.integration import Integration from sentry.integrations.repository import get_default_metric_alert_repository from sentry.integrations.repository.metric_alert import ( MetricAlertNotificationMessageRepository, @@ -28,7 +30,6 @@ ) from sentry.integrations.slack.sdk_client import SlackSdkClient from sentry.integrations.slack.utils.errors import EXPIRED_URL, unpack_slack_api_error -from sentry.integrations.slack.views.types import IdentityParams from sentry.models.options.organization_option import OrganizationOption from sentry.utils import metrics @@ -164,55 +165,62 @@ def send_incident_alert_notification( return success +@dataclass(frozen=True, eq=True) +class SlackCommandResponse: + command: str + message: str + log_key: str + + def respond_to_slack_command( - params: IdentityParams, - text: str, - command: str, + command_response: SlackCommandResponse, + integration: Integration, + slack_id: str, + response_url: str | None, ) -> None: - log = "slack.link-identity." if command == "link" else "slack.unlink-identity." + def log_msg(tag: str) -> str: + return f"{command_response.log_key}.{tag}" - if params.response_url: - _logger.info( - "%s, respond-webhook", - log, - extra={"response_url": params.response_url}, - ) + if response_url: + _logger.info(log_msg("respond-webhook"), extra={"response_url": response_url}) try: - webhook_client = WebhookClient(params.response_url) - webhook_client.send(text=text, replace_original=False, response_type="ephemeral") + webhook_client = WebhookClient(response_url) + webhook_client.send( + text=command_response.message, replace_original=False, response_type="ephemeral" + ) metrics.incr( SLACK_LINK_IDENTITY_MSG_SUCCESS_DATADOG_METRIC, sample_rate=1.0, - tags={"type": "webhook", "command": command}, + tags={"type": "webhook", "command": command_response.command}, ) except (SlackApiError, SlackRequestError) as e: if unpack_slack_api_error(e) != EXPIRED_URL: metrics.incr( SLACK_LINK_IDENTITY_MSG_FAILURE_DATADOG_METRIC, sample_rate=1.0, - tags={"type": "webhook", "command": command}, + tags={"type": "webhook", "command": command_response.command}, ) - _logger.exception("%serror", log) + _logger.exception(log_msg("error"), extra={"error": str(e)}) else: - _logger.info("%s respond-ephemeral", log) + _logger.info(log_msg("respond-ephemeral")) try: - client = SlackSdkClient(integration_id=params.integration.id) + client = SlackSdkClient(integration_id=integration.id) client.chat_postMessage( - text=text, - channel=params.slack_id, + text=command_response.message, + channel=slack_id, replace_original=False, response_type="ephemeral", ) metrics.incr( SLACK_LINK_IDENTITY_MSG_SUCCESS_DATADOG_METRIC, sample_rate=1.0, - tags={"type": "ephemeral", "command": command}, + tags={"type": "ephemeral", "command": command_response.command}, ) except SlackApiError as e: if unpack_slack_api_error(e) != EXPIRED_URL: metrics.incr( SLACK_LINK_IDENTITY_MSG_FAILURE_DATADOG_METRIC, sample_rate=1.0, - tags={"type": "ephemeral", "command": command}, + tags={"type": "ephemeral", "command": command_response.command}, ) - _logger.exception("%serror", log) + _logger.exception(log_msg("error"), extra={"error": str(e)}) diff --git a/src/sentry/integrations/slack/views/__init__.py b/src/sentry/integrations/slack/views/__init__.py index 395e049ffdaa7..eb84bef98ad73 100644 --- a/src/sentry/integrations/slack/views/__init__.py +++ b/src/sentry/integrations/slack/views/__init__.py @@ -27,7 +27,7 @@ def build_linking_url(endpoint: str, **kwargs: Any) -> str: def render_error_page(request: Request | HttpRequest, status: int, body_text: str) -> HttpResponse: return render_to_response( - "sentry/integrations/slack/link-team-error.html", + "sentry/integrations/generic-error.html", request=request, status=status, context={"body_text": body_text}, diff --git a/src/sentry/integrations/slack/views/link_identity.py b/src/sentry/integrations/slack/views/link_identity.py index 8757c49e2f7bf..f15fa8e468792 100644 --- a/src/sentry/integrations/slack/views/link_identity.py +++ b/src/sentry/integrations/slack/views/link_identity.py @@ -1,34 +1,15 @@ import logging +from collections.abc import Mapping +from typing import Any -from django.contrib.auth.models import AnonymousUser -from django.core.signing import BadSignature, SignatureExpired -from django.db import IntegrityError -from django.http import Http404, HttpRequest, HttpResponse -from django.http.response import HttpResponseBase -from django.utils.decorators import method_decorator -from rest_framework.request import Request - +from sentry.integrations.messaging import LinkIdentityView +from sentry.integrations.models.integration import Integration from sentry.integrations.services.integration.model import RpcIntegration -from sentry.integrations.slack.metrics import ( - SLACK_BOT_COMMAND_LINK_IDENTITY_FAILURE_DATADOG_METRIC, - SLACK_BOT_COMMAND_LINK_IDENTITY_SUCCESS_DATADOG_METRIC, -) -from sentry.integrations.slack.utils.notifications import respond_to_slack_command -from sentry.integrations.slack.views import render_error_page -from sentry.integrations.slack.views.types import IdentityParams -from sentry.integrations.types import ExternalProviderEnum, ExternalProviders -from sentry.integrations.utils import get_identity_or_404 -from sentry.notifications.notificationcontroller import NotificationController -from sentry.notifications.notifications.integration_nudge import IntegrationNudgeNotification -from sentry.users.models.identity import Identity -from sentry.utils import metrics -from sentry.utils.signing import unsign -from sentry.web.frontend.base import BaseView, control_silo_view -from sentry.web.helpers import render_to_response +from sentry.integrations.slack.utils.notifications import SlackCommandResponse +from sentry.integrations.slack.views.linkage import SlackIdentityLinkageView +from sentry.web.frontend.base import control_silo_view -from . import SALT from . import build_linking_url as base_build_linking_url -from . import never_cache _logger = logging.getLogger(__name__) @@ -50,117 +31,19 @@ def build_linking_url( @control_silo_view -class SlackLinkIdentityView(BaseView): +class SlackLinkIdentityView(SlackIdentityLinkageView, LinkIdentityView): """ Django view for linking user to slack account. Creates an entry on Identity table. """ - _METRICS_SUCCESS_KEY = SLACK_BOT_COMMAND_LINK_IDENTITY_SUCCESS_DATADOG_METRIC - _METRICS_FAILURE_KEY = SLACK_BOT_COMMAND_LINK_IDENTITY_FAILURE_DATADOG_METRIC - - @method_decorator(never_cache) - def dispatch(self, request: HttpRequest, signed_params: str) -> HttpResponseBase: - try: - params = unsign(signed_params, salt=SALT) - except (SignatureExpired, BadSignature) as e: - _logger.warning("dispatch.signature_error", exc_info=e) - metrics.incr(self._METRICS_FAILURE_KEY, tags={"error": str(e)}, sample_rate=1.0) - return render_to_response( - "sentry/integrations/slack/expired-link.html", - request=request, - ) - - try: - organization, integration, idp = get_identity_or_404( - ExternalProviders.SLACK, - request.user, - integration_id=params["integration_id"], - ) - except Http404: - _logger.exception( - "get_identity_error", extra={"integration_id": params["integration_id"]} - ) - metrics.incr(self._METRICS_FAILURE_KEY + ".get_identity", sample_rate=1.0) - return render_error_page( - request, - status=404, - body_text="HTTP 404: Could not find the Slack identity.", - ) - - _logger.info("get_identity_success", extra={"integration_id": params["integration_id"]}) - metrics.incr(self._METRICS_SUCCESS_KEY + ".get_identity", sample_rate=1.0) - params.update({"organization": organization, "integration": integration, "idp": idp}) - return super().dispatch( - request, organization=organization, integration=integration, idp=idp, params=params - ) - - def get(self, request: Request, *args, **kwargs) -> HttpResponse: - params = kwargs["params"] - organization, integration = params["organization"], params["integration"] - - return render_to_response( - "sentry/auth-link-identity.html", - request=request, - context={"organization": organization, "provider": integration.get_provider()}, - ) - - def post(self, request: Request, *args, **kwargs) -> HttpResponse: - if isinstance(request.user, AnonymousUser): - return HttpResponse(status=401) - - try: - params_dict = kwargs["params"] - params = IdentityParams( - organization=kwargs["organization"], - integration=kwargs["integration"], - idp=kwargs["idp"], - slack_id=params_dict["slack_id"], - channel_id=params_dict["channel_id"], - response_url=params_dict.get("response_url"), - ) - except KeyError as e: - _logger.exception("slack.link.missing_params") - metrics.incr( - self._METRICS_FAILURE_KEY + ".post.missing_params", - tags={"error": str(e)}, - sample_rate=1.0, - ) - return render_error_page( - request, - status=400, - body_text="HTTP 400: Missing required parameters.", - ) - - try: - Identity.objects.link_identity( - user=request.user, idp=params.idp, external_id=params.slack_id - ) - except IntegrityError: - _logger.exception("slack.link.integrity_error") - metrics.incr( - self._METRICS_FAILURE_KEY + ".post.identity.integrity_error", - sample_rate=1.0, - ) - raise Http404 - - respond_to_slack_command(params, SUCCESS_LINKED_MESSAGE, command="link") - - controller = NotificationController( - recipients=[request.user], - organization_id=params.organization.id, - provider=ExternalProviderEnum.SLACK, - ) - has_slack_settings = controller.user_has_any_provider_settings(ExternalProviderEnum.SLACK) - - if not has_slack_settings: - IntegrationNudgeNotification( - params.organization, request.user, ExternalProviders.SLACK - ).send() - - metrics.incr(self._METRICS_SUCCESS_KEY + ".post.link_identity", sample_rate=1.0) - - return render_to_response( - "sentry/integrations/slack/linked.html", - request=request, - context={"channel_id": params.channel_id, "team_id": params.integration.external_id}, - ) + @property + def command_response(self) -> SlackCommandResponse: + return SlackCommandResponse("link", SUCCESS_LINKED_MESSAGE, "slack.link-identity") + + def get_success_template_and_context( + self, params: Mapping[str, Any], integration: Integration | None + ) -> tuple[str, dict[str, Any]]: + return "sentry/integrations/slack/linked.html", { + "channel_id": params["channel_id"], + "team_id": integration.external_id, + } diff --git a/src/sentry/integrations/slack/views/linkage.py b/src/sentry/integrations/slack/views/linkage.py new file mode 100644 index 0000000000000..f57dfe8f4f177 --- /dev/null +++ b/src/sentry/integrations/slack/views/linkage.py @@ -0,0 +1,57 @@ +from abc import ABC, abstractmethod +from collections.abc import Mapping +from typing import Any + +from sentry.integrations.messaging import IdentityLinkageView, MessagingIntegrationSpec +from sentry.integrations.models.integration import Integration +from sentry.integrations.slack.utils.notifications import ( + SlackCommandResponse, + respond_to_slack_command, +) +from sentry.integrations.types import ExternalProviderEnum, ExternalProviders + +from . import SALT + + +class SlackIdentityLinkageView(IdentityLinkageView, ABC): + @property + def parent_messaging_spec(self) -> MessagingIntegrationSpec: + from sentry.integrations.slack.spec import SlackMessagingSpec + + return SlackMessagingSpec() + + @property + def provider(self) -> ExternalProviders: + return ExternalProviders.SLACK + + @property + def external_provider_enum(self) -> ExternalProviderEnum: + return ExternalProviderEnum.SLACK + + @property + def salt(self) -> str: + return SALT + + @property + def external_id_parameter(self) -> str: + return "slack_id" + + @property + def expired_link_template(self) -> str: + return "sentry/integrations/slack/expired-link.html" + + def notify_on_success( + self, external_id: str, params: Mapping[str, Any], integration: Integration | None + ) -> None: + if integration is None: + raise ValueError( + 'integration is required for linking (params must include "integration_id")' + ) + respond_to_slack_command( + self.command_response, integration, external_id, params.get("response_url") + ) + + @property + @abstractmethod + def command_response(self) -> SlackCommandResponse: + raise NotImplementedError diff --git a/src/sentry/integrations/slack/views/types.py b/src/sentry/integrations/slack/views/types.py index 24b3229906239..e3feb7cc5131e 100644 --- a/src/sentry/integrations/slack/views/types.py +++ b/src/sentry/integrations/slack/views/types.py @@ -1,19 +1,5 @@ from dataclasses import dataclass -from sentry.integrations.models.integration import Integration -from sentry.organizations.services.organization import RpcOrganization -from sentry.users.models.identity import IdentityProvider - - -@dataclass(frozen=True) -class IdentityParams: - organization: RpcOrganization - integration: Integration - idp: IdentityProvider - slack_id: str - channel_id: str - response_url: str | None = None - @dataclass(frozen=True) class TeamLinkRequest: diff --git a/src/sentry/integrations/slack/views/unlink_identity.py b/src/sentry/integrations/slack/views/unlink_identity.py index ce93e9ab90ef2..f62adce55fca4 100644 --- a/src/sentry/integrations/slack/views/unlink_identity.py +++ b/src/sentry/integrations/slack/views/unlink_identity.py @@ -1,29 +1,13 @@ import logging +from collections.abc import Mapping +from typing import Any -from django.core.signing import BadSignature, SignatureExpired -from django.db import IntegrityError -from django.http import Http404, HttpRequest, HttpResponse -from django.http.response import HttpResponseBase -from django.utils.decorators import method_decorator -from rest_framework.request import Request - -from sentry.integrations.slack.metrics import ( - SLACK_BOT_COMMAND_UNLINK_IDENTITY_FAILURE_DATADOG_METRIC, - SLACK_BOT_COMMAND_UNLINK_IDENTITY_SUCCESS_DATADOG_METRIC, -) -from sentry.integrations.slack.utils.notifications import respond_to_slack_command +from sentry.integrations.messaging import UnlinkIdentityView +from sentry.integrations.models.integration import Integration +from sentry.integrations.slack.utils.notifications import SlackCommandResponse from sentry.integrations.slack.views import build_linking_url as base_build_linking_url -from sentry.integrations.slack.views import never_cache, render_error_page -from sentry.integrations.slack.views.types import IdentityParams -from sentry.integrations.types import ExternalProviders -from sentry.integrations.utils import get_identity_or_404 -from sentry.users.models.identity import Identity -from sentry.utils import metrics -from sentry.utils.signing import unsign -from sentry.web.frontend.base import BaseView, control_silo_view -from sentry.web.helpers import render_to_response - -from . import SALT +from sentry.integrations.slack.views.linkage import SlackIdentityLinkageView +from sentry.web.frontend.base import control_silo_view SUCCESS_UNLINKED_MESSAGE = "Your Slack identity has been unlinked from your Sentry account." @@ -43,96 +27,19 @@ def build_unlinking_url( @control_silo_view -class SlackUnlinkIdentityView(BaseView): +class SlackUnlinkIdentityView(SlackIdentityLinkageView, UnlinkIdentityView): """ Django view for unlinking user from slack account. Deletes from Identity table. """ - _METRICS_SUCCESS_KEY = SLACK_BOT_COMMAND_UNLINK_IDENTITY_SUCCESS_DATADOG_METRIC - _METRICS_FAILURE_KEY = SLACK_BOT_COMMAND_UNLINK_IDENTITY_FAILURE_DATADOG_METRIC - - @method_decorator(never_cache) - def dispatch(self, request: HttpRequest, signed_params: str) -> HttpResponseBase: - try: - params = unsign(signed_params, salt=SALT) - except (SignatureExpired, BadSignature) as e: - _logger.warning("dispatch.signature_error", exc_info=e) - metrics.incr(self._METRICS_FAILURE_KEY, tags={"error": str(e)}, sample_rate=1.0) - return render_to_response( - "sentry/integrations/slack/expired-link.html", - request=request, - ) - - try: - organization, integration, idp = get_identity_or_404( - ExternalProviders.SLACK, - request.user, - integration_id=params["integration_id"], - ) - except Http404: - _logger.exception( - "get_identity_error", extra={"integration_id": params["integration_id"]} - ) - metrics.incr(self._METRICS_FAILURE_KEY + ".get_identity", sample_rate=1.0) - return render_error_page( - request, - status=404, - body_text="HTTP 404: Could not find the Slack identity.", - ) - - _logger.info("get_identity_success", extra={"integration_id": params["integration_id"]}) - metrics.incr(self._METRICS_SUCCESS_KEY + ".get_identity", sample_rate=1.0) - params.update({"organization": organization, "integration": integration, "idp": idp}) - return super().dispatch( - request, organization=organization, integration=integration, idp=idp, params=params - ) - - def get(self, request: Request, *args, **kwargs) -> HttpResponse: - params = kwargs["params"] - organization, integration = params["organization"], params["integration"] - - return render_to_response( - "sentry/auth-unlink-identity.html", - request=request, - context={"organization": organization, "provider": integration.get_provider()}, - ) - - def post(self, request: Request, *args, **kwargs) -> HttpResponse: - try: - params_dict = kwargs["params"] - params = IdentityParams( - organization=kwargs["organization"], - integration=kwargs["integration"], - idp=kwargs["idp"], - slack_id=params_dict["slack_id"], - channel_id=params_dict["channel_id"], - response_url=params_dict.get("response_url"), - ) - except KeyError as e: - _logger.exception("slack.unlink.missing_params", extra={"error": str(e)}) - metrics.incr(self._METRICS_FAILURE_KEY + ".post.missing_params", sample_rate=1.0) - return render_error_page( - request, - status=400, - body_text="HTTP 400: Missing required parameters.", - ) - - try: - Identity.objects.filter(idp_id=params.idp, external_id=params.slack_id).delete() - except IntegrityError: - _logger.exception("slack.unlink.integrity_error") - metrics.incr( - self._METRICS_FAILURE_KEY + ".post.identity.integrity_error", - sample_rate=1.0, - ) - raise Http404 - - respond_to_slack_command(params, SUCCESS_UNLINKED_MESSAGE, command="link") - - metrics.incr(self._METRICS_SUCCESS_KEY + ".post.unlink_identity", sample_rate=1.0) - - return render_to_response( - "sentry/integrations/slack/unlinked.html", - request=request, - context={"channel_id": params.channel_id, "team_id": params.integration.external_id}, - ) + @property + def command_response(self) -> SlackCommandResponse: + return SlackCommandResponse("unlink", SUCCESS_UNLINKED_MESSAGE, "slack.unlink-identity") + + def get_success_template_and_context( + self, params: Mapping[str, Any], integration: Integration | None + ) -> tuple[str, dict[str, Any]]: + if integration is None: + raise ValueError + context = {"channel_id": params["channel_id"], "team_id": integration.external_id} + return "sentry/integrations/slack/unlinked.html", context diff --git a/src/sentry/integrations/slack/webhooks/action.py b/src/sentry/integrations/slack/webhooks/action.py index f2fd3223f188c..ddb2fa76d03b3 100644 --- a/src/sentry/integrations/slack/webhooks/action.py +++ b/src/sentry/integrations/slack/webhooks/action.py @@ -34,8 +34,6 @@ from sentry.integrations.slack.requests.action import SlackActionRequest from sentry.integrations.slack.requests.base import SlackRequestError from sentry.integrations.slack.sdk_client import SlackSdkClient -from sentry.integrations.slack.views.link_identity import build_linking_url -from sentry.integrations.slack.views.unlink_identity import build_unlinking_url from sentry.integrations.types import ExternalProviderEnum from sentry.integrations.utils.scope import bind_org_context_from_integration from sentry.models.activity import ActivityIntegration @@ -188,6 +186,8 @@ def api_error( error: ApiClient.ApiError, action_type: str, ) -> Response: + from sentry.integrations.slack.views.unlink_identity import build_unlinking_url + _logger.info( "slack.action.api-error", extra={ @@ -496,6 +496,8 @@ def _handle_group_actions( request: Request, action_list: Sequence[MessageAction], ) -> Response: + from sentry.integrations.slack.views.link_identity import build_linking_url + group = get_group(slack_request) if not group: return self.respond(status=403) diff --git a/src/sentry/integrations/slack/webhooks/base.py b/src/sentry/integrations/slack/webhooks/base.py index 7dd71626e138d..1d2eba49c6ba1 100644 --- a/src/sentry/integrations/slack/webhooks/base.py +++ b/src/sentry/integrations/slack/webhooks/base.py @@ -12,8 +12,6 @@ SLACK_WEBHOOK_DM_ENDPOINT_SUCCESS_DATADOG_METRIC, ) from sentry.integrations.slack.requests.base import SlackDMRequest, SlackRequestError -from sentry.integrations.slack.views.link_identity import build_linking_url -from sentry.integrations.slack.views.unlink_identity import build_unlinking_url LINK_USER_MESSAGE = ( "<{associate_url}|Link your Slack identity> to your Sentry account to receive notifications. " @@ -72,6 +70,8 @@ def reply(self, slack_request: SlackDMRequest, message: str) -> Response: raise NotImplementedError def link_user(self, slack_request: SlackDMRequest) -> Response: + from sentry.integrations.slack.views.link_identity import build_linking_url + if slack_request.has_identity: return self.reply( slack_request, ALREADY_LINKED_MESSAGE.format(username=slack_request.identity_str) @@ -94,6 +94,8 @@ def link_user(self, slack_request: SlackDMRequest) -> Response: return self.reply(slack_request, LINK_USER_MESSAGE.format(associate_url=associate_url)) def unlink_user(self, slack_request: SlackDMRequest) -> Response: + from sentry.integrations.slack.views.unlink_identity import build_unlinking_url + if not slack_request.has_identity: logger.error(".unlink-user.no-identity.error", extra={"slack_request": slack_request}) metrics.incr(self._METRIC_FAILURE_KEY + "unlink_user.no_identity", sample_rate=1.0) diff --git a/src/sentry/templates/sentry/integrations/generic-error.html b/src/sentry/templates/sentry/integrations/generic-error.html new file mode 100644 index 0000000000000..c861a930da994 --- /dev/null +++ b/src/sentry/templates/sentry/integrations/generic-error.html @@ -0,0 +1,14 @@ +{% extends "sentry/bases/modal.html" %} + +{% load i18n %} + +{% block title %}{% trans "Error" %} | {{ block.super }}{% endblock %} +{% block wrapperclass %}narrow auth{% endblock %} + +{% block main %} +
+

+ {% trans body_text %} +

+
+{% endblock %} diff --git a/tests/sentry/integrations/discord/test_views.py b/tests/sentry/integrations/discord/test_views.py index e273d06bd2122..2b2bd74372e50 100644 --- a/tests/sentry/integrations/discord/test_views.py +++ b/tests/sentry/integrations/discord/test_views.py @@ -43,7 +43,7 @@ def test_basic_flow(self): assert identity[0].idp == self.provider assert identity[0].status == IdentityStatus.VALID - @mock.patch("sentry.integrations.discord.views.link_identity.unsign") + @mock.patch("sentry.integrations.messaging.unsign") def test_expired_signature(self, mock_sign): mock_sign.side_effect = SignatureExpired url = build_linking_url(self.discord_integration, self.discord_user_id) # type: ignore[arg-type] @@ -72,7 +72,7 @@ def test_basic_flow(self): external_id=self.discord_user_id, user=self.user ).exists() - @mock.patch("sentry.integrations.discord.views.unlink_identity.unsign") + @mock.patch("sentry.integrations.messaging.unsign") def test_expired_signature(self, mock_sign): mock_sign.side_effect = SignatureExpired url = build_unlinking_url(self.discord_integration, self.discord_user_id) # type: ignore[arg-type] diff --git a/tests/sentry/integrations/msteams/test_link_identity.py b/tests/sentry/integrations/msteams/test_link_identity.py index 913bdb7a5ee99..14278e4b6356e 100644 --- a/tests/sentry/integrations/msteams/test_link_identity.py +++ b/tests/sentry/integrations/msteams/test_link_identity.py @@ -40,7 +40,7 @@ def setUp(self): self.idp = self.create_identity_provider(type="msteams", external_id="1_50l3mnly_5w34r") @responses.activate - @patch("sentry.integrations.msteams.link_identity.unsign") + @patch("sentry.integrations.messaging.unsign") def test_basic_flow(self, unsign): unsign.return_value = { "integration_id": self.integration.id, @@ -93,7 +93,7 @@ def user_conversation_id_callback(request): assert len(responses.calls) == 2 @responses.activate - @patch("sentry.integrations.msteams.link_identity.unsign") + @patch("sentry.integrations.messaging.unsign") def test_overwrites_existing_identities(self, unsign): Identity.objects.create( user=self.user1, idp=self.idp, external_id="h_p", status=IdentityStatus.VALID diff --git a/tests/sentry/integrations/slack/test_link_identity.py b/tests/sentry/integrations/slack/test_link_identity.py index d039e032b245a..13950a39415ac 100644 --- a/tests/sentry/integrations/slack/test_link_identity.py +++ b/tests/sentry/integrations/slack/test_link_identity.py @@ -99,7 +99,7 @@ def test_basic_flow_with_webhook_client_error(self, mock_logger): assert len(identity) == 1 assert mock_logger.exception.call_count == 1 - assert mock_logger.exception.call_args.args[0] == "%serror" + assert mock_logger.exception.call_args.args == ("slack.link-identity.error",) def test_basic_flow_with_web_client(self): """No response URL is provided, so we use WebClient.""" @@ -139,7 +139,7 @@ def test_basic_flow_with_web_client_error(self, mock_logger): assert len(identity) == 1 assert mock_logger.exception.call_count == 1 - assert mock_logger.exception.call_args.args[0] == "%serror" + assert mock_logger.exception.call_args.args == ("slack.link-identity.error",) @patch("sentry.integrations.slack.utils.notifications._logger") def test_basic_flow_with_web_client_expired_url(self, mock_logger): diff --git a/tests/sentry/integrations/slack/test_link_team.py b/tests/sentry/integrations/slack/test_link_team.py index b06bb1bf0b8c3..12fb8a9061c20 100644 --- a/tests/sentry/integrations/slack/test_link_team.py +++ b/tests/sentry/integrations/slack/test_link_team.py @@ -77,7 +77,7 @@ def get_error_response( else: response = self.client.get(self.url, content_type="application/x-www-form-urlencoded") assert response.status_code == status_code - self.assertTemplateUsed(response, "sentry/integrations/slack/link-team-error.html") + self.assertTemplateUsed(response, "sentry/integrations/generic-error.html") return response