Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@
from sentry.integrations.api.endpoints.organization_repository_settings import (
OrganizationRepositorySettingsEndpoint,
)
from sentry.integrations.slack.endpoints.link_team_context import SlackLinkTeamContextEndpoint
from sentry.investigations.endpoints.organization_investigation_candidates import (
OrganizationInvestigationCandidatesEndpoint,
)
Expand Down Expand Up @@ -4005,6 +4006,11 @@ def create_group_urls(
IntegrationFeaturesEndpoint.as_view(),
name="sentry-api-0-integration-features",
),
re_path(
r"^integrations/slack/link-team/(?P<signed_params>[^/]+)/$",
SlackLinkTeamContextEndpoint.as_view(),
name="sentry-api-0-slack-link-team-context",
),
# Project Wizard
re_path(
r"^wizard/$",
Expand Down
Empty file.
87 changes: 87 additions & 0 deletions src/sentry/integrations/slack/endpoints/link_team_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
from __future__ import annotations

from typing import TypedDict

from django.utils.decorators import method_decorator
from django.views.decorators.cache import never_cache
from rest_framework.request import Request
from rest_framework.response import Response

from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import Endpoint, control_silo_endpoint
from sentry.api.permissions import SentryIsAuthenticated
from sentry.api.serializers import serialize
from sentry.api.serializers.models.organization import (
ControlSiloOrganizationMappingSerializer,
ControlSiloOrganizationMappingSerializerResponse,
)
from sentry.auth.principal import require_user
from sentry.constants import ObjectStatus
from sentry.integrations.models.integration import Integration
from sentry.integrations.models.organization_integration import OrganizationIntegration
from sentry.integrations.slack.team_linking import (
INVALID_LINK_MESSAGE,
InvalidTeamLinkParams,
TeamLinkParams,
)
from sentry.models.organization import OrganizationStatus
from sentry.models.organizationmapping import OrganizationMapping
from sentry.models.organizationmembermapping import OrganizationMemberMapping
from sentry.types.cell import find_all_cell_names


class SlackLinkTeamContextResponse(TypedDict):
channelName: str
channelUrl: str
organizations: list[ControlSiloOrganizationMappingSerializerResponse]


@control_silo_endpoint
@method_decorator(never_cache, name="dispatch")
class SlackLinkTeamContextEndpoint(Endpoint):
owner = ApiOwner.MESSAGING_INTEGRATIONS
publish_status = {"GET": ApiPublishStatus.PRIVATE}
permission_classes = (SentryIsAuthenticated,)

def get(self, request: Request, signed_params: str) -> Response:
"""
Describe a `/sentry link team` request: the Slack channel being linked,
with a deep link back to it, and the user's organizations, across every
cell, that have the Slack workspace installed.
"""
try:
params = TeamLinkParams.from_signed(signed_params)
except InvalidTeamLinkParams:
return Response({"detail": INVALID_LINK_MESSAGE}, status=400)

integration = Integration.objects.filter(
id=params.integration_id, status=ObjectStatus.ACTIVE
).first()
if integration is None:
return Response({"detail": "The Slack workspace is no longer installed."}, status=404)

installed_org_ids = OrganizationIntegration.objects.filter(
integration_id=params.integration_id, status=ObjectStatus.ACTIVE
).values("organization_id")
member_org_ids = OrganizationMemberMapping.objects.filter(
user_id=require_user(request).id, organization_id__in=installed_org_ids
).values("organization_id")
# Serializing an org in a cell this deploy doesn't know about raises,
# so skip those instead of failing the whole page.
organizations = OrganizationMapping.objects.filter(
organization_id__in=member_org_ids,
status=OrganizationStatus.ACTIVE,
cell_name__in=list(find_all_cell_names()),
).order_by("name")

response: SlackLinkTeamContextResponse = {
"channelName": params.channel_name,
"channelUrl": f"slack://channel?team={integration.external_id}&id={params.channel_id}",
"organizations": serialize(
list(organizations),
request.user,
serializer=ControlSiloOrganizationMappingSerializer(),
),
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Resolved
}
return Response(response)
39 changes: 38 additions & 1 deletion src/sentry/integrations/slack/team_linking.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from enum import StrEnum

import sentry_sdk
from django.core.signing import BadSignature
from slack_sdk.errors import SlackApiError

from sentry import analytics
Expand All @@ -13,6 +14,7 @@
from sentry.integrations.services.integration import RpcIntegration
from sentry.integrations.slack.analytics import SlackIntegrationIdentityLinked
from sentry.integrations.slack.sdk_client import SlackSdkClient
from sentry.integrations.slack.views import SALT
from sentry.integrations.types import (
ExternalActorSource,
ExternalProviderEnum,
Expand All @@ -23,13 +25,17 @@
from sentry.models.team import Team
from sentry.notifications.services import notifications_service
from sentry.notifications.types import NotificationSettingEnum
from sentry.users.models.user import User
from sentry.users.services.user import RpcUser
from sentry.utils.signing import unsign

ALREADY_LINKED_MESSAGE = "The {slug} team has already been linked to a Slack channel."
SUCCESS_LINKED_MESSAGE = (
"The {team} team will now receive issue alert notifications in the {channel} channel."
)

INVALID_LINK_MESSAGE = "This link is invalid or has expired. Run the command again."

TEAM_LINKING_ROLES = frozenset(["admin", "manager", "owner"])


Expand All @@ -38,6 +44,10 @@ class TeamLinkResult(StrEnum):
ALREADY_LINKED = "already_linked"


class InvalidTeamLinkParams(Exception):
pass


class IdentityProviderNotFound(Exception):
pass

Expand All @@ -46,14 +56,41 @@ class IdentityNotFound(Exception):
pass


@dataclass(frozen=True)
class TeamLinkParams:
"""The Slack context signed into a `/sentry link team` URL."""

integration_id: int
slack_user_id: str
channel_id: str
channel_name: str

@classmethod
def from_signed(cls, signed_params: str) -> TeamLinkParams:
try:
params = unsign(signed_params, salt=SALT)
except (BadSignature, ValueError) as e:
raise InvalidTeamLinkParams from e

try:
return cls(
integration_id=int(params["integration_id"]),
slack_user_id=params["slack_id"],
channel_id=params["channel_id"],
channel_name=params["channel_name"],
)
except (KeyError, TypeError, ValueError) as e:
raise InvalidTeamLinkParams from e


@dataclass(frozen=True)
class SlackChannel:
integration: RpcIntegration
channel_id: str
channel_name: str


def get_linkable_teams(member: OrganizationMember, user: RpcUser) -> Sequence[Team]:
def get_linkable_teams(member: OrganizationMember, user: User | RpcUser) -> Sequence[Team]:
"""
Teams the member may link to a Slack channel. Organization admins, managers,
and owners may link any of their teams; other members may only link teams
Expand Down
1 change: 1 addition & 0 deletions static/app/data/controlsiloUrlPatterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ export const controlsiloUrlPatterns: RegExp[] = [
new RegExp('^api/0/doc-integrations/[^/]+/$'),
new RegExp('^api/0/doc-integrations/[^/]+/avatar/$'),
new RegExp('^api/0/integration-features/$'),
new RegExp('^api/0/integrations/slack/link-team/[^/]+/$'),
new RegExp('^api/0/wizard/$'),
new RegExp('^api/0/wizard/[^/]+/$'),
new RegExp('^api/0/internal/beacon/$'),
Expand Down
1 change: 1 addition & 0 deletions static/app/utils/api/knownSentryApiUrls.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export type KnownSentryApiUrls =
| '/doc-integrations/$docIntegrationIdOrSlug/'
| '/doc-integrations/$docIntegrationIdOrSlug/avatar/'
| '/integration-features/'
| '/integrations/slack/link-team/$signedParams/'
| '/internal/$organizationIdOrSlug/$projectIdOrSlug/files/preprodartifacts/$headArtifactId/'
| '/internal/$organizationIdOrSlug/$projectIdOrSlug/files/preprodartifacts/$headArtifactId/assemble-generic/'
| '/internal/$organizationIdOrSlug/$projectIdOrSlug/files/preprodartifacts/$headArtifactId/size/'
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
from datetime import timedelta

from django.utils import timezone

from sentry.constants import ObjectStatus
from sentry.integrations.slack.views import SALT
from sentry.models.organizationmapping import OrganizationMapping
from sentry.testutils.cases import APITestCase
from sentry.testutils.helpers.datetime import freeze_time
from sentry.testutils.silo import control_silo_test
from sentry.utils.signing import sign


@control_silo_test
class SlackLinkTeamContextTest(APITestCase):
endpoint = "sentry-api-0-slack-link-team-context"

def setUp(self) -> None:
super().setUp()
self.login_as(self.user)
self.integration = self.create_slack_integration(self.organization, external_id="TXXXXXXX1")

def sign_params(self, **overrides: object) -> str:
params: dict[str, object] = {
"integration_id": self.integration.id,
"slack_id": "UXXXXXXX1",
"channel_id": "CXXXXXXX1",
"channel_name": "alerts",
"response_url": "https://hooks.slack.com/commands/response",
**overrides,
}
return sign(salt=SALT, **params)

def test_lists_member_organizations_with_the_workspace(self) -> None:
other_org = self.create_organization(name="Another Org", owner=self.user)
self.integration.add_organization(other_org)
self.create_organization(name="No Slack", owner=self.user)
foreign_org = self.create_organization(name="Not A Member")
self.integration.add_organization(foreign_org)

response = self.get_success_response(self.sign_params())

assert response.data["channelName"] == "alerts"
assert response.data["channelUrl"] == "slack://channel?team=TXXXXXXX1&id=CXXXXXXX1"
assert [org["slug"] for org in response.data["organizations"]] == [
other_org.slug,
self.organization.slug,
]
assert response.data["organizations"][0]["links"]["regionUrl"]

def test_omits_organizations_with_a_disabled_installation(self) -> None:
self.integration.organizationintegration_set.update(status=ObjectStatus.DISABLED)

response = self.get_success_response(self.sign_params())

assert response.data["organizations"] == []

def test_omits_organizations_in_unknown_cells(self) -> None:
other_org = self.create_organization(name="Another Org", owner=self.user)
self.integration.add_organization(other_org)
OrganizationMapping.objects.filter(organization_id=other_org.id).update(
cell_name="unknown-cell"
)

response = self.get_success_response(self.sign_params())

assert [org["slug"] for org in response.data["organizations"]] == [self.organization.slug]

def test_rejects_a_tampered_link(self) -> None:
response = self.get_error_response(self.sign_params() + "x", status_code=400)

assert "invalid or has expired" in response.data["detail"]

def test_rejects_an_expired_link(self) -> None:
signed_params = self.sign_params()

with freeze_time(timezone.now() + timedelta(days=3)):
self.get_error_response(signed_params, status_code=400)

def test_rejects_a_link_missing_the_channel(self) -> None:
signed_params = sign(salt=SALT, integration_id=self.integration.id, slack_id="UXXXXXXX1")

self.get_error_response(signed_params, status_code=400)

def test_rejects_an_uninstalled_workspace(self) -> None:
self.integration.update(status=ObjectStatus.DISABLED)

self.get_error_response(self.sign_params(), status_code=404)

def test_requires_authentication(self) -> None:
self.client.logout()

self.get_error_response(self.sign_params(), status_code=401)
Loading