diff --git a/src/sentry/api/bases/organization.py b/src/sentry/api/bases/organization.py index f597e4be73944b..bbea3d6ef176f2 100644 --- a/src/sentry/api/bases/organization.py +++ b/src/sentry/api/bases/organization.py @@ -196,17 +196,26 @@ class OrganizationUserReportsPermission(OrganizationPermission): class OrganizationPinnedSearchPermission(OrganizationPermission): scope_map = { - "PUT": ["org:read", "org:write", "org:admin"], - "DELETE": ["org:read", "org:write", "org:admin"], + "PUT": ["user:preferences"], + "DELETE": ["user:preferences"], } class OrganizationSearchPermission(OrganizationPermission): scope_map = { - "GET": ["org:read", "org:write", "org:admin"], - "POST": ["org:read", "org:write", "org:admin"], - "PUT": ["org:read", "org:write", "org:admin"], - "DELETE": ["org:read", "org:write", "org:admin"], + "GET": ["org:read", "org:searches"], + "POST": ["org:searches"], + "PUT": ["org:searches"], + "DELETE": ["org:searches"], + } + + +class OrganizationPreferencePermission(OrganizationPermission): + scope_map = { + "GET": ["user:preferences"], + "POST": ["user:preferences"], + "PUT": ["user:preferences"], + "DELETE": ["user:preferences"], } @@ -281,8 +290,8 @@ class OrgAuthTokenPermission(OrganizationPermission): class OrganizationFlagWebHookSigningSecretPermission(OrganizationPermission): scope_map = { "GET": ["org:read", "org:write", "org:admin"], - "POST": ["org:read", "org:write", "org:admin"], - "DELETE": ["org:write", "org:admin"], + "POST": ["flags:write"], + "DELETE": ["flags:write"], } diff --git a/src/sentry/api/endpoints/organization_onboarding_continuation_email.py b/src/sentry/api/endpoints/organization_onboarding_continuation_email.py index 8351444da7ce97..e1c410dc6b433d 100644 --- a/src/sentry/api/endpoints/organization_onboarding_continuation_email.py +++ b/src/sentry/api/endpoints/organization_onboarding_continuation_email.py @@ -7,7 +7,7 @@ from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint -from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPreferencePermission from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer from sentry.models.organization import Organization from sentry.users.models.user import User @@ -41,17 +41,13 @@ def get_request_builder_args(user: User, organization: Organization, platforms: } -class OnboardingContinuationPermission(OrganizationPermission): - scope_map = {"POST": ["org:read", "org:write", "org:admin"]} - - @cell_silo_endpoint class OrganizationOnboardingContinuationEmail(OrganizationEndpoint): publish_status = { "POST": ApiPublishStatus.PRIVATE, } owner = ApiOwner.VALUE_DISCOVERY - permission_classes = (OnboardingContinuationPermission,) + permission_classes = (OrganizationPreferencePermission,) def post(self, request: Request, organization: Organization): serializer = OnboardingContinuationSerializer(data=request.data) diff --git a/src/sentry/api/endpoints/organization_onboarding_tasks.py b/src/sentry/api/endpoints/organization_onboarding_tasks.py index c1aae4b2f25a25..6ff491062b88bb 100644 --- a/src/sentry/api/endpoints/organization_onboarding_tasks.py +++ b/src/sentry/api/endpoints/organization_onboarding_tasks.py @@ -7,16 +7,12 @@ from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint -from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPreferencePermission from sentry.api.serializers import serialize from sentry.models.organization import Organization from sentry.models.organizationonboardingtask import OnboardingTask, OnboardingTaskStatus -class OnboardingTaskPermission(OrganizationPermission): - scope_map = {"POST": ["org:read"], "GET": ["org:read"]} - - @cell_silo_endpoint class OrganizationOnboardingTaskEndpoint(OrganizationEndpoint): publish_status = { @@ -24,7 +20,7 @@ class OrganizationOnboardingTaskEndpoint(OrganizationEndpoint): "GET": ApiPublishStatus.PRIVATE, } owner = ApiOwner.VALUE_DISCOVERY - permission_classes = (OnboardingTaskPermission,) + permission_classes = (OrganizationPreferencePermission,) def post(self, request: Request, organization) -> Response: task_id = onboarding_tasks.get_task_lookup_by_key(request.data["task"]) diff --git a/src/sentry/api/endpoints/organization_recent_searches.py b/src/sentry/api/endpoints/organization_recent_searches.py index 4b6c1398ccfc63..757bb7d80584cb 100644 --- a/src/sentry/api/endpoints/organization_recent_searches.py +++ b/src/sentry/api/endpoints/organization_recent_searches.py @@ -19,8 +19,8 @@ class RecentSearchSerializer(serializers.Serializer): class OrganizationRecentSearchPermission(OrganizationPermission): scope_map = { - "GET": ["org:read", "org:write", "org:admin"], - "POST": ["org:read", "org:write", "org:admin"], + "GET": ["user:preferences"], + "POST": ["user:preferences"], } diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 5999a79f401530..fcf79a64cb886f 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -1817,7 +1817,9 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "org:write", "org:admin", "org:integrations", + "org:searches", "org:ci", + "user:preferences", # "org:superuser", Do not use for any type of superuser permission/access checks # Assigned to active SU sessions in src/sentry/auth/access.py to enable UI elements "member:invite", @@ -1839,6 +1841,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "event:admin", "alerts:read", "alerts:write", + "flags:write", # openid, profile, and email aren't prefixed to maintain compliance with the OIDC spec. # https://auth0.com/docs/get-started/apis/scopes/openid-connect-scopes. "openid", @@ -1857,10 +1860,19 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: SENTRY_SCOPE_HIERARCHY_MAPPING = { "org:read": {"org:read"}, - "org:write": {"org:read", "org:write"}, - "org:admin": {"org:read", "org:write", "org:admin", "org:integrations"}, + "org:write": {"org:read", "org:write", "org:searches", "flags:write"}, + "org:admin": { + "org:read", + "org:write", + "org:admin", + "org:integrations", + "org:searches", + "flags:write", + }, "org:integrations": {"org:integrations"}, + "org:searches": {"org:searches"}, "org:ci": {"org:ci"}, + "user:preferences": {"user:preferences"}, "member:invite": {"member:read", "member:invite"}, "member:read": {"member:read"}, "member:write": {"member:read", "member:invite", "member:write"}, @@ -1891,6 +1903,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "event:admin": {"event:read", "event:write", "event:admin"}, "alerts:read": {"alerts:read"}, "alerts:write": {"alerts:read", "alerts:write"}, + "flags:write": {"flags:write"}, "openid": {"openid"}, "profile": {"profile"}, "email": {"email"}, @@ -1905,6 +1918,15 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: ] ) +# Scopes that are valid for first-party session flows and direct tokens, but +# should not be grantable to third-party API applications or Sentry Apps. +SENTRY_NON_APP_GRANTABLE_SCOPES = frozenset( + [ + "flags:write", + "user:preferences", + ] +) + SENTRY_SCOPE_SETS = ( ( ("org:admin", "Read, write, and admin access to organization details."), @@ -1917,6 +1939,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "Read, write, and admin access to organization integrations.", ), ), + (("user:preferences", "Manage personal preferences and user-owned state."),), + (("org:searches", "Manage saved searches, saved queries, and custom views."),), ( ("member:admin", "Read, write, and admin access to organization members."), ("member:write", "Read and write access to organization members."), @@ -1946,6 +1970,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: ("alerts:write", "Read and write alerts"), ("alerts:read", "Read alerts"), ), + (("flags:write", "Manage feature flag webhook signing secrets."),), (("openid", "Confirms authentication status and provides basic information."),), ( ( @@ -1973,6 +1998,9 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "event:read", "event:write", "event:admin", + "flags:write", + "org:searches", + "user:preferences", "project:releases", "project:create", "project:codeowners", @@ -2002,6 +2030,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "event:read", "event:write", "event:admin", + "org:searches", + "user:preferences", "org:read", "member:read", "member:invite", @@ -2027,6 +2057,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "event:read", "event:write", "event:admin", + "org:searches", + "user:preferences", "member:invite", "member:read", "member:write", @@ -2062,6 +2094,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "org:write", "org:admin", "org:integrations", + "org:searches", + "user:preferences", "member:invite", "member:read", "member:write", diff --git a/src/sentry/core/endpoints/project_details.py b/src/sentry/core/endpoints/project_details.py index 5b70d26b87dc65..71e27cae1939a4 100644 --- a/src/sentry/core/endpoints/project_details.py +++ b/src/sentry/core/endpoints/project_details.py @@ -96,32 +96,14 @@ def validate(self, data): return data -class ProjectMemberSerializer(serializers.Serializer): +PROJECT_PREFERENCE_FIELDS = frozenset({"isBookmarked"}) + + +class ProjectPreferenceSerializer(serializers.Serializer): isBookmarked = serializers.BooleanField( - help_text="Enables starring the project within the projects tab. Can be updated with **`project:read`** permission.", + help_text="Enables starring the project within the projects tab. Can be updated with **`user:preferences`** permission.", required=False, ) - autofixAutomationTuning = serializers.ChoiceField( - choices=[item.value for item in AutofixAutomationTuningSettings], - required=False, - ) - seerScannerAutomation = serializers.BooleanField(required=False) - preprodSizeStatusChecksEnabled = serializers.BooleanField( - help_text="Enable preprod size status checks. Can be updated with **`project:read`** permission.", - required=False, - ) - preprodSizeStatusChecksRules = serializers.JSONField(required=False) - preprodSnapshotStatusChecksEnabled = serializers.BooleanField(required=False) - preprodSnapshotStatusChecksFailOnAdded = serializers.BooleanField(required=False) - preprodSnapshotStatusChecksFailOnRemoved = serializers.BooleanField(required=False) - preprodSizeEnabledByCustomer = serializers.BooleanField(required=False, allow_null=True) - preprodDistributionEnabledByCustomer = serializers.BooleanField(required=False, allow_null=True) - preprodDistributionPrCommentsEnabledByCustomer = serializers.BooleanField( - required=False, allow_null=True - ) - preprodSnapshotPrCommentsEnabled = serializers.BooleanField(required=False, allow_null=True) - preprodSizeEnabledQuery = serializers.CharField(required=False, allow_null=True) - preprodDistributionEnabledQuery = serializers.CharField(required=False, allow_null=True) @extend_schema_serializer( @@ -171,7 +153,29 @@ class ProjectMemberSerializer(serializers.Serializer): "preprodSnapshotPrCommentsEnabled", ] ) -class ProjectAdminSerializer(ProjectMemberSerializer): +class ProjectAdminSerializer(ProjectPreferenceSerializer): + autofixAutomationTuning = serializers.ChoiceField( + choices=[item.value for item in AutofixAutomationTuningSettings], + required=False, + ) + seerScannerAutomation = serializers.BooleanField(required=False) + preprodSizeStatusChecksEnabled = serializers.BooleanField( + help_text="Enable preprod size status checks. Can be updated with **`project:write`** permission.", + required=False, + ) + preprodSizeStatusChecksRules = serializers.JSONField(required=False) + preprodSnapshotStatusChecksEnabled = serializers.BooleanField(required=False) + preprodSnapshotStatusChecksFailOnAdded = serializers.BooleanField(required=False) + preprodSnapshotStatusChecksFailOnRemoved = serializers.BooleanField(required=False) + preprodSizeEnabledByCustomer = serializers.BooleanField(required=False, allow_null=True) + preprodDistributionEnabledByCustomer = serializers.BooleanField(required=False, allow_null=True) + preprodDistributionPrCommentsEnabledByCustomer = serializers.BooleanField( + required=False, allow_null=True + ) + preprodSnapshotPrCommentsEnabled = serializers.BooleanField(required=False, allow_null=True) + preprodSizeEnabledQuery = serializers.CharField(required=False, allow_null=True) + preprodDistributionEnabledQuery = serializers.CharField(required=False, allow_null=True) + name = serializers.CharField( help_text="The name for the project", max_length=200, @@ -501,10 +505,10 @@ def validate_debugFilesRole(self, value): class RelaxedProjectPermission(ProjectPermission): scope_map = { - "GET": ["project:read", "project:write", "project:admin"], - "POST": ["project:write", "project:admin"], + "GET": ["project:read"], + "POST": ["project:write"], # PUT checks for permissions based on fields - "PUT": ["project:read", "project:write", "project:admin"], + "PUT": ["project:write", "project:admin", "user:preferences"], "DELETE": ["project:admin"], } @@ -595,27 +599,30 @@ def put(self, request: Request, project) -> Response: """ Update various attributes and configurable settings for the given project. - Note that solely having the **`project:read`** scope restricts updatable settings to - `isBookmarked`, `autofixAutomationTuning`, `seerScannerAutomation`, - `preprodSizeStatusChecksEnabled`, `preprodSizeStatusChecksRules`, - `preprodSizeEnabledQuery`, `preprodDistributionEnabledQuery`, - `preprodSizeEnabledByCustomer`, `preprodDistributionEnabledByCustomer`, - and `preprodDistributionPrCommentsEnabledByCustomer`. + Note that the **`user:preferences`** scope only allows updating + `isBookmarked`. All project-wide settings require **`project:write`**. """ if not request.user.is_authenticated: return Response(status=status.HTTP_400_BAD_REQUEST) old_data = serialize(project, request.user, DetailedProjectSerializer()) - has_elevated_scopes = request.access and ( + has_project_write_scope = request.access and ( request.access.has_scope("project:write") or request.access.has_scope("project:admin") or request.access.has_any_project_scope(project, ["project:write", "project:admin"]) ) + requested_fields = set(request.data.keys()) - if has_elevated_scopes: - serializer_cls: type[ProjectMemberSerializer] = ProjectAdminSerializer + if not has_project_write_scope and not requested_fields.issubset(PROJECT_PREFERENCE_FIELDS): + return Response( + {"detail": "You do not have permission to perform this action."}, + status=403, + ) + + if has_project_write_scope: + serializer_cls: type[ProjectPreferenceSerializer] = ProjectAdminSerializer else: - serializer_cls = ProjectMemberSerializer + serializer_cls = ProjectPreferenceSerializer serializer = serializer_cls( data=request.data, partial=True, context={"project": project, "request": request} @@ -631,14 +638,6 @@ def put(self, request: Request, project) -> Response: ) if not serializer.is_valid(): return Response(serializer.errors, status=400) - - if not has_elevated_scopes: - for key in ProjectAdminSerializer().fields.keys(): - if request.data.get(key) and not result.get(key): - return Response( - {"detail": "You do not have permission to perform this action."}, - status=403, - ) changed = False changed_proj_settings = {} @@ -907,7 +906,7 @@ def put(self, request: Request, project) -> Response: if project.update_option("sentry:debug_files_role", result["debugFilesRole"]): changed_proj_settings["sentry:debug_files_role"] = result["debugFilesRole"] - if has_elevated_scopes: + if has_project_write_scope: options = result.get("options", {}) if "sentry:origins" in options: project.update_option( diff --git a/src/sentry/dashboards/endpoints/organization_dashboards_starred.py b/src/sentry/dashboards/endpoints/organization_dashboards_starred.py index bbd1a2f8cafc71..0ccb0817c37b5e 100644 --- a/src/sentry/dashboards/endpoints/organization_dashboards_starred.py +++ b/src/sentry/dashboards/endpoints/organization_dashboards_starred.py @@ -20,8 +20,8 @@ class MemberPermission(OrganizationPermission): scope_map = { - "GET": ["member:read", "member:write"], - "PUT": ["member:read", "member:write"], + "GET": ["user:preferences"], + "PUT": ["user:preferences"], } diff --git a/src/sentry/discover/endpoints/bases.py b/src/sentry/discover/endpoints/bases.py index 9d067e9e0ee8dd..36802720dafa7b 100644 --- a/src/sentry/discover/endpoints/bases.py +++ b/src/sentry/discover/endpoints/bases.py @@ -4,12 +4,11 @@ class DiscoverSavedQueryPermission(OrganizationPermission): - # Relaxed permissions for saved queries in Discover scope_map = { - "GET": ["org:read", "org:write", "org:admin"], - "POST": ["org:read", "org:write", "org:admin"], - "PUT": ["org:read", "org:write", "org:admin"], - "DELETE": ["org:read", "org:write", "org:admin"], + "GET": ["org:read", "org:searches"], + "POST": ["org:searches"], + "PUT": ["org:searches"], + "DELETE": ["org:searches"], } def has_object_permission(self, request, view, obj): diff --git a/src/sentry/explore/endpoints/bases.py b/src/sentry/explore/endpoints/bases.py index 8512c577eb49dd..cea9b78a38d9f2 100644 --- a/src/sentry/explore/endpoints/bases.py +++ b/src/sentry/explore/endpoints/bases.py @@ -4,12 +4,11 @@ class ExploreSavedQueryPermission(OrganizationPermission): - # Relaxed permissions for saved queries in Explore scope_map = { - "GET": ["org:read", "org:write", "org:admin"], - "POST": ["org:read", "org:write", "org:admin"], - "PUT": ["org:read", "org:write", "org:admin"], - "DELETE": ["org:read", "org:write", "org:admin"], + "GET": ["org:read", "org:searches"], + "POST": ["org:searches"], + "PUT": ["org:searches"], + "DELETE": ["org:searches"], } def has_object_permission(self, request, view, obj): diff --git a/src/sentry/explore/endpoints/explore_saved_query_starred.py b/src/sentry/explore/endpoints/explore_saved_query_starred.py index 21050bb57d3a1c..94c11b01a11870 100644 --- a/src/sentry/explore/endpoints/explore_saved_query_starred.py +++ b/src/sentry/explore/endpoints/explore_saved_query_starred.py @@ -23,7 +23,7 @@ def validate(self, data): class MemberPermission(OrganizationPermission): scope_map = { - "POST": ["member:read", "member:write"], + "POST": ["user:preferences"], } diff --git a/src/sentry/explore/endpoints/explore_saved_query_starred_order.py b/src/sentry/explore/endpoints/explore_saved_query_starred_order.py index 94128542620e53..17a99408517d7c 100644 --- a/src/sentry/explore/endpoints/explore_saved_query_starred_order.py +++ b/src/sentry/explore/endpoints/explore_saved_query_starred_order.py @@ -15,7 +15,7 @@ class MemberPermission(OrganizationPermission): scope_map = { - "PUT": ["member:read", "member:write"], + "PUT": ["user:preferences"], } diff --git a/src/sentry/insights/endpoints/starred_segments.py b/src/sentry/insights/endpoints/starred_segments.py index b7c068ee007dea..9906149b273904 100644 --- a/src/sentry/insights/endpoints/starred_segments.py +++ b/src/sentry/insights/endpoints/starred_segments.py @@ -7,7 +7,7 @@ from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint -from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPreferencePermission from sentry.insights.models import InsightsStarredSegment from sentry.models.organization import Organization from sentry.utils.db import atomic_transaction @@ -18,13 +18,6 @@ class StarSegmentSerializer(serializers.Serializer): project_id = serializers.IntegerField(required=True) -class MemberPermission(OrganizationPermission): - scope_map = { - "POST": ["member:read", "member:write"], - "DELETE": ["member:read", "member:write"], - } - - @cell_silo_endpoint class InsightsStarredSegmentsEndpoint(OrganizationEndpoint): publish_status = { @@ -32,7 +25,7 @@ class InsightsStarredSegmentsEndpoint(OrganizationEndpoint): "DELETE": ApiPublishStatus.EXPERIMENTAL, } owner = ApiOwner.DATA_BROWSING - permission_classes = (MemberPermission,) + permission_classes = (OrganizationPreferencePermission,) def has_feature(self, organization, request): return features.has( diff --git a/src/sentry/issues/endpoints/bases/group_search_view.py b/src/sentry/issues/endpoints/bases/group_search_view.py index 26c91ceb772412..51c1af43a9fe1a 100644 --- a/src/sentry/issues/endpoints/bases/group_search_view.py +++ b/src/sentry/issues/endpoints/bases/group_search_view.py @@ -9,10 +9,10 @@ class GroupSearchViewPermission(OrganizationPermission): scope_map = { - "GET": ["org:read", "org:write", "org:admin"], - "POST": ["org:read", "org:write", "org:admin"], - "PUT": ["org:read", "org:write", "org:admin"], - "DELETE": ["org:read", "org:write", "org:admin"], + "GET": ["org:read", "org:searches"], + "POST": ["org:searches"], + "PUT": ["org:searches"], + "DELETE": ["org:searches"], } def has_object_permission(self, request: Request, view: APIView, obj: object) -> bool: diff --git a/src/sentry/issues/endpoints/organization_group_search_view_details_starred.py b/src/sentry/issues/endpoints/organization_group_search_view_details_starred.py index 68d7c586ddbc78..f5a781b2d8cb09 100644 --- a/src/sentry/issues/endpoints/organization_group_search_view_details_starred.py +++ b/src/sentry/issues/endpoints/organization_group_search_view_details_starred.py @@ -23,7 +23,7 @@ def validate(self, data): class MemberPermission(OrganizationPermission): scope_map = { - "POST": ["member:read", "member:write"], + "POST": ["user:preferences"], } diff --git a/src/sentry/issues/endpoints/organization_group_search_view_starred_order.py b/src/sentry/issues/endpoints/organization_group_search_view_starred_order.py index 593cba439fbcdf..5e69c108047a6f 100644 --- a/src/sentry/issues/endpoints/organization_group_search_view_starred_order.py +++ b/src/sentry/issues/endpoints/organization_group_search_view_starred_order.py @@ -13,7 +13,7 @@ class MemberPermission(OrganizationPermission): scope_map = { - "PUT": ["member:read", "member:write"], + "PUT": ["user:preferences"], } diff --git a/src/sentry/issues/endpoints/organization_group_search_view_visit.py b/src/sentry/issues/endpoints/organization_group_search_view_visit.py index bd713fc2a0ab5d..342d8fb8635d95 100644 --- a/src/sentry/issues/endpoints/organization_group_search_view_visit.py +++ b/src/sentry/issues/endpoints/organization_group_search_view_visit.py @@ -14,7 +14,7 @@ class MemberPermission(OrganizationPermission): scope_map = { - "POST": ["member:read"], + "POST": ["user:preferences"], } diff --git a/src/sentry/issues/endpoints/organization_group_search_views.py b/src/sentry/issues/endpoints/organization_group_search_views.py index 69ce98ce88176c..99a9b729d4c1ac 100644 --- a/src/sentry/issues/endpoints/organization_group_search_views.py +++ b/src/sentry/issues/endpoints/organization_group_search_views.py @@ -26,7 +26,7 @@ class MemberPermission(OrganizationPermission): scope_map = { "GET": ["member:read", "member:write"], - "POST": ["member:read", "member:write"], + "POST": ["org:searches"], } def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: diff --git a/src/sentry/migrations/1064_add_user_preferences_org_searches_and_flags_write_scopes.py b/src/sentry/migrations/1064_add_user_preferences_org_searches_and_flags_write_scopes.py new file mode 100644 index 00000000000000..3717ed78d41825 --- /dev/null +++ b/src/sentry/migrations/1064_add_user_preferences_org_searches_and_flags_write_scopes.py @@ -0,0 +1,157 @@ +# Generated by Django 5.2.12 on 2026-04-15 00:00 + +from django.db import migrations + +import bitfield.models +from sentry.new_migrations.migrations import CheckedMigration + + +class Migration(CheckedMigration): + is_post_deployment = False + + dependencies = [ + ("sentry", "1063_add_project_create_and_codeowners_scopes"), + ] + + operations = [ + migrations.AlterField( + model_name="apiauthorization", + name="scopes", + field=bitfield.models.BitField( + [ + "project:read", + "project:write", + "project:admin", + "project:releases", + "team:read", + "team:write", + "team:admin", + "event:read", + "event:write", + "event:admin", + "org:read", + "org:write", + "org:admin", + "member:read", + "member:write", + "member:admin", + "org:integrations", + "alerts:read", + "alerts:write", + "member:invite", + "project:distribution", + "project:create", + "project:codeowners", + "user:preferences", + "org:searches", + "flags:write", + ], + default=None, + ), + ), + migrations.AlterField( + model_name="apikey", + name="scopes", + field=bitfield.models.BitField( + [ + "project:read", + "project:write", + "project:admin", + "project:releases", + "team:read", + "team:write", + "team:admin", + "event:read", + "event:write", + "event:admin", + "org:read", + "org:write", + "org:admin", + "member:read", + "member:write", + "member:admin", + "org:integrations", + "alerts:read", + "alerts:write", + "member:invite", + "project:distribution", + "project:create", + "project:codeowners", + "user:preferences", + "org:searches", + "flags:write", + ], + default=None, + ), + ), + migrations.AlterField( + model_name="apitoken", + name="scopes", + field=bitfield.models.BitField( + [ + "project:read", + "project:write", + "project:admin", + "project:releases", + "team:read", + "team:write", + "team:admin", + "event:read", + "event:write", + "event:admin", + "org:read", + "org:write", + "org:admin", + "member:read", + "member:write", + "member:admin", + "org:integrations", + "alerts:read", + "alerts:write", + "member:invite", + "project:distribution", + "project:create", + "project:codeowners", + "user:preferences", + "org:searches", + "flags:write", + ], + default=None, + ), + ), + migrations.AlterField( + model_name="sentryapp", + name="scopes", + field=bitfield.models.BitField( + [ + "project:read", + "project:write", + "project:admin", + "project:releases", + "team:read", + "team:write", + "team:admin", + "event:read", + "event:write", + "event:admin", + "org:read", + "org:write", + "org:admin", + "member:read", + "member:write", + "member:admin", + "org:integrations", + "alerts:read", + "alerts:write", + "member:invite", + "project:distribution", + "project:create", + "project:codeowners", + "user:preferences", + "org:searches", + "flags:write", + ], + default=None, + ), + ), + ] diff --git a/src/sentry/models/apiscopes.py b/src/sentry/models/apiscopes.py index a4c8b3952bd91f..618cc031ad663e 100644 --- a/src/sentry/models/apiscopes.py +++ b/src/sentry/models/apiscopes.py @@ -49,6 +49,9 @@ class ApiScopes(Sequence): appended = ( ("project:create"), ("project:codeowners"), + ("user:preferences"), + ("org:searches"), + ("flags:write"), ) def __init__(self): @@ -107,6 +110,9 @@ class Meta: "project:distribution": bool, "project:create": bool, "project:codeowners": bool, + "user:preferences": bool, + "org:searches": bool, + "flags:write": bool, }, ) assert set(ScopesDict.__annotations__) == set(ApiScopes()) diff --git a/src/sentry/sentry_apps/api/parsers/sentry_app.py b/src/sentry/sentry_apps/api/parsers/sentry_app.py index 64f473eed51e46..6a625609cb029c 100644 --- a/src/sentry/sentry_apps/api/parsers/sentry_app.py +++ b/src/sentry/sentry_apps/api/parsers/sentry_app.py @@ -176,7 +176,7 @@ def validate_scopes(self, value): if not value: return value - from sentry.conf.server import SENTRY_TOKEN_ONLY_SCOPES + from sentry.conf.server import SENTRY_NON_APP_GRANTABLE_SCOPES, SENTRY_TOKEN_ONLY_SCOPES validation_errors = [] for scope in value: @@ -184,6 +184,12 @@ def validate_scopes(self, value): if self.instance and self.instance.has_scope(scope): continue + if scope in SENTRY_NON_APP_GRANTABLE_SCOPES: + validation_errors.append( + f"Requested permission of {scope} is not grantable to Sentry Apps." + ) + 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. diff --git a/src/sentry/web/frontend/oauth_authorize.py b/src/sentry/web/frontend/oauth_authorize.py index 337fee20b44eec..5a7477a047c4c7 100644 --- a/src/sentry/web/frontend/oauth_authorize.py +++ b/src/sentry/web/frontend/oauth_authorize.py @@ -203,6 +203,15 @@ def get(self, request: HttpRequest, **kwargs) -> HttpResponseBase: name="invalid_scope", state=state, ) + if scope in settings.SENTRY_NON_APP_GRANTABLE_SCOPES: + return self.error( + request=request, + client_id=client_id, + response_type=response_type, + redirect_uri=redirect_uri, + name="invalid_scope", + state=state, + ) # PKCE support (RFC 7636): accept code_challenge and code_challenge_method. # This implementation only supports S256 method (plain method not supported for security). diff --git a/src/sentry/web/frontend/oauth_device_authorization.py b/src/sentry/web/frontend/oauth_device_authorization.py index 83205a4a9c00f9..d7648f68678f19 100644 --- a/src/sentry/web/frontend/oauth_device_authorization.py +++ b/src/sentry/web/frontend/oauth_device_authorization.py @@ -137,6 +137,12 @@ def post(self, request: HttpRequest) -> HttpResponse: name="invalid_scope", description=f"Unknown scope: {scope}", ) + if scope in settings.SENTRY_NON_APP_GRANTABLE_SCOPES: + return self.error( + request, + name="invalid_scope", + description=f"Scope '{scope}' is not grantable to API applications", + ) # For org-level access apps, validate scopes against app's max scopes if application.requires_org_level_access: diff --git a/tests/sentry/api/endpoints/test_organization_onboarding_continuation.py b/tests/sentry/api/endpoints/test_organization_onboarding_continuation.py index 820b998cc53de6..4c570c10c6b612 100644 --- a/tests/sentry/api/endpoints/test_organization_onboarding_continuation.py +++ b/tests/sentry/api/endpoints/test_organization_onboarding_continuation.py @@ -1,5 +1,7 @@ from unittest import mock +from django.urls import reverse + from sentry.testutils.cases import APITestCase @@ -10,6 +12,7 @@ class OrganizationOnboardingContinuation(APITestCase): def setUp(self) -> None: super().setUp() self.login_as(self.user) + self.path = reverse(self.endpoint, args=[self.organization.slug]) @mock.patch("sentry.api.endpoints.organization_onboarding_continuation_email.MessageBuilder") def test_basic(self, builder: mock.MagicMock) -> None: @@ -42,6 +45,35 @@ def test_validation_error(self) -> None: resp = self.get_error_response(self.organization.slug, status_code=400, **data) assert resp.data["platforms"][0].code == "not_a_list" + @mock.patch("sentry.api.endpoints.organization_onboarding_continuation_email.MessageBuilder") + def test_basic_with_user_preferences_token(self, builder: mock.MagicMock) -> None: + builder.return_value.send_async = mock.Mock() + token = self.create_user_auth_token(user=self.user, scope_list=["user:preferences"]) + + response = self.client.post( + self.path, + {"platforms": ["javascript"]}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 202, response.content + builder.return_value.send_async.assert_called_with([self.user.email]) + + @mock.patch("sentry.api.endpoints.organization_onboarding_continuation_email.MessageBuilder") + def test_org_write_token_rejected(self, builder: mock.MagicMock) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["org:write"]) + + response = self.client.post( + self.path, + {"platforms": ["javascript"]}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403, response.content + builder.assert_not_called() + @mock.patch("sentry.api.endpoints.organization_onboarding_continuation_email.MessageBuilder") def test_non_member_rejected(self, builder: mock.MagicMock) -> None: other_user = self.create_user() diff --git a/tests/sentry/api/endpoints/test_organization_onboarding_tasks.py b/tests/sentry/api/endpoints/test_organization_onboarding_tasks.py index 2e07bcd30ad7bc..0c37ef1ec3a1e0 100644 --- a/tests/sentry/api/endpoints/test_organization_onboarding_tasks.py +++ b/tests/sentry/api/endpoints/test_organization_onboarding_tasks.py @@ -60,6 +60,56 @@ def test_mark_completion_seen_as_member(self) -> None: assert task.completion_seen is not None + def test_member_can_mark_complete_with_user_preferences_token(self) -> None: + token = self.create_user_auth_token(user=self.member_user, scope_list=["user:preferences"]) + + response = self.client.post( + self.path, + {"task": "create_project", "status": "complete"}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 204, response.content + + task = OrganizationOnboardingTask.objects.get( + organization=self.org, task=OnboardingTask.FIRST_PROJECT + ) + + assert task.status == OnboardingTaskStatus.COMPLETE + assert task.user_id == self.member_user.id + + def test_member_cannot_mark_complete_with_org_write_token(self) -> None: + token = self.create_user_auth_token(user=self.member_user, scope_list=["org:write"]) + + response = self.client.post( + self.path, + {"task": "create_project", "status": "complete"}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403, response.content + + def test_member_can_get_tasks_with_user_preferences_token(self) -> None: + token = self.create_user_auth_token(user=self.member_user, scope_list=["user:preferences"]) + + response = self.client.get( + self.path, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 200, response.content + assert "onboardingTasks" in response.data + + def test_member_cannot_get_tasks_with_org_write_token(self) -> None: + token = self.create_user_auth_token(user=self.member_user, scope_list=["org:write"]) + + response = self.client.get( + self.path, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403, response.content + def test_cannot_skip_unskippable(self) -> None: response = self.client.post(self.path, {"task": "create_project", "status": "skipped"}) diff --git a/tests/sentry/api/endpoints/test_organization_pinned_searches.py b/tests/sentry/api/endpoints/test_organization_pinned_searches.py index 3da3782a76c397..0f83445b0452dc 100644 --- a/tests/sentry/api/endpoints/test_organization_pinned_searches.py +++ b/tests/sentry/api/endpoints/test_organization_pinned_searches.py @@ -138,6 +138,28 @@ def test_empty_query(self) -> None: visibility=Visibility.OWNER_PINNED, ).exists() + def test_put_with_user_preferences_token(self) -> None: + token = self.create_user_auth_token(user=self.member, scope_list=["user:preferences"]) + + response = self.client.put( + self.get_path(self.organization.slug), + {"type": SearchType.ISSUE.value, "query": "test", "sort": SortOptions.DATE}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 201, response.content + + def test_put_rejects_org_write_token(self) -> None: + token = self.create_user_auth_token(user=self.member, scope_list=["org:write"]) + + response = self.client.put( + self.get_path(self.organization.slug), + {"type": SearchType.ISSUE.value, "query": "test", "sort": SortOptions.DATE}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403, response.content + class DeleteOrganizationPinnedSearchTest(APITestCase): endpoint = "sentry-api-0-organization-pinned-searches" @@ -200,3 +222,39 @@ def test_invalid_type(self) -> None: resp = self.get_response(type=55) assert resp.status_code == 400 assert "Invalid input for `type`" in resp.data["detail"] + + def test_delete_with_user_preferences_token(self) -> None: + token = self.create_user_auth_token(user=self.member, scope_list=["user:preferences"]) + saved_search = SavedSearch.objects.create( + organization=self.organization, + owner_id=self.member.id, + type=SearchType.ISSUE.value, + query="wat", + visibility=Visibility.OWNER_PINNED, + ) + + response = self.client.delete( + self.get_path(self.organization.slug), + {"type": saved_search.type}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 204, response.content + + def test_delete_rejects_org_write_token(self) -> None: + token = self.create_user_auth_token(user=self.member, scope_list=["org:write"]) + saved_search = SavedSearch.objects.create( + organization=self.organization, + owner_id=self.member.id, + type=SearchType.ISSUE.value, + query="wat", + visibility=Visibility.OWNER_PINNED, + ) + + response = self.client.delete( + self.get_path(self.organization.slug), + {"type": saved_search.type}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403, response.content diff --git a/tests/sentry/api/endpoints/test_organization_recent_searches.py b/tests/sentry/api/endpoints/test_organization_recent_searches.py index a2b3ee901c9c9e..13d96f8344537d 100644 --- a/tests/sentry/api/endpoints/test_organization_recent_searches.py +++ b/tests/sentry/api/endpoints/test_organization_recent_searches.py @@ -185,6 +185,28 @@ def test_query(self) -> None: ] self.check_results(issue_recent_searches[1:], search_type=SearchType.ISSUE, query="lde") + def test_get_with_user_preferences_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["user:preferences"]) + + response = self.client.get( + self.get_path(self.organization.slug), + {"type": SearchType.ISSUE.value}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 200, response.content + + def test_get_rejects_org_write_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["org:write"]) + + response = self.client.get( + self.get_path(self.organization.slug), + {"type": SearchType.ISSUE.value}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403, response.content + class RecentSearchesCreateTest(APITestCase): endpoint = "sentry-api-0-organization-recent-searches" @@ -226,3 +248,25 @@ def test(self) -> None: query=query, last_seen=the_date, ).exists() + + def test_post_with_user_preferences_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["user:preferences"]) + + response = self.client.post( + self.get_path(self.organization.slug), + {"type": SearchType.ISSUE.value, "query": "something"}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 201, response.content + + def test_post_rejects_org_write_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["org:write"]) + + response = self.client.post( + self.get_path(self.organization.slug), + {"type": SearchType.ISSUE.value, "query": "something"}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403, response.content diff --git a/tests/sentry/api/test_permissions.py b/tests/sentry/api/test_permissions.py index faef4de77ea996..d1bd71bf259c91 100644 --- a/tests/sentry/api/test_permissions.py +++ b/tests/sentry/api/test_permissions.py @@ -1,4 +1,5 @@ from collections.abc import Generator +from types import SimpleNamespace from django.test import SimpleTestCase from django.urls import URLPattern, URLResolver @@ -6,6 +7,7 @@ from rest_framework.views import APIView from sentry.api.base import Endpoint +from sentry.api.bases.organization import OrganizationSearchPermission from sentry.api.permissions import ( DemoSafePermission, DisallowImpersonatedTokenCreation, @@ -76,6 +78,20 @@ def test_superuser_or_staff_feature_flagged_permission_inactive_option(self) -> self.superuser_request, APIView() ) + def test_org_write_scope_satisfies_org_searches_permissions(self) -> None: + user = self.create_user() + organization = self.create_organization(owner=user, flags=0) + permission = OrganizationSearchPermission() + auth = SimpleNamespace(get_scopes=lambda: ["org:write"]) + request = self.make_request(user=user, auth=auth, method="POST") + rpc_context = organization_service.get_organization_by_id( + id=organization.id, user_id=user.id + ) + + assert rpc_context + assert permission.has_permission(request, APIView()) + assert permission.has_object_permission(request, APIView(), rpc_context) + class DisallowImpersonatedTokenCreationTest(DRFPermissionTestCase): permission = DisallowImpersonatedTokenCreation() diff --git a/tests/sentry/core/endpoints/test_project_details.py b/tests/sentry/core/endpoints/test_project_details.py index c38dac58c9f816..c466f600bed01a 100644 --- a/tests/sentry/core/endpoints/test_project_details.py +++ b/tests/sentry/core/endpoints/test_project_details.py @@ -299,14 +299,14 @@ def setUp(self) -> None: self.platforms = ["rust", "java"] - def test_member_can_update_limited_project_details(self) -> None: + def test_member_can_update_bookmark_with_user_preferences_scope(self) -> None: self.create_member( user=self.user, organization=self.project.organization, teams=[self.team], role="member", ) - token = self.create_user_auth_token(user=self.user, scope_list=["project:read"]) + token = self.create_user_auth_token(user=self.user, scope_list=["user:preferences"]) response = self.client.put( self.url, @@ -317,6 +317,24 @@ def test_member_can_update_limited_project_details(self) -> None: assert response.status_code == 200 assert response.data["isBookmarked"] is True + def test_member_cannot_update_project_settings_with_user_preferences_scope(self) -> None: + self.create_member( + user=self.user, + organization=self.project.organization, + teams=[self.team], + role="member", + ) + token = self.create_user_auth_token(user=self.user, scope_list=["user:preferences"]) + + response = self.client.put( + self.url, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + data={"platform": "rust"}, + ) + assert response.status_code == 403 + assert response.data["detail"] == "You do not have permission to perform this action." + def test_admin_update_allowed_with_correct_token_scope(self) -> None: self.create_member( user=self.user, @@ -409,7 +427,7 @@ def test_member_update_denied_with_token(self) -> None: teams=[self.team], role="member", ) - # members are only allowed to update 'isBookmarked' fields + # members need a dedicated user preferences scope to update bookmarks token = self.create_user_auth_token(user=self.user, scope_list=["project:read"]) response = self.client.put( @@ -540,6 +558,25 @@ def test_member_changes_permission_denied(self) -> None: assert Project.objects.get(id=project.id).slug != "zzz" assert not ProjectBookmark.objects.filter(user_id=user.id, project_id=project.id).exists() + def test_member_can_bookmark_project(self) -> None: + project = self.create_project() + user = self.create_user("bar@example.com") + self.create_member( + user=user, + organization=project.organization, + teams=[project.teams.first()], + role="member", + ) + self.login_as(user=user) + + self.get_success_response( + project.organization.slug, + project.slug, + isBookmarked="true", + ) + + assert ProjectBookmark.objects.filter(user_id=user.id, project_id=project.id).exists() + @with_feature("organizations:team-roles") def test_member_with_team_role(self) -> None: user = self.create_user("bar@example.com") diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboards_starred.py b/tests/sentry/dashboards/endpoints/test_organization_dashboards_starred.py index 98a17eeb74f450..8904a76b86bba4 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboards_starred.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboards_starred.py @@ -132,6 +132,49 @@ def test_reorder_dashboards(self) -> None: self.dashboard_2.id, ] + def test_reorder_dashboards_with_user_preferences_token(self) -> None: + self.create_dashboard_favorite(self.dashboard_1, self.user, self.organization, 0) + self.create_dashboard_favorite(self.dashboard_2, self.user, self.organization, 1) + self.create_dashboard_favorite(self.dashboard_3, self.user, self.organization, 2) + token = self.create_user_auth_token(user=self.user, scope_list=["user:preferences"]) + + with self.feature("organizations:dashboards-starred-reordering"): + response = self.client.put( + self.url, + data={ + "dashboard_ids": [self.dashboard_3.id, self.dashboard_1.id, self.dashboard_2.id] + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 204 + assert list( + DashboardFavoriteUser.objects.filter( + organization=self.organization, user_id=self.user.id + ) + .order_by("position") + .values_list("dashboard_id", flat=True) + ) == [self.dashboard_3.id, self.dashboard_1.id, self.dashboard_2.id] + + def test_cannot_reorder_dashboards_with_org_write_token(self) -> None: + self.create_dashboard_favorite(self.dashboard_1, self.user, self.organization, 0) + self.create_dashboard_favorite(self.dashboard_2, self.user, self.organization, 1) + self.create_dashboard_favorite(self.dashboard_3, self.user, self.organization, 2) + token = self.create_user_auth_token(user=self.user, scope_list=["org:write"]) + + with self.feature("organizations:dashboards-starred-reordering"): + response = self.client.put( + self.url, + data={ + "dashboard_ids": [self.dashboard_3.id, self.dashboard_1.id, self.dashboard_2.id] + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403 + def test_throws_an_error_if_dashboard_ids_are_not_unique(self) -> None: self.create_dashboard_favorite(self.dashboard_1, self.user, self.organization, 0) self.create_dashboard_favorite(self.dashboard_2, self.user, self.organization, 1) diff --git a/tests/sentry/insights/endpoints/test_insights_starred_segment.py b/tests/sentry/insights/endpoints/test_insights_starred_segment.py index 4c6760cbed0f45..0c9e2e06651929 100644 --- a/tests/sentry/insights/endpoints/test_insights_starred_segment.py +++ b/tests/sentry/insights/endpoints/test_insights_starred_segment.py @@ -69,3 +69,46 @@ def test_error_creating_duplicate_segment(self) -> None: self.url, data={"segment_name": segment_name, "project_id": self.project_ids[0]} ) assert response.status_code == 403 + + def test_post_with_user_preferences_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["user:preferences"]) + + with self.feature(self.feature_name): + response = self.client.post( + self.url, + data={"segment_name": "my_segment", "project_id": self.project_ids[0]}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 200, response.content + + def test_post_rejects_org_write_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["org:write"]) + + with self.feature(self.feature_name): + response = self.client.post( + self.url, + data={"segment_name": "my_segment", "project_id": self.project_ids[0]}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403, response.content + + def test_delete_rejects_org_write_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["org:write"]) + + with self.feature(self.feature_name): + InsightsStarredSegment.objects.create( + segment_name="my_segment", + project_id=self.project_ids[0], + organization=self.org, + user_id=self.user.id, + ) + + response = self.client.delete( + self.url, + data={"segment_name": "my_segment", "project_id": self.project_ids[0]}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403, response.content 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 62588888a687da..b089f462ef2a13 100644 --- a/tests/sentry/sentry_apps/api/endpoints/test_sentry_apps.py +++ b/tests/sentry/sentry_apps/api/endpoints/test_sentry_apps.py @@ -810,6 +810,17 @@ def test_create_integration_with_token_only_scopes(self) -> None: response = self.get_success_response(**data, status_code=201) assert response.data["scopes"] == ["project:distribution", "project:read"] + def test_create_integration_with_non_app_grantable_scopes(self) -> None: + data = self.get_data(events=(), scopes=("project:read", "user:preferences", "flags:write")) + response = self.get_error_response(**data, status_code=400) + + assert response.data == { + "scopes": [ + "Requested permission of user:preferences is not grantable to Sentry Apps.", + "Requested permission of flags:write is not grantable to Sentry Apps.", + ] + } + 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) diff --git a/tests/sentry/web/frontend/test_oauth_authorize.py b/tests/sentry/web/frontend/test_oauth_authorize.py index 0ca4db343439b8..b909d71de52bfa 100644 --- a/tests/sentry/web/frontend/test_oauth_authorize.py +++ b/tests/sentry/web/frontend/test_oauth_authorize.py @@ -64,6 +64,17 @@ def test_invalid_scope(self) -> None: assert "code=" not in resp["Location"] assert not ApiGrant.objects.filter(user=self.user).exists() + def test_non_app_grantable_scope(self) -> None: + self.login_as(self.user) + + resp = self.client.get( + f"{self.path}?response_type=code&client_id={self.application.client_id}&scope=user:preferences" + ) + + assert resp.status_code == 302 + assert resp["Location"] == "https://example.com/?error=invalid_scope" + assert not ApiGrant.objects.filter(user=self.user).exists() + def test_invalid_redirect_uri(self) -> None: self.login_as(self.user) @@ -316,6 +327,17 @@ def test_invalid_scope(self) -> None: assert "access_token" not in resp["Location"] assert not ApiToken.objects.filter(user=self.user).exists() + def test_non_app_grantable_scope(self) -> None: + self.login_as(self.user) + + resp = self.client.get( + f"{self.path}?response_type=token&client_id={self.application.client_id}&scope=user:preferences" + ) + + assert resp.status_code == 302 + assert resp["Location"] == "https://example.com/#error=invalid_scope" + assert not ApiToken.objects.filter(user=self.user).exists() + def test_minimal_params_approve_flow(self) -> None: self.login_as(self.user) diff --git a/tests/sentry/web/frontend/test_oauth_device_authorization.py b/tests/sentry/web/frontend/test_oauth_device_authorization.py index c1e9711681e57e..dd2c7cc67f3b63 100644 --- a/tests/sentry/web/frontend/test_oauth_device_authorization.py +++ b/tests/sentry/web/frontend/test_oauth_device_authorization.py @@ -92,6 +92,15 @@ def test_invalid_scope(self) -> None: data = json.loads(resp.content) assert data["error"] == "invalid_scope" + def test_non_app_grantable_scope(self) -> None: + resp = self.client.post( + self.path, + {"client_id": self.application.client_id, "scope": "user:preferences"}, + ) + assert resp.status_code == 400 + data = json.loads(resp.content) + assert data["error"] == "invalid_scope" + def test_user_code_format(self) -> None: """User code should be in XXXX-XXXX format.""" resp = self.client.post(self.path, {"client_id": self.application.client_id})