From b4a065793b7ba047b7956fc6cd22ecbd3e311b78 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 7 Nov 2025 18:14:13 +0100 Subject: [PATCH] fix(scopes): Allow project:distribution scope to be granted to integration tokens The project:distribution scope is a specialized token-only scope that is not included in any user role (including owner). However, the validation logic for creating Custom Integration tokens was checking if the user had the scope in their role first, causing it to fail. Additionally, the show_auth_info method was preventing the client secret from being visible when an integration had token-only scopes, because it checked if the user had all the scopes in the integration. This change introduces SENTRY_TOKEN_ONLY_SCOPES to define scopes that can be granted to integration tokens even if the user doesn't have them, and updates both the validation and visibility logic to handle these scopes properly. This allows users to: 1. Create integration tokens with the project:distribution scope 2. View the client secret for integrations with token-only scopes --- src/sentry/conf/server.py | 10 ++++++++++ src/sentry/sentry_apps/api/parsers/sentry_app.py | 8 ++++++++ src/sentry/sentry_apps/models/sentry_app.py | 6 +++++- .../sentry_apps/api/endpoints/test_sentry_apps.py | 15 +++++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) 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)