Skip to content

Commit 9deb0e3

Browse files
authored
fix(scopes): Allow project:distribution scope to be granted to integration tokens (#102967)
## Summary Fixes two related issues with the `project:distribution` scope for Custom Integration tokens: 1. **Creation Error**: Users couldn't add the `project:distribution` permission when creating Custom Integration tokens, getting the error: *"Requested permission of project:distribution exceeds requester's permission. Please contact an administrator to make the requested change."* <img width="1266" height="241" alt="Screenshot 2025-11-07 at 18 01 11" src="https://github.com/user-attachments/assets/a7fdee78-182b-4f0c-a9da-be0b6d89d520" /> 2. **Client Secret Masked**: Even if users bypassed the creation error, the client secret was immediately masked as `****` instead of being visible. <img width="604" height="223" alt="Screenshot 2025-11-07 at 18 20 03" src="https://github.com/user-attachments/assets/66f50d31-06bd-4454-9f7e-c280a9f6ab9e" /> ## Root Cause The `project:distribution` scope is a specialized token-only scope that is intentionally not included in any user role (including owner). This design allows distribution tokens to be used in apps that are distributed without risking accidentally leaking a token with broader permissions. However, two pieces of validation logic were checking if the user personally had these scopes: 1. **`SentryAppParser.validate_scopes()`** - Blocked creation if the user didn't have the requested scopes 2. **`SentryApp.show_auth_info()`** - Hid the client secret if the user didn't have all the integration's scopes ## Changes - Added `SENTRY_TOKEN_ONLY_SCOPES` constant in `server.py` to define scopes that can be granted to integration tokens even if the user doesn't have them - Updated `SentryAppParser.validate_scopes()` to skip permission checks for token-only scopes - Updated `SentryApp.show_auth_info()` to exclude token-only scopes when determining if the client secret should be visible - Added `project:distribution` to `SENTRY_SCOPE_SETS` for documentation - Added test coverage for token-only scope validation and visibility
1 parent 7616b7f commit 9deb0e3

File tree

4 files changed

+38
-1
lines changed

4 files changed

+38
-1
lines changed

src/sentry/conf/server.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1789,6 +1789,15 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
17891789
"email": {"email"},
17901790
}
17911791

1792+
# Specialized scopes that can be granted to integration tokens even if the
1793+
# user doesn't have them in their role. These are token-only scopes not intended
1794+
# for user roles.
1795+
SENTRY_TOKEN_ONLY_SCOPES = frozenset(
1796+
[
1797+
"project:distribution", # App distribution/preprod artifacts
1798+
]
1799+
)
1800+
17921801
SENTRY_SCOPE_SETS = (
17931802
(
17941803
("org:admin", "Read, write, and admin access to organization details."),
@@ -1818,6 +1827,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
18181827
("project:read", "Read access to projects."),
18191828
),
18201829
(("project:releases", "Read, write, and admin access to project releases."),),
1830+
(("project:distribution", "Access to app distribution and preprod artifacts."),),
18211831
(
18221832
("event:admin", "Read, write, and admin access to events."),
18231833
("event:write", "Read and write access to events."),

src/sentry/sentry_apps/api/parsers/sentry_app.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,12 +176,20 @@ def validate_scopes(self, value):
176176
if not value:
177177
return value
178178

179+
from sentry.conf.server import SENTRY_TOKEN_ONLY_SCOPES
180+
179181
validation_errors = []
180182
for scope in value:
181183
# if the existing instance already has this scope, skip the check
182184
if self.instance and self.instance.has_scope(scope):
183185
continue
184186

187+
# Token-only scopes can be granted even if the user doesn't have them.
188+
# These are specialized scopes (like project:distribution) that are not
189+
# included in any user role but can be granted to integration tokens.
190+
if scope in SENTRY_TOKEN_ONLY_SCOPES:
191+
continue
192+
185193
assert (
186194
self.access is not None
187195
), "Access is required to validate scopes in SentryAppParser"

src/sentry/sentry_apps/models/sentry_app.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,12 @@ def build_signature(self, body):
200200
).hexdigest()
201201

202202
def show_auth_info(self, access):
203+
from sentry.conf.server import SENTRY_TOKEN_ONLY_SCOPES
204+
203205
encoded_scopes = set({"%s" % scope for scope in list(access.scopes)})
204-
return set(self.scope_list).issubset(encoded_scopes)
206+
# Exclude token-only scopes from the check since users don't have them in their roles
207+
integration_scopes = set(self.scope_list) - SENTRY_TOKEN_ONLY_SCOPES
208+
return integration_scopes.issubset(encoded_scopes)
205209

206210
def outboxes_for_update(self) -> list[ControlOutbox]:
207211
return [

tests/sentry/sentry_apps/api/endpoints/test_sentry_apps.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,21 @@ def test_create_integration_exceeding_scopes(self) -> None:
793793
]
794794
}
795795

796+
def test_create_integration_with_token_only_scopes(self) -> None:
797+
"""Test that token-only scopes (like project:distribution) can be granted
798+
even if the user doesn't have them in their role."""
799+
self.create_project(organization=self.organization)
800+
801+
# Token-only scopes like project:distribution are not in any user role,
802+
# but should still be grantable to integration tokens
803+
data = self.get_data(
804+
events=(),
805+
scopes=("project:read", "project:distribution"),
806+
isInternal=True,
807+
)
808+
response = self.get_success_response(**data, status_code=201)
809+
assert response.data["scopes"] == ["project:distribution", "project:read"]
810+
796811
def test_create_internal_integration_with_non_globally_unique_name(self) -> None:
797812
# Internal integration names should only need to be unique within an organization.
798813
self.create_project(organization=self.organization)

0 commit comments

Comments
 (0)