Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 49 additions & 8 deletions src/sentry/api/endpoints/project_rule_details.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from typing import cast

import sentry_sdk
from django.db import router, transaction
from drf_spectacular.utils import extend_schema
Expand All @@ -12,7 +14,11 @@
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
from sentry.api.bases.rule import WorkflowEngineRuleEndpoint
from sentry.api.endpoints.project_rules import find_duplicate_rule
from sentry.api.endpoints.project_rules import (
ProjectRulePostData,
find_duplicate_rule,
format_request_data,
)
from sentry.api.fields.actor import OwnerActorField
from sentry.api.serializers import serialize
from sentry.api.serializers.models.rule import RuleSerializer, WorkflowEngineRuleSerializer
Expand All @@ -39,8 +45,10 @@
from sentry.sentry_apps.utils.errors import SentryAppBaseError
from sentry.signals import alert_rule_edited
from sentry.types.actor import Actor
from sentry.workflow_engine.endpoints.validators.base.workflow import WorkflowValidator
from sentry.workflow_engine.models.alertrule_workflow import AlertRuleWorkflow
from sentry.workflow_engine.models.workflow import Workflow
from sentry.workflow_engine.models.workflow_data_condition_group import WorkflowDataConditionGroup
from sentry.workflow_engine.utils.legacy_metric_tracking import (
report_used_legacy_models,
track_alert_endpoint_execution,
Expand Down Expand Up @@ -131,12 +139,13 @@ def get(self, request: Request, project: Project, rule: Rule | Workflow) -> Resp
- Actions - specify what should happen when the trigger conditions are met and the filters match.
"""
if isinstance(rule, Workflow):
workflow = rule
workflow_engine_rule_serializer = WorkflowEngineRuleSerializer(
expand=request.GET.getlist("expand", []),
prepare_component_fields=True,
project_slug=project.slug,
)
serialized_rule = serialize(rule, request.user, workflow_engine_rule_serializer)
serialized_rule = serialize(workflow, request.user, workflow_engine_rule_serializer)
else:
# Serialize Rule object
rule_serializer = RuleSerializer(
Expand Down Expand Up @@ -193,13 +202,45 @@ def put(self, request: Request, project: Project, rule: Rule | Workflow) -> Resp
- Actions - specify what should happen when the trigger conditions are met and the filters match.
"""
if isinstance(rule, Workflow):
return Response(
{
"rule": [
"Passing a workflow through this endpoint is not yet supported",
]
workflow = rule
organization = project.organization

data = request.data.copy()

if not data.get("filterMatch"):
# if filterMatch is not passed, don't overwrite it with default value, use the saved dcg type
wdcg = WorkflowDataConditionGroup.objects.filter(workflow=workflow).first()
if wdcg:
data["filterMatch"] = wdcg.condition_group.logic_type

request_data = format_request_data(cast(ProjectRulePostData, data))
if not request_data.get("config", {}).get("frequency"):
request_data["config"] = workflow.config
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Falsy frequency check silently ignores invalid values

Low Severity

The check if not request_data.get("config", {}).get("frequency") uses a falsy test, so a frequency of 0 (or any other falsy value) silently falls back to workflow.config instead of being passed to the WorkflowValidator for proper rejection. This means an explicitly invalid frequency input gets silently replaced with the saved value rather than producing a validation error.

Fix in Cursor Fix in Web


validator = WorkflowValidator(
data=request_data,
context={
"organization": organization,
"request": request,
"workflow": workflow,
},
status=status.HTTP_400_BAD_REQUEST,
)
validator.is_valid(raise_exception=True)

with transaction.atomic(router.db_for_write(Workflow)):
validator.update(workflow, validator.validated_data)
self.create_audit_entry(
request=request,
organization=organization,
target_object=workflow.id,
event=audit_log.get_event_id("WORKFLOW_EDIT"),
data=workflow.get_audit_log_data(),
)

workflow.refresh_from_db()
return Response(
serialize(workflow, request.user, WorkflowEngineRuleSerializer()),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PUT response serializer missing project_slug parameter

Medium Severity

The WorkflowEngineRuleSerializer() in the PUT response is instantiated without project_slug or prepare_component_fields, unlike the GET handler which passes project_slug=project.slug and prepare_component_fields=True. This means the PUT response may serialize the workflow with missing or incorrect project information and unprepared SentryApp component fields, producing an inconsistent response compared to what GET returns for the same workflow.

Additional Locations (1)
Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's consistence with the RuleSerializer usage

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PUT response serializer missing project_slug parameter

Medium Severity

The PUT handler's response creates WorkflowEngineRuleSerializer() with no arguments, while the GET handler creates it with expand, prepare_component_fields=True, and project_slug=project.slug. This means the PUT response may serialize differently than the GET response for the same workflow — notably missing sentry app component field preparation and the project_slug context.

Additional Locations (1)
Fix in Cursor Fix in Web

status=200,
)
Comment on lines +241 to 244
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Updating a workflow rule with an empty action_filters list deletes all filters, causing an IndexError in the serializer which assumes at least one filter exists.
Severity: HIGH

Suggested Fix

Add a check in the WorkflowEngineRuleSerializer's get_attrs method to ensure workflow.prefetched_wdcgs is not empty before attempting to access its first element. If it is empty, handle it gracefully, for example by returning a default value or an empty structure for the related attributes.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/sentry/api/endpoints/project_rule_details.py#L241-L244

Potential issue: When a workflow rule is updated via a PUT request and the
`action_filters` payload is empty, the `remove_items_by_api_input` function deletes all
associated `WorkflowDataConditionGroup` entries. Subsequently, when the updated rule is
serialized, the `WorkflowEngineRuleSerializer` attempts to access
`workflow.prefetched_wdcgs[0]`. Because all filters were removed, `prefetched_wdcgs` is
an empty list, leading to an `IndexError: list index out of range` and a 500 Internal
Server Error response to the user. The serializer incorrectly assumes at least one
action filter will always be present.


rule_data_before = dict(rule.data)
Expand Down
20 changes: 16 additions & 4 deletions src/sentry/api/endpoints/project_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django.dispatch import receiver
from drf_spectacular.utils import extend_schema
from rest_framework import serializers, status
from rest_framework.exceptions import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response

Expand Down Expand Up @@ -727,7 +728,7 @@ class ProjectRulePostData(TypedDict):
conditions: list[ConditionsData]
actions: list[dict[str, Any]]
environment: NotRequired[str | None]
owner: NotRequired[str | None]
owner: NotRequired[str | int | None]
filterMatch: NotRequired[Literal["all", "any", "none"]]
filters: NotRequired[list[FiltersData]]
status: NotRequired[str]
Expand All @@ -744,22 +745,33 @@ def format_request_data(
"environment": data.get("environment"),
"config": {"frequency": data.get("frequency")},
}

triggers: dict[str, Any] = {"logicType": "any-short", "conditions": []}
translated_filter_list = []
fake_dcg = DataConditionGroup()
# XXX: In order to avoid making bigger changes to translate_to_data_condition in issue_alert_conditions.py
# we pass in a dummy DCG and then pop it off since we just need the formatted data

for condition in data.get("conditions", []):
translated_conditions = asdict(translate_to_data_condition_data(condition, fake_dcg))
try:
translated_conditions = asdict(translate_to_data_condition_data(condition, fake_dcg))
except KeyError:
raise ValidationError("Ensure all required fields are filled in.")
except ValueError:
raise ValidationError("Invalid condition data")

translated_conditions.pop("condition_group")
triggers["conditions"].append(translated_conditions)

workflow_payload["triggers"] = triggers

for filter_data in data.get("filters", []):
translated_filters = asdict(translate_to_data_condition_data(filter_data, fake_dcg))
try:
translated_filters = asdict(translate_to_data_condition_data(filter_data, fake_dcg))
except KeyError:
raise ValidationError("Ensure all required fields are filled in.")
except ValueError:
raise ValidationError("Invalid filter data")

translated_filters.pop("condition_group")
translated_filter_list.append(translated_filters)

Expand Down
15 changes: 15 additions & 0 deletions src/sentry/api/serializers/models/rule.py
Original file line number Diff line number Diff line change
Expand Up @@ -635,12 +635,27 @@ def get_attrs(self, item_list: Sequence[Workflow], user, **kwargs):
if action.data.get("notes") == "":
action_data.pop("notes", None)

# XXX: workspace needs to be returned as a string
if action_data.get("workspace"):
action_data["workspace"] = str(action_data["workspace"])

serialized_actions.append(action_data)

# Generate conditions and filters
conditions, filters = self._generate_rule_conditions_filters(
workflow, result[workflow]["projects"][0], workflow_dcg
)
for f in filters:
# IssueCategoryFilter stores numeric string choices and must stay as str
if (
f.get("value")
and f.get("id") != "sentry.rules.filters.issue_category.IssueCategoryFilter"
Copy link
Member Author

@ceorourke ceorourke Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is sort of hacky but outside of changing the value type of one of these it's the only way for the tests to pass and the serializers to be in sync.

):
try:
f["value"] = int(f["value"])
except (ValueError, TypeError):
continue

result[workflow]["conditions"] = conditions
result[workflow]["filters"] = filters

Expand Down
Loading
Loading