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..bbea3d6ef176f2 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": ["user:preferences"], + "DELETE": ["user:preferences"], } class OrganizationSearchPermission(OrganizationPermission): scope_map = { - "GET": ["org:read", "org:write", "org:admin"], - "POST": ["org:read", "org:write", "org:admin"], - "PUT": ["org:read", "org:write", "org:admin"], - "DELETE": ["org:read", "org:write", "org:admin"], + "GET": ["org:read", "org:searches"], + "POST": ["org:searches"], + "PUT": ["org:searches"], + "DELETE": ["org:searches"], + } + + +class OrganizationPreferencePermission(OrganizationPermission): + scope_map = { + "GET": ["user:preferences"], + "POST": ["user:preferences"], + "PUT": ["user:preferences"], + "DELETE": ["user:preferences"], } 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,8 +290,8 @@ class OrgAuthTokenPermission(OrganizationPermission): class OrganizationFlagWebHookSigningSecretPermission(OrganizationPermission): scope_map = { "GET": ["org:read", "org:write", "org:admin"], - "POST": ["org:read", "org:write", "org:admin"], - "DELETE": ["org:write", "org:admin"], + "POST": ["flags:write"], + "DELETE": ["flags:write"], } diff --git a/src/sentry/api/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..ee65f2162a0141 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"], "POST": ["project:write", "project:admin"], - "PUT": ["project:read", "project:write", "project:admin"], + "PUT": ["project:codeowners"], "DELETE": ["project:admin"], } +class ProjectCodeOwnersPermission(ProjectPermission): + scope_map = { + "GET": ["project:codeowners", "project:read"], + "POST": ["project:codeowners"], + "PUT": ["project:codeowners"], + "DELETE": ["project:codeowners"], + } + + 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..757bb7d80584cb 100644 --- a/src/sentry/api/endpoints/organization_recent_searches.py +++ b/src/sentry/api/endpoints/organization_recent_searches.py @@ -19,8 +19,8 @@ class RecentSearchSerializer(serializers.Serializer): class OrganizationRecentSearchPermission(OrganizationPermission): scope_map = { - "GET": ["org:read", "org:write", "org:admin"], - "POST": ["org:read", "org:write", "org:admin"], + "GET": ["user:preferences"], + "POST": ["user:preferences"], } diff --git a/src/sentry/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/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/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..fcf79a64cb886f 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -1817,7 +1817,9 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "org:write", "org:admin", "org:integrations", + "org:searches", "org:ci", + "user:preferences", # "org:superuser", Do not use for any type of superuser permission/access checks # Assigned to active SU sessions in src/sentry/auth/access.py to enable UI elements "member:invite", @@ -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,19 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: SENTRY_SCOPE_HIERARCHY_MAPPING = { "org:read": {"org:read"}, - "org:write": {"org:read", "org:write"}, - "org:admin": {"org:read", "org:write", "org:admin", "org:integrations"}, + "org:write": {"org:read", "org:write", "org:searches", "flags:write"}, + "org:admin": { + "org:read", + "org:write", + "org:admin", + "org:integrations", + "org:searches", + "flags:write", + }, "org:integrations": {"org:integrations"}, + "org:searches": {"org:searches"}, "org:ci": {"org:ci"}, + "user:preferences": {"user:preferences"}, "member:invite": {"member:read", "member:invite"}, "member:read": {"member:read"}, "member:write": {"member:read", "member:invite", "member:write"}, @@ -1867,8 +1881,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 +1903,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "event:admin": {"event:read", "event:write", "event:admin"}, "alerts:read": {"alerts:read"}, "alerts:write": {"alerts:read", "alerts:write"}, + "flags:write": {"flags:write"}, "openid": {"openid"}, "profile": {"profile"}, "email": {"email"}, @@ -1890,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."), @@ -1902,6 +1939,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "Read, write, and admin access to organization integrations.", ), ), + (("user:preferences", "Manage personal preferences and user-owned state."),), + (("org:searches", "Manage saved searches, saved queries, and custom views."),), ( ("member:admin", "Read, write, and admin access to organization members."), ("member:write", "Read and write access to organization members."), @@ -1916,7 +1955,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 +1970,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: ("alerts:write", "Read and write alerts"), ("alerts:read", "Read alerts"), ), + (("flags:write", "Manage feature flag webhook signing secrets."),), (("openid", "Confirms authentication status and provides basic information."),), ( ( @@ -1956,7 +1998,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 +2030,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 +2057,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "event:read", "event:write", "event:admin", + "org:searches", + "user:preferences", "member:invite", "member:read", "member:write", @@ -2014,6 +2066,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 +2094,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "org:write", "org:admin", "org:integrations", + "org:searches", + "user:preferences", "member:invite", "member:read", "member:write", @@ -2051,6 +2106,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 +2153,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..798fd42d2ebe9e 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"], } 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..434237fc454f88 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"], "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..0ccb0817c37b5e 100644 --- a/src/sentry/dashboards/endpoints/organization_dashboards_starred.py +++ b/src/sentry/dashboards/endpoints/organization_dashboards_starred.py @@ -20,8 +20,8 @@ class MemberPermission(OrganizationPermission): scope_map = { - "GET": ["member:read", "member:write"], - "PUT": ["member:read", "member:write"], + "GET": ["user:preferences"], + "PUT": ["user:preferences"], } diff --git a/src/sentry/discover/endpoints/bases.py b/src/sentry/discover/endpoints/bases.py index 9d067e9e0ee8dd..36802720dafa7b 100644 --- a/src/sentry/discover/endpoints/bases.py +++ b/src/sentry/discover/endpoints/bases.py @@ -4,12 +4,11 @@ class DiscoverSavedQueryPermission(OrganizationPermission): - # Relaxed permissions for saved queries in Discover scope_map = { - "GET": ["org:read", "org:write", "org:admin"], - "POST": ["org:read", "org:write", "org:admin"], - "PUT": ["org:read", "org:write", "org:admin"], - "DELETE": ["org:read", "org:write", "org:admin"], + "GET": ["org:read", "org:searches"], + "POST": ["org:searches"], + "PUT": ["org:searches"], + "DELETE": ["org:searches"], } def has_object_permission(self, request, view, obj): diff --git a/src/sentry/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..94c11b01a11870 100644 --- a/src/sentry/explore/endpoints/explore_saved_query_starred.py +++ b/src/sentry/explore/endpoints/explore_saved_query_starred.py @@ -23,7 +23,7 @@ def validate(self, data): class MemberPermission(OrganizationPermission): scope_map = { - "POST": ["member:read", "member:write"], + "POST": ["user:preferences"], } diff --git a/src/sentry/explore/endpoints/explore_saved_query_starred_order.py b/src/sentry/explore/endpoints/explore_saved_query_starred_order.py index 94128542620e53..17a99408517d7c 100644 --- a/src/sentry/explore/endpoints/explore_saved_query_starred_order.py +++ b/src/sentry/explore/endpoints/explore_saved_query_starred_order.py @@ -15,7 +15,7 @@ class MemberPermission(OrganizationPermission): scope_map = { - "PUT": ["member:read", "member:write"], + "PUT": ["user:preferences"], } diff --git a/src/sentry/insights/endpoints/starred_segments.py b/src/sentry/insights/endpoints/starred_segments.py index b7c068ee007dea..9906149b273904 100644 --- a/src/sentry/insights/endpoints/starred_segments.py +++ b/src/sentry/insights/endpoints/starred_segments.py @@ -7,7 +7,7 @@ from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint -from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPreferencePermission from sentry.insights.models import InsightsStarredSegment from sentry.models.organization import Organization from sentry.utils.db import atomic_transaction @@ -18,13 +18,6 @@ class StarSegmentSerializer(serializers.Serializer): project_id = serializers.IntegerField(required=True) -class MemberPermission(OrganizationPermission): - scope_map = { - "POST": ["member:read", "member:write"], - "DELETE": ["member:read", "member:write"], - } - - @cell_silo_endpoint class InsightsStarredSegmentsEndpoint(OrganizationEndpoint): publish_status = { @@ -32,7 +25,7 @@ class InsightsStarredSegmentsEndpoint(OrganizationEndpoint): "DELETE": ApiPublishStatus.EXPERIMENTAL, } owner = ApiOwner.DATA_BROWSING - permission_classes = (MemberPermission,) + permission_classes = (OrganizationPreferencePermission,) def has_feature(self, organization, request): return features.has( diff --git a/src/sentry/issues/endpoints/bases/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..f5a781b2d8cb09 100644 --- a/src/sentry/issues/endpoints/organization_group_search_view_details_starred.py +++ b/src/sentry/issues/endpoints/organization_group_search_view_details_starred.py @@ -23,7 +23,7 @@ def validate(self, data): class MemberPermission(OrganizationPermission): scope_map = { - "POST": ["member:read", "member:write"], + "POST": ["user:preferences"], } diff --git a/src/sentry/issues/endpoints/organization_group_search_view_starred_order.py b/src/sentry/issues/endpoints/organization_group_search_view_starred_order.py index 593cba439fbcdf..5e69c108047a6f 100644 --- a/src/sentry/issues/endpoints/organization_group_search_view_starred_order.py +++ b/src/sentry/issues/endpoints/organization_group_search_view_starred_order.py @@ -13,7 +13,7 @@ class MemberPermission(OrganizationPermission): scope_map = { - "PUT": ["member:read", "member:write"], + "PUT": ["user:preferences"], } diff --git a/src/sentry/issues/endpoints/organization_group_search_view_visit.py b/src/sentry/issues/endpoints/organization_group_search_view_visit.py index bd713fc2a0ab5d..342d8fb8635d95 100644 --- a/src/sentry/issues/endpoints/organization_group_search_view_visit.py +++ b/src/sentry/issues/endpoints/organization_group_search_view_visit.py @@ -14,7 +14,7 @@ class MemberPermission(OrganizationPermission): scope_map = { - "POST": ["member:read"], + "POST": ["user:preferences"], } diff --git a/src/sentry/issues/endpoints/organization_group_search_views.py b/src/sentry/issues/endpoints/organization_group_search_views.py index 69ce98ce88176c..99a9b729d4c1ac 100644 --- a/src/sentry/issues/endpoints/organization_group_search_views.py +++ b/src/sentry/issues/endpoints/organization_group_search_views.py @@ -26,7 +26,7 @@ class MemberPermission(OrganizationPermission): scope_map = { "GET": ["member:read", "member:write"], - "POST": ["member:read", "member:write"], + "POST": ["org:searches"], } def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: diff --git a/src/sentry/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/src/sentry/sentry_apps/api/parsers/sentry_app.py b/src/sentry/sentry_apps/api/parsers/sentry_app.py index 64f473eed51e46..6a625609cb029c 100644 --- a/src/sentry/sentry_apps/api/parsers/sentry_app.py +++ b/src/sentry/sentry_apps/api/parsers/sentry_app.py @@ -176,7 +176,7 @@ def validate_scopes(self, value): if not value: return value - from sentry.conf.server import SENTRY_TOKEN_ONLY_SCOPES + from sentry.conf.server import SENTRY_NON_APP_GRANTABLE_SCOPES, SENTRY_TOKEN_ONLY_SCOPES validation_errors = [] for scope in value: @@ -184,6 +184,12 @@ def validate_scopes(self, value): if self.instance and self.instance.has_scope(scope): continue + if scope in SENTRY_NON_APP_GRANTABLE_SCOPES: + validation_errors.append( + f"Requested permission of {scope} is not grantable to Sentry Apps." + ) + continue + # Token-only scopes can be granted even if the user doesn't have them. # These are specialized scopes (like project:distribution) that are not # included in any user role but can be granted to integration tokens. diff --git a/src/sentry/web/frontend/oauth_authorize.py b/src/sentry/web/frontend/oauth_authorize.py index 337fee20b44eec..5a7477a047c4c7 100644 --- a/src/sentry/web/frontend/oauth_authorize.py +++ b/src/sentry/web/frontend/oauth_authorize.py @@ -203,6 +203,15 @@ def get(self, request: HttpRequest, **kwargs) -> HttpResponseBase: name="invalid_scope", state=state, ) + if scope in settings.SENTRY_NON_APP_GRANTABLE_SCOPES: + return self.error( + request=request, + client_id=client_id, + response_type=response_type, + redirect_uri=redirect_uri, + name="invalid_scope", + state=state, + ) # PKCE support (RFC 7636): accept code_challenge and code_challenge_method. # This implementation only supports S256 method (plain method not supported for security). diff --git a/src/sentry/web/frontend/oauth_device_authorization.py b/src/sentry/web/frontend/oauth_device_authorization.py index 83205a4a9c00f9..d7648f68678f19 100644 --- a/src/sentry/web/frontend/oauth_device_authorization.py +++ b/src/sentry/web/frontend/oauth_device_authorization.py @@ -137,6 +137,12 @@ def post(self, request: HttpRequest) -> HttpResponse: name="invalid_scope", description=f"Unknown scope: {scope}", ) + if scope in settings.SENTRY_NON_APP_GRANTABLE_SCOPES: + return self.error( + request, + name="invalid_scope", + description=f"Scope '{scope}' is not grantable to API applications", + ) # For org-level access apps, validate scopes against app's max scopes if application.requires_org_level_access: diff --git a/tests/sentry/api/endpoints/test_organization_onboarding_continuation.py b/tests/sentry/api/endpoints/test_organization_onboarding_continuation.py index 820b998cc53de6..4c570c10c6b612 100644 --- a/tests/sentry/api/endpoints/test_organization_onboarding_continuation.py +++ b/tests/sentry/api/endpoints/test_organization_onboarding_continuation.py @@ -1,5 +1,7 @@ from unittest import mock +from django.urls import reverse + from sentry.testutils.cases import APITestCase @@ -10,6 +12,7 @@ class OrganizationOnboardingContinuation(APITestCase): def setUp(self) -> None: super().setUp() self.login_as(self.user) + self.path = reverse(self.endpoint, args=[self.organization.slug]) @mock.patch("sentry.api.endpoints.organization_onboarding_continuation_email.MessageBuilder") def test_basic(self, builder: mock.MagicMock) -> None: @@ -42,6 +45,35 @@ def test_validation_error(self) -> None: resp = self.get_error_response(self.organization.slug, status_code=400, **data) assert resp.data["platforms"][0].code == "not_a_list" + @mock.patch("sentry.api.endpoints.organization_onboarding_continuation_email.MessageBuilder") + def test_basic_with_user_preferences_token(self, builder: mock.MagicMock) -> None: + builder.return_value.send_async = mock.Mock() + token = self.create_user_auth_token(user=self.user, scope_list=["user:preferences"]) + + response = self.client.post( + self.path, + {"platforms": ["javascript"]}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 202, response.content + builder.return_value.send_async.assert_called_with([self.user.email]) + + @mock.patch("sentry.api.endpoints.organization_onboarding_continuation_email.MessageBuilder") + def test_org_write_token_rejected(self, builder: mock.MagicMock) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["org:write"]) + + response = self.client.post( + self.path, + {"platforms": ["javascript"]}, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403, response.content + builder.assert_not_called() + @mock.patch("sentry.api.endpoints.organization_onboarding_continuation_email.MessageBuilder") def test_non_member_rejected(self, builder: mock.MagicMock) -> None: other_user = self.create_user() diff --git a/tests/sentry/api/endpoints/test_organization_onboarding_tasks.py b/tests/sentry/api/endpoints/test_organization_onboarding_tasks.py index 2e07bcd30ad7bc..0c37ef1ec3a1e0 100644 --- a/tests/sentry/api/endpoints/test_organization_onboarding_tasks.py +++ b/tests/sentry/api/endpoints/test_organization_onboarding_tasks.py @@ -60,6 +60,56 @@ def test_mark_completion_seen_as_member(self) -> None: assert task.completion_seen is not None + def test_member_can_mark_complete_with_user_preferences_token(self) -> None: + token = self.create_user_auth_token(user=self.member_user, scope_list=["user:preferences"]) + + response = self.client.post( + self.path, + {"task": "create_project", "status": "complete"}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 204, response.content + + task = OrganizationOnboardingTask.objects.get( + organization=self.org, task=OnboardingTask.FIRST_PROJECT + ) + + assert task.status == OnboardingTaskStatus.COMPLETE + assert task.user_id == self.member_user.id + + def test_member_cannot_mark_complete_with_org_write_token(self) -> None: + token = self.create_user_auth_token(user=self.member_user, scope_list=["org:write"]) + + response = self.client.post( + self.path, + {"task": "create_project", "status": "complete"}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403, response.content + + def test_member_can_get_tasks_with_user_preferences_token(self) -> None: + token = self.create_user_auth_token(user=self.member_user, scope_list=["user:preferences"]) + + response = self.client.get( + self.path, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 200, response.content + assert "onboardingTasks" in response.data + + def test_member_cannot_get_tasks_with_org_write_token(self) -> None: + token = self.create_user_auth_token(user=self.member_user, scope_list=["org:write"]) + + response = self.client.get( + self.path, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403, response.content + def test_cannot_skip_unskippable(self) -> None: response = self.client.post(self.path, {"task": "create_project", "status": "skipped"}) diff --git a/tests/sentry/api/endpoints/test_organization_pinned_searches.py b/tests/sentry/api/endpoints/test_organization_pinned_searches.py index 3da3782a76c397..0f83445b0452dc 100644 --- a/tests/sentry/api/endpoints/test_organization_pinned_searches.py +++ b/tests/sentry/api/endpoints/test_organization_pinned_searches.py @@ -138,6 +138,28 @@ def test_empty_query(self) -> None: visibility=Visibility.OWNER_PINNED, ).exists() + def test_put_with_user_preferences_token(self) -> None: + token = self.create_user_auth_token(user=self.member, scope_list=["user:preferences"]) + + response = self.client.put( + self.get_path(self.organization.slug), + {"type": SearchType.ISSUE.value, "query": "test", "sort": SortOptions.DATE}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 201, response.content + + def test_put_rejects_org_write_token(self) -> None: + token = self.create_user_auth_token(user=self.member, scope_list=["org:write"]) + + response = self.client.put( + self.get_path(self.organization.slug), + {"type": SearchType.ISSUE.value, "query": "test", "sort": SortOptions.DATE}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403, response.content + class DeleteOrganizationPinnedSearchTest(APITestCase): endpoint = "sentry-api-0-organization-pinned-searches" @@ -200,3 +222,39 @@ def test_invalid_type(self) -> None: resp = self.get_response(type=55) assert resp.status_code == 400 assert "Invalid input for `type`" in resp.data["detail"] + + def test_delete_with_user_preferences_token(self) -> None: + token = self.create_user_auth_token(user=self.member, scope_list=["user:preferences"]) + saved_search = SavedSearch.objects.create( + organization=self.organization, + owner_id=self.member.id, + type=SearchType.ISSUE.value, + query="wat", + visibility=Visibility.OWNER_PINNED, + ) + + response = self.client.delete( + self.get_path(self.organization.slug), + {"type": saved_search.type}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 204, response.content + + def test_delete_rejects_org_write_token(self) -> None: + token = self.create_user_auth_token(user=self.member, scope_list=["org:write"]) + saved_search = SavedSearch.objects.create( + organization=self.organization, + owner_id=self.member.id, + type=SearchType.ISSUE.value, + query="wat", + visibility=Visibility.OWNER_PINNED, + ) + + response = self.client.delete( + self.get_path(self.organization.slug), + {"type": saved_search.type}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403, response.content diff --git a/tests/sentry/api/endpoints/test_organization_recent_searches.py b/tests/sentry/api/endpoints/test_organization_recent_searches.py index a2b3ee901c9c9e..13d96f8344537d 100644 --- a/tests/sentry/api/endpoints/test_organization_recent_searches.py +++ b/tests/sentry/api/endpoints/test_organization_recent_searches.py @@ -185,6 +185,28 @@ def test_query(self) -> None: ] self.check_results(issue_recent_searches[1:], search_type=SearchType.ISSUE, query="lde") + def test_get_with_user_preferences_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["user:preferences"]) + + response = self.client.get( + self.get_path(self.organization.slug), + {"type": SearchType.ISSUE.value}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 200, response.content + + def test_get_rejects_org_write_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["org:write"]) + + response = self.client.get( + self.get_path(self.organization.slug), + {"type": SearchType.ISSUE.value}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403, response.content + class RecentSearchesCreateTest(APITestCase): endpoint = "sentry-api-0-organization-recent-searches" @@ -226,3 +248,25 @@ def test(self) -> None: query=query, last_seen=the_date, ).exists() + + def test_post_with_user_preferences_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["user:preferences"]) + + response = self.client.post( + self.get_path(self.organization.slug), + {"type": SearchType.ISSUE.value, "query": "something"}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 201, response.content + + def test_post_rejects_org_write_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["org:write"]) + + response = self.client.post( + self.get_path(self.organization.slug), + {"type": SearchType.ISSUE.value, "query": "something"}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403, response.content diff --git a/tests/sentry/api/test_permissions.py b/tests/sentry/api/test_permissions.py index 1d2286204b35e1..d1bd71bf259c91 100644 --- a/tests/sentry/api/test_permissions.py +++ b/tests/sentry/api/test_permissions.py @@ -1,5 +1,13 @@ +from collections.abc import Generator +from types import SimpleNamespace + +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.bases.organization import OrganizationSearchPermission from sentry.api.permissions import ( DemoSafePermission, DisallowImpersonatedTokenCreation, @@ -8,10 +16,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): @@ -46,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() @@ -223,3 +269,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..7a497192fec944 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,45 @@ 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 + + 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 + + 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..8904a76b86bba4 100644 --- a/tests/sentry/dashboards/endpoints/test_organization_dashboards_starred.py +++ b/tests/sentry/dashboards/endpoints/test_organization_dashboards_starred.py @@ -132,6 +132,49 @@ def test_reorder_dashboards(self) -> None: self.dashboard_2.id, ] + def test_reorder_dashboards_with_user_preferences_token(self) -> None: + self.create_dashboard_favorite(self.dashboard_1, self.user, self.organization, 0) + self.create_dashboard_favorite(self.dashboard_2, self.user, self.organization, 1) + self.create_dashboard_favorite(self.dashboard_3, self.user, self.organization, 2) + token = self.create_user_auth_token(user=self.user, scope_list=["user:preferences"]) + + with self.feature("organizations:dashboards-starred-reordering"): + response = self.client.put( + self.url, + data={ + "dashboard_ids": [self.dashboard_3.id, self.dashboard_1.id, self.dashboard_2.id] + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 204 + assert list( + DashboardFavoriteUser.objects.filter( + organization=self.organization, user_id=self.user.id + ) + .order_by("position") + .values_list("dashboard_id", flat=True) + ) == [self.dashboard_3.id, self.dashboard_1.id, self.dashboard_2.id] + + def test_cannot_reorder_dashboards_with_org_write_token(self) -> None: + self.create_dashboard_favorite(self.dashboard_1, self.user, self.organization, 0) + self.create_dashboard_favorite(self.dashboard_2, self.user, self.organization, 1) + self.create_dashboard_favorite(self.dashboard_3, self.user, self.organization, 2) + token = self.create_user_auth_token(user=self.user, scope_list=["org:write"]) + + with self.feature("organizations:dashboards-starred-reordering"): + response = self.client.put( + self.url, + data={ + "dashboard_ids": [self.dashboard_3.id, self.dashboard_1.id, self.dashboard_2.id] + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403 + def test_throws_an_error_if_dashboard_ids_are_not_unique(self) -> None: self.create_dashboard_favorite(self.dashboard_1, self.user, self.organization, 0) self.create_dashboard_favorite(self.dashboard_2, self.user, self.organization, 1) diff --git a/tests/sentry/insights/endpoints/test_insights_starred_segment.py b/tests/sentry/insights/endpoints/test_insights_starred_segment.py index 4c6760cbed0f45..0c9e2e06651929 100644 --- a/tests/sentry/insights/endpoints/test_insights_starred_segment.py +++ b/tests/sentry/insights/endpoints/test_insights_starred_segment.py @@ -69,3 +69,46 @@ def test_error_creating_duplicate_segment(self) -> None: self.url, data={"segment_name": segment_name, "project_id": self.project_ids[0]} ) assert response.status_code == 403 + + def test_post_with_user_preferences_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["user:preferences"]) + + with self.feature(self.feature_name): + response = self.client.post( + self.url, + data={"segment_name": "my_segment", "project_id": self.project_ids[0]}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 200, response.content + + def test_post_rejects_org_write_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["org:write"]) + + with self.feature(self.feature_name): + response = self.client.post( + self.url, + data={"segment_name": "my_segment", "project_id": self.project_ids[0]}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403, response.content + + def test_delete_rejects_org_write_token(self) -> None: + token = self.create_user_auth_token(user=self.user, scope_list=["org:write"]) + + with self.feature(self.feature_name): + InsightsStarredSegment.objects.create( + segment_name="my_segment", + project_id=self.project_ids[0], + organization=self.org, + user_id=self.user.id, + ) + + response = self.client.delete( + self.url, + data={"segment_name": "my_segment", "project_id": self.project_ids[0]}, + HTTP_AUTHORIZATION=f"Bearer {token.token}", + ) + + assert response.status_code == 403, response.content diff --git a/tests/sentry/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..8a999845e48992 100644 --- a/tests/sentry/issues/endpoints/test_project_ownership.py +++ b/tests/sentry/issues/endpoints/test_project_ownership.py @@ -220,6 +220,53 @@ 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"]) + + 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"}) 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})