diff --git a/src/sentry/sentry_apps/token_exchange/validator.py b/src/sentry/sentry_apps/token_exchange/validator.py index 5f6428699463..f136dba08bb7 100644 --- a/src/sentry/sentry_apps/token_exchange/validator.py +++ b/src/sentry/sentry_apps/token_exchange/validator.py @@ -2,6 +2,7 @@ from django.utils.functional import cached_property +from sentry.constants import SentryAppInstallationStatus, SentryAppStatus from sentry.models.apiapplication import ApiApplication from sentry.sentry_apps.models.sentry_app import SentryApp from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation @@ -24,6 +25,7 @@ def run(self) -> bool: self._validate_is_sentry_app_making_request() self._validate_app_is_owned_by_user() self._validate_application_is_active() + self._validate_sentry_app() self._validate_installation() return True @@ -58,13 +60,40 @@ def _validate_application_is_active(self) -> None: }, ) + def _validate_sentry_app(self) -> None: + if self.sentry_app.status == SentryAppStatus.DELETION_IN_PROGRESS: + raise SentryAppIntegratorError( + "Integration is being deleted", + status_code=401, + webhook_context={"sentry_app_id": self.sentry_app.id}, + ) + def _validate_installation(self) -> None: - if self.install.sentry_app.id != self.sentry_app.id: + # The caller may have loaded the installation before deletion started. + try: + installation = SentryAppInstallation.objects.get( + id=self.install.id, date_deleted__isnull=True + ) + except SentryAppInstallation.DoesNotExist: + raise SentryAppIntegratorError( + "Installation not found", + status_code=401, + webhook_context={"installation_uuid": self.install.uuid}, + ) + + if installation.sentry_app_id != self.sentry_app.id: raise SentryAppIntegratorError( f"Given installation is not for integration: {self.sentry_app.slug}", webhook_context={"installation_uuid": self.install.uuid}, ) + if installation.status == SentryAppInstallationStatus.PENDING_DELETION: + raise SentryAppIntegratorError( + "Installation is being deleted", + status_code=401, + webhook_context={"installation_uuid": self.install.uuid}, + ) + @cached_property def sentry_app(self) -> SentryApp: try: diff --git a/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_authorizations.py b/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_authorizations.py index 946d23084121..2ecd2f093e1c 100644 --- a/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_authorizations.py +++ b/tests/sentry/sentry_apps/api/endpoints/test_sentry_app_authorizations.py @@ -5,7 +5,9 @@ from django.utils import timezone from rest_framework.response import Response +from sentry.constants import SentryAppInstallationStatus, SentryAppStatus from sentry.models.apiapplication import ApiApplication +from sentry.models.apigrant import ApiGrant from sentry.models.apitoken import ApiToken from sentry.sentry_apps.token_exchange.util import GrantTypes from sentry.silo.base import SiloMode @@ -82,6 +84,90 @@ def test_exchange_for_token_missing_data(self) -> None: response = self.get_error_response(client_id=None) assert response.status_code == 401 + def test_rejects_authorization_for_deleting_app(self) -> None: + self.sentry_app.update(status=SentryAppStatus.DELETION_IN_PROGRESS) + assert self.sentry_app.application.is_active + + response = self.get_error_response(status_code=401) + + assert response.data["detail"] == "Integration is being deleted" + assert ApiGrant.objects.filter(id=self.install.api_grant_id).exists() + assert not ApiToken.objects.filter(application=self.sentry_app.application).exists() + + def test_rejects_authorization_for_deleting_installation(self) -> None: + self.install.update(status=SentryAppInstallationStatus.PENDING_DELETION) + assert self.sentry_app.application.is_active + + response = self.get_error_response(status_code=401) + + assert response.data["detail"] == "Installation is being deleted" + assert ApiGrant.objects.filter(id=self.install.api_grant_id).exists() + assert not ApiToken.objects.filter(application=self.sentry_app.application).exists() + + def test_rejects_refresh_for_deleting_app(self) -> None: + initial_response = self.get_success_response() + self.sentry_app.update(status=SentryAppStatus.DELETION_IN_PROGRESS) + + response = self.get_error_response( + grant_type=GrantTypes.REFRESH, + refresh_token=initial_response.data["refreshToken"], + status_code=401, + ) + + assert response.data["detail"] == "Integration is being deleted" + self._assert_token_unchanged(initial_response.data["id"]) + + def test_rejects_refresh_for_deleting_installation(self) -> None: + initial_response = self.get_success_response() + self.install.update(status=SentryAppInstallationStatus.PENDING_DELETION) + + response = self.get_error_response( + grant_type=GrantTypes.REFRESH, + refresh_token=initial_response.data["refreshToken"], + status_code=401, + ) + + assert response.data["detail"] == "Installation is being deleted" + self._assert_token_unchanged(initial_response.data["id"]) + + def test_rejects_client_secret_jwt_refresh_for_deleting_app(self) -> None: + initial_response = self.get_success_response() + jwt_token = self._create_jwt( + self.sentry_app.application.client_id, self.sentry_app.application.client_secret + ) + self.sentry_app.update(status=SentryAppStatus.DELETION_IN_PROGRESS) + + response = self.get_error_response( + grant_type=GrantTypes.CLIENT_SECRET_JWT, + extra_headers={"HTTP_AUTHORIZATION": f"Bearer {jwt_token}"}, + status_code=401, + ) + + assert response.data["detail"] == "Integration is being deleted" + self._assert_token_unchanged(initial_response.data["id"]) + + def test_rejects_client_secret_jwt_refresh_for_deleting_installation(self) -> None: + initial_response = self.get_success_response() + jwt_token = self._create_jwt( + self.sentry_app.application.client_id, self.sentry_app.application.client_secret + ) + self.install.update(status=SentryAppInstallationStatus.PENDING_DELETION) + + response = self.get_error_response( + grant_type=GrantTypes.CLIENT_SECRET_JWT, + extra_headers={"HTTP_AUTHORIZATION": f"Bearer {jwt_token}"}, + status_code=401, + ) + + assert response.data["detail"] == "Installation is being deleted" + self._assert_token_unchanged(initial_response.data["id"]) + + def _assert_token_unchanged(self, token_id: str) -> None: + token = ApiToken.objects.get(application=self.sentry_app.application) + assert str(token.id) == token_id + self.install.refresh_from_db() + assert self.install.api_token_id == token.id + def test_incorrect_grant_type(self) -> None: self.get_error_response(grant_type="notit", status_code=403) diff --git a/tests/sentry/sentry_apps/token_exchange/test_validator.py b/tests/sentry/sentry_apps/token_exchange/test_validator.py index 1123b89bc811..73ee7462d9ef 100644 --- a/tests/sentry/sentry_apps/token_exchange/test_validator.py +++ b/tests/sentry/sentry_apps/token_exchange/test_validator.py @@ -2,7 +2,9 @@ import pytest +from sentry.constants import SentryAppInstallationStatus, SentryAppStatus from sentry.sentry_apps.models.sentry_app import SentryApp +from sentry.sentry_apps.models.sentry_app_installation import SentryAppInstallation from sentry.sentry_apps.token_exchange.validator import Validator from sentry.sentry_apps.utils.errors import SentryAppIntegratorError, SentryAppSentryError from sentry.testutils.cases import TestCase @@ -24,6 +26,44 @@ def setUp(self) -> None: def test_happy_path(self) -> None: assert self.validator.run() + def test_allows_pending_installation(self) -> None: + self.install.update(status=SentryAppInstallationStatus.PENDING) + + assert self.validator.run() + + def test_rejects_deleting_app(self) -> None: + SentryApp.objects.get(id=self.install.sentry_app_id).update( + status=SentryAppStatus.DELETION_IN_PROGRESS + ) + assert self.install.sentry_app.status != SentryAppStatus.DELETION_IN_PROGRESS + + with pytest.raises(SentryAppIntegratorError) as exc: + self.validator.run() + + assert exc.value.message == "Integration is being deleted" + assert exc.value.status_code == 401 + + def test_rejects_deleting_installation_with_stale_instance(self) -> None: + SentryAppInstallation.objects.get(id=self.install.id).update( + status=SentryAppInstallationStatus.PENDING_DELETION + ) + assert self.install.status != SentryAppInstallationStatus.PENDING_DELETION + + with pytest.raises(SentryAppIntegratorError) as exc: + self.validator.run() + + assert exc.value.message == "Installation is being deleted" + assert exc.value.status_code == 401 + + def test_rejects_deleted_installation(self) -> None: + self.install.delete() + + with pytest.raises(SentryAppIntegratorError) as exc: + self.validator.run() + + assert exc.value.message == "Installation not found" + assert exc.value.status_code == 401 + def test_request_must_be_made_by_sentry_app(self) -> None: self.validator.user = self.create_user()