diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 489003eb0bd58a..8cb5d8e070a47d 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -1789,6 +1789,15 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "email": {"email"}, } +# Specialized scopes that can be granted to integration tokens even if the +# user doesn't have them in their role. These are token-only scopes not intended +# for user roles. +SENTRY_TOKEN_ONLY_SCOPES = frozenset( + [ + "project:distribution", # App distribution/preprod artifacts + ] +) + SENTRY_SCOPE_SETS = ( ( ("org:admin", "Read, write, and admin access to organization details."), @@ -1818,6 +1827,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: ("project:read", "Read access to projects."), ), (("project:releases", "Read, write, and admin access to project releases."),), + (("project:distribution", "Access to app distribution and preprod artifacts."),), ( ("event:admin", "Read, write, and admin access to events."), ("event:write", "Read and write access to events."), diff --git a/src/sentry/sentry_apps/api/parsers/sentry_app.py b/src/sentry/sentry_apps/api/parsers/sentry_app.py index c26137004396ec..c647ea5f27790a 100644 --- a/src/sentry/sentry_apps/api/parsers/sentry_app.py +++ b/src/sentry/sentry_apps/api/parsers/sentry_app.py @@ -176,12 +176,20 @@ def validate_scopes(self, value): if not value: return value + from sentry.conf.server import SENTRY_TOKEN_ONLY_SCOPES + validation_errors = [] for scope in value: # if the existing instance already has this scope, skip the check if self.instance and self.instance.has_scope(scope): continue + # Token-only scopes can be granted even if the user doesn't have them. + # These are specialized scopes (like project:distribution) that are not + # included in any user role but can be granted to integration tokens. + if scope in SENTRY_TOKEN_ONLY_SCOPES: + continue + assert ( self.access is not None ), "Access is required to validate scopes in SentryAppParser" diff --git a/src/sentry/sentry_apps/models/sentry_app.py b/src/sentry/sentry_apps/models/sentry_app.py index 90fc208f69dffd..26218ea992c668 100644 --- a/src/sentry/sentry_apps/models/sentry_app.py +++ b/src/sentry/sentry_apps/models/sentry_app.py @@ -200,8 +200,12 @@ def build_signature(self, body): ).hexdigest() def show_auth_info(self, access): + from sentry.conf.server import SENTRY_TOKEN_ONLY_SCOPES + encoded_scopes = set({"%s" % scope for scope in list(access.scopes)}) - return set(self.scope_list).issubset(encoded_scopes) + # Exclude token-only scopes from the check since users don't have them in their roles + integration_scopes = set(self.scope_list) - SENTRY_TOKEN_ONLY_SCOPES + return integration_scopes.issubset(encoded_scopes) def outboxes_for_update(self) -> list[ControlOutbox]: return [ diff --git a/tests/sentry/sentry_apps/api/endpoints/test_sentry_apps.py b/tests/sentry/sentry_apps/api/endpoints/test_sentry_apps.py index 1a561c18a5ad73..c75790652b8f99 100644 --- a/tests/sentry/sentry_apps/api/endpoints/test_sentry_apps.py +++ b/tests/sentry/sentry_apps/api/endpoints/test_sentry_apps.py @@ -793,6 +793,21 @@ def test_create_integration_exceeding_scopes(self) -> None: ] } + def test_create_integration_with_token_only_scopes(self) -> None: + """Test that token-only scopes (like project:distribution) can be granted + even if the user doesn't have them in their role.""" + self.create_project(organization=self.organization) + + # Token-only scopes like project:distribution are not in any user role, + # but should still be grantable to integration tokens + data = self.get_data( + events=(), + scopes=("project:read", "project:distribution"), + isInternal=True, + ) + response = self.get_success_response(**data, status_code=201) + assert response.data["scopes"] == ["project:distribution", "project:read"] + def test_create_internal_integration_with_non_globally_unique_name(self) -> None: # Internal integration names should only need to be unique within an organization. self.create_project(organization=self.organization)