Skip to content

Commit df3aac6

Browse files
dcramercodex
andcommitted
fix(api): Enforce write scopes on published mutations
Remove readonly scopes from published mutation endpoints and add dedicated write scopes where the API intentionally allows narrower writes. This keeps the public token contract explicit while preserving the existing session behavior for user-owned state and team-scoped workflows. Add a published-endpoint invariant test plus endpoint-level permission coverage for the new scope contracts, including user preferences, project creation, and codeowners flows. Refs getsentry/getsentry#19897 Co-Authored-By: OpenAI Codex <noreply@openai.com>
1 parent 95827eb commit df3aac6

File tree

60 files changed

+929
-145
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+929
-145
lines changed

src/sentry/api/bases/incident.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ class IncidentPermission(OrganizationPermission):
2020
"project:write",
2121
"project:admin",
2222
],
23-
"POST": ["org:write", "org:admin", "project:read", "project:write", "project:admin"],
24-
"PUT": ["org:write", "org:admin", "project:read", "project:write", "project:admin"],
25-
"DELETE": ["org:write", "org:admin", "project:read", "project:write", "project:admin"],
23+
"POST": ["org:write", "org:admin", "project:write", "project:admin"],
24+
"PUT": ["org:write", "org:admin", "project:write", "project:admin"],
25+
"DELETE": ["org:write", "org:admin", "project:write", "project:admin"],
2626
}
2727

2828

src/sentry/api/bases/organization.py

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from sentry.models.project import Project
3030
from sentry.models.release import Release
3131
from sentry.models.releases.release_project import ReleaseProject
32+
from sentry.models.team import Team
3233
from sentry.organizations.services.organization import (
3334
RpcOrganization,
3435
RpcUserOrganizationContext,
@@ -158,16 +159,16 @@ class OrganizationIntegrationsPermission(OrganizationPermission):
158159
class OrganizationIntegrationsLoosePermission(OrganizationPermission):
159160
scope_map = {
160161
"GET": ["org:read", "org:write", "org:admin", "org:integrations", "org:ci"],
161-
"POST": ["org:read", "org:write", "org:admin", "org:integrations"],
162-
"PUT": ["org:read", "org:write", "org:admin", "org:integrations"],
162+
"POST": ["org:write", "org:admin", "org:integrations"],
163+
"PUT": ["org:write", "org:admin", "org:integrations"],
163164
"DELETE": ["org:admin", "org:integrations"],
164165
}
165166

166167

167168
class OrganizationCodeMappingsBulkPermission(OrganizationPermission):
168169
scope_map = {
169170
"GET": ["org:read", "org:write", "org:admin", "org:integrations", "org:ci"],
170-
"POST": ["org:read", "org:write", "org:admin", "org:integrations", "org:ci"],
171+
"POST": ["org:write", "org:admin", "org:integrations", "org:ci"],
171172
}
172173

173174

@@ -195,62 +196,101 @@ class OrganizationUserReportsPermission(OrganizationPermission):
195196

196197
class OrganizationPinnedSearchPermission(OrganizationPermission):
197198
scope_map = {
198-
"PUT": ["org:read", "org:write", "org:admin"],
199-
"DELETE": ["org:read", "org:write", "org:admin"],
199+
"PUT": ["org:searches"],
200+
"DELETE": ["org:searches"],
200201
}
201202

202203

203204
class OrganizationSearchPermission(OrganizationPermission):
204205
scope_map = {
205-
"GET": ["org:read", "org:write", "org:admin"],
206-
"POST": ["org:read", "org:write", "org:admin"],
207-
"PUT": ["org:read", "org:write", "org:admin"],
208-
"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"],
209219
}
210220

211221

212222
class OrganizationDataExportPermission(OrganizationPermission):
213223
scope_map = {
214224
"GET": ["event:read", "event:write", "event:admin"],
215-
"POST": ["event:read", "event:write", "event:admin"],
225+
"POST": ["event:write", "event:admin"],
216226
}
217227

218228

229+
def _has_any_team_scope(request: Request, scope: str) -> bool:
230+
if not request.access.team_ids_with_membership:
231+
return False
232+
233+
teams = Team.objects.filter(id__in=request.access.team_ids_with_membership)
234+
return any(request.access.has_team_scope(team, scope) for team in teams)
235+
236+
219237
class OrganizationAlertRulePermission(OrganizationPermission):
220238
scope_map = {
221239
"GET": ["org:read", "org:write", "org:admin", "alerts:read"],
222-
# grant org:read permission, but raise permission denied if the members aren't allowed
223-
# to create alerts and the user isn't a team admin
224-
"POST": ["org:read", "org:write", "org:admin", "alerts:write"],
240+
"POST": ["org:write", "org:admin", "alerts:write"],
225241
"PUT": ["org:write", "org:admin", "alerts:write"],
226242
"DELETE": ["org:write", "org:admin", "alerts:write"],
227243
}
228244

245+
def has_object_permission(
246+
self,
247+
request: Request,
248+
view: APIView,
249+
organization: Organization | RpcOrganization | RpcUserOrganizationContext,
250+
) -> bool:
251+
if super().has_object_permission(request, view, organization):
252+
return True
253+
254+
return request.method in {"POST", "PUT", "DELETE"} and _has_any_team_scope(
255+
request, "alerts:write"
256+
)
257+
229258

230259
class OrganizationDetectorPermission(OrganizationPermission):
231260
scope_map = {
232261
"GET": ["org:read", "org:write", "org:admin", "alerts:read"],
233-
# grant org:read permission, but raise permission denied if the members aren't allowed
234-
# to create alerts and the user isn't a team admin
235-
"POST": ["org:read", "org:write", "org:admin", "alerts:write"],
236-
"PUT": ["org:read", "org:write", "org:admin", "alerts:write"],
237-
"DELETE": ["org:read", "org:write", "org:admin", "alerts:write"],
262+
"POST": ["org:write", "org:admin", "alerts:write"],
263+
"PUT": ["org:write", "org:admin", "alerts:write"],
264+
"DELETE": ["org:write", "org:admin", "alerts:write"],
238265
}
239266

267+
def has_object_permission(
268+
self,
269+
request: Request,
270+
view: APIView,
271+
organization: Organization | RpcOrganization | RpcUserOrganizationContext,
272+
) -> bool:
273+
if super().has_object_permission(request, view, organization):
274+
return True
275+
276+
return request.method in {"POST", "PUT", "DELETE"} and _has_any_team_scope(
277+
request, "alerts:write"
278+
)
279+
240280

241281
class OrgAuthTokenPermission(OrganizationPermission):
242282
scope_map = {
243283
"GET": ["org:read", "org:write", "org:admin"],
244-
"POST": ["org:read", "org:write", "org:admin"],
245-
"PUT": ["org:read", "org:write", "org:admin"],
284+
"POST": ["org:write", "org:admin"],
285+
"PUT": ["org:write", "org:admin"],
246286
"DELETE": ["org:write", "org:admin"],
247287
}
248288

249289

250290
class OrganizationFlagWebHookSigningSecretPermission(OrganizationPermission):
251291
scope_map = {
252292
"GET": ["org:read", "org:write", "org:admin"],
253-
"POST": ["org:read", "org:write", "org:admin"],
293+
"POST": ["flags:write", "org:write", "org:admin"],
254294
"DELETE": ["org:write", "org:admin"],
255295
}
256296

src/sentry/api/bases/organization_request_change.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77

88

99
class OrganizationRequestChangeEndpointPermission(OrganizationPermission):
10+
readonly_mutation_scope_exceptions = {
11+
"POST": "This endpoint only files a request for an organization change; members use it without organization write access.",
12+
}
1013
# just requesting so read permission is enough
1114
scope_map = {
1215
"POST": ["org:read"],

src/sentry/api/bases/project.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,13 +128,22 @@ class ProjectAlertRulePermission(ProjectPermission):
128128

129129
class ProjectOwnershipPermission(ProjectPermission):
130130
scope_map = {
131-
"GET": ["project:read", "project:write", "project:admin"],
131+
"GET": ["project:codeowners", "project:read", "project:write", "project:admin"],
132132
"POST": ["project:write", "project:admin"],
133-
"PUT": ["project:read", "project:write", "project:admin"],
133+
"PUT": ["project:codeowners", "project:write", "project:admin"],
134134
"DELETE": ["project:admin"],
135135
}
136136

137137

138+
class ProjectCodeOwnersPermission(ProjectPermission):
139+
scope_map = {
140+
"GET": ["project:codeowners", "project:read", "project:write", "project:admin"],
141+
"POST": ["project:codeowners", "project:write", "project:admin"],
142+
"PUT": ["project:codeowners", "project:write", "project:admin"],
143+
"DELETE": ["project:codeowners", "project:write", "project:admin"],
144+
}
145+
146+
138147
class ProjectEndpoint(Endpoint):
139148
permission_classes: tuple[type[BasePermission], ...] = (ProjectPermission,)
140149

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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class RecentSearchSerializer(serializers.Serializer):
2020
class OrganizationRecentSearchPermission(OrganizationPermission):
2121
scope_map = {
2222
"GET": ["org:read", "org:write", "org:admin"],
23-
"POST": ["org:read", "org:write", "org:admin"],
23+
"POST": ["org:searches"],
2424
}
2525

2626

src/sentry/api/endpoints/project_repo_path_parsing.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ class ProjectRepoPathParsingEndpointLoosePermission(ProjectPermission):
9696
"""
9797

9898
scope_map = {
99-
"POST": ["org:read", "project:write", "project:admin"],
99+
"POST": ["project:write"],
100100
}
101101

102102

src/sentry/codecov/endpoints/repository_token_regenerate/repository_token_regenerate.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818

1919

2020
class RepositoryTokenRegeneratePermission(OrganizationPermission):
21+
readonly_mutation_scope_exceptions = {
22+
"POST": "Codecov token regeneration preserves read-only token access for now.",
23+
}
2124
scope_map = {
2225
"GET": ["org:read", "org:write", "org:admin"],
2326
"POST": ["org:read", "org:write", "org:admin"],

src/sentry/codecov/endpoints/sync_repos/sync_repos.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818

1919

2020
class SyncReposPermission(OrganizationPermission):
21+
readonly_mutation_scope_exceptions = {
22+
"POST": "Codecov sync preserves read-only token access pending integration-scope cleanup.",
23+
}
2124
scope_map = {
2225
"GET": ["org:read", "org:write", "org:admin"],
2326
"POST": ["org:read", "org:write", "org:admin"],

0 commit comments

Comments
 (0)