Skip to content

Commit 7cc0772

Browse files
dcramercodex
andcommitted
fix(api): Split personal state and search mutations
Move user-owned state and saved-search mutation flows onto dedicated scopes. Previously, endpoints for bookmarks, starred state, onboarding progress, recent searches, and saved-search mutations could be reached with readonly org or member scopes, and those scopes were also requestable by third-party apps. Add user:preferences, org:searches, and flags:write, move the affected endpoints onto those scopes, and block the personal-only scopes from OAuth and Sentry App grant flows. Co-Authored-By: OpenAI Codex <noreply@openai.com>
1 parent 70087ed commit 7cc0772

33 files changed

+679
-105
lines changed

src/sentry/api/bases/organization.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -196,17 +196,26 @@ class OrganizationUserReportsPermission(OrganizationPermission):
196196

197197
class OrganizationPinnedSearchPermission(OrganizationPermission):
198198
scope_map = {
199-
"PUT": ["org:read", "org:write", "org:admin"],
200-
"DELETE": ["org:read", "org:write", "org:admin"],
199+
"PUT": ["user:preferences"],
200+
"DELETE": ["user:preferences"],
201201
}
202202

203203

204204
class OrganizationSearchPermission(OrganizationPermission):
205205
scope_map = {
206-
"GET": ["org:read", "org:write", "org:admin"],
207-
"POST": ["org:read", "org:write", "org:admin"],
208-
"PUT": ["org:read", "org:write", "org:admin"],
209-
"DELETE": ["org:read", "org:write", "org:admin"],
206+
"GET": ["org:read", "org:searches"],
207+
"POST": ["org:searches"],
208+
"PUT": ["org:searches"],
209+
"DELETE": ["org:searches"],
210+
}
211+
212+
213+
class OrganizationPreferencePermission(OrganizationPermission):
214+
scope_map = {
215+
"GET": ["user:preferences"],
216+
"POST": ["user:preferences"],
217+
"PUT": ["user:preferences"],
218+
"DELETE": ["user:preferences"],
210219
}
211220

212221

@@ -281,8 +290,8 @@ class OrgAuthTokenPermission(OrganizationPermission):
281290
class OrganizationFlagWebHookSigningSecretPermission(OrganizationPermission):
282291
scope_map = {
283292
"GET": ["org:read", "org:write", "org:admin"],
284-
"POST": ["org:read", "org:write", "org:admin"],
285-
"DELETE": ["org:write", "org:admin"],
293+
"POST": ["flags:write"],
294+
"DELETE": ["flags:write"],
286295
}
287296

288297

src/sentry/api/endpoints/organization_onboarding_continuation_email.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from sentry.api.api_owners import ApiOwner
88
from sentry.api.api_publish_status import ApiPublishStatus
99
from sentry.api.base import cell_silo_endpoint
10-
from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission
10+
from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPreferencePermission
1111
from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer
1212
from sentry.models.organization import Organization
1313
from sentry.users.models.user import User
@@ -41,17 +41,13 @@ def get_request_builder_args(user: User, organization: Organization, platforms:
4141
}
4242

4343

44-
class OnboardingContinuationPermission(OrganizationPermission):
45-
scope_map = {"POST": ["org:read", "org:write", "org:admin"]}
46-
47-
4844
@cell_silo_endpoint
4945
class OrganizationOnboardingContinuationEmail(OrganizationEndpoint):
5046
publish_status = {
5147
"POST": ApiPublishStatus.PRIVATE,
5248
}
5349
owner = ApiOwner.VALUE_DISCOVERY
54-
permission_classes = (OnboardingContinuationPermission,)
50+
permission_classes = (OrganizationPreferencePermission,)
5551

5652
def post(self, request: Request, organization: Organization):
5753
serializer = OnboardingContinuationSerializer(data=request.data)

src/sentry/api/endpoints/organization_onboarding_tasks.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,20 @@
77
from sentry.api.api_owners import ApiOwner
88
from sentry.api.api_publish_status import ApiPublishStatus
99
from sentry.api.base import cell_silo_endpoint
10-
from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission
10+
from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPreferencePermission
1111
from sentry.api.serializers import serialize
1212
from sentry.models.organization import Organization
1313
from sentry.models.organizationonboardingtask import OnboardingTask, OnboardingTaskStatus
1414

1515

16-
class OnboardingTaskPermission(OrganizationPermission):
17-
scope_map = {"POST": ["org:read"], "GET": ["org:read"]}
18-
19-
2016
@cell_silo_endpoint
2117
class OrganizationOnboardingTaskEndpoint(OrganizationEndpoint):
2218
publish_status = {
2319
"POST": ApiPublishStatus.PRIVATE,
2420
"GET": ApiPublishStatus.PRIVATE,
2521
}
2622
owner = ApiOwner.VALUE_DISCOVERY
27-
permission_classes = (OnboardingTaskPermission,)
23+
permission_classes = (OrganizationPreferencePermission,)
2824

2925
def post(self, request: Request, organization) -> Response:
3026
task_id = onboarding_tasks.get_task_lookup_by_key(request.data["task"])

src/sentry/api/endpoints/organization_recent_searches.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ class RecentSearchSerializer(serializers.Serializer):
1919

2020
class OrganizationRecentSearchPermission(OrganizationPermission):
2121
scope_map = {
22-
"GET": ["org:read", "org:write", "org:admin"],
23-
"POST": ["org:read", "org:write", "org:admin"],
22+
"GET": ["user:preferences"],
23+
"POST": ["user:preferences"],
2424
}
2525

2626

src/sentry/conf/server.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1817,7 +1817,9 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
18171817
"org:write",
18181818
"org:admin",
18191819
"org:integrations",
1820+
"org:searches",
18201821
"org:ci",
1822+
"user:preferences",
18211823
# "org:superuser", Do not use for any type of superuser permission/access checks
18221824
# Assigned to active SU sessions in src/sentry/auth/access.py to enable UI elements
18231825
"member:invite",
@@ -1839,6 +1841,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
18391841
"event:admin",
18401842
"alerts:read",
18411843
"alerts:write",
1844+
"flags:write",
18421845
# openid, profile, and email aren't prefixed to maintain compliance with the OIDC spec.
18431846
# https://auth0.com/docs/get-started/apis/scopes/openid-connect-scopes.
18441847
"openid",
@@ -1857,10 +1860,19 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
18571860

18581861
SENTRY_SCOPE_HIERARCHY_MAPPING = {
18591862
"org:read": {"org:read"},
1860-
"org:write": {"org:read", "org:write"},
1861-
"org:admin": {"org:read", "org:write", "org:admin", "org:integrations"},
1863+
"org:write": {"org:read", "org:write", "org:searches", "flags:write"},
1864+
"org:admin": {
1865+
"org:read",
1866+
"org:write",
1867+
"org:admin",
1868+
"org:integrations",
1869+
"org:searches",
1870+
"flags:write",
1871+
},
18621872
"org:integrations": {"org:integrations"},
1873+
"org:searches": {"org:searches"},
18631874
"org:ci": {"org:ci"},
1875+
"user:preferences": {"user:preferences"},
18641876
"member:invite": {"member:read", "member:invite"},
18651877
"member:read": {"member:read"},
18661878
"member:write": {"member:read", "member:invite", "member:write"},
@@ -1891,6 +1903,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
18911903
"event:admin": {"event:read", "event:write", "event:admin"},
18921904
"alerts:read": {"alerts:read"},
18931905
"alerts:write": {"alerts:read", "alerts:write"},
1906+
"flags:write": {"flags:write"},
18941907
"openid": {"openid"},
18951908
"profile": {"profile"},
18961909
"email": {"email"},
@@ -1905,6 +1918,15 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
19051918
]
19061919
)
19071920

1921+
# Scopes that are valid for first-party session flows and direct tokens, but
1922+
# should not be grantable to third-party API applications or Sentry Apps.
1923+
SENTRY_NON_APP_GRANTABLE_SCOPES = frozenset(
1924+
[
1925+
"flags:write",
1926+
"user:preferences",
1927+
]
1928+
)
1929+
19081930
SENTRY_SCOPE_SETS = (
19091931
(
19101932
("org:admin", "Read, write, and admin access to organization details."),
@@ -1917,6 +1939,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
19171939
"Read, write, and admin access to organization integrations.",
19181940
),
19191941
),
1942+
(("user:preferences", "Manage personal preferences and user-owned state."),),
1943+
(("org:searches", "Manage saved searches, saved queries, and custom views."),),
19201944
(
19211945
("member:admin", "Read, write, and admin access to organization members."),
19221946
("member:write", "Read and write access to organization members."),
@@ -1946,6 +1970,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
19461970
("alerts:write", "Read and write alerts"),
19471971
("alerts:read", "Read alerts"),
19481972
),
1973+
(("flags:write", "Manage feature flag webhook signing secrets."),),
19491974
(("openid", "Confirms authentication status and provides basic information."),),
19501975
(
19511976
(
@@ -1973,6 +1998,9 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
19731998
"event:read",
19741999
"event:write",
19752000
"event:admin",
2001+
"flags:write",
2002+
"org:searches",
2003+
"user:preferences",
19762004
"project:releases",
19772005
"project:create",
19782006
"project:codeowners",
@@ -2002,6 +2030,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
20022030
"event:read",
20032031
"event:write",
20042032
"event:admin",
2033+
"org:searches",
2034+
"user:preferences",
20052035
"org:read",
20062036
"member:read",
20072037
"member:invite",
@@ -2027,6 +2057,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
20272057
"event:read",
20282058
"event:write",
20292059
"event:admin",
2060+
"org:searches",
2061+
"user:preferences",
20302062
"member:invite",
20312063
"member:read",
20322064
"member:write",
@@ -2062,6 +2094,8 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
20622094
"org:write",
20632095
"org:admin",
20642096
"org:integrations",
2097+
"org:searches",
2098+
"user:preferences",
20652099
"member:invite",
20662100
"member:read",
20672101
"member:write",

src/sentry/core/endpoints/project_details.py

Lines changed: 45 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -96,32 +96,14 @@ def validate(self, data):
9696
return data
9797

9898

99-
class ProjectMemberSerializer(serializers.Serializer):
99+
PROJECT_PREFERENCE_FIELDS = frozenset({"isBookmarked"})
100+
101+
102+
class ProjectPreferenceSerializer(serializers.Serializer):
100103
isBookmarked = serializers.BooleanField(
101-
help_text="Enables starring the project within the projects tab. Can be updated with **`project:read`** permission.",
104+
help_text="Enables starring the project within the projects tab. Can be updated with **`user:preferences`** permission.",
102105
required=False,
103106
)
104-
autofixAutomationTuning = serializers.ChoiceField(
105-
choices=[item.value for item in AutofixAutomationTuningSettings],
106-
required=False,
107-
)
108-
seerScannerAutomation = serializers.BooleanField(required=False)
109-
preprodSizeStatusChecksEnabled = serializers.BooleanField(
110-
help_text="Enable preprod size status checks. Can be updated with **`project:read`** permission.",
111-
required=False,
112-
)
113-
preprodSizeStatusChecksRules = serializers.JSONField(required=False)
114-
preprodSnapshotStatusChecksEnabled = serializers.BooleanField(required=False)
115-
preprodSnapshotStatusChecksFailOnAdded = serializers.BooleanField(required=False)
116-
preprodSnapshotStatusChecksFailOnRemoved = serializers.BooleanField(required=False)
117-
preprodSizeEnabledByCustomer = serializers.BooleanField(required=False, allow_null=True)
118-
preprodDistributionEnabledByCustomer = serializers.BooleanField(required=False, allow_null=True)
119-
preprodDistributionPrCommentsEnabledByCustomer = serializers.BooleanField(
120-
required=False, allow_null=True
121-
)
122-
preprodSnapshotPrCommentsEnabled = serializers.BooleanField(required=False, allow_null=True)
123-
preprodSizeEnabledQuery = serializers.CharField(required=False, allow_null=True)
124-
preprodDistributionEnabledQuery = serializers.CharField(required=False, allow_null=True)
125107

126108

127109
@extend_schema_serializer(
@@ -171,7 +153,29 @@ class ProjectMemberSerializer(serializers.Serializer):
171153
"preprodSnapshotPrCommentsEnabled",
172154
]
173155
)
174-
class ProjectAdminSerializer(ProjectMemberSerializer):
156+
class ProjectAdminSerializer(ProjectPreferenceSerializer):
157+
autofixAutomationTuning = serializers.ChoiceField(
158+
choices=[item.value for item in AutofixAutomationTuningSettings],
159+
required=False,
160+
)
161+
seerScannerAutomation = serializers.BooleanField(required=False)
162+
preprodSizeStatusChecksEnabled = serializers.BooleanField(
163+
help_text="Enable preprod size status checks. Can be updated with **`project:write`** permission.",
164+
required=False,
165+
)
166+
preprodSizeStatusChecksRules = serializers.JSONField(required=False)
167+
preprodSnapshotStatusChecksEnabled = serializers.BooleanField(required=False)
168+
preprodSnapshotStatusChecksFailOnAdded = serializers.BooleanField(required=False)
169+
preprodSnapshotStatusChecksFailOnRemoved = serializers.BooleanField(required=False)
170+
preprodSizeEnabledByCustomer = serializers.BooleanField(required=False, allow_null=True)
171+
preprodDistributionEnabledByCustomer = serializers.BooleanField(required=False, allow_null=True)
172+
preprodDistributionPrCommentsEnabledByCustomer = serializers.BooleanField(
173+
required=False, allow_null=True
174+
)
175+
preprodSnapshotPrCommentsEnabled = serializers.BooleanField(required=False, allow_null=True)
176+
preprodSizeEnabledQuery = serializers.CharField(required=False, allow_null=True)
177+
preprodDistributionEnabledQuery = serializers.CharField(required=False, allow_null=True)
178+
175179
name = serializers.CharField(
176180
help_text="The name for the project",
177181
max_length=200,
@@ -501,10 +505,10 @@ def validate_debugFilesRole(self, value):
501505

502506
class RelaxedProjectPermission(ProjectPermission):
503507
scope_map = {
504-
"GET": ["project:read", "project:write", "project:admin"],
505-
"POST": ["project:write", "project:admin"],
508+
"GET": ["project:read"],
509+
"POST": ["project:write"],
506510
# PUT checks for permissions based on fields
507-
"PUT": ["project:read", "project:write", "project:admin"],
511+
"PUT": ["project:write", "project:admin", "user:preferences"],
508512
"DELETE": ["project:admin"],
509513
}
510514

@@ -595,27 +599,30 @@ def put(self, request: Request, project) -> Response:
595599
"""
596600
Update various attributes and configurable settings for the given project.
597601
598-
Note that solely having the **`project:read`** scope restricts updatable settings to
599-
`isBookmarked`, `autofixAutomationTuning`, `seerScannerAutomation`,
600-
`preprodSizeStatusChecksEnabled`, `preprodSizeStatusChecksRules`,
601-
`preprodSizeEnabledQuery`, `preprodDistributionEnabledQuery`,
602-
`preprodSizeEnabledByCustomer`, `preprodDistributionEnabledByCustomer`,
603-
and `preprodDistributionPrCommentsEnabledByCustomer`.
602+
Note that the **`user:preferences`** scope only allows updating
603+
`isBookmarked`. All project-wide settings require **`project:write`**.
604604
"""
605605
if not request.user.is_authenticated:
606606
return Response(status=status.HTTP_400_BAD_REQUEST)
607607

608608
old_data = serialize(project, request.user, DetailedProjectSerializer())
609-
has_elevated_scopes = request.access and (
609+
has_project_write_scope = request.access and (
610610
request.access.has_scope("project:write")
611611
or request.access.has_scope("project:admin")
612612
or request.access.has_any_project_scope(project, ["project:write", "project:admin"])
613613
)
614+
requested_fields = set(request.data.keys())
614615

615-
if has_elevated_scopes:
616-
serializer_cls: type[ProjectMemberSerializer] = ProjectAdminSerializer
616+
if not has_project_write_scope and not requested_fields.issubset(PROJECT_PREFERENCE_FIELDS):
617+
return Response(
618+
{"detail": "You do not have permission to perform this action."},
619+
status=403,
620+
)
621+
622+
if has_project_write_scope:
623+
serializer_cls: type[ProjectPreferenceSerializer] = ProjectAdminSerializer
617624
else:
618-
serializer_cls = ProjectMemberSerializer
625+
serializer_cls = ProjectPreferenceSerializer
619626

620627
serializer = serializer_cls(
621628
data=request.data, partial=True, context={"project": project, "request": request}
@@ -631,14 +638,6 @@ def put(self, request: Request, project) -> Response:
631638
)
632639
if not serializer.is_valid():
633640
return Response(serializer.errors, status=400)
634-
635-
if not has_elevated_scopes:
636-
for key in ProjectAdminSerializer().fields.keys():
637-
if request.data.get(key) and not result.get(key):
638-
return Response(
639-
{"detail": "You do not have permission to perform this action."},
640-
status=403,
641-
)
642641
changed = False
643642
changed_proj_settings = {}
644643

@@ -907,7 +906,7 @@ def put(self, request: Request, project) -> Response:
907906
if project.update_option("sentry:debug_files_role", result["debugFilesRole"]):
908907
changed_proj_settings["sentry:debug_files_role"] = result["debugFilesRole"]
909908

910-
if has_elevated_scopes:
909+
if has_project_write_scope:
911910
options = result.get("options", {})
912911
if "sentry:origins" in options:
913912
project.update_option(

src/sentry/dashboards/endpoints/organization_dashboards_starred.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020

2121
class MemberPermission(OrganizationPermission):
2222
scope_map = {
23-
"GET": ["member:read", "member:write"],
24-
"PUT": ["member:read", "member:write"],
23+
"GET": ["user:preferences"],
24+
"PUT": ["user:preferences"],
2525
}
2626

2727

0 commit comments

Comments
 (0)