diff --git a/src/sentry/identity/slack/provider.py b/src/sentry/identity/slack/provider.py index d8cd87778ca2ef..61208b02c7c841 100644 --- a/src/sentry/identity/slack/provider.py +++ b/src/sentry/identity/slack/provider.py @@ -29,9 +29,13 @@ def get_oauth_access_token_url(self) -> str: return "https://slack.com/api/oauth.v2.access" def get_oauth_client_id(self): + if self.config.get("use_staging"): + return options.get("slack-staging.client-id") return options.get("slack.client-id") def get_oauth_client_secret(self): + if self.config.get("use_staging"): + return options.get("slack-staging.client-secret") return options.get("slack.client-secret") def get_user_scopes(self): diff --git a/src/sentry/integrations/slack/integration.py b/src/sentry/integrations/slack/integration.py index 81a1106abc23cd..3f957a3a35ef0c 100644 --- a/src/sentry/integrations/slack/integration.py +++ b/src/sentry/integrations/slack/integration.py @@ -328,15 +328,19 @@ def _get_oauth_scopes(self) -> frozenset[str]: setup_dialog_config = {"width": 600, "height": 900} def _identity_pipeline_view(self) -> PipelineView[IntegrationPipeline]: + nested_config: dict[str, Any] = { + "oauth_scopes": self._get_oauth_scopes(), + "user_scopes": self.user_scopes, + "redirect_url": absolute_uri("/extensions/slack/setup/"), + } + if self.config.get("use_staging"): + nested_config["use_staging"] = True + return NestedPipelineView( bind_key="identity", provider_key=IntegrationProviderSlug.SLACK.value, pipeline_cls=IdentityPipeline, - config={ - "oauth_scopes": self._get_oauth_scopes(), - "user_scopes": self.user_scopes, - "redirect_url": absolute_uri("/extensions/slack/setup/"), - }, + config=nested_config, ) def get_pipeline_views(self) -> Sequence[PipelineView[IntegrationPipeline]]: @@ -377,7 +381,7 @@ def build_integration(self, state: Mapping[str, Any]) -> IntegrationData: "scopes": sorted(scopes), "icon": team_data["icon"]["image_132"], "domain_name": team_data["domain"] + ".slack.com", - "installation_type": "born_as_bot", + "installation_type": "staging" if self.config.get("use_staging") else "born_as_bot", } return { diff --git a/src/sentry/integrations/slack/requests/base.py b/src/sentry/integrations/slack/requests/base.py index 4be0e1874c1f4c..59c9b2264641b4 100644 --- a/src/sentry/integrations/slack/requests/base.py +++ b/src/sentry/integrations/slack/requests/base.py @@ -206,6 +206,11 @@ def authorize(self) -> None: elif verification_token and self._check_verification_token(verification_token): return + # If production credentials failed, try the staging app's signing secret. + staging_signing_secret = options.get("slack-staging.signing-secret") + if staging_signing_secret and self._check_signing_secret(staging_signing_secret): + return + # unfortunately, we can't know which auth was supposed to succeed self._error("slack.action.auth") raise SlackRequestError(status=status_.HTTP_401_UNAUTHORIZED) diff --git a/src/sentry/integrations/web/organization_integration_setup.py b/src/sentry/integrations/web/organization_integration_setup.py index e99d2faade04a3..975ded238faeef 100644 --- a/src/sentry/integrations/web/organization_integration_setup.py +++ b/src/sentry/integrations/web/organization_integration_setup.py @@ -1,4 +1,5 @@ import logging +from typing import Any import sentry_sdk from django.http import Http404, HttpRequest @@ -24,8 +25,19 @@ def handle(self, request: HttpRequest, organization, provider_id) -> HttpRespons scope = sentry_sdk.get_current_scope() scope.set_transaction_name(f"integration.{provider_id}", source=TransactionSource.VIEW) + config: dict[str, Any] = {} + use_staging = request.GET.get("use_staging") == "1" and provider_id == "slack" + + if use_staging: + if not features.has("organizations:slack-staging-app", organization): + raise Http404 + config["use_staging"] = True + pipeline = IntegrationPipeline( - request=request, organization=organization, provider_key=provider_id + request=request, + organization=organization, + provider_key=provider_id, + config=config, ) is_feature_enabled = {} diff --git a/tests/sentry/integrations/slack/test_helpers.py b/tests/sentry/integrations/slack/test_helpers.py new file mode 100644 index 00000000000000..ec82bf610f959c --- /dev/null +++ b/tests/sentry/integrations/slack/test_helpers.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any +from unittest.mock import patch +from urllib.parse import parse_qs, urlencode, urlparse + +import responses +from slack_sdk.web import SlackResponse + +from sentry.testutils.cases import IntegrationTestCase + + +def assert_slack_setup_flow( + test_case: IntegrationTestCase, + team_id: str = "TXXXXXXX1", + authorizing_user_id: str = "UXXXXXXX1", + expected_client_id: str = "slack-client-id", + expected_client_secret: str = "slack-client-secret", + customer_domain: str | None = None, + init_params: Mapping[str, str] | None = None, +) -> None: + responses.reset() + + extra_kwargs: dict[str, Any] = {} + if customer_domain: + extra_kwargs["HTTP_HOST"] = customer_domain + + init_path = test_case.init_path + if init_params: + init_path = f"{init_path}?{urlencode(init_params)}" + + resp = test_case.client.get(init_path, **extra_kwargs) + assert resp.status_code == 302 + redirect = urlparse(resp["Location"]) + assert redirect.scheme == "https" + assert redirect.netloc == "slack.com" + assert redirect.path == "/oauth/v2/authorize" + params = parse_qs(redirect.query) + scopes = test_case.provider.identity_oauth_scopes + assert params["scope"] == [" ".join(scopes)] + assert params["state"] + assert params["redirect_uri"] == ["http://testserver/extensions/slack/setup/"] + assert params["response_type"] == ["code"] + assert params["client_id"] == [expected_client_id] + + assert params.get("user_scope") == [" ".join(test_case.provider.user_scopes)] + # once we've asserted on it, switch to singular values to make life easier + authorize_params = {k: v[0] for k, v in params.items()} + + access_json = { + "ok": True, + "access_token": "xoxb-xxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxx", + "scope": ",".join(sorted(test_case.provider.identity_oauth_scopes)), + "team": {"id": team_id, "name": "Example"}, + "authed_user": {"id": authorizing_user_id}, + } + responses.add(responses.POST, "https://slack.com/api/oauth.v2.access", json=access_json) + + response_json = { + "ok": True, + "members": [ + { + "id": authorizing_user_id, + "team_id": team_id, + "deleted": False, + "profile": { + "email": test_case.user.email, + "team": team_id, + }, + }, + ], + "response_metadata": {"next_cursor": ""}, + } + with patch( + "slack_sdk.web.client.WebClient.users_list", + return_value=SlackResponse( + client=None, + http_verb="GET", + api_url="https://slack.com/api/users.list", + req_args={}, + data=response_json, + headers={}, + status_code=200, + ), + ) as mock_post: + test_case.mock_post = mock_post # type: ignore[attr-defined] + resp = test_case.client.get( + "{}?{}".format( + test_case.setup_path, + urlencode({"code": "oauth-code", "state": authorize_params["state"]}), + ) + ) + + if customer_domain: + assert resp.status_code == 302 + assert resp["Location"].startswith(f"http://{customer_domain}/extensions/slack/setup/") + resp = test_case.client.get(resp["Location"], **extra_kwargs) + + mock_request = responses.calls[0].request + req_params = parse_qs(mock_request.body) + assert req_params["grant_type"] == ["authorization_code"] + assert req_params["code"] == ["oauth-code"] + assert req_params["redirect_uri"] == ["http://testserver/extensions/slack/setup/"] + assert req_params["client_id"] == [expected_client_id] + assert req_params["client_secret"] == [expected_client_secret] + + assert resp.status_code == 200 + test_case.assertDialogSuccess(resp) diff --git a/tests/sentry/integrations/slack/test_integration.py b/tests/sentry/integrations/slack/test_integration.py index d924ac5d9fb8c3..54bcc937a0072c 100644 --- a/tests/sentry/integrations/slack/test_integration.py +++ b/tests/sentry/integrations/slack/test_integration.py @@ -1,12 +1,10 @@ from unittest.mock import MagicMock, patch -from urllib.parse import parse_qs, urlencode, urlparse import orjson import pytest import responses from responses.matchers import query_string_matcher from slack_sdk.errors import SlackApiError -from slack_sdk.web import SlackResponse from sentry import audit_log from sentry.integrations.models.integration import Integration @@ -27,6 +25,7 @@ from sentry.testutils.notifications.platform import MockNotification, MockNotificationTemplate from sentry.testutils.silo import control_silo_test from sentry.users.models.identity import Identity, IdentityProvider, IdentityStatus +from tests.sentry.integrations.slack.test_helpers import assert_slack_setup_flow @control_silo_test @@ -52,99 +51,8 @@ class SlackIntegrationTest(IntegrationTestCase): def setUp(self) -> None: super().setUp() - def assert_setup_flow( - self, - team_id="TXXXXXXX1", - authorizing_user_id="UXXXXXXX1", - expected_client_id="slack-client-id", - expected_client_secret="slack-client-secret", - customer_domain=None, - ): - responses.reset() - - kwargs = {} - if customer_domain: - kwargs["HTTP_HOST"] = customer_domain - - resp = self.client.get(self.init_path, **kwargs) - assert resp.status_code == 302 - redirect = urlparse(resp["Location"]) - assert redirect.scheme == "https" - assert redirect.netloc == "slack.com" - assert redirect.path == "/oauth/v2/authorize" - params = parse_qs(redirect.query) - scopes = self.provider.identity_oauth_scopes - assert params["scope"] == [" ".join(scopes)] - assert params["state"] - assert params["redirect_uri"] == ["http://testserver/extensions/slack/setup/"] - assert params["response_type"] == ["code"] - assert params["client_id"] == [expected_client_id] - - assert params.get("user_scope") == [" ".join(self.provider.user_scopes)] - # once we've asserted on it, switch to a singular values to make life - # easier - authorize_params = {k: v[0] for k, v in params.items()} - - access_json = { - "ok": True, - "access_token": "xoxb-xxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxx", - "scope": ",".join(sorted(self.provider.identity_oauth_scopes)), - "team": {"id": team_id, "name": "Example"}, - "authed_user": {"id": authorizing_user_id}, - } - responses.add(responses.POST, "https://slack.com/api/oauth.v2.access", json=access_json) - - response_json = { - "ok": True, - "members": [ - { - "id": authorizing_user_id, - "team_id": team_id, - "deleted": False, - "profile": { - "email": self.user.email, - "team": team_id, - }, - }, - ], - "response_metadata": {"next_cursor": ""}, - } - with patch( - "slack_sdk.web.client.WebClient.users_list", - return_value=SlackResponse( - client=None, - http_verb="GET", - api_url="https://slack.com/api/users.list", - req_args={}, - data=response_json, - headers={}, - status_code=200, - ), - ) as self.mock_post: - resp = self.client.get( - "{}?{}".format( - self.setup_path, - urlencode({"code": "oauth-code", "state": authorize_params["state"]}), - ) - ) - - if customer_domain: - assert resp.status_code == 302 - assert resp["Location"].startswith( - f"http://{customer_domain}/extensions/slack/setup/" - ) - resp = self.client.get(resp["Location"], **kwargs) - - mock_request = responses.calls[0].request - req_params = parse_qs(mock_request.body) - assert req_params["grant_type"] == ["authorization_code"] - assert req_params["code"] == ["oauth-code"] - assert req_params["redirect_uri"] == ["http://testserver/extensions/slack/setup/"] - assert req_params["client_id"] == [expected_client_id] - assert req_params["client_secret"] == [expected_client_secret] - - assert resp.status_code == 200 - self.assertDialogSuccess(resp) + def assert_setup_flow(self, **kwargs): + assert_slack_setup_flow(self, **kwargs) @responses.activate def test_bot_flow(self, mock_api_call: MagicMock) -> None: diff --git a/tests/sentry/integrations/slack/test_slack_staging_sidegrade.py b/tests/sentry/integrations/slack/test_slack_staging_sidegrade.py new file mode 100644 index 00000000000000..dcf5012594c9f1 --- /dev/null +++ b/tests/sentry/integrations/slack/test_slack_staging_sidegrade.py @@ -0,0 +1,270 @@ +"""Tests for the Slack staging sidegrade feature (Option 4). + +This feature allows organizations to sidegrade their production Slack integration +to a staging app and vice versa, by swapping credentials in-place on the existing +Integration row. +""" + +from unittest import mock +from unittest.mock import MagicMock, patch +from urllib.parse import parse_qs, urlencode, urlparse + +import orjson +import pytest +import responses + +from sentry import options +from sentry.integrations.models.integration import Integration +from sentry.integrations.slack import SlackIntegrationProvider +from sentry.integrations.slack.requests.base import SlackRequest, SlackRequestError +from sentry.integrations.slack.utils.auth import set_signing_secret +from sentry.testutils.cases import IntegrationTestCase, TestCase +from sentry.testutils.helpers import override_options +from sentry.testutils.helpers.features import with_feature +from sentry.testutils.silo import control_silo_test +from tests.sentry.integrations.slack.test_helpers import assert_slack_setup_flow + +STAGING_CLIENT_ID = "staging-client-id" +STAGING_CLIENT_SECRET = "staging-client-secret" +STAGING_SIGNING_SECRET = "staging-signing-secret" + +STAGING_OPTIONS = { + "slack-staging.client-id": STAGING_CLIENT_ID, + "slack-staging.client-secret": STAGING_CLIENT_SECRET, + "slack-staging.signing-secret": STAGING_SIGNING_SECRET, +} + + +@control_silo_test +class SlackStagingSetupGatingTest(TestCase): + """Tests for the use_staging query param gating in OrganizationIntegrationSetupView.""" + + def setUp(self) -> None: + super().setUp() + self.organization = self.create_organization(name="foo", owner=self.user) + self.login_as(self.user) + self.path = f"/organizations/{self.organization.slug}/integrations/slack/setup/" + + def test_use_staging_without_feature_flag_returns_404(self) -> None: + resp = self.client.get(f"{self.path}?use_staging=1") + assert resp.status_code == 404 + + @with_feature({"organizations:slack-staging-app": True}) + def test_use_staging_with_feature_flag_proceeds(self) -> None: + resp = self.client.get(f"{self.path}?use_staging=1") + assert resp.status_code == 302 + + def test_non_slack_provider_ignores_use_staging(self) -> None: + path = f"/organizations/{self.organization.slug}/integrations/example/setup/" + resp = self.client.get(f"{path}?use_staging=1") + assert resp.status_code == 200 + + @with_feature({"organizations:slack-staging-app": True}) + def test_use_staging_value_must_be_1(self) -> None: + """Only use_staging=1 triggers staging mode, not other truthy values.""" + resp = self.client.get(f"{self.path}?use_staging=true") + assert resp.status_code == 302 + redirect = urlparse(resp["Location"]) + params = parse_qs(redirect.query) + assert params["client_id"] == [options.get("slack.client-id")] + + +@control_silo_test +class SlackIdentityProviderStagingTest(TestCase): + """Tests for SlackIdentityProvider staging credential selection.""" + + def _make_provider(self, use_staging=False): + from sentry.identity.slack.provider import SlackIdentityProvider + + provider = SlackIdentityProvider() + if use_staging: + provider.update_config({"use_staging": True}) + return provider + + def test_returns_production_credentials_by_default(self) -> None: + provider = self._make_provider() + assert provider.get_oauth_client_id() == options.get("slack.client-id") + assert provider.get_oauth_client_secret() == options.get("slack.client-secret") + + @override_options(STAGING_OPTIONS) + def test_returns_staging_credentials_when_use_staging(self) -> None: + provider = self._make_provider(use_staging=True) + assert provider.get_oauth_client_id() == STAGING_CLIENT_ID + assert provider.get_oauth_client_secret() == STAGING_CLIENT_SECRET + + @override_options(STAGING_OPTIONS) + def test_use_staging_false_returns_production(self) -> None: + from sentry.identity.slack.provider import SlackIdentityProvider + + provider = SlackIdentityProvider() + provider.update_config({"use_staging": False}) + assert provider.get_oauth_client_id() == options.get("slack.client-id") + assert provider.get_oauth_client_secret() == options.get("slack.client-secret") + + +@control_silo_test +class SlackIntegrationProviderBuildIntegrationTest(TestCase): + """Tests for the installation_type field in build_integration().""" + + def _build_integration(self, use_staging=False): + provider = SlackIntegrationProvider() + if use_staging: + provider.config = {"use_staging": True} + + state = { + "identity": { + "data": { + "ok": True, + "access_token": "xoxb-token", + "scope": "chat:write,commands", + "team": {"id": "TXXXXXXX1", "name": "Example"}, + "authed_user": {"id": "UXXXXXXX1"}, + } + } + } + with patch( + "slack_sdk.web.client.WebClient._perform_urllib_http_request", + return_value={ + "body": orjson.dumps( + { + "ok": True, + "team": { + "domain": "test-workspace", + "icon": {"image_132": "http://example.com/icon.jpg"}, + }, + } + ).decode(), + "headers": {}, + "status": 200, + }, + ): + return provider.build_integration(state) + + def test_production_installation_type(self) -> None: + result = self._build_integration(use_staging=False) + assert result["metadata"]["installation_type"] == "born_as_bot" + + def test_staging_installation_type(self) -> None: + result = self._build_integration(use_staging=True) + assert result["metadata"]["installation_type"] == "staging" + + +@control_silo_test +class SlackRequestStagingAuthTest(TestCase): + """Tests for SlackRequest.authorize() staging signing secret fallback.""" + + def _make_signed_request(self, secret: str) -> mock.Mock: + request = mock.Mock() + request.data = { + "type": "foo", + "team_id": "T001", + "channel": {"id": "1"}, + "user": {"id": "2"}, + "api_app_id": "S1", + } + request.body = urlencode(request.data).encode("utf-8") + request.META = set_signing_secret(secret, request.body) + return request + + def test_production_signing_secret_accepted(self) -> None: + request = self._make_signed_request(options.get("slack.signing-secret")) + SlackRequest(request).authorize() + + @override_options(STAGING_OPTIONS) + def test_staging_signing_secret_accepted(self) -> None: + request = self._make_signed_request(STAGING_SIGNING_SECRET) + SlackRequest(request).authorize() + + def test_invalid_signing_secret_rejected(self) -> None: + request = self._make_signed_request("totally-wrong-secret") + with pytest.raises(SlackRequestError) as exc_info: + SlackRequest(request).authorize() + assert exc_info.value.status == 401 + + +@control_silo_test +@patch( + "slack_sdk.web.client.WebClient._perform_urllib_http_request", + return_value={ + "body": orjson.dumps( + { + "ok": True, + "team": { + "domain": "test-slack-workspace", + "icon": {"image_132": "http://example.com/ws_icon.jpg"}, + }, + } + ).decode(), + "headers": {}, + "status": 200, + }, +) +class SlackStagingSidegradeFlowTest(IntegrationTestCase): + """Tests for the end-to-end sidegrade: production -> staging -> production.""" + + provider = SlackIntegrationProvider + + def assert_setup_flow(self, **kwargs): + assert_slack_setup_flow(self, **kwargs) + + @responses.activate + @with_feature({"organizations:slack-staging-app": True}) + @override_options(STAGING_OPTIONS) + def test_staging_flow_uses_staging_credentials(self, mock_api_call: MagicMock) -> None: + """The staging OAuth flow uses staging client ID/secret and sets installation_type.""" + with self.tasks(): + self.assert_setup_flow( + expected_client_id=STAGING_CLIENT_ID, + expected_client_secret=STAGING_CLIENT_SECRET, + init_params={"use_staging": "1"}, + ) + + integration = Integration.objects.get(provider=self.provider.key) + assert integration.metadata["installation_type"] == "staging" + + @responses.activate + @with_feature({"organizations:slack-staging-app": True}) + @override_options(STAGING_OPTIONS) + def test_sidegrade_updates_existing_integration_in_place( + self, mock_api_call: MagicMock + ) -> None: + """Sidegrading replaces credentials on the existing Integration row.""" + with self.tasks(): + self.assert_setup_flow() + + integration = Integration.objects.get(provider=self.provider.key) + assert integration.metadata["installation_type"] == "born_as_bot" + original_id = integration.id + + with self.tasks(): + self.assert_setup_flow( + expected_client_id=STAGING_CLIENT_ID, + expected_client_secret=STAGING_CLIENT_SECRET, + init_params={"use_staging": "1"}, + ) + + integration.refresh_from_db() + assert integration.id == original_id + assert integration.metadata["installation_type"] == "staging" + assert Integration.objects.filter(provider=self.provider.key).count() == 1 + + @responses.activate + @with_feature({"organizations:slack-staging-app": True}) + @override_options(STAGING_OPTIONS) + def test_sidegrade_back_to_production(self, mock_api_call: MagicMock) -> None: + """Re-installing without staging after a sidegrade restores production metadata.""" + with self.tasks(): + self.assert_setup_flow( + expected_client_id=STAGING_CLIENT_ID, + expected_client_secret=STAGING_CLIENT_SECRET, + init_params={"use_staging": "1"}, + ) + + integration = Integration.objects.get(provider=self.provider.key) + assert integration.metadata["installation_type"] == "staging" + + with self.tasks(): + self.assert_setup_flow() + + integration.refresh_from_db() + assert integration.metadata["installation_type"] == "born_as_bot"