Skip to content

Commit 799caef

Browse files
ref(uptime): Restrict detector mode changes to superusers only (#103178)
Adds validation to prevent non-superusers from creating or modifying uptime detectors with restricted mode values (AUTO_DETECTED_*). Only superusers (internal systems) can now set modes other than MANUAL. The validation smartly only triggers when mode is being changed, allowing users to pass config with an unchanged mode value. Fixes [NEW-621: Ensure Uptime detector `mode` is not configurable via API endpoints](https://linear.app/getsentry/issue/NEW-621/ensure-uptime-detector-mode-is-not-configurable-via-api-endpoints)
1 parent 22328e2 commit 799caef

File tree

3 files changed

+172
-3
lines changed

3 files changed

+172
-3
lines changed

src/sentry/uptime/endpoints/validators.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,30 @@ def create_source(self, validated_data: dict[str, Any]) -> UptimeSubscription:
466466
class UptimeDomainCheckFailureValidator(BaseDetectorTypeValidator):
467467
data_sources = serializers.ListField(child=UptimeMonitorDataSourceValidator(), required=False)
468468

469+
def validate_config(self, config: dict[str, Any]) -> dict[str, Any]:
470+
"""
471+
Validate that only superusers can change mode to non-MANUAL values.
472+
"""
473+
if "mode" not in config:
474+
return config
475+
476+
mode = config["mode"]
477+
478+
# For updates, only validate if mode is being changed
479+
if self.instance:
480+
current_mode = self.instance.config.get("mode")
481+
# If mode hasn't changed, no validation needed
482+
if current_mode == mode:
483+
return config
484+
485+
# Only superusers can set/change mode to anything other than MANUAL
486+
if mode != UptimeMonitorMode.MANUAL:
487+
request = self.context["request"]
488+
if not is_active_superuser(request):
489+
raise serializers.ValidationError("Only superusers can modify `mode`")
490+
491+
return config
492+
469493
def validate_enabled(self, value: bool) -> bool:
470494
"""
471495
Validate that enabling a detector is allowed based on seat availability.

tests/sentry/uptime/endpoints/test_detector.py

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,53 @@ def setUp(self) -> None:
7878
"request": self.make_request(),
7979
}
8080

81+
def test_update_non_superuser_cannot_change_mode_via_endpoint(self) -> None:
82+
"""Integration test: non-superuser cannot change mode via API endpoint."""
83+
# Create a detector with MANUAL mode specifically for this test
84+
manual_detector = self.create_uptime_detector(
85+
project=self.project,
86+
env=self.environment,
87+
uptime_subscription=self.uptime_subscription,
88+
name="Manual Test Detector",
89+
mode=UptimeMonitorMode.MANUAL,
90+
)
91+
92+
assert manual_detector.workflow_condition_group is not None
93+
invalid_data = {
94+
"id": manual_detector.id,
95+
"projectId": self.project.id,
96+
"name": "Manual Test Detector",
97+
"type": UptimeDomainCheckFailure.slug,
98+
"dateCreated": manual_detector.date_added,
99+
"dateUpdated": timezone.now(),
100+
"conditionGroup": {
101+
"id": manual_detector.workflow_condition_group.id,
102+
"organizationId": self.organization.id,
103+
},
104+
"config": {
105+
"environment": self.environment.name,
106+
"mode": UptimeMonitorMode.AUTO_DETECTED_ACTIVE.value,
107+
"recovery_threshold": 1,
108+
"downtime_threshold": 1,
109+
},
110+
}
111+
112+
response = self.get_error_response(
113+
self.organization.slug,
114+
manual_detector.id,
115+
**invalid_data,
116+
status_code=status.HTTP_400_BAD_REQUEST,
117+
method="PUT",
118+
)
119+
120+
assert response.data["config"] == ["Only superusers can modify `mode`"]
121+
122+
# Verify that mode was NOT changed
123+
manual_detector.refresh_from_db()
124+
assert manual_detector.config["mode"] == UptimeMonitorMode.MANUAL.value
125+
81126
def test_update(self) -> None:
82-
assert self.detector.workflow_condition_group
127+
assert self.detector.workflow_condition_group is not None
83128
valid_data = {
84129
"id": self.detector.id,
85130
"projectId": self.project.id,
@@ -113,8 +158,7 @@ def test_update(self) -> None:
113158
assert updated_sub.timeout_ms == 15000
114159

115160
def test_update_invalid(self) -> None:
116-
assert self.detector.workflow_condition_group
117-
161+
assert self.detector.workflow_condition_group is not None
118162
valid_data = {
119163
"id": self.detector.id,
120164
"projectId": self.project.id,
@@ -252,3 +296,24 @@ def test_create_detector_missing_config_property(self):
252296

253297
assert "config" in response.data
254298
assert "downtime_threshold" in str(response.data["config"])
299+
300+
def test_create_detector_non_superuser_cannot_set_auto_detected_mode(self):
301+
"""Integration test: non-superuser cannot create with AUTO_DETECTED mode via API."""
302+
invalid_data = _get_valid_data(
303+
self.project.id,
304+
self.environment.name,
305+
config={
306+
"environment": self.environment.name,
307+
"mode": UptimeMonitorMode.AUTO_DETECTED_ACTIVE.value,
308+
"recovery_threshold": 1,
309+
"downtime_threshold": 1,
310+
},
311+
)
312+
313+
response = self.get_error_response(
314+
self.organization.slug,
315+
**invalid_data,
316+
status_code=status.HTTP_400_BAD_REQUEST,
317+
)
318+
319+
assert response.data["config"] == ["Only superusers can modify `mode`"]

tests/sentry/uptime/endpoints/test_validators.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,83 @@ def test_update_no_enable_change_no_seat_call(self, mock_assign_seat: mock.Magic
294294

295295
# Verify no seat operations were called
296296
mock_assign_seat.assert_not_called()
297+
298+
def test_non_superuser_cannot_create_with_auto_detected_mode(self) -> None:
299+
"""Test that non-superuser cannot create detector with AUTO_DETECTED mode."""
300+
data = self.get_valid_data(
301+
config={
302+
"mode": UptimeMonitorMode.AUTO_DETECTED_ACTIVE.value,
303+
"environment": None,
304+
"recovery_threshold": DEFAULT_RECOVERY_THRESHOLD,
305+
"downtime_threshold": DEFAULT_DOWNTIME_THRESHOLD,
306+
}
307+
)
308+
309+
validator = UptimeDomainCheckFailureValidator(data=data, context=self.context)
310+
assert not validator.is_valid()
311+
assert validator.errors["config"] == ["Only superusers can modify `mode`"]
312+
313+
def test_non_superuser_cannot_change_mode(self) -> None:
314+
"""Test that non-superuser cannot change mode via update."""
315+
# Create a detector with MANUAL mode
316+
detector = self.create_uptime_detector(mode=UptimeMonitorMode.MANUAL)
317+
318+
data = {
319+
"config": {
320+
"mode": UptimeMonitorMode.AUTO_DETECTED_ACTIVE.value,
321+
"environment": None,
322+
"recovery_threshold": DEFAULT_RECOVERY_THRESHOLD,
323+
"downtime_threshold": DEFAULT_DOWNTIME_THRESHOLD,
324+
}
325+
}
326+
327+
validator = UptimeDomainCheckFailureValidator(
328+
instance=detector, data=data, context=self.context, partial=True
329+
)
330+
assert not validator.is_valid()
331+
assert validator.errors["config"] == ["Only superusers can modify `mode`"]
332+
333+
# Verify mode was not changed
334+
detector.refresh_from_db()
335+
assert detector.config["mode"] == UptimeMonitorMode.MANUAL.value
336+
337+
def test_non_superuser_can_update_with_same_mode(self) -> None:
338+
"""Test that non-superuser can pass config if mode doesn't change."""
339+
# Create a detector with AUTO_DETECTED_ACTIVE mode (e.g., from autodetection)
340+
detector = self.create_uptime_detector(mode=UptimeMonitorMode.AUTO_DETECTED_ACTIVE)
341+
342+
data = {
343+
"config": {
344+
"mode": UptimeMonitorMode.AUTO_DETECTED_ACTIVE.value, # Same mode
345+
"environment": None,
346+
"recovery_threshold": DEFAULT_RECOVERY_THRESHOLD,
347+
"downtime_threshold": DEFAULT_DOWNTIME_THRESHOLD,
348+
}
349+
}
350+
351+
validator = UptimeDomainCheckFailureValidator(
352+
instance=detector, data=data, context=self.context, partial=True
353+
)
354+
# Should be valid since mode hasn't changed
355+
assert validator.is_valid(), validator.errors
356+
357+
def test_superuser_can_create_with_auto_detected_mode(self) -> None:
358+
"""Test that superuser can create detector with AUTO_DETECTED mode."""
359+
superuser = self.create_user(is_superuser=True)
360+
self.context["request"] = self.make_request(user=superuser, is_superuser=True)
361+
362+
data = self.get_valid_data(
363+
config={
364+
"mode": UptimeMonitorMode.AUTO_DETECTED_ACTIVE.value,
365+
"environment": None,
366+
"recovery_threshold": DEFAULT_RECOVERY_THRESHOLD,
367+
"downtime_threshold": DEFAULT_DOWNTIME_THRESHOLD,
368+
}
369+
)
370+
371+
validator = UptimeDomainCheckFailureValidator(data=data, context=self.context)
372+
assert validator.is_valid(), validator.errors
373+
detector = validator.save()
374+
375+
detector.refresh_from_db()
376+
assert detector.config["mode"] == UptimeMonitorMode.AUTO_DETECTED_ACTIVE.value

0 commit comments

Comments
 (0)