Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/sentry/conf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
Expand Down Expand Up @@ -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."),
Expand Down
8 changes: 8 additions & 0 deletions src/sentry/sentry_apps/api/parsers/sentry_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 5 additions & 1 deletion src/sentry/sentry_apps/models/sentry_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down
15 changes: 15 additions & 0 deletions tests/sentry/sentry_apps/api/endpoints/test_sentry_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading