Skip to content

Commit c599713

Browse files
evanpurkhisergeorge-sentry
authored andcommitted
feat(integrations): Add API driven pipeline for Slack (#112315)
Implement `get_pipeline_api_steps()` on `SlackIntegrationProvider` using a single OAuth2 step with Slack's `user_scope` parameter. Refactors `OAuth2Provider.get_pipeline_api_steps()` to expose `make_oauth_api_step()` so providers can customize step parameters (like extra_authorize_params). Updates `build_integration()` to handle both legacy `state["identity"]["data"]` and new `state["oauth_data"]` paths. Ref [VDY-42](https://linear.app/getsentry/issue/VDY-42/slack-api-driven-integration-setup)
1 parent 4b8ba1f commit c599713

File tree

4 files changed

+165
-16
lines changed

4 files changed

+165
-16
lines changed

src/sentry/identity/oauth2.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -147,22 +147,24 @@ def get_pipeline_views(self) -> list[PipelineView[IdentityPipeline]]:
147147
),
148148
]
149149

150-
def get_pipeline_api_steps(self) -> list[OAuth2ApiStep]:
150+
def make_oauth_api_step(self, **kwargs: Any) -> OAuth2ApiStep:
151151
redirect_url = self.config.get(
152152
"redirect_url",
153153
reverse("sentry-extension-setup", kwargs={"provider_id": "default"}),
154154
)
155-
return [
156-
OAuth2ApiStep(
157-
authorize_url=self.get_oauth_authorize_url(),
158-
client_id=self.get_oauth_client_id(),
159-
client_secret=self.get_oauth_client_secret(),
160-
access_token_url=self.get_oauth_access_token_url(),
161-
scope=" ".join(self.get_oauth_scopes()),
162-
redirect_url=redirect_url,
163-
verify_ssl=self.config.get("verify_ssl", True),
164-
),
165-
]
155+
return OAuth2ApiStep(
156+
authorize_url=self.get_oauth_authorize_url(),
157+
client_id=self.get_oauth_client_id(),
158+
client_secret=self.get_oauth_client_secret(),
159+
access_token_url=self.get_oauth_access_token_url(),
160+
scope=" ".join(self.get_oauth_scopes()),
161+
redirect_url=redirect_url,
162+
verify_ssl=self.config.get("verify_ssl", True),
163+
**kwargs,
164+
)
165+
166+
def get_pipeline_api_steps(self) -> list[OAuth2ApiStep]:
167+
return [self.make_oauth_api_step()]
166168

167169
def get_refresh_token_params(
168170
self, refresh_token: str, identity: Identity | RpcIdentity, **kwargs: Any

src/sentry/integrations/slack/integration.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from slack_sdk.errors import SlackApiError
1111

1212
from sentry.identity.pipeline import IdentityPipeline
13+
from sentry.identity.slack.provider import SlackIdentityProvider
1314
from sentry.integrations.base import (
1415
FeatureDescription,
1516
IntegrationData,
@@ -36,7 +37,7 @@
3637
)
3738
from sentry.notifications.platform.target import IntegrationNotificationTarget
3839
from sentry.organizations.services.organization.model import RpcOrganization
39-
from sentry.pipeline.views.base import PipelineView
40+
from sentry.pipeline.views.base import ApiPipelineSteps, PipelineView
4041
from sentry.pipeline.views.nested import NestedPipelineView
4142
from sentry.shared_integrations.exceptions import IntegrationError
4243
from sentry.utils.http import absolute_uri
@@ -335,6 +336,7 @@ def _get_oauth_scopes(self) -> frozenset[str]:
335336
return self.identity_oauth_scopes
336337

337338
setup_dialog_config = {"width": 600, "height": 900}
339+
setup_url_path = "/extensions/slack/setup/"
338340

339341
def _identity_pipeline_view(self) -> PipelineView[IntegrationPipeline]:
340342
return NestedPipelineView(
@@ -344,13 +346,28 @@ def _identity_pipeline_view(self) -> PipelineView[IntegrationPipeline]:
344346
config={
345347
"oauth_scopes": self._get_oauth_scopes(),
346348
"user_scopes": self.user_scopes,
347-
"redirect_url": absolute_uri("/extensions/slack/setup/"),
349+
"redirect_url": absolute_uri(self.setup_url_path),
348350
},
349351
)
350352

351353
def get_pipeline_views(self) -> Sequence[PipelineView[IntegrationPipeline]]:
352354
return [self._identity_pipeline_view()]
353355

356+
def _make_identity_provider(self) -> SlackIdentityProvider:
357+
return SlackIdentityProvider(
358+
oauth_scopes=self._get_oauth_scopes(),
359+
redirect_url=absolute_uri(self.setup_url_path),
360+
)
361+
362+
def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]:
363+
provider = self._make_identity_provider()
364+
return [
365+
provider.make_oauth_api_step(
366+
bind_key="oauth_data",
367+
extra_authorize_params={"user_scope": " ".join(self.user_scopes)},
368+
),
369+
]
370+
354371
def _get_team_info(self, access_token: str) -> Any:
355372
# Manually add authorization since this method is part of slack installation
356373

@@ -365,7 +382,13 @@ def _get_team_info(self, access_token: str) -> Any:
365382
raise IntegrationError("Could not retrieve Slack team information.")
366383

367384
def build_integration(self, state: Mapping[str, Any]) -> IntegrationData:
368-
data = state["identity"]["data"]
385+
# TODO: legacy views write token data to state["identity"]["data"] via
386+
# NestedPipelineView. API steps write directly to state["oauth_data"].
387+
# Remove the legacy path once the old views are retired.
388+
if "oauth_data" in state:
389+
data = state["oauth_data"]
390+
else:
391+
data = state["identity"]["data"]
369392
assert data["ok"]
370393

371394
access_token = data["access_token"]

src/sentry/integrations/slack/staging/integration.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any
66

77
from sentry.identity.pipeline import IdentityPipeline
8+
from sentry.identity.slack.provider import SlackStagingIdentityProvider
89
from sentry.integrations.base import (
910
IntegrationData,
1011
)
@@ -22,10 +23,17 @@ class SlackStagingIntegrationProvider(SlackIntegrationProvider):
2223
key = IntegrationProviderSlug.SLACK_STAGING.value
2324
name = "Slack (Staging)"
2425
requires_feature_flag = True
26+
setup_url_path = "/extensions/slack-staging/setup/"
2527

2628
def _get_oauth_scopes(self) -> frozenset[str]:
2729
return self.identity_oauth_scopes | self.extended_oauth_scopes
2830

31+
def _make_identity_provider(self) -> SlackStagingIdentityProvider:
32+
return SlackStagingIdentityProvider(
33+
oauth_scopes=self._get_oauth_scopes(),
34+
redirect_url=absolute_uri(self.setup_url_path),
35+
)
36+
2937
def _identity_pipeline_view(self) -> PipelineView[IntegrationPipeline]:
3038
return NestedPipelineView(
3139
bind_key="identity",
@@ -34,7 +42,7 @@ def _identity_pipeline_view(self) -> PipelineView[IntegrationPipeline]:
3442
config={
3543
"oauth_scopes": self._get_oauth_scopes(),
3644
"user_scopes": self.user_scopes,
37-
"redirect_url": absolute_uri("/extensions/slack-staging/setup/"),
45+
"redirect_url": absolute_uri(self.setup_url_path),
3846
},
3947
)
4048

tests/sentry/integrations/slack/test_integration.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
14
from unittest.mock import MagicMock, patch
25
from urllib.parse import parse_qs, urlencode, urlparse
36

47
import orjson
58
import pytest
69
import responses
10+
from django.urls import reverse
711
from responses.matchers import query_string_matcher
812
from slack_sdk.errors import SlackApiError
913
from slack_sdk.web import SlackResponse
1014

1115
from sentry import audit_log
1216
from sentry.integrations.models.integration import Integration
1317
from sentry.integrations.models.organization_integration import OrganizationIntegration
18+
from sentry.integrations.pipeline import IntegrationPipeline
1419
from sentry.integrations.slack import SlackIntegration, SlackIntegrationProvider
1520
from sentry.integrations.slack.utils.constants import SlackScope
1621
from sentry.integrations.slack.utils.users import SLACK_GET_USERS_PAGE_SIZE
@@ -658,3 +663,114 @@ def test_get_thread_history_error_returns_empty_list(
658663
channel_id=self.channel_id, thread_ts=self.thread_ts
659664
)
660665
assert result == []
666+
667+
668+
@control_silo_test
669+
class SlackApiPipelineTest(APITestCase):
670+
endpoint = "sentry-api-0-organization-pipeline"
671+
method = "post"
672+
673+
def setUp(self) -> None:
674+
super().setUp()
675+
self.login_as(self.user)
676+
677+
def tearDown(self) -> None:
678+
responses.reset()
679+
super().tearDown()
680+
681+
def _get_pipeline_url(self) -> str:
682+
return reverse(
683+
self.endpoint,
684+
args=[self.organization.slug, IntegrationPipeline.pipeline_name],
685+
)
686+
687+
def _initialize_pipeline(self) -> Any:
688+
return self.client.post(
689+
self._get_pipeline_url(),
690+
data={"action": "initialize", "provider": "slack"},
691+
format="json",
692+
)
693+
694+
def _advance_step(self, data: dict[str, Any]) -> Any:
695+
return self.client.post(self._get_pipeline_url(), data=data, format="json")
696+
697+
def _get_pipeline_signature(self, resp: Any) -> str:
698+
return resp.data["data"]["oauthUrl"].split("state=")[1].split("&")[0]
699+
700+
@responses.activate
701+
def test_initialize_pipeline(self) -> None:
702+
resp = self._initialize_pipeline()
703+
assert resp.status_code == 200
704+
assert resp.data["step"] == "oauth_login"
705+
assert resp.data["stepIndex"] == 0
706+
assert resp.data["totalSteps"] == 1
707+
assert resp.data["provider"] == "slack"
708+
oauth_url = resp.data["data"]["oauthUrl"]
709+
assert "slack.com/oauth/v2/authorize" in oauth_url
710+
assert "user_scope=" in oauth_url
711+
712+
# Verify the OAuth URL requests the correct bot scopes, not the
713+
# identity provider's default scopes
714+
parsed = urlparse(oauth_url)
715+
params = parse_qs(parsed.query)
716+
requested_scopes = set(params["scope"][0].split(" "))
717+
assert requested_scopes == SlackIntegrationProvider.identity_oauth_scopes
718+
719+
@responses.activate
720+
def test_oauth_step_missing_code(self) -> None:
721+
self._initialize_pipeline()
722+
resp = self._advance_step({})
723+
assert resp.status_code == 400
724+
725+
@responses.activate
726+
@patch("sentry.integrations.slack.integration.WebClient")
727+
def test_full_pipeline_flow(self, mock_web_client_cls: MagicMock) -> None:
728+
mock_client = MagicMock()
729+
mock_web_client_cls.return_value = mock_client
730+
mock_client.team_info.return_value = SlackResponse(
731+
client=mock_client,
732+
http_verb="GET",
733+
api_url="https://slack.com/api/team.info",
734+
req_args={},
735+
data={
736+
"ok": True,
737+
"team": {
738+
"name": "Test Team",
739+
"id": "T1234",
740+
"domain": "test-team",
741+
"icon": {"image_132": "https://example.com/icon.png"},
742+
},
743+
},
744+
headers={},
745+
status_code=200,
746+
)
747+
748+
responses.add(
749+
responses.POST,
750+
"https://slack.com/api/oauth.v2.access",
751+
json={
752+
"ok": True,
753+
"access_token": "xoxb-test-token",
754+
"scope": "channels:read,chat:write",
755+
"team": {"name": "Test Team", "id": "T1234"},
756+
"authed_user": {"id": "U1234"},
757+
},
758+
)
759+
760+
resp = self._initialize_pipeline()
761+
assert resp.data["step"] == "oauth_login"
762+
pipeline_signature = self._get_pipeline_signature(resp)
763+
764+
resp = self._advance_step({"code": "slack-auth-code", "state": pipeline_signature})
765+
assert resp.status_code == 200
766+
assert resp.data["status"] == "complete"
767+
assert "data" in resp.data
768+
769+
integration = Integration.objects.get(provider="slack")
770+
assert integration.external_id == "T1234"
771+
assert integration.name == "Test Team"
772+
773+
assert OrganizationIntegration.objects.filter(
774+
organization_id=self.organization.id,
775+
integration=integration,
776+
).exists()

0 commit comments

Comments
 (0)