From df3aac6e3f83513736638abc155d62436648fc2f Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 15 Apr 2026 13:29:33 -0700 Subject: [PATCH 1/2] fix(api): Enforce write scopes on published mutations Remove readonly scopes from published mutation endpoints and add dedicated write scopes where the API intentionally allows narrower writes. This keeps the public token contract explicit while preserving the existing session behavior for user-owned state and team-scoped workflows. Add a published-endpoint invariant test plus endpoint-level permission coverage for the new scope contracts, including user preferences, project creation, and codeowners flows. Refs getsentry/getsentry#19897 Co-Authored-By: OpenAI Codex --- src/sentry/api/bases/incident.py | 6 +- src/sentry/api/bases/organization.py | 82 ++++++--- .../api/bases/organization_request_change.py | 3 + src/sentry/api/bases/project.py | 13 +- ...anization_onboarding_continuation_email.py | 8 +- .../organization_onboarding_tasks.py | 8 +- .../endpoints/organization_recent_searches.py | 2 +- .../endpoints/project_repo_path_parsing.py | 2 +- .../repository_token_regenerate.py | 3 + .../endpoints/sync_repos/sync_repos.py | 3 + .../endpoints/organization_conduit_demo.py | 3 + src/sentry/conf/server.py | 55 +++++- .../organization_member_invite/index.py | 3 + .../organization_member_invite/utils.py | 3 + ...ganization_member_requests_invite_index.py | 3 + .../organization_member_team_details.py | 5 + .../endpoints/organization_member_utils.py | 3 + .../organization_projects_experiment.py | 6 +- src/sentry/core/endpoints/project_details.py | 91 +++++----- src/sentry/core/endpoints/team_projects.py | 8 +- .../organization_dashboard_generate.py | 3 + .../endpoints/organization_dashboards.py | 6 +- .../organization_dashboards_starred.py | 2 +- src/sentry/discover/endpoints/bases.py | 9 +- .../endpoints/discover_key_transactions.py | 25 ++- src/sentry/explore/endpoints/bases.py | 9 +- .../endpoints/explore_saved_query_starred.py | 2 +- .../explore_saved_query_starred_order.py | 2 +- .../insights/endpoints/starred_segments.py | 11 +- .../issues/endpoints/bases/codeowners.py | 4 +- .../endpoints/bases/group_search_view.py | 8 +- ...ation_group_search_view_details_starred.py | 2 +- ...ization_group_search_view_starred_order.py | 2 +- .../organization_group_search_view_visit.py | 2 +- .../organization_group_search_views.py | 2 +- .../issues/endpoints/project_user_issue.py | 2 +- ...d_project_create_and_flags_write_scopes.py | 157 ++++++++++++++++++ src/sentry/models/apiscopes.py | 23 ++- src/sentry/models/organization.py | 2 + .../endpoints/notification_actions_index.py | 5 + .../api/bases/preprod_artifact_endpoint.py | 4 + .../endpoints/project_replay_details.py | 8 +- .../endpoints/project_replay_summary.py | 4 +- .../endpoints/issue_view_title_generate.py | 3 + .../organization_seer_explorer_chat.py | 3 + .../organization_seer_explorer_update.py | 3 + .../seer/endpoints/organization_seer_rpc.py | 3 + .../endpoints/organization_trace_summary.py | 3 + .../seer/endpoints/trace_explorer_ai_setup.py | 3 + .../sentry_apps/api/bases/sentryapps.py | 3 + ...st_organization_onboarding_continuation.py | 32 ++++ .../test_organization_onboarding_tasks.py | 50 ++++++ tests/sentry/api/test_permissions.py | 89 ++++++++++ .../core/endpoints/test_project_details.py | 43 ++++- .../core/endpoints/test_team_projects.py | 36 ++++ .../test_organization_dashboards_starred.py | 43 +++++ .../test_insights_starred_segment.py | 43 +++++ .../test_project_codeowners_details.py | 37 +++++ .../test_project_codeowners_index.py | 41 +++++ .../endpoints/test_project_ownership.py | 35 ++++ 60 files changed, 929 insertions(+), 145 deletions(-) create mode 100644 src/sentry/migrations/1063_add_project_create_and_flags_write_scopes.py diff --git a/src/sentry/api/bases/incident.py b/src/sentry/api/bases/incident.py index 1a0bd6e04a865c..d705fe83c14c54 100644 --- a/src/sentry/api/bases/incident.py +++ b/src/sentry/api/bases/incident.py @@ -20,9 +20,9 @@ class IncidentPermission(OrganizationPermission): "project:write", "project:admin", ], - "POST": ["org:write", "org:admin", "project:read", "project:write", "project:admin"], - "PUT": ["org:write", "org:admin", "project:read", "project:write", "project:admin"], - "DELETE": ["org:write", "org:admin", "project:read", "project:write", "project:admin"], + "POST": ["org:write", "org:admin", "project:write", "project:admin"], + "PUT": ["org:write", "org:admin", "project:write", "project:admin"], + "DELETE": ["org:write", "org:admin", "project:write", "project:admin"], } diff --git a/src/sentry/api/bases/organization.py b/src/sentry/api/bases/organization.py index 24bc36b1ed0a3d..acc68231fbe2b4 100644 --- a/src/sentry/api/bases/organization.py +++ b/src/sentry/api/bases/organization.py @@ -29,6 +29,7 @@ from sentry.models.project import Project from sentry.models.release import Release from sentry.models.releases.release_project import ReleaseProject +from sentry.models.team import Team from sentry.organizations.services.organization import ( RpcOrganization, RpcUserOrganizationContext, @@ -158,8 +159,8 @@ class OrganizationIntegrationsPermission(OrganizationPermission): class OrganizationIntegrationsLoosePermission(OrganizationPermission): scope_map = { "GET": ["org:read", "org:write", "org:admin", "org:integrations", "org:ci"], - "POST": ["org:read", "org:write", "org:admin", "org:integrations"], - "PUT": ["org:read", "org:write", "org:admin", "org:integrations"], + "POST": ["org:write", "org:admin", "org:integrations"], + "PUT": ["org:write", "org:admin", "org:integrations"], "DELETE": ["org:admin", "org:integrations"], } @@ -167,7 +168,7 @@ class OrganizationIntegrationsLoosePermission(OrganizationPermission): class OrganizationCodeMappingsBulkPermission(OrganizationPermission): scope_map = { "GET": ["org:read", "org:write", "org:admin", "org:integrations", "org:ci"], - "POST": ["org:read", "org:write", "org:admin", "org:integrations", "org:ci"], + "POST": ["org:write", "org:admin", "org:integrations", "org:ci"], } @@ -195,54 +196,93 @@ class OrganizationUserReportsPermission(OrganizationPermission): class OrganizationPinnedSearchPermission(OrganizationPermission): scope_map = { - "PUT": ["org:read", "org:write", "org:admin"], - "DELETE": ["org:read", "org:write", "org:admin"], + "PUT": ["org:searches"], + "DELETE": ["org:searches"], } 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"], } class OrganizationDataExportPermission(OrganizationPermission): scope_map = { "GET": ["event:read", "event:write", "event:admin"], - "POST": ["event:read", "event:write", "event:admin"], + "POST": ["event:write", "event:admin"], } +def _has_any_team_scope(request: Request, scope: str) -> bool: + if not request.access.team_ids_with_membership: + return False + + teams = Team.objects.filter(id__in=request.access.team_ids_with_membership) + return any(request.access.has_team_scope(team, scope) for team in teams) + + class OrganizationAlertRulePermission(OrganizationPermission): scope_map = { "GET": ["org:read", "org:write", "org:admin", "alerts:read"], - # grant org:read permission, but raise permission denied if the members aren't allowed - # to create alerts and the user isn't a team admin - "POST": ["org:read", "org:write", "org:admin", "alerts:write"], + "POST": ["org:write", "org:admin", "alerts:write"], "PUT": ["org:write", "org:admin", "alerts:write"], "DELETE": ["org:write", "org:admin", "alerts:write"], } + def has_object_permission( + self, + request: Request, + view: APIView, + organization: Organization | RpcOrganization | RpcUserOrganizationContext, + ) -> bool: + if super().has_object_permission(request, view, organization): + return True + + return request.method in {"POST", "PUT", "DELETE"} and _has_any_team_scope( + request, "alerts:write" + ) + class OrganizationDetectorPermission(OrganizationPermission): scope_map = { "GET": ["org:read", "org:write", "org:admin", "alerts:read"], - # grant org:read permission, but raise permission denied if the members aren't allowed - # to create alerts and the user isn't a team admin - "POST": ["org:read", "org:write", "org:admin", "alerts:write"], - "PUT": ["org:read", "org:write", "org:admin", "alerts:write"], - "DELETE": ["org:read", "org:write", "org:admin", "alerts:write"], + "POST": ["org:write", "org:admin", "alerts:write"], + "PUT": ["org:write", "org:admin", "alerts:write"], + "DELETE": ["org:write", "org:admin", "alerts:write"], } + def has_object_permission( + self, + request: Request, + view: APIView, + organization: Organization | RpcOrganization | RpcUserOrganizationContext, + ) -> bool: + if super().has_object_permission(request, view, organization): + return True + + return request.method in {"POST", "PUT", "DELETE"} and _has_any_team_scope( + request, "alerts:write" + ) + class OrgAuthTokenPermission(OrganizationPermission): scope_map = { "GET": ["org:read", "org:write", "org:admin"], - "POST": ["org:read", "org:write", "org:admin"], - "PUT": ["org:read", "org:write", "org:admin"], + "POST": ["org:write", "org:admin"], + "PUT": ["org:write", "org:admin"], "DELETE": ["org:write", "org:admin"], } @@ -250,7 +290,7 @@ class OrgAuthTokenPermission(OrganizationPermission): class OrganizationFlagWebHookSigningSecretPermission(OrganizationPermission): scope_map = { "GET": ["org:read", "org:write", "org:admin"], - "POST": ["org:read", "org:write", "org:admin"], + "POST": ["flags:write", "org:write", "org:admin"], "DELETE": ["org:write", "org:admin"], } diff --git a/src/sentry/api/bases/organization_request_change.py b/src/sentry/api/bases/organization_request_change.py index 905325f6ec1626..d52ed444ce694e 100644 --- a/src/sentry/api/bases/organization_request_change.py +++ b/src/sentry/api/bases/organization_request_change.py @@ -7,6 +7,9 @@ class OrganizationRequestChangeEndpointPermission(OrganizationPermission): + readonly_mutation_scope_exceptions = { + "POST": "This endpoint only files a request for an organization change; members use it without organization write access.", + } # just requesting so read permission is enough scope_map = { "POST": ["org:read"], diff --git a/src/sentry/api/bases/project.py b/src/sentry/api/bases/project.py index 58d5133030fef7..75b41861f06035 100644 --- a/src/sentry/api/bases/project.py +++ b/src/sentry/api/bases/project.py @@ -128,13 +128,22 @@ class ProjectAlertRulePermission(ProjectPermission): class ProjectOwnershipPermission(ProjectPermission): scope_map = { - "GET": ["project:read", "project:write", "project:admin"], + "GET": ["project:codeowners", "project:read", "project:write", "project:admin"], "POST": ["project:write", "project:admin"], - "PUT": ["project:read", "project:write", "project:admin"], + "PUT": ["project:codeowners", "project:write", "project:admin"], "DELETE": ["project:admin"], } +class ProjectCodeOwnersPermission(ProjectPermission): + scope_map = { + "GET": ["project:codeowners", "project:read", "project:write", "project:admin"], + "POST": ["project:codeowners", "project:write", "project:admin"], + "PUT": ["project:codeowners", "project:write", "project:admin"], + "DELETE": ["project:codeowners", "project:write", "project:admin"], + } + + class ProjectEndpoint(Endpoint): permission_classes: tuple[type[BasePermission], ...] = (ProjectPermission,) 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..f3a249b129d955 100644 --- a/src/sentry/api/endpoints/organization_recent_searches.py +++ b/src/sentry/api/endpoints/organization_recent_searches.py @@ -20,7 +20,7 @@ class RecentSearchSerializer(serializers.Serializer): class OrganizationRecentSearchPermission(OrganizationPermission): scope_map = { "GET": ["org:read", "org:write", "org:admin"], - "POST": ["org:read", "org:write", "org:admin"], + "POST": ["org:searches"], } diff --git a/src/sentry/api/endpoints/project_repo_path_parsing.py b/src/sentry/api/endpoints/project_repo_path_parsing.py index c530e5d1105fea..830fc1fe62e52e 100644 --- a/src/sentry/api/endpoints/project_repo_path_parsing.py +++ b/src/sentry/api/endpoints/project_repo_path_parsing.py @@ -96,7 +96,7 @@ class ProjectRepoPathParsingEndpointLoosePermission(ProjectPermission): """ scope_map = { - "POST": ["org:read", "project:write", "project:admin"], + "POST": ["project:write"], } diff --git a/src/sentry/codecov/endpoints/repository_token_regenerate/repository_token_regenerate.py b/src/sentry/codecov/endpoints/repository_token_regenerate/repository_token_regenerate.py index fa1c10855f459a..070044325d81f0 100644 --- a/src/sentry/codecov/endpoints/repository_token_regenerate/repository_token_regenerate.py +++ b/src/sentry/codecov/endpoints/repository_token_regenerate/repository_token_regenerate.py @@ -18,6 +18,9 @@ class RepositoryTokenRegeneratePermission(OrganizationPermission): + readonly_mutation_scope_exceptions = { + "POST": "Codecov token regeneration preserves read-only token access for now.", + } scope_map = { "GET": ["org:read", "org:write", "org:admin"], "POST": ["org:read", "org:write", "org:admin"], diff --git a/src/sentry/codecov/endpoints/sync_repos/sync_repos.py b/src/sentry/codecov/endpoints/sync_repos/sync_repos.py index a00af3014addd5..1ef5e9cfa93c6f 100644 --- a/src/sentry/codecov/endpoints/sync_repos/sync_repos.py +++ b/src/sentry/codecov/endpoints/sync_repos/sync_repos.py @@ -18,6 +18,9 @@ class SyncReposPermission(OrganizationPermission): + readonly_mutation_scope_exceptions = { + "POST": "Codecov sync preserves read-only token access pending integration-scope cleanup.", + } scope_map = { "GET": ["org:read", "org:write", "org:admin"], "POST": ["org:read", "org:write", "org:admin"], diff --git a/src/sentry/conduit/endpoints/organization_conduit_demo.py b/src/sentry/conduit/endpoints/organization_conduit_demo.py index 60ad92cb22c860..7ad015ff5223f9 100644 --- a/src/sentry/conduit/endpoints/organization_conduit_demo.py +++ b/src/sentry/conduit/endpoints/organization_conduit_demo.py @@ -31,6 +31,9 @@ class OrganizationConduitDemoPermission(OrganizationPermission): This is a demo-only feature and doesn't modify organization state. """ + readonly_mutation_scope_exceptions = { + "POST": "Demo credential generation preserves read-only token access for now.", + } scope_map = { "POST": ["org:read", "org:write", "org:admin"], } diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 057cff8966274d..3c4f9da412ef8b 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", @@ -1830,6 +1832,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "project:read", "project:write", "project:admin", + "project:create", + "project:codeowners", "project:releases", "project:distribution", "event:read", @@ -1837,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", @@ -1855,10 +1860,18 @@ 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"}, + "org:admin": { + "org:read", + "org:write", + "org:admin", + "org:integrations", + "org:searches", + }, "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"}, @@ -1867,8 +1880,21 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "team:write": {"team:read", "team:write"}, "team:admin": {"team:read", "team:write", "team:admin"}, "project:read": {"project:read"}, - "project:write": {"project:read", "project:write"}, - "project:admin": {"project:read", "project:write", "project:admin"}, + "project:write": { + "project:read", + "project:write", + "project:create", + "project:codeowners", + }, + "project:admin": { + "project:read", + "project:write", + "project:admin", + "project:create", + "project:codeowners", + }, + "project:create": {"project:create"}, + "project:codeowners": {"project:codeowners"}, "project:releases": {"project:releases"}, "project:distribution": {"project:distribution"}, "event:read": {"event:read"}, @@ -1876,6 +1902,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"}, @@ -1902,6 +1929,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."), @@ -1916,7 +1945,9 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: ( ("project:admin", "Read, write, and admin access to projects."), ("project:write", "Read and write access to projects."), + ("project:codeowners", "Manage code owner and ownership rules."), ("project:read", "Read access to projects."), + ("project:create", "Create projects."), ), (("project:releases", "Read, write, and admin access to project releases."),), (("project:distribution", "Access to app distribution and preprod artifacts."),), @@ -1929,6 +1960,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."),), ( ( @@ -1956,7 +1988,12 @@ 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", "project:read", "org:read", "member:invite", @@ -1983,12 +2020,15 @@ 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", "project:read", "project:write", "project:admin", + "project:codeowners", "project:releases", "team:read", "team:write", @@ -2007,6 +2047,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", @@ -2014,6 +2056,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "project:read", "project:write", "project:admin", + "project:codeowners", "project:releases", "team:read", "team:write", @@ -2041,6 +2084,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", @@ -2051,6 +2096,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "project:read", "project:write", "project:admin", + "project:codeowners", "project:releases", "event:read", "event:write", @@ -2097,6 +2143,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "org:read", "member:read", "project:read", + "project:codeowners", "project:write", "project:admin", "project:releases", diff --git a/src/sentry/core/endpoints/organization_member_invite/index.py b/src/sentry/core/endpoints/organization_member_invite/index.py index 999c77af08ec80..ff40ad0542f9a5 100644 --- a/src/sentry/core/endpoints/organization_member_invite/index.py +++ b/src/sentry/core/endpoints/organization_member_invite/index.py @@ -38,6 +38,9 @@ class MemberInvitePermission(OrganizationPermission): + readonly_mutation_scope_exceptions = { + "POST": "Members can create invite requests on this endpoint even when they cannot send invites directly.", + } scope_map = { "GET": ["member:read", "member:write", "member:admin"], # We will do an additional check to see if a user can invite members. If diff --git a/src/sentry/core/endpoints/organization_member_invite/utils.py b/src/sentry/core/endpoints/organization_member_invite/utils.py index 6be047608e3795..67f6b36d2457d0 100644 --- a/src/sentry/core/endpoints/organization_member_invite/utils.py +++ b/src/sentry/core/endpoints/organization_member_invite/utils.py @@ -6,6 +6,9 @@ class MemberInviteDetailsPermission(OrganizationPermission): + readonly_mutation_scope_exceptions = { + "DELETE": "Invite deletion keeps current member-read token semantics for now.", + } scope_map = { "GET": ["member:read", "member:write", "member:admin"], "PUT": ["member:write", "member:admin"], diff --git a/src/sentry/core/endpoints/organization_member_requests_invite_index.py b/src/sentry/core/endpoints/organization_member_requests_invite_index.py index 4fd8a274c763d3..2a0113bd22b163 100644 --- a/src/sentry/core/endpoints/organization_member_requests_invite_index.py +++ b/src/sentry/core/endpoints/organization_member_requests_invite_index.py @@ -20,6 +20,9 @@ class InviteRequestPermissions(OrganizationPermission): + readonly_mutation_scope_exceptions = { + "POST": "Invite request creation keeps member-read token access for now.", + } scope_map = { "GET": ["member:read", "member:write", "member:admin"], "POST": ["member:read", "member:write", "member:admin"], diff --git a/src/sentry/core/endpoints/organization_member_team_details.py b/src/sentry/core/endpoints/organization_member_team_details.py index a569d7f7cc575a..bbb1e9e61293ac 100644 --- a/src/sentry/core/endpoints/organization_member_team_details.py +++ b/src/sentry/core/endpoints/organization_member_team_details.py @@ -72,6 +72,11 @@ def serialize( class OrganizationTeamMemberPermission(OrganizationPermission): + readonly_mutation_scope_exceptions = { + "POST": "Team membership writes keep current mixed org/member/team token semantics for now.", + "PUT": "Team membership writes keep current mixed org/member/team token semantics for now.", + "DELETE": "Team membership writes keep current mixed org/member/team token semantics for now.", + } scope_map = { "GET": [ "org:read", diff --git a/src/sentry/core/endpoints/organization_member_utils.py b/src/sentry/core/endpoints/organization_member_utils.py index 9993a68d563cbd..346f5d2e709744 100644 --- a/src/sentry/core/endpoints/organization_member_utils.py +++ b/src/sentry/core/endpoints/organization_member_utils.py @@ -61,6 +61,9 @@ class MemberConflictValidationError(serializers.ValidationError): class RelaxedMemberPermission(OrganizationPermission): + readonly_mutation_scope_exceptions = { + "DELETE": "Member deletion keeps self-service and role-comparison semantics for now.", + } scope_map = { "GET": ["member:read", "member:write", "member:admin"], "POST": ["member:write", "member:admin"], diff --git a/src/sentry/core/endpoints/organization_projects_experiment.py b/src/sentry/core/endpoints/organization_projects_experiment.py index 44db7c491b67e8..8554f81f67456e 100644 --- a/src/sentry/core/endpoints/organization_projects_experiment.py +++ b/src/sentry/core/endpoints/organization_projects_experiment.py @@ -47,13 +47,9 @@ def fetch_slugifed_email_username(email: str) -> str: return slugify(Address(addr_spec=email).username) -# This endpoint is intended to be available to all members of an -# organization so we include "project:read" in the POST scopes. - - class OrgProjectPermission(OrganizationPermission): scope_map = { - "POST": ["project:read", "project:write", "project:admin"], + "POST": ["project:create", "project:write", "project:admin"], } 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/core/endpoints/team_projects.py b/src/sentry/core/endpoints/team_projects.py index b82545bfdbb5d4..bb3df28041bfb6 100644 --- a/src/sentry/core/endpoints/team_projects.py +++ b/src/sentry/core/endpoints/team_projects.py @@ -118,11 +118,17 @@ def validate_name(self, value: str) -> str: class TeamProjectPermission(TeamPermission): scope_map = { "GET": ["project:read", "project:write", "project:admin"], - "POST": ["project:write", "project:admin"], + "POST": ["project:create", "project:write", "project:admin"], "PUT": ["project:write", "project:admin"], "DELETE": ["project:admin"], } + def has_object_permission(self, request: Request, view, team) -> bool: + if request.method == "POST" and request.access.has_scope("project:create"): + return request.access.has_team_access(team) + + return super().has_object_permission(request, view, team) + class AuditData(TypedDict): request: Request diff --git a/src/sentry/dashboards/endpoints/organization_dashboard_generate.py b/src/sentry/dashboards/endpoints/organization_dashboard_generate.py index 66b90b0d6f3742..cd36350241d18f 100644 --- a/src/sentry/dashboards/endpoints/organization_dashboard_generate.py +++ b/src/sentry/dashboards/endpoints/organization_dashboard_generate.py @@ -49,6 +49,9 @@ class DashboardGenerateSerializer(serializers.Serializer[dict[str, Any]]): class OrganizationDashboardGeneratePermission(OrganizationPermission): + readonly_mutation_scope_exceptions = { + "POST": "Dashboard generation is a POST helper/action and needs separate contract cleanup.", + } scope_map = { "POST": ["org:read"], } diff --git a/src/sentry/dashboards/endpoints/organization_dashboards.py b/src/sentry/dashboards/endpoints/organization_dashboards.py index 358fb6cef5fc1c..1e4008b8532ce5 100644 --- a/src/sentry/dashboards/endpoints/organization_dashboards.py +++ b/src/sentry/dashboards/endpoints/organization_dashboards.py @@ -303,9 +303,9 @@ def sync_prebuilt_dashboards(organization: Organization) -> None: class OrganizationDashboardsPermission(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"], + "POST": ["org:write", "org:admin"], + "PUT": ["org:write", "org:admin"], + "DELETE": ["org:write", "org:admin"], } def has_object_permission( diff --git a/src/sentry/dashboards/endpoints/organization_dashboards_starred.py b/src/sentry/dashboards/endpoints/organization_dashboards_starred.py index bbd1a2f8cafc71..4928dd6b9140db 100644 --- a/src/sentry/dashboards/endpoints/organization_dashboards_starred.py +++ b/src/sentry/dashboards/endpoints/organization_dashboards_starred.py @@ -21,7 +21,7 @@ class MemberPermission(OrganizationPermission): scope_map = { "GET": ["member:read", "member:write"], - "PUT": ["member:read", "member:write"], + "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/discover/endpoints/discover_key_transactions.py b/src/sentry/discover/endpoints/discover_key_transactions.py index 4c630e0f3d94c7..d65ef53f892e48 100644 --- a/src/sentry/discover/endpoints/discover_key_transactions.py +++ b/src/sentry/discover/endpoints/discover_key_transactions.py @@ -23,14 +23,31 @@ from sentry.models.team import Team +def _has_any_team_scope(request: Request, scopes: list[str]) -> bool: + if not request.access.team_ids_with_membership: + return False + + teams = Team.objects.filter(id__in=request.access.team_ids_with_membership) + return any( + any(request.access.has_team_scope(team, scope) for scope in scopes) for team in teams + ) + + class KeyTransactionPermission(OrganizationPermission): scope_map = { - "GET": ["org:read"], - "POST": ["org:read"], - "PUT": ["org:read"], - "DELETE": ["org:read"], + "GET": ["project:read"], + "POST": ["project:write"], + "PUT": ["project:write"], + "DELETE": ["project:write"], } + def has_object_permission(self, request: Request, view, obj: object) -> bool: + if super().has_object_permission(request, view, obj): + return True + + allowed_scopes = self.scope_map.get(request.method or "", []) + return _has_any_team_scope(request, allowed_scopes) + @cell_silo_endpoint class KeyTransactionEndpoint(KeyTransactionBase): 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..ac0c6827693b24 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": ["org:searches"], } 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..9d433545c0fb09 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": ["org:searches"], } 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/codeowners.py b/src/sentry/issues/endpoints/bases/codeowners.py index 7caa45086ea0ae..86d1a397697756 100644 --- a/src/sentry/issues/endpoints/bases/codeowners.py +++ b/src/sentry/issues/endpoints/bases/codeowners.py @@ -1,12 +1,14 @@ from rest_framework.request import Request from sentry import features -from sentry.api.bases.project import ProjectEndpoint +from sentry.api.bases.project import ProjectCodeOwnersPermission, ProjectEndpoint from sentry.models.project import Project from sentry.utils import metrics class ProjectCodeOwnersBase(ProjectEndpoint): + permission_classes = (ProjectCodeOwnersPermission,) + def has_feature(self, request: Request, project: Project) -> bool: return bool( 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..fddc5be1639b9e 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": ["org:searches"], } 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..af637c78225c3f 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": ["org:searches"], } 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..a77bf0d6da4542 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": ["org:searches"], } 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/issues/endpoints/project_user_issue.py b/src/sentry/issues/endpoints/project_user_issue.py index d8e6b07e51a660..3effe6dd744393 100644 --- a/src/sentry/issues/endpoints/project_user_issue.py +++ b/src/sentry/issues/endpoints/project_user_issue.py @@ -180,7 +180,7 @@ class WebVitalsIssueDataSerializer(ProjectUserIssueRequestSerializer): class ProjectUserIssuePermission(ProjectPermission): scope_map = { "GET": [], - "POST": ["event:read", "event:write", "event:admin"], + "POST": ["event:write", "event:admin"], "PUT": [], "DELETE": [], } diff --git a/src/sentry/migrations/1063_add_project_create_and_flags_write_scopes.py b/src/sentry/migrations/1063_add_project_create_and_flags_write_scopes.py new file mode 100644 index 00000000000000..917d2dd12f3988 --- /dev/null +++ b/src/sentry/migrations/1063_add_project_create_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", "1062_backfill_eventattachment_date_expires"), + ] + + 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 3bd4acdf2f9133..618cc031ad663e 100644 --- a/src/sentry/models/apiscopes.py +++ b/src/sentry/models/apiscopes.py @@ -21,6 +21,8 @@ def add_scope_hierarchy(curr_scopes: Sequence[str]) -> list[str]: class ApiScopes(Sequence): + # Append new scopes to the end of the overall bitfield ordering. Legacy + # tokens can still fall back to the bitfield when `scope_list` is empty. project = ( ("project:read"), ("project:write"), @@ -33,12 +35,25 @@ class ApiScopes(Sequence): event = (("event:read"), ("event:write"), ("event:admin")) - org = (("org:read"), ("org:write"), ("org:integrations"), ("org:admin")) + org = ( + ("org:read"), + ("org:write"), + ("org:integrations"), + ("org:admin"), + ) member = (("member:read"), ("member:write"), ("member:admin"), ("member:invite")) alerts = (("alerts:read"), ("alerts:write")) + appended = ( + ("project:create"), + ("project:codeowners"), + ("user:preferences"), + ("org:searches"), + ("flags:write"), + ) + def __init__(self): self.scopes = ( self.__class__.project @@ -47,6 +62,7 @@ def __init__(self): + self.__class__.org + self.__class__.member + self.__class__.alerts + + self.__class__.appended ) def __getitem__(self, value): @@ -92,6 +108,11 @@ class Meta: "alerts:write": bool, "member:invite": bool, "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/models/organization.py b/src/sentry/models/organization.py index ea93ac28fb6b78..8d32ce32cf02fe 100644 --- a/src/sentry/models/organization.py +++ b/src/sentry/models/organization.py @@ -537,6 +537,8 @@ def get_scopes(self, role: Role) -> frozenset[str]: scopes.discard("event:admin") if not self.get_option("sentry:alerts_member_write", ALERTS_MEMBER_WRITE_DEFAULT): scopes.discard("alerts:write") + if role.id == "member" and self.flags.disable_member_project_creation: + scopes.discard("project:create") return frozenset(scopes) def get_option( diff --git a/src/sentry/notifications/api/endpoints/notification_actions_index.py b/src/sentry/notifications/api/endpoints/notification_actions_index.py index f1bcfee6590675..1e1a27feedad64 100644 --- a/src/sentry/notifications/api/endpoints/notification_actions_index.py +++ b/src/sentry/notifications/api/endpoints/notification_actions_index.py @@ -30,6 +30,11 @@ class NotificationActionsPermission(OrganizationPermission): + readonly_mutation_scope_exceptions = { + "POST": "Notification action writes also rely on project-scoped checks; cleanup is deferred.", + "PUT": "Notification action writes also rely on project-scoped checks; cleanup is deferred.", + "DELETE": "Notification action writes also rely on project-scoped checks; cleanup is deferred.", + } scope_map = { "GET": ["org:read", "org:write", "org:admin"], "POST": ["org:read", "org:write", "org:admin"], diff --git a/src/sentry/preprod/api/bases/preprod_artifact_endpoint.py b/src/sentry/preprod/api/bases/preprod_artifact_endpoint.py index 2e328ce4818d57..bc60e21c71c159 100644 --- a/src/sentry/preprod/api/bases/preprod_artifact_endpoint.py +++ b/src/sentry/preprod/api/bases/preprod_artifact_endpoint.py @@ -33,6 +33,10 @@ class BasePreprodArtifactResourceDoesNotExist(APIException): # This is not a general permission. It specifically for triggering comparisons. class ProjectPreprodArtifactPermission(OrganizationEventPermission): + readonly_mutation_scope_exceptions = { + "POST": "Preprod comparison triggers preserve read-only token access for now.", + "PUT": "Preprod comparison triggers preserve read-only token access for now.", + } scope_map = { "GET": ["event:read", "event:write", "event:admin"], # Some simple actions, like triggering comparisons, should be allowed diff --git a/src/sentry/replays/endpoints/project_replay_details.py b/src/sentry/replays/endpoints/project_replay_details.py index 24e2b844439038..bf41f407ff5402 100644 --- a/src/sentry/replays/endpoints/project_replay_details.py +++ b/src/sentry/replays/endpoints/project_replay_details.py @@ -20,10 +20,10 @@ class ReplayDetailsPermission(ProjectPermission): scope_map = { - "GET": ["project:read", "project:write", "project:admin"], - "POST": ["project:write", "project:admin"], - "PUT": ["project:write", "project:admin"], - "DELETE": ["project:read", "project:write", "project:admin"], + "GET": ["project:read"], + "POST": ["project:write"], + "PUT": ["project:write"], + "DELETE": ["event:write"], } diff --git a/src/sentry/replays/endpoints/project_replay_summary.py b/src/sentry/replays/endpoints/project_replay_summary.py index 4b074f5b3dd295..f1f1150bf4803c 100644 --- a/src/sentry/replays/endpoints/project_replay_summary.py +++ b/src/sentry/replays/endpoints/project_replay_summary.py @@ -38,8 +38,8 @@ class ReplaySummaryPermission(ProjectPermission): scope_map = { - "GET": ["event:read", "event:write", "event:admin"], - "POST": ["event:read", "event:write", "event:admin"], + "GET": ["event:read"], + "POST": ["event:write"], "PUT": [], "DELETE": [], } diff --git a/src/sentry/seer/endpoints/issue_view_title_generate.py b/src/sentry/seer/endpoints/issue_view_title_generate.py index 1e02445b93e525..bef34aa8b64db9 100644 --- a/src/sentry/seer/endpoints/issue_view_title_generate.py +++ b/src/sentry/seer/endpoints/issue_view_title_generate.py @@ -29,6 +29,9 @@ class IssueViewTitleGeneratePermission(OrganizationPermission): + readonly_mutation_scope_exceptions = { + "POST": "Issue title generation is a read-like POST helper and needs separate cleanup.", + } scope_map = { "POST": ["org:read"], } diff --git a/src/sentry/seer/endpoints/organization_seer_explorer_chat.py b/src/sentry/seer/endpoints/organization_seer_explorer_chat.py index a0ba5be568ce1d..a38a7d2ceb7efb 100644 --- a/src/sentry/seer/endpoints/organization_seer_explorer_chat.py +++ b/src/sentry/seer/endpoints/organization_seer_explorer_chat.py @@ -58,6 +58,9 @@ class SeerExplorerChatSerializer(serializers.Serializer): class OrganizationSeerExplorerChatPermission(OrganizationPermission): + readonly_mutation_scope_exceptions = { + "POST": "Seer explorer chat is a read-like POST helper and needs separate cleanup.", + } scope_map = { "GET": ["org:read"], "POST": ["org:read"], diff --git a/src/sentry/seer/endpoints/organization_seer_explorer_update.py b/src/sentry/seer/endpoints/organization_seer_explorer_update.py index 722fedc69ac669..24752d9741720a 100644 --- a/src/sentry/seer/endpoints/organization_seer_explorer_update.py +++ b/src/sentry/seer/endpoints/organization_seer_explorer_update.py @@ -24,6 +24,9 @@ class OrganizationSeerExplorerUpdatePermission(OrganizationPermission): + readonly_mutation_scope_exceptions = { + "POST": "Seer explorer updates are POST helpers and need separate contract cleanup.", + } scope_map = { "POST": ["org:read"], } diff --git a/src/sentry/seer/endpoints/organization_seer_rpc.py b/src/sentry/seer/endpoints/organization_seer_rpc.py index e46abba59b58c8..8526893983f494 100644 --- a/src/sentry/seer/endpoints/organization_seer_rpc.py +++ b/src/sentry/seer/endpoints/organization_seer_rpc.py @@ -147,6 +147,9 @@ class SeerRpcPermission(OrganizationPermission): # Seer RPCs uses POST requests but is actually read only # So relax the permissions here. + readonly_mutation_scope_exceptions = { + "POST": "Seer RPC POST is intentionally read-only and tracked separately.", + } scope_map = { "POST": ["org:read", "org:write", "org:admin"], } diff --git a/src/sentry/seer/endpoints/organization_trace_summary.py b/src/sentry/seer/endpoints/organization_trace_summary.py index e0b9b766a24c21..8eb28b0dd89a5c 100644 --- a/src/sentry/seer/endpoints/organization_trace_summary.py +++ b/src/sentry/seer/endpoints/organization_trace_summary.py @@ -21,6 +21,9 @@ class OrganizationTraceSummaryPermission(OrganizationPermission): + readonly_mutation_scope_exceptions = { + "POST": "Trace summary is a read-like POST helper and needs separate contract cleanup.", + } scope_map = { "POST": ["org:read"], } diff --git a/src/sentry/seer/endpoints/trace_explorer_ai_setup.py b/src/sentry/seer/endpoints/trace_explorer_ai_setup.py index 35016ca955ab86..ef4a9b05b9458f 100644 --- a/src/sentry/seer/endpoints/trace_explorer_ai_setup.py +++ b/src/sentry/seer/endpoints/trace_explorer_ai_setup.py @@ -27,6 +27,9 @@ class OrganizationTraceExplorerAIPermission(OrganizationPermission): + readonly_mutation_scope_exceptions = { + "POST": "Trace explorer POST helpers are read-like actions and need separate cleanup.", + } scope_map = { "GET": ["org:read"], "POST": ["org:read"], diff --git a/src/sentry/sentry_apps/api/bases/sentryapps.py b/src/sentry/sentry_apps/api/bases/sentryapps.py index 637d0a27d64515..7fcf6f57afc30f 100644 --- a/src/sentry/sentry_apps/api/bases/sentryapps.py +++ b/src/sentry/sentry_apps/api/bases/sentryapps.py @@ -432,6 +432,9 @@ def convert_args(self, request: Request, uuid, *args, **kwargs): class SentryAppInstallationExternalIssuePermission(SentryAppInstallationPermission): + readonly_mutation_scope_exceptions = { + "POST": "This endpoint creates an external issue link for an issue the caller can already read.", + } scope_map = { "POST": ("event:read", "event:write", "event:admin"), "DELETE": ("event:admin",), diff --git a/tests/sentry/api/endpoints/test_organization_onboarding_continuation.py b/tests/sentry/api/endpoints/test_organization_onboarding_continuation.py index 820b998cc53de6..d68911085483bc 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_read_token_rejected(self, builder: mock.MagicMock) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["org:read"]) + + 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..e7a9b3f1efce45 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_read_token(self) -> None: + token = self.create_user_auth_token(user=self.member_user, scope_list=["org:read"]) + + 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_read_token(self) -> None: + token = self.create_user_auth_token(user=self.member_user, scope_list=["org:read"]) + + 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/test_permissions.py b/tests/sentry/api/test_permissions.py index 1d2286204b35e1..faef4de77ea996 100644 --- a/tests/sentry/api/test_permissions.py +++ b/tests/sentry/api/test_permissions.py @@ -1,5 +1,11 @@ +from collections.abc import Generator + +from django.test import SimpleTestCase +from django.urls import URLPattern, URLResolver +from django.urls.resolvers import get_resolver from rest_framework.views import APIView +from sentry.api.base import Endpoint from sentry.api.permissions import ( DemoSafePermission, DisallowImpersonatedTokenCreation, @@ -8,10 +14,34 @@ SuperuserOrStaffFeatureFlaggedPermission, SuperuserPermission, ) +from sentry.conf.server import SENTRY_READONLY_SCOPES from sentry.demo_mode.utils import READONLY_SCOPES from sentry.organizations.services.organization import organization_service from sentry.testutils.cases import DRFPermissionTestCase from sentry.testutils.helpers.options import override_options +from sentry.testutils.silo import no_silo_test + +MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) + + +def _iter_endpoint_view_classes(urlpatterns, base: str = "") -> Generator[type[Endpoint]]: + for pattern in urlpatterns: + if isinstance(pattern, URLResolver): + yield from _iter_endpoint_view_classes( + pattern.url_patterns, base + str(pattern.pattern) + ) + elif isinstance(pattern, URLPattern): + callback = pattern.callback + if hasattr(callback, "view_class") and issubclass(callback.view_class, Endpoint): + yield callback.view_class + + +def _get_class_path(cls: type[object]) -> str: + return f"{cls.__module__}.{cls.__name__}" + + +def _get_readonly_mutation_scope_exceptions(cls: type[object]) -> dict[str, str]: + return getattr(cls, "readonly_mutation_scope_exceptions", {}) or {} class PermissionsTest(DRFPermissionTestCase): @@ -223,3 +253,62 @@ def test_determine_access_no_demo_users(self) -> None: ) assert readonly_rpc_context.member.scopes == list(self.org_member_scopes) + + +@no_silo_test +class PublishedMutationScopeTest(SimpleTestCase): + def test_readonly_mutation_scope_exceptions_are_notes(self) -> None: + for view_cls in sorted( + set(_iter_endpoint_view_classes(get_resolver().url_patterns)), key=_get_class_path + ): + for cls in (view_cls, *getattr(view_cls, "permission_classes", ())): + exceptions = getattr(cls, "readonly_mutation_scope_exceptions", None) + if exceptions is None: + continue + + assert isinstance(exceptions, dict), ( + f"{_get_class_path(cls)} readonly_mutation_scope_exceptions must be a dict" + ) + + for method, note in exceptions.items(): + assert method in MUTATION_METHODS, ( + f"{_get_class_path(cls)} readonly_mutation_scope_exceptions[{method!r}] " + "must target a mutation method" + ) + assert isinstance(note, str) and note.strip(), ( + f"{_get_class_path(cls)} readonly_mutation_scope_exceptions[{method!r}] " + "must be a non-empty note" + ) + + def test_published_mutation_endpoints_require_readonly_scope_notes(self) -> None: + missing_notes = [] + + for view_cls in sorted( + set(_iter_endpoint_view_classes(get_resolver().url_patterns)), key=_get_class_path + ): + publish_status = getattr(view_cls, "publish_status", {}) or {} + permission_classes = getattr(view_cls, "permission_classes", ()) or () + view_exceptions = _get_readonly_mutation_scope_exceptions(view_cls) + + for method in MUTATION_METHODS & set(publish_status): + for permission_cls in permission_classes: + readonly_scopes = ( + set(getattr(permission_cls, "scope_map", {}).get(method, ())) + & SENTRY_READONLY_SCOPES + ) + if not readonly_scopes: + continue + + if view_exceptions.get(method) or _get_readonly_mutation_scope_exceptions( + permission_cls + ).get(method): + continue + + missing_notes.append( + f"{_get_class_path(view_cls)} {method} accepts readonly scopes " + f"{sorted(readonly_scopes)} via {_get_class_path(permission_cls)}. " + "Remove the readonly scopes or add " + "readonly_mutation_scope_exceptions[method] with a justification note." + ) + + assert not missing_notes, "\n".join(missing_notes) 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/core/endpoints/test_team_projects.py b/tests/sentry/core/endpoints/test_team_projects.py index 2303868a277499..60ee4828b2f7b2 100644 --- a/tests/sentry/core/endpoints/test_team_projects.py +++ b/tests/sentry/core/endpoints/test_team_projects.py @@ -1,6 +1,8 @@ from unittest import TestCase, mock from unittest.mock import MagicMock, Mock, patch +from django.urls import reverse + from sentry.constants import RESERVED_PROJECT_SLUGS from sentry.ingest import inbound_filters from sentry.models.options.project_option import ProjectOption @@ -51,6 +53,13 @@ def setUp(self) -> None: self.team = self.create_team(members=[self.user]) self.data = {"name": "foo", "slug": "bar", "platform": "python"} self.login_as(user=self.user) + self.url = reverse( + self.endpoint, + kwargs={ + "organization_id_or_slug": self.organization.slug, + "team_id_or_slug": self.team.slug, + }, + ) def test_simple(self) -> None: response = self.get_success_response( @@ -221,6 +230,33 @@ def test_disable_member_project_creation(self) -> None: platform="python", ) + def test_create_with_project_create_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:create"]) + + response = self.client.post( + self.url, + self.data, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 201, response.content + project = Project.objects.get(id=response.data["id"]) + assert project.name == "foo" + assert project.teams.first() == self.team + + def test_create_rejects_project_read_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:read"]) + + response = self.client.post( + self.url, + self.data, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403, response.content + def test_default_inbound_filters(self) -> None: filters = ["browser-extensions", "legacy-browsers", "web-crawlers", "filtered-transaction"] python_response = self.get_success_response( diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboards_starred.py b/tests/sentry/dashboards/endpoints/test_organization_dashboards_starred.py index 98a17eeb74f450..51993cfeed5b42 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_read_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:read"]) + + 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..5ddb11f33179b1 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_read_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["org:read"]) + + 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_read_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["org:read"]) + + 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/issues/endpoints/test_project_codeowners_details.py b/tests/sentry/issues/endpoints/test_project_codeowners_details.py index a8014406137645..995f1b375bb9ab 100644 --- a/tests/sentry/issues/endpoints/test_project_codeowners_details.py +++ b/tests/sentry/issues/endpoints/test_project_codeowners_details.py @@ -63,6 +63,29 @@ def test_basic_delete(self) -> None: assert response.status_code == 204 assert not ProjectCodeOwners.objects.filter(id=str(self.codeowners.id)).exists() + def test_delete_with_project_codeowners_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:codeowners"]) + + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.delete( + self.url, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 204 + assert not ProjectCodeOwners.objects.filter(id=str(self.codeowners.id)).exists() + + def test_delete_rejects_project_read_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:read"]) + + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.delete( + self.url, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403 + @freeze_time("2023-10-03 00:00:00") def test_basic_update(self) -> None: self.create_external_team(external_name="@getsentry/frontend", integration=self.integration) @@ -89,6 +112,20 @@ def test_basic_update(self) -> None: codeowner = ProjectCodeOwners.objects.get(id=self.codeowners.id) assert codeowner.date_updated.strftime("%Y-%m-%d %H:%M:%S") == "2023-10-03 00:00:00" + def test_update_with_project_codeowners_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:codeowners"]) + + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.put( + self.url, + self.data, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 200 + assert response.data["id"] == str(self.codeowners.id) + def test_wrong_codeowners_id(self) -> None: self.url = reverse( "sentry-api-0-project-codeowners-details", diff --git a/tests/sentry/issues/endpoints/test_project_codeowners_index.py b/tests/sentry/issues/endpoints/test_project_codeowners_index.py index 23ce681d610cfb..8ef74d44d2277d 100644 --- a/tests/sentry/issues/endpoints/test_project_codeowners_index.py +++ b/tests/sentry/issues/endpoints/test_project_codeowners_index.py @@ -50,6 +50,47 @@ def test_without_feature_flag(self) -> None: assert resp.status_code == 403 assert resp.data == {"detail": "You do not have permission to perform this action."} + def test_get_with_project_codeowners_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:codeowners"]) + + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.get( + self.url, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 200 + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_post_with_project_codeowners_token(self, get_codeowner_mock_file: MagicMock) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:codeowners"]) + + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.post( + self.url, + self.data, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 201, response.content + + def test_post_rejects_project_read_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:read"]) + + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.post( + self.url, + self.data, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403 + @patch( "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", return_value={"html_url": "https://github.com/test/CODEOWNERS"}, diff --git a/tests/sentry/issues/endpoints/test_project_ownership.py b/tests/sentry/issues/endpoints/test_project_ownership.py index 6263e7620ce4c8..261162d1c896aa 100644 --- a/tests/sentry/issues/endpoints/test_project_ownership.py +++ b/tests/sentry/issues/endpoints/test_project_ownership.py @@ -220,6 +220,41 @@ def test_get_empty_schema(self) -> None: "schema": None, } + def test_get_with_project_codeowners_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:codeowners"]) + + response = self.client.get( + self.path, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 200 + + def test_update_with_project_codeowners_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:codeowners"]) + + response = self.client.put( + self.path, + {"raw": "*.js admin@localhost #tiger-team"}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 200 + assert response.data["raw"] == "*.js admin@localhost #tiger-team" + + def test_update_rejects_project_read_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:read"]) + + response = self.client.put( + self.path, + {"raw": "*.js admin@localhost #tiger-team"}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403 + def test_get_schema_empty_raw(self) -> None: # Create ProjectOwnership... self.client.put(self.path, {"raw": "*.js admin@localhost #tiger-team"}) From 23b5b5ed935fec097ca01c237d022fed89dc0e6f Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 15 Apr 2026 14:00:21 -0700 Subject: [PATCH 2/2] fix(api): Restrict app-grantable personal scopes Expand token scope hierarchies during permission evaluation so leaf scopes like org:searches, project:create, and project:codeowners do not need parent fallbacks in endpoint scope maps. Move clearly user-owned search and starring endpoints onto user:preferences, keep org:searches for real saved-search and custom-view resources, and reject user:preferences and flags:write when third-party API apps request scopes. This keeps personal state out of app-granted scopes while preserving the intended session and broad-token behavior for higher-level write scopes. Refs getsentry/getsentry#19897 Co-Authored-By: OpenAI Codex --- src/sentry/api/bases/organization.py | 8 +-- src/sentry/api/bases/project.py | 12 ++-- .../endpoints/organization_recent_searches.py | 4 +- src/sentry/api/permissions.py | 3 +- src/sentry/auth/access.py | 4 +- src/sentry/conf/server.py | 12 +++- .../organization_projects_experiment.py | 2 +- src/sentry/core/endpoints/team_projects.py | 2 +- .../organization_dashboards_starred.py | 2 +- .../endpoints/explore_saved_query_starred.py | 2 +- .../explore_saved_query_starred_order.py | 2 +- ...ation_group_search_view_details_starred.py | 2 +- ...ization_group_search_view_starred_order.py | 2 +- .../organization_group_search_view_visit.py | 2 +- .../sentry_apps/api/parsers/sentry_app.py | 8 ++- src/sentry/web/frontend/oauth_authorize.py | 9 +++ .../frontend/oauth_device_authorization.py | 6 ++ ...st_organization_onboarding_continuation.py | 4 +- .../test_organization_onboarding_tasks.py | 8 +-- .../test_organization_pinned_searches.py | 58 +++++++++++++++++++ .../test_organization_recent_searches.py | 44 ++++++++++++++ tests/sentry/api/test_permissions.py | 16 +++++ .../core/endpoints/test_team_projects.py | 12 ++++ .../test_organization_dashboards_starred.py | 4 +- .../test_insights_starred_segment.py | 8 +-- .../endpoints/test_project_ownership.py | 12 ++++ .../api/endpoints/test_sentry_apps.py | 11 ++++ .../web/frontend/test_oauth_authorize.py | 22 +++++++ .../test_oauth_device_authorization.py | 9 +++ 29 files changed, 254 insertions(+), 36 deletions(-) diff --git a/src/sentry/api/bases/organization.py b/src/sentry/api/bases/organization.py index acc68231fbe2b4..bbea3d6ef176f2 100644 --- a/src/sentry/api/bases/organization.py +++ b/src/sentry/api/bases/organization.py @@ -196,8 +196,8 @@ class OrganizationUserReportsPermission(OrganizationPermission): class OrganizationPinnedSearchPermission(OrganizationPermission): scope_map = { - "PUT": ["org:searches"], - "DELETE": ["org:searches"], + "PUT": ["user:preferences"], + "DELETE": ["user:preferences"], } @@ -290,8 +290,8 @@ class OrgAuthTokenPermission(OrganizationPermission): class OrganizationFlagWebHookSigningSecretPermission(OrganizationPermission): scope_map = { "GET": ["org:read", "org:write", "org:admin"], - "POST": ["flags:write", "org:write", "org:admin"], - "DELETE": ["org:write", "org:admin"], + "POST": ["flags:write"], + "DELETE": ["flags:write"], } diff --git a/src/sentry/api/bases/project.py b/src/sentry/api/bases/project.py index 75b41861f06035..ee65f2162a0141 100644 --- a/src/sentry/api/bases/project.py +++ b/src/sentry/api/bases/project.py @@ -128,19 +128,19 @@ class ProjectAlertRulePermission(ProjectPermission): class ProjectOwnershipPermission(ProjectPermission): scope_map = { - "GET": ["project:codeowners", "project:read", "project:write", "project:admin"], + "GET": ["project:codeowners", "project:read"], "POST": ["project:write", "project:admin"], - "PUT": ["project:codeowners", "project:write", "project:admin"], + "PUT": ["project:codeowners"], "DELETE": ["project:admin"], } class ProjectCodeOwnersPermission(ProjectPermission): scope_map = { - "GET": ["project:codeowners", "project:read", "project:write", "project:admin"], - "POST": ["project:codeowners", "project:write", "project:admin"], - "PUT": ["project:codeowners", "project:write", "project:admin"], - "DELETE": ["project:codeowners", "project:write", "project:admin"], + "GET": ["project:codeowners", "project:read"], + "POST": ["project:codeowners"], + "PUT": ["project:codeowners"], + "DELETE": ["project:codeowners"], } diff --git a/src/sentry/api/endpoints/organization_recent_searches.py b/src/sentry/api/endpoints/organization_recent_searches.py index f3a249b129d955..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:searches"], + "GET": ["user:preferences"], + "POST": ["user:preferences"], } diff --git a/src/sentry/api/permissions.py b/src/sentry/api/permissions.py index fec09d3e594a28..05f21454f6dab8 100644 --- a/src/sentry/api/permissions.py +++ b/src/sentry/api/permissions.py @@ -19,6 +19,7 @@ from sentry.auth.system import is_system_auth from sentry.demo_mode.utils import get_readonly_scopes, is_demo_mode_enabled, is_demo_user from sentry.hybridcloud.rpc import extract_id_from +from sentry.models.apiscopes import add_scope_hierarchy from sentry.models.orgauthtoken import is_org_auth_token_auth, update_org_auth_token_last_used from sentry.organizations.services.organization import ( RpcOrganization, @@ -120,7 +121,7 @@ def has_permission(self, request: Request, view: APIView) -> bool: assert request.method is not None allowed_scopes = set(self.scope_map.get(request.method, [])) - current_scopes = request.auth.get_scopes() + current_scopes = add_scope_hierarchy(request.auth.get_scopes()) return any(s in allowed_scopes for s in current_scopes) def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: diff --git a/src/sentry/auth/access.py b/src/sentry/auth/access.py index a93d97f77eb0f4..02d6e6d3dbdcb1 100644 --- a/src/sentry/auth/access.py +++ b/src/sentry/auth/access.py @@ -590,7 +590,9 @@ def has_any_project_scope(self, project: Project, scopes: Collection[str]) -> bo def _wrap_scopes(scopes_upper_bound: Iterable[str] | None) -> frozenset[str] | None: if scopes_upper_bound is not None: - return frozenset(scopes_upper_bound) + from sentry.models.apiscopes import add_scope_hierarchy + + return frozenset(add_scope_hierarchy(list(scopes_upper_bound))) return None diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 3c4f9da412ef8b..fcf79a64cb886f 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -1860,13 +1860,14 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: SENTRY_SCOPE_HIERARCHY_MAPPING = { "org:read": {"org:read"}, - "org:write": {"org:read", "org:write", "org:searches"}, + "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"}, @@ -1917,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."), diff --git a/src/sentry/core/endpoints/organization_projects_experiment.py b/src/sentry/core/endpoints/organization_projects_experiment.py index 8554f81f67456e..798fd42d2ebe9e 100644 --- a/src/sentry/core/endpoints/organization_projects_experiment.py +++ b/src/sentry/core/endpoints/organization_projects_experiment.py @@ -49,7 +49,7 @@ def fetch_slugifed_email_username(email: str) -> str: class OrgProjectPermission(OrganizationPermission): scope_map = { - "POST": ["project:create", "project:write", "project:admin"], + "POST": ["project:create"], } diff --git a/src/sentry/core/endpoints/team_projects.py b/src/sentry/core/endpoints/team_projects.py index bb3df28041bfb6..434237fc454f88 100644 --- a/src/sentry/core/endpoints/team_projects.py +++ b/src/sentry/core/endpoints/team_projects.py @@ -118,7 +118,7 @@ def validate_name(self, value: str) -> str: class TeamProjectPermission(TeamPermission): scope_map = { "GET": ["project:read", "project:write", "project:admin"], - "POST": ["project:create", "project:write", "project:admin"], + "POST": ["project:create"], "PUT": ["project:write", "project:admin"], "DELETE": ["project:admin"], } diff --git a/src/sentry/dashboards/endpoints/organization_dashboards_starred.py b/src/sentry/dashboards/endpoints/organization_dashboards_starred.py index 4928dd6b9140db..0ccb0817c37b5e 100644 --- a/src/sentry/dashboards/endpoints/organization_dashboards_starred.py +++ b/src/sentry/dashboards/endpoints/organization_dashboards_starred.py @@ -20,7 +20,7 @@ class MemberPermission(OrganizationPermission): scope_map = { - "GET": ["member:read", "member:write"], + "GET": ["user:preferences"], "PUT": ["user:preferences"], } diff --git a/src/sentry/explore/endpoints/explore_saved_query_starred.py b/src/sentry/explore/endpoints/explore_saved_query_starred.py index ac0c6827693b24..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": ["org:searches"], + "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 9d433545c0fb09..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": ["org:searches"], + "PUT": ["user:preferences"], } 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 fddc5be1639b9e..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": ["org:searches"], + "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 af637c78225c3f..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": ["org:searches"], + "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 a77bf0d6da4542..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": ["org:searches"], + "POST": ["user:preferences"], } 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 d68911085483bc..4c570c10c6b612 100644 --- a/tests/sentry/api/endpoints/test_organization_onboarding_continuation.py +++ b/tests/sentry/api/endpoints/test_organization_onboarding_continuation.py @@ -61,8 +61,8 @@ def test_basic_with_user_preferences_token(self, builder: mock.MagicMock) -> Non builder.return_value.send_async.assert_called_with([self.user.email]) @mock.patch("sentry.api.endpoints.organization_onboarding_continuation_email.MessageBuilder") - def test_org_read_token_rejected(self, builder: mock.MagicMock) -> None: - token = self.create_user_auth_token(user=self.user, scope_list=["org:read"]) + 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, diff --git a/tests/sentry/api/endpoints/test_organization_onboarding_tasks.py b/tests/sentry/api/endpoints/test_organization_onboarding_tasks.py index e7a9b3f1efce45..0c37ef1ec3a1e0 100644 --- a/tests/sentry/api/endpoints/test_organization_onboarding_tasks.py +++ b/tests/sentry/api/endpoints/test_organization_onboarding_tasks.py @@ -78,8 +78,8 @@ def test_member_can_mark_complete_with_user_preferences_token(self) -> None: assert task.status == OnboardingTaskStatus.COMPLETE assert task.user_id == self.member_user.id - def test_member_cannot_mark_complete_with_org_read_token(self) -> None: - token = self.create_user_auth_token(user=self.member_user, scope_list=["org:read"]) + 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, @@ -100,8 +100,8 @@ def test_member_can_get_tasks_with_user_preferences_token(self) -> None: assert response.status_code == 200, response.content assert "onboardingTasks" in response.data - def test_member_cannot_get_tasks_with_org_read_token(self) -> None: - token = self.create_user_auth_token(user=self.member_user, scope_list=["org:read"]) + 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, 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_team_projects.py b/tests/sentry/core/endpoints/test_team_projects.py index 60ee4828b2f7b2..7a497192fec944 100644 --- a/tests/sentry/core/endpoints/test_team_projects.py +++ b/tests/sentry/core/endpoints/test_team_projects.py @@ -241,6 +241,18 @@ def test_create_with_project_create_token(self) -> None: ) assert response.status_code == 201, response.content + + def test_create_with_project_write_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:write"]) + + response = self.client.post( + self.url, + self.data, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 201, response.content project = Project.objects.get(id=response.data["id"]) assert project.name == "foo" assert project.teams.first() == self.team diff --git a/tests/sentry/dashboards/endpoints/test_organization_dashboards_starred.py b/tests/sentry/dashboards/endpoints/test_organization_dashboards_starred.py index 51993cfeed5b42..8904a76b86bba4 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboards_starred.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboards_starred.py @@ -157,11 +157,11 @@ def test_reorder_dashboards_with_user_preferences_token(self) -> None: .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_read_token(self) -> None: + 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:read"]) + token = self.create_user_auth_token(user=self.user, scope_list=["org:write"]) with self.feature("organizations:dashboards-starred-reordering"): response = self.client.put( diff --git a/tests/sentry/insights/endpoints/test_insights_starred_segment.py b/tests/sentry/insights/endpoints/test_insights_starred_segment.py index 5ddb11f33179b1..0c9e2e06651929 100644 --- a/tests/sentry/insights/endpoints/test_insights_starred_segment.py +++ b/tests/sentry/insights/endpoints/test_insights_starred_segment.py @@ -82,8 +82,8 @@ def test_post_with_user_preferences_token(self) -> None: assert response.status_code == 200, response.content - def test_post_rejects_org_read_token(self) -> None: - token = self.create_user_auth_token(user=self.user, scope_list=["org:read"]) + 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( @@ -94,8 +94,8 @@ def test_post_rejects_org_read_token(self) -> None: assert response.status_code == 403, response.content - def test_delete_rejects_org_read_token(self) -> None: - token = self.create_user_auth_token(user=self.user, scope_list=["org:read"]) + 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( diff --git a/tests/sentry/issues/endpoints/test_project_ownership.py b/tests/sentry/issues/endpoints/test_project_ownership.py index 261162d1c896aa..8a999845e48992 100644 --- a/tests/sentry/issues/endpoints/test_project_ownership.py +++ b/tests/sentry/issues/endpoints/test_project_ownership.py @@ -220,6 +220,18 @@ def test_get_empty_schema(self) -> None: "schema": None, } + def test_update_with_project_write_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["project:write"]) + + response = self.client.put( + self.path, + {"raw": "*.js admin@localhost #tiger-team"}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 200, response.content + def test_get_with_project_codeowners_token(self) -> None: token = self.create_user_auth_token(user=self.user, scope_list=["project:codeowners"]) 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})