Skip to content
Merged
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
3 changes: 3 additions & 0 deletions src/sentry/features/temporary.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,9 @@ def register_temporary_features(manager: FeatureManager) -> None:
# Use workflow engine exclusively for OrganizationCombinedRuleIndexEndpoint.get results.
# See src/sentry/workflow_engine/docs/legacy_backport.md for context.
manager.add("organizations:workflow-engine-combinedruleindex-get", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
# Use workflow engine exclusively for ProjectRuleGroupHistoryIndexEndpoint.get and ProjectRuleStatsIndexEndpoint.get results.
# See src/sentry/workflow_engine/docs/legacy_backport.md for context.
manager.add("organizations:workflow-engine-projectrulegroupstats-get", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
# Enable metric detector limits by plan type
manager.add("organizations:workflow-engine-metric-detector-limit", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
# Enable EventUniqueUserFrequencyConditionWithConditions special alert condition
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from collections.abc import Mapping
from typing import Any
from typing import Any, Sequence

from django.contrib.auth.models import AnonymousUser

Expand All @@ -20,6 +20,17 @@


class WorkflowEngineActionSerializer(Serializer):
def get_attrs(
self, item_list: Sequence[Action], user: User | RpcUser | AnonymousUser, **kwargs: Any
) -> dict[Action, dict[str, Any]]:
aarta_by_action_id = {
aarta.action_id: aarta
for aarta in ActionAlertRuleTriggerAction.objects.filter(
action__in=[item.id for item in item_list]
)
}
return {item: {"aarta": aarta_by_action_id.get(item.id)} for item in item_list}

def serialize(
self, obj: Action, attrs: Mapping[str, Any], user: User | RpcUser | AnonymousUser, **kwargs
) -> dict[str, Any]:
Expand All @@ -30,10 +41,7 @@ def serialize(

alert_rule_trigger_id = kwargs.get("alert_rule_trigger_id", -1)

try:
aarta = ActionAlertRuleTriggerAction.objects.get(action=obj.id)
except ActionAlertRuleTriggerAction.DoesNotExist:
aarta = None
aarta = attrs.get("aarta")
priority = obj.data.get("priority")
type_value = ActionService.get_value(obj.type)
target = MetricAlertRegistryHandler.target(obj)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,12 @@ def get_attrs(
# Map (condition_group_id, comparison) → action-filter DC exists in that DCG
# We need: for a given detector's DCGs + priority level → matching DCG IDs
# NOTE: Assumes DataConditions are limited to what would be dual written.
dcg_comparison_pairs: dict[int, set[int]] = defaultdict(set)
dcg_comparison_pairs: dict[int, set[int | float]] = defaultdict(set)
for dc in DataCondition.objects.filter(condition_group__in=all_dcg_ids):
# Map comparison value → set of DCG IDs that have an action filter at that level
dcg_comparison_pairs[dc.condition_group_id].add(dc.comparison)
# Only collect numeric comparison values; non-numeric values (e.g. dicts
# from anomaly detection conditions) don't match condition_result levels.
if isinstance(dc.comparison, (int, float)):
dcg_comparison_pairs[dc.condition_group_id].add(dc.comparison)

# Bulk-fetch all DCG → action mappings
dcg_to_action_ids: dict[int, list[int]] = defaultdict(list)
Expand Down
15 changes: 7 additions & 8 deletions src/sentry/incidents/metric_issue_detector.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from datetime import timedelta
from typing import Any

from django.core.exceptions import ValidationError
from parsimonious.exceptions import ParseError
from rest_framework import serializers
from urllib3.exceptions import MaxRetryError, TimeoutError

from sentry import features, quotas
from sentry.constants import ObjectStatus
Expand Down Expand Up @@ -325,11 +328,9 @@
validated_data_source: dict[str, Any] = {"data_sources": [data_source]}
if not seer_updated:
update_detector_data(instance, validated_data_source)
except Exception:
except (TimeoutError, MaxRetryError, ParseError, ValidationError) as e:
# don't update the snuba query if we failed to send data to Seer
raise serializers.ValidationError(
"Failed to send data to Seer, cannot update detector"
)
raise serializers.ValidationError(str(e))

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.

extrapolation_mode = format_extrapolation_mode(
data_source.get("extrapolation_mode", snuba_query.extrapolation_mode)
Expand Down Expand Up @@ -367,11 +368,9 @@
try:
update_detector_data(instance, validated_data)
seer_updated = True
except Exception:
except (TimeoutError, MaxRetryError, ParseError, ValidationError) as e:
# Don't update if we failed to send data to Seer
raise serializers.ValidationError(
"Failed to send data to Seer, cannot update detector"
)
raise serializers.ValidationError(str(e))

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.

elif (
validated_data.get("config")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ def serialize(
@extend_schema(tags=["issue_alerts"])
@cell_silo_endpoint
class ProjectRuleGroupHistoryIndexEndpoint(WorkflowEngineRuleEndpoint):
workflow_engine_method_flags = {
"GET": "organizations:workflow-engine-projectrulegroupstats-get",
}
publish_status = {
"GET": ApiPublishStatus.EXPERIMENTAL,
}
Expand Down
3 changes: 3 additions & 0 deletions src/sentry/rules/history/endpoints/project_rule_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ def serialize(
@extend_schema(tags=["issue_alerts"])
@cell_silo_endpoint
class ProjectRuleStatsIndexEndpoint(WorkflowEngineRuleEndpoint):
workflow_engine_method_flags = {
"GET": "organizations:workflow-engine-projectrulegroupstats-get",
}
publish_status = {
"GET": ApiPublishStatus.EXPERIMENTAL,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@
from sentry.workflow_engine.endpoints.validators.base import BaseDetectorTypeValidator
from sentry.workflow_engine.endpoints.validators.detector_workflow import (
BulkDetectorWorkflowsValidator,
)
from sentry.workflow_engine.endpoints.validators.utils import (
can_delete_detector,
can_edit_detector,
get_unknown_detector_type_error,
)
from sentry.workflow_engine.endpoints.validators.utils import get_unknown_detector_type_error
from sentry.workflow_engine.models import Detector


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,15 @@
from sentry.workflow_engine.endpoints.validators.base import BaseDetectorTypeValidator
from sentry.workflow_engine.endpoints.validators.detector_workflow import (
BulkDetectorWorkflowsValidator,
can_delete_detectors,
can_edit_detectors,
)
from sentry.workflow_engine.endpoints.validators.detector_workflow_mutation import (
DetectorWorkflowMutationValidator,
)
from sentry.workflow_engine.endpoints.validators.utils import get_unknown_detector_type_error
from sentry.workflow_engine.endpoints.validators.utils import (
can_delete_detectors,
can_edit_detectors,
get_unknown_detector_type_error,
)
from sentry.workflow_engine.models import Detector
from sentry.workflow_engine.models.detector_group import DetectorGroup

Expand Down
185 changes: 5 additions & 180 deletions src/sentry/workflow_engine/endpoints/validators/detector_workflow.py
Original file line number Diff line number Diff line change
@@ -1,189 +1,14 @@
from collections.abc import Sequence
from typing import Any, Literal

from django.db import router, transaction
from django.db.models import QuerySet
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from rest_framework.request import Request

from sentry import audit_log
from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer
from sentry.grouping.grouptype import ErrorGroupType
from sentry.issue_detection.performance_detection import PERFORMANCE_WFE_DETECTOR_TYPES
from sentry.models.organization import Organization
from sentry.models.project import Project
from sentry.utils.audit import create_audit_entry
from sentry.workflow_engine.models.detector import Detector
from sentry.workflow_engine.endpoints.validators.utils import (
perform_bulk_detector_workflow_operations,
validate_detectors_exist_and_have_permissions,
validate_workflows_exist,
)
from sentry.workflow_engine.models.detector_workflow import DetectorWorkflow
from sentry.workflow_engine.models.workflow import Workflow
from sentry.workflow_engine.typings.grouptype import IssueStreamGroupType

# Only those with organization write permissions can edit system-created detectors (e.g. error detectors).
SYSTEM_CREATED_DETECTOR_REQUIRED_SCOPES = {"org:write"}
USER_CREATED_DETECTOR_REQUIRED_SCOPES = {"org:write", "alerts:write"}


def is_system_created_detector(detector: Detector) -> bool:
return (
detector.type in (ErrorGroupType.slug, IssueStreamGroupType.slug)
or detector.type in PERFORMANCE_WFE_DETECTOR_TYPES
)


def can_edit_system_created_detectors(request: Request, project: Project) -> bool:
return request.access.has_any_project_scope(project, SYSTEM_CREATED_DETECTOR_REQUIRED_SCOPES)


def can_edit_user_created_detectors(request: Request, project: Project) -> bool:
return request.access.has_any_project_scope(project, USER_CREATED_DETECTOR_REQUIRED_SCOPES)


def can_edit_detectors(detectors: QuerySet[Detector], request: Request) -> bool:
"""
Determine if the requesting user has access to edit the given detectors.
System created detectors lock edit access to org:write, while user created detectors
are more permissive.
"""
required_scopes = (
SYSTEM_CREATED_DETECTOR_REQUIRED_SCOPES
if any(is_system_created_detector(detector) for detector in detectors)
else USER_CREATED_DETECTOR_REQUIRED_SCOPES
)

projects = Project.objects.filter(
id__in=detectors.values_list("project_id", flat=True).distinct()
)

return all(
request.access.has_any_project_scope(project, required_scopes) for project in projects
)


def can_edit_detector(detector: Detector, request: Request) -> bool:
"""
Determine if the requesting user has access to detector edit. If the request does not have the "alerts:write"
permission, then we must verify that the user is a team admin with "alerts:write" access to the project(s)
in their request.
"""
if is_system_created_detector(detector) and not can_edit_system_created_detectors(
request, detector.project
):
return False

return can_edit_user_created_detectors(request, detector.project)


def can_delete_detectors(detectors: QuerySet[Detector], request: Request) -> bool:
"""
Determine if the requesting user has access to delete the given detectors.
Only user-created detectors can be deleted, and require "alerts:write" permission.
"""
if any(is_system_created_detector(detector) for detector in detectors):
return False

projects = Project.objects.filter(
id__in=detectors.values_list("project_id", flat=True).distinct()
)
return all(can_edit_user_created_detectors(request, project) for project in projects)


def can_delete_detector(detector: Detector, request: Request) -> bool:
"""
Determine if the requesting user has access to delete the given detector.
Only user-created detectors can be deleted, and require "alerts:write" permission.
"""
if is_system_created_detector(detector):
return False

return can_edit_user_created_detectors(request, detector.project)


def can_edit_detector_workflow_connections(detector: Detector, request: Request) -> bool:
"""
Anyone with alert write access to the project can connect/disconnect detectors of any type,
which is slightly different from full edit access which differs by detector type.
"""
return request.access.has_any_project_scope(
detector.project, USER_CREATED_DETECTOR_REQUIRED_SCOPES
)


def validate_detectors_exist_and_have_permissions(
detector_ids: list[int], organization: Organization, request: Request
) -> QuerySet[Detector]:
detectors = Detector.objects.filter(
project__organization=organization,
id__in=detector_ids,
)
found_detector_ids = set(detectors.values_list("id", flat=True))
missing_detector_ids = set(detector_ids) - found_detector_ids

if missing_detector_ids:
raise serializers.ValidationError(f"Some detectors do not exist: {missing_detector_ids}")

if not all(can_edit_detector_workflow_connections(detector, request) for detector in detectors):
raise PermissionDenied

return detectors


def validate_workflows_exist(
workflow_ids: list[int], organization: Organization
) -> QuerySet[Workflow]:
workflows = Workflow.objects.filter(organization=organization, id__in=workflow_ids)
found_workflow_ids = set(workflows.values_list("id", flat=True))
missing_workflow_ids = set(workflow_ids) - found_workflow_ids

if missing_workflow_ids:
raise serializers.ValidationError(f"Some workflows do not exist: {missing_workflow_ids}")

return workflows


def perform_bulk_detector_workflow_operations(
detector_workflows_to_add: list[dict[Literal["detector_id", "workflow_id"], int]],
detector_workflows_to_remove: Sequence[DetectorWorkflow],
request: Request,
organization: Organization,
) -> list[DetectorWorkflow]:
created_detector_workflows: list[DetectorWorkflow] = []

with transaction.atomic(router.db_for_write(DetectorWorkflow)):
if detector_workflows_to_remove:
DetectorWorkflow.objects.filter(
id__in=[detector_workflow.id for detector_workflow in detector_workflows_to_remove]
).delete()

if detector_workflows_to_add:
created_detector_workflows = DetectorWorkflow.objects.bulk_create(
[
DetectorWorkflow(
detector_id=pair["detector_id"], workflow_id=pair["workflow_id"]
)
for pair in detector_workflows_to_add
]
)

for detector_workflow in detector_workflows_to_remove:
create_audit_entry(
request=request,
organization=organization,
target_object=detector_workflow.id,
event=audit_log.get_event_id("DETECTOR_WORKFLOW_REMOVE"),
data=detector_workflow.get_audit_log_data(),
)

for detector_workflow in created_detector_workflows:
create_audit_entry(
request=request,
organization=organization,
target_object=detector_workflow.id,
event=audit_log.get_event_id("DETECTOR_WORKFLOW_ADD"),
data=detector_workflow.get_audit_log_data(),
)

return created_detector_workflows


class BulkDetectorWorkflowsValidator(CamelSnakeSerializer[Any]):
Expand Down
Loading
Loading