Skip to content

Commit 161b48f

Browse files
cathtengandrewshie-sentry
authored andcommitted
feat(aci): schema validation for DataCondition.comparison (#83457)
1 parent c5579d0 commit 161b48f

File tree

7 files changed

+82
-34
lines changed

7 files changed

+82
-34
lines changed

src/sentry/workflow_engine/handlers/condition/age_comparison_handler.py

+17-3
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,27 @@
1111

1212
@condition_handler_registry.register(Condition.AGE_COMPARISON)
1313
class AgeComparisonConditionHandler(DataConditionHandler[WorkflowJob]):
14+
comparison_json_schema = {
15+
"type": "object",
16+
"properties": {
17+
"comparison_type": {
18+
"type": "string",
19+
"enum": [AgeComparisonType.OLDER, AgeComparisonType.NEWER],
20+
},
21+
"value": {"type": "integer", "minimum": 0},
22+
"time": {"type": "string", "enum": list(timeranges.keys())},
23+
},
24+
"required": ["comparison_type", "value", "time"],
25+
"additionalProperties": False,
26+
}
27+
1428
@staticmethod
1529
def evaluate_value(job: WorkflowJob, comparison: Any) -> bool:
1630
event = job["event"]
1731
first_seen = event.group.first_seen
1832
current_time = timezone.now()
19-
comparison_type = comparison.get("comparison_type")
20-
time = comparison.get("time")
33+
comparison_type = comparison["comparison_type"]
34+
time = comparison["time"]
2135

2236
if (
2337
not comparison_type
@@ -31,7 +45,7 @@ def evaluate_value(job: WorkflowJob, comparison: Any) -> bool:
3145
return False
3246

3347
try:
34-
value = int(comparison.get("value"))
48+
value = int(comparison["value"])
3549
except (TypeError, ValueError):
3650
return False
3751

src/sentry/workflow_engine/migration_helpers/issue_alert_conditions.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,9 @@ def create_tagged_event_data_condition(
165165
def create_age_comparison_data_condition(
166166
data: dict[str, Any], dcg: DataConditionGroup
167167
) -> DataCondition:
168-
# TODO: Add comparison validation (error if not enough information)
169168
comparison = {
170169
"comparison_type": data["comparison_type"],
171-
"value": data["value"],
170+
"value": int(data["value"]),
172171
"time": data["time"],
173172
}
174173

src/sentry/workflow_engine/models/data_condition.py

+31-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
from typing import Any, TypeVar, cast
44

55
from django.db import models
6+
from django.db.models.signals import pre_save
7+
from django.dispatch import receiver
8+
from jsonschema import ValidationError, validate
69

710
from sentry.backup.scopes import RelocationScope
811
from sentry.db.models import DefaultFieldsModel, region_silo_model, sane_repr
@@ -40,7 +43,7 @@ class Condition(models.TextChoices):
4043
ISSUE_PRIORITY_EQUALS = "issue_priority_equals"
4144

4245

43-
condition_ops = {
46+
CONDITION_OPS = {
4447
Condition.EQUAL: operator.eq,
4548
Condition.GREATER_OR_EQUAL: operator.ge,
4649
Condition.GREATER: operator.gt,
@@ -103,9 +106,9 @@ def evaluate_value(self, value: T) -> DataConditionResult:
103106
)
104107
return None
105108

106-
if condition_type in condition_ops:
109+
if condition_type in CONDITION_OPS:
107110
# If the condition is a base type, handle it directly
108-
op = condition_ops[Condition(self.type)]
111+
op = CONDITION_OPS[Condition(self.type)]
109112
result = op(cast(Any, value), self.comparison)
110113
return self.get_condition_result() if result else None
111114

@@ -121,3 +124,28 @@ def evaluate_value(self, value: T) -> DataConditionResult:
121124

122125
result = handler.evaluate_value(value, self.comparison)
123126
return self.get_condition_result() if result else None
127+
128+
129+
@receiver(pre_save, sender=DataCondition)
130+
def enforce_comparison_schema(sender, instance: DataCondition, **kwargs):
131+
132+
condition_type = Condition(instance.type)
133+
if condition_type in CONDITION_OPS:
134+
# don't enforce schema for default ops, this can be any type
135+
return
136+
137+
try:
138+
handler = condition_handler_registry.get(condition_type)
139+
except NoRegistrationExistsError:
140+
logger.exception(
141+
"No registration exists for condition",
142+
extra={"type": instance.type, "id": instance.id},
143+
)
144+
return None
145+
146+
schema = handler.comparison_json_schema
147+
148+
try:
149+
validate(instance.comparison, schema)
150+
except ValidationError as e:
151+
raise ValidationError(f"Invalid config: {e.message}")

src/sentry/workflow_engine/types.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from enum import IntEnum
4-
from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar
4+
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypedDict, TypeVar
55

66
from sentry.types.group import PriorityLevel
77

@@ -55,6 +55,8 @@ def bulk_get_query_object(data_sources) -> dict[int, T | None]:
5555

5656

5757
class DataConditionHandler(Generic[T]):
58+
comparison_json_schema: ClassVar[dict[str, Any]] = {}
59+
5860
@staticmethod
5961
def evaluate_value(value: T, comparison: Any) -> DataConditionResult:
6062
raise NotImplementedError

tests/sentry/workflow_engine/handlers/condition/test_age_comparison_handler.py

+24-16
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from datetime import datetime, timedelta, timezone
22

3+
import pytest
4+
from jsonschema import ValidationError
5+
36
from sentry.rules.age import AgeComparisonType
47
from sentry.rules.filters.age_comparison import AgeComparisonFilter
58
from sentry.testutils.helpers.datetime import freeze_time
@@ -36,7 +39,7 @@ def setUp(self):
3639
)
3740
self.dc = self.create_data_condition(
3841
type=self.condition,
39-
comparison={"comparison_type": AgeComparisonType.OLDER, "value": "10", "time": "hour"},
42+
comparison={"comparison_type": AgeComparisonType.OLDER, "value": 10, "time": "hour"},
4043
condition_result=True,
4144
)
4245

@@ -47,16 +50,30 @@ def test_dual_write(self):
4750
assert dc.type == self.condition
4851
assert dc.comparison == {
4952
"comparison_type": AgeComparisonType.OLDER,
50-
"value": "10",
53+
"value": 10,
5154
"time": "hour",
5255
}
5356
assert dc.condition_result is True
5457
assert dc.condition_group == dcg
5558

59+
def test_json_schema(self):
60+
self.dc.comparison.update({"time": "asdf"})
61+
with pytest.raises(ValidationError):
62+
self.dc.save()
63+
64+
self.dc.comparison.update({"value": "bad_value"})
65+
with pytest.raises(ValidationError):
66+
self.dc.save()
67+
68+
self.dc.comparison.update({"comparison_type": "bad_value"})
69+
with pytest.raises(ValidationError):
70+
self.dc.save()
71+
5672
def test_older_applies_correctly(self):
57-
self.dc.update(
58-
comparison={"comparison_type": AgeComparisonType.OLDER, "value": "10", "time": "hour"}
73+
self.dc.comparison.update(
74+
{"comparison_type": AgeComparisonType.OLDER, "value": 10, "time": "hour"}
5975
)
76+
self.dc.save()
6077

6178
self.group.update(first_seen=datetime.now(timezone.utc) - timedelta(hours=3))
6279
self.assert_does_not_pass(self.dc, self.job)
@@ -67,22 +84,13 @@ def test_older_applies_correctly(self):
6784
self.assert_passes(self.dc, self.job)
6885

6986
def test_newer_applies_correctly(self):
70-
self.dc.update(
71-
comparison={"comparison_type": AgeComparisonType.NEWER, "value": "10", "time": "hour"}
87+
self.dc.comparison.update(
88+
{"comparison_type": AgeComparisonType.NEWER, "value": 10, "time": "hour"}
7289
)
90+
self.dc.save()
7391

7492
self.group.update(first_seen=datetime.now(timezone.utc) - timedelta(hours=3))
7593
self.assert_passes(self.dc, self.job)
7694

7795
self.group.update(first_seen=datetime.now(timezone.utc) - timedelta(hours=10))
7896
self.assert_does_not_pass(self.dc, self.job)
79-
80-
def test_fails_on_insufficient_data(self):
81-
self.dc.update(comparison={"time": "hour"})
82-
self.assert_does_not_pass(self.dc, self.job)
83-
84-
self.dc.update(comparison={"value": "bad_value"})
85-
self.assert_does_not_pass(self.dc, self.job)
86-
87-
self.dc.update(comparison={"comparison_type": "bad_value"})
88-
self.assert_does_not_pass(self.dc, self.job)

tests/sentry/workflow_engine/migration_helpers/test_migrate_rule.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def test_create_issue_alert_no_actions(self):
8484
type=Condition.AGE_COMPARISON,
8585
comparison={
8686
"comparison_type": AgeComparisonType.OLDER,
87-
"value": "10",
87+
"value": 10,
8888
"time": "hour",
8989
},
9090
condition_result=True,

tests/sentry/workflow_engine/models/test_data_condition.py

+5-8
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,11 @@ def test(self):
4646
assert dc.evaluate_value(1) is None
4747

4848
def test_bad_condition(self):
49-
50-
dc = self.create_data_condition(
51-
type="invalid", comparison=1.0, condition_result=DetectorPriorityLevel.HIGH
52-
)
53-
54-
with mock.patch("sentry.workflow_engine.models.data_condition.logger") as mock_logger:
55-
assert dc.evaluate_value(2) is None
56-
assert mock_logger.exception.call_args[0][0] == "Invalid condition type"
49+
with pytest.raises(ValueError):
50+
# Raises ValueError because the condition is invalid
51+
self.create_data_condition(
52+
type="invalid", comparison=1.0, condition_result=DetectorPriorityLevel.HIGH
53+
)
5754

5855
def test_bad_comparison(self):
5956
dc = self.create_data_condition(

0 commit comments

Comments
 (0)