Skip to content

Commit 33632d9

Browse files
committed
feat(integrations): Add API driven pipeline for Slack
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 b7608da commit 33632d9

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
@@ -322,6 +323,7 @@ def _get_oauth_scopes(self) -> frozenset[str]:
322323
return self.identity_oauth_scopes
323324

324325
setup_dialog_config = {"width": 600, "height": 900}
326+
setup_url_path = "/extensions/slack/setup/"
325327

326328
def _identity_pipeline_view(self) -> PipelineView[IntegrationPipeline]:
327329
return NestedPipelineView(
@@ -331,13 +333,28 @@ def _identity_pipeline_view(self) -> PipelineView[IntegrationPipeline]:
331333
config={
332334
"oauth_scopes": self._get_oauth_scopes(),
333335
"user_scopes": self.user_scopes,
334-
"redirect_url": absolute_uri("/extensions/slack/setup/"),
336+
"redirect_url": absolute_uri(self.setup_url_path),
335337
},
336338
)
337339

338340
def get_pipeline_views(self) -> Sequence[PipelineView[IntegrationPipeline]]:
339341
return [self._identity_pipeline_view()]
340342

343+
def _make_identity_provider(self) -> SlackIdentityProvider:
344+
return SlackIdentityProvider(
345+
oauth_scopes=self._get_oauth_scopes(),
346+
redirect_url=absolute_uri(self.setup_url_path),
347+
)
348+
349+
def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]:
350+
provider = self._make_identity_provider()
351+
return [
352+
provider.make_oauth_api_step(
353+
bind_key="oauth_data",
354+
extra_authorize_params={"user_scope": " ".join(self.user_scopes)},
355+
),
356+
]
357+
341358
def _get_team_info(self, access_token: str) -> Any:
342359
# Manually add authorization since this method is part of slack installation
343360

@@ -352,7 +369,13 @@ def _get_team_info(self, access_token: str) -> Any:
352369
raise IntegrationError("Could not retrieve Slack team information.")
353370

354371
def build_integration(self, state: Mapping[str, Any]) -> IntegrationData:
355-
data = state["identity"]["data"]
372+
# TODO: legacy views write token data to state["identity"]["data"] via
373+
# NestedPipelineView. API steps write directly to state["oauth_data"].
374+
# Remove the legacy path once the old views are retired.
375+
if "oauth_data" in state:
376+
data = state["oauth_data"]
377+
else:
378+
data = state["identity"]["data"]
356379
assert data["ok"]
357380

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

0 commit comments

Comments
 (0)