Skip to content

Commit 2a711cc

Browse files
dcramercodex
andcommitted
fix(api): Tighten alert mutations and replay delete scope
Limit org-scoped alert and detector mutations to either the target project's alerts:write access or an explicit endpoint opt-in for the older team-scoped fallback. This closes the cross-team mutation path on alert rule and detector details, and adds project-scoped checks for the other shared consumers that still relied on the broad organization permission. Align replay deletion with the rest of the event delete surface by requiring event:admin instead of event:write. Add regression coverage for the scope changes and the cross-project authorization cases. Co-Authored-By: Codex <noreply@openai.com>
1 parent 604f729 commit 2a711cc

File tree

15 files changed

+386
-15
lines changed

15 files changed

+386
-15
lines changed

src/sentry/api/bases/organization.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,31 @@ def _has_any_team_scope(request: Request, scope: str) -> bool:
225225
return any(request.access.has_team_scope(team, scope) for team in teams)
226226

227227

228+
ALERT_MUTATION_SCOPES = frozenset({"org:write", "alerts:write"})
229+
230+
231+
def _has_project_alert_write_access(request: Request, projects: Sequence[Project]) -> bool:
232+
return bool(projects) and all(
233+
request.access.has_any_project_scope(project, ALERT_MUTATION_SCOPES) for project in projects
234+
)
235+
236+
237+
def _has_view_project_scoped_alert_write(
238+
request: Request,
239+
view: APIView,
240+
organization: Organization | RpcOrganization | RpcUserOrganizationContext,
241+
) -> bool | None:
242+
get_projects = getattr(view, "get_alert_mutation_projects", None)
243+
if not callable(get_projects):
244+
return None
245+
246+
projects = get_projects(request, organization)
247+
if projects is None:
248+
return None
249+
250+
return _has_project_alert_write_access(request, projects)
251+
252+
228253
class OrganizationAlertRulePermission(OrganizationPermission):
229254
scope_map = {
230255
"GET": ["org:read", "org:write", "org:admin", "alerts:read"],
@@ -242,8 +267,15 @@ def has_object_permission(
242267
if super().has_object_permission(request, view, organization):
243268
return True
244269

245-
return request.method in {"POST", "PUT", "DELETE"} and _has_any_team_scope(
246-
request, "alerts:write"
270+
if request.method not in {"POST", "PUT", "DELETE"}:
271+
return False
272+
273+
project_scoped_access = _has_view_project_scoped_alert_write(request, view, organization)
274+
if project_scoped_access is not None:
275+
return project_scoped_access
276+
277+
return bool(getattr(view, "allow_any_team_alert_write_fallback", False)) and (
278+
_has_any_team_scope(request, "alerts:write")
247279
)
248280

249281

@@ -264,8 +296,15 @@ def has_object_permission(
264296
if super().has_object_permission(request, view, organization):
265297
return True
266298

267-
return request.method in {"POST", "PUT", "DELETE"} and _has_any_team_scope(
268-
request, "alerts:write"
299+
if request.method not in {"POST", "PUT", "DELETE"}:
300+
return False
301+
302+
project_scoped_access = _has_view_project_scoped_alert_write(request, view, organization)
303+
if project_scoped_access is not None:
304+
return project_scoped_access
305+
306+
return bool(getattr(view, "allow_any_team_alert_write_fallback", False)) and (
307+
_has_any_team_scope(request, "alerts:write")
269308
)
270309

271310

src/sentry/incidents/endpoints/bases.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
from collections.abc import Sequence
12
from typing import Any
23

34
from rest_framework.exceptions import PermissionDenied
5+
from rest_framework.exceptions import ValidationError as RestFrameworkValidationError
46
from rest_framework.request import Request
57

68
from sentry import features
@@ -11,6 +13,7 @@
1113
from sentry.incidents.endpoints.serializers.utils import get_object_id_from_fake_id
1214
from sentry.incidents.models.alert_rule import AlertRule
1315
from sentry.models.organization import Organization
16+
from sentry.models.project import Project
1417
from sentry.workflow_engine.endpoints.utils.ids import to_valid_int_id
1518
from sentry.workflow_engine.models.alertrule_detector import AlertRuleDetector
1619
from sentry.workflow_engine.models.detector import Detector
@@ -24,6 +27,8 @@ class OrganizationAlertRuleBaseEndpoint(OrganizationEndpoint):
2427
org-level permissions and team admin project-scoped permissions.
2528
"""
2629

30+
allow_any_team_alert_write_fallback = True
31+
2732
def check_can_create_alert(self, request: Request, organization: Organization) -> None:
2833
"""
2934
Determine if the requesting user has access to alert creation. If the request does not have the "alerts:write"
@@ -83,6 +88,34 @@ def convert_args(
8388
class OrganizationAlertRuleEndpoint(OrganizationEndpoint):
8489
permission_classes = (OrganizationAlertRulePermission,)
8590

91+
def get_alert_mutation_projects(
92+
self, request: Request, organization: Organization
93+
) -> Sequence[Project] | None:
94+
if request.method not in {"PUT", "DELETE"}:
95+
return None
96+
97+
raw_alert_rule_id = self.kwargs.get("alert_rule_id")
98+
if raw_alert_rule_id is None:
99+
return None
100+
101+
try:
102+
validated_alert_rule_id = to_valid_int_id("alert_rule_id", raw_alert_rule_id)
103+
except RestFrameworkValidationError:
104+
return None
105+
106+
try:
107+
alert_rule = AlertRule.objects.prefetch_related("projects").get(
108+
organization=organization, id=validated_alert_rule_id
109+
)
110+
return [alert_rule.projects.get()]
111+
except (
112+
AlertRule.DoesNotExist,
113+
AlertRule.MultipleObjectsReturned,
114+
Project.DoesNotExist,
115+
Project.MultipleObjectsReturned,
116+
):
117+
return None
118+
86119
def convert_args(
87120
self, request: Request, alert_rule_id: int, *args: Any, **kwargs: Any
88121
) -> tuple[tuple[Any, ...], dict[str, Any]]:
@@ -158,6 +191,54 @@ class WorkflowEngineOrganizationAlertRuleEndpoint(OrganizationAlertRuleEndpoint)
158191
# with the broad workflow-engine-rule-serializers flag.
159192
workflow_engine_method_flags: dict[str, str] = {}
160193

194+
def get_alert_mutation_projects(
195+
self, request: Request, organization: Organization
196+
) -> Sequence[Project] | None:
197+
projects = super().get_alert_mutation_projects(request, organization)
198+
if projects is not None:
199+
return projects
200+
201+
if request.method not in {"PUT", "DELETE"}:
202+
return None
203+
204+
raw_alert_rule_id = self.kwargs.get("alert_rule_id")
205+
if raw_alert_rule_id is None:
206+
return None
207+
208+
try:
209+
validated_alert_rule_id = to_valid_int_id("alert_rule_id", raw_alert_rule_id)
210+
except RestFrameworkValidationError:
211+
return None
212+
213+
ard = (
214+
AlertRuleDetector.objects.select_related("detector__project")
215+
.filter(
216+
alert_rule_id=validated_alert_rule_id,
217+
detector__project__organization=organization,
218+
)
219+
.first()
220+
)
221+
if ard is not None:
222+
return [ard.detector.project]
223+
224+
try:
225+
detector_id = get_object_id_from_fake_id(validated_alert_rule_id)
226+
except ValueError:
227+
return None
228+
229+
detector = (
230+
Detector.objects.select_related("project")
231+
.filter(
232+
id=detector_id,
233+
project__organization=organization,
234+
)
235+
.first()
236+
)
237+
if detector is None:
238+
return None
239+
240+
return [detector.project]
241+
161242
def convert_args(
162243
self, request: Request, alert_rule_id: int, *args: Any, **kwargs: Any
163244
) -> tuple[tuple[Any, ...], dict[str, Any]]:

src/sentry/incidents/endpoints/organization_alert_rule_details.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from sentry.api.api_owners import ApiOwner
1414
from sentry.api.api_publish_status import ApiPublishStatus
1515
from sentry.api.base import cell_silo_endpoint
16+
from sentry.api.bases.organization import ALERT_MUTATION_SCOPES
1617
from sentry.api.fields.actor import OwnerActorField
1718
from sentry.api.serializers import serialize
1819
from sentry.api.serializers.rest_framework.project import ProjectField
@@ -325,6 +326,11 @@ def wrapper(
325326
if not request.access.has_project_access(project):
326327
return Response(status=status.HTTP_403_FORBIDDEN)
327328

329+
if request.method in {"PUT", "DELETE"} and not request.access.has_any_project_scope(
330+
project, ALERT_MUTATION_SCOPES
331+
):
332+
return Response(status=status.HTTP_403_FORBIDDEN)
333+
328334
return func(self, request, organization, alert_rule)
329335

330336
return wrapper

src/sentry/monitors/endpoints/organization_monitor_index.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from sentry.api.api_publish_status import ApiPublishStatus
1919
from sentry.api.base import cell_silo_endpoint
2020
from sentry.api.bases import NoProjects
21-
from sentry.api.bases.organization import OrganizationAlertRulePermission
21+
from sentry.api.bases.organization import ALERT_MUTATION_SCOPES, OrganizationAlertRulePermission
2222
from sentry.api.helpers.teams import get_teams
2323
from sentry.api.paginator import OffsetPaginator
2424
from sentry.api.serializers import serialize
@@ -331,6 +331,11 @@ def put(self, request: AuthenticatedHttpRequest, organization) -> Response:
331331

332332
monitor_guids = result.pop("ids", [])
333333
monitors = list(Monitor.objects.filter(guid__in=monitor_guids, project_id__in=project_ids))
334+
if not all(
335+
request.access.has_any_project_scope(monitor.project, ALERT_MUTATION_SCOPES)
336+
for monitor in monitors
337+
):
338+
return self.respond(status=403)
334339

335340
status = result.get("status")
336341
# If enabling monitors, ensure we can assign all before moving forward

src/sentry/replays/endpoints/project_replay_details.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class ReplayDetailsPermission(ProjectPermission):
2323
"GET": ["project:read", "project:write", "project:admin"],
2424
"POST": ["project:write", "project:admin"],
2525
"PUT": ["project:write", "project:admin"],
26-
"DELETE": ["event:write", "event:admin"],
26+
"DELETE": ["event:admin"],
2727
}
2828

2929

src/sentry/seer/endpoints/organization_events_anomalies.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from sentry.api.api_owners import ApiOwner
77
from sentry.api.api_publish_status import ApiPublishStatus
88
from sentry.api.base import cell_silo_endpoint
9-
from sentry.api.bases.organization import OrganizationAlertRulePermission
9+
from sentry.api.bases.organization import ALERT_MUTATION_SCOPES, OrganizationAlertRulePermission
1010
from sentry.api.bases.organization_events import OrganizationEventsEndpointBase
1111
from sentry.api.exceptions import ResourceDoesNotExist
1212
from sentry.api.serializers.base import serialize
@@ -33,10 +33,22 @@ class OrganizationEventsAnomaliesEndpoint(OrganizationEventsEndpointBase):
3333
publish_status = {
3434
"POST": ApiPublishStatus.EXPERIMENTAL,
3535
}
36+
allow_any_team_alert_write_fallback = True
3637
# This POST previews anomaly-detection config used while authoring metric
3738
# alerts/detectors, so it intentionally follows alert-write permissions.
3839
permission_classes = (OrganizationAlertRulePermission,)
3940

41+
def get_alert_mutation_projects(self, request: Request, organization: Organization):
42+
raw_project_id = request.data.get("project_id")
43+
if raw_project_id is None:
44+
return None
45+
46+
try:
47+
project_id = to_valid_int_id("project_id", raw_project_id)
48+
return self.get_projects(request, organization, project_ids={project_id})
49+
except Exception:
50+
return None
51+
4052
@extend_schema(
4153
operation_id="Identify anomalies in historical data",
4254
parameters=[GlobalParams.ORG_ID_OR_SLUG],
@@ -94,6 +106,11 @@ def post(self, request: Request, organization: Organization) -> Response:
94106
projects = self.get_projects(request, organization, project_ids={project_id})
95107
if not projects:
96108
return Response({"detail": "Invalid project"}, status=400)
109+
if not all(
110+
request.access.has_any_project_scope(project, ALERT_MUTATION_SCOPES)
111+
for project in projects
112+
):
113+
return Response(status=403)
97114

98115
anomalies = get_historical_anomaly_data_from_seer_preview(
99116
current_data=current_data,

src/sentry/uptime/endpoints/organization_uptime_alert_preview_check.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
@cell_silo_endpoint
3131
class OrganizationUptimeAlertPreviewCheckEndpoint(OrganizationEndpoint):
3232
owner = ApiOwner.CRONS
33+
allow_any_team_alert_write_fallback = True
3334
# This POST previews monitor creation and validation, so it intentionally
3435
# uses the same permission surface as creating the alert itself.
3536
permission_classes = (OrganizationAlertRulePermission,)

src/sentry/uptime/endpoints/organization_uptime_assertion_suggestions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ class OrganizationUptimeAssertionSuggestionsEndpoint(OrganizationEndpoint):
5050
"""
5151

5252
owner = ApiOwner.CRONS
53+
allow_any_team_alert_write_fallback = True
5354
# This POST is part of the uptime monitor authoring flow, so it should
5455
# track the same alert-write permission as the monitor it helps create.
5556
permission_classes = (OrganizationAlertRulePermission,)

src/sentry/workflow_engine/endpoints/organization_detector_details.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,34 @@ def get_detector_validator(
9595
@cell_silo_endpoint
9696
@extend_schema(tags=["Monitors"])
9797
class OrganizationDetectorDetailsEndpoint(OrganizationEndpoint):
98+
def get_alert_mutation_projects(
99+
self, request: Request, organization: Organization
100+
) -> list[Project] | None:
101+
if request.method not in {"PUT", "DELETE"}:
102+
return None
103+
104+
raw_detector_id = self.kwargs.get("detector_id")
105+
if raw_detector_id is None:
106+
return None
107+
108+
try:
109+
validated_detector_id = to_valid_int_id("detector_id", raw_detector_id)
110+
except ValidationError:
111+
return None
112+
113+
detector = (
114+
Detector.objects.select_related("project")
115+
.filter(
116+
id=validated_detector_id,
117+
project__organization_id=organization.id,
118+
)
119+
.first()
120+
)
121+
if detector is None:
122+
return None
123+
124+
return [detector.project]
125+
98126
def convert_args(
99127
self, request: Request, detector_id: str, *args: Any, **kwargs: Any
100128
) -> tuple[tuple[Any, ...], dict[str, Organization | Detector]]:

src/sentry/workflow_engine/endpoints/organization_detector_index.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ class OrganizationDetectorIndexEndpoint(OrganizationEndpoint):
150150
"DELETE": ApiPublishStatus.PUBLIC,
151151
}
152152
owner = ApiOwner.ISSUES
153+
allow_any_team_alert_write_fallback = True
153154

154155
permission_classes = (OrganizationDetectorPermission,)
155156

0 commit comments

Comments
 (0)