From 7741f1c0b745678d18969db50a6d69e2e05bb7bd Mon Sep 17 00:00:00 2001 From: Kyle Consalus Date: Fri, 29 May 2026 14:06:52 -0700 Subject: [PATCH 1/4] fix(aci): Forbid unsafe legacy interactions with shared Workflows/Detectors --- src/sentry/api/bases/rule.py | 25 ++++++++++++ src/sentry/incidents/endpoints/bases.py | 32 +++++++++++++++- .../endpoints/test_project_rule_details.py | 21 ++++++++++ tests/sentry/deletions/test_alert_rule.py | 5 +++ tests/sentry/deletions/test_rule.py | 38 ++++++++++++++----- .../test_organization_alert_rule_details.py | 21 ++++++++++ 6 files changed, 132 insertions(+), 10 deletions(-) diff --git a/src/sentry/api/bases/rule.py b/src/sentry/api/bases/rule.py index 68278e74dcc901..dc788d48215317 100644 --- a/src/sentry/api/bases/rule.py +++ b/src/sentry/api/bases/rule.py @@ -1,5 +1,7 @@ from typing import Any +from rest_framework import status +from rest_framework.exceptions import APIException from rest_framework.request import Request from sentry import features @@ -14,6 +16,19 @@ from sentry.workflow_engine.utils.legacy_metric_tracking import report_used_legacy_models +class SharedWorkflowError(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = ( + "This rule's workflow is shared with other rules. Use the workflow API to manage it." + ) + + def __init__(self, workflow_id: int) -> None: + detail = ( + f"Workflow {workflow_id} is shared with other rules. Use the workflow API to manage it." + ) + super().__init__(detail=detail) + + class RuleEndpoint(ProjectEndpoint): owner = ApiOwner.ISSUES permission_classes = (ProjectAlertRulePermission,) @@ -66,6 +81,16 @@ def convert_args( workflow__organization=project.organization, workflow__status=ObjectStatus.ACTIVE, ) + # For mutating requests via a legacy Rule ID, reject if the + # Workflow is shared with other Rules or AlertRules. + if request.method in ("PUT", "DELETE"): + has_other_links = ( + AlertRuleWorkflow.objects.filter(workflow_id=arw.workflow_id) + .exclude(id=arw.id) + .exists() + ) + if has_other_links: + raise SharedWorkflowError(arw.workflow_id) kwargs["rule"] = arw.workflow except AlertRuleWorkflow.DoesNotExist: # XXX: this means the workflow was single written and has no ARW or related Rule object diff --git a/src/sentry/incidents/endpoints/bases.py b/src/sentry/incidents/endpoints/bases.py index 7e9c70aaf97521..93301120382b62 100644 --- a/src/sentry/incidents/endpoints/bases.py +++ b/src/sentry/incidents/endpoints/bases.py @@ -1,6 +1,7 @@ from typing import Any -from rest_framework.exceptions import PermissionDenied +from rest_framework import status +from rest_framework.exceptions import APIException, PermissionDenied from rest_framework.request import Request from sentry import features @@ -16,6 +17,19 @@ from sentry.workflow_engine.models.detector import Detector +class SharedDetectorError(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = ( + "This alert rule's detector is shared with other rules. Use the detector API to manage it." + ) + + def __init__(self, detector_id: int) -> None: + detail = ( + f"Detector {detector_id} is shared with other rules. Use the detector API to manage it." + ) + super().__init__(detail=detail) + + class OrganizationAlertRuleBaseEndpoint(OrganizationEndpoint): """ Base endpoint for organization-scoped alert rule creation. @@ -131,6 +145,14 @@ def convert_args( alert_rule_id=validated_alert_rule_id, detector__project=project, ) + if request.method in ("PUT", "DELETE"): + has_other_links = ( + AlertRuleDetector.objects.filter(detector_id=ard.detector_id) + .exclude(id=ard.id) + .exists() + ) + if has_other_links: + raise SharedDetectorError(ard.detector_id) kwargs["alert_rule"] = ard.detector except AlertRuleDetector.DoesNotExist: # XXX: this means the detector was single written and has no ARD or related AlertRule object @@ -179,6 +201,14 @@ def convert_args( alert_rule_id=validated_alert_rule_id, detector__project__organization=organization, ) + if request.method in ("PUT", "DELETE"): + has_other_links = ( + AlertRuleDetector.objects.filter(detector_id=ard.detector_id) + .exclude(id=ard.id) + .exists() + ) + if has_other_links: + raise SharedDetectorError(ard.detector_id) kwargs["alert_rule"] = ard.detector except AlertRuleDetector.DoesNotExist: # XXX: this means the detector was single written and has no ARD or related AlertRule object diff --git a/tests/sentry/api/endpoints/test_project_rule_details.py b/tests/sentry/api/endpoints/test_project_rule_details.py index ffd9c7ac194dd6..7cf287499ffc94 100644 --- a/tests/sentry/api/endpoints/test_project_rule_details.py +++ b/tests/sentry/api/endpoints/test_project_rule_details.py @@ -1473,6 +1473,27 @@ def test_delete_does_not_cascade_to_cross_org_rule(self) -> None: other_rule.refresh_from_db() assert other_rule.status == ObjectStatus.ACTIVE + def test_delete_shared_workflow_returns_400(self) -> None: + rule = self.create_project_rule(self.project) + arw = AlertRuleWorkflow.objects.get(rule_id=rule.id) + workflow = arw.workflow + + # Create another Rule linked to the same Workflow. + other_rule = self.create_project_rule(self.project) + other_arw = AlertRuleWorkflow.objects.get(rule_id=other_rule.id) + other_arw.update(workflow_id=workflow.id) + + response = self.get_error_response( + self.organization.slug, self.project.slug, rule.id, status_code=400 + ) + assert "Workflow" in response.data["detail"] + assert str(workflow.id) in response.data["detail"] + + # Neither rule nor workflow were deleted. + rule.refresh_from_db() + assert rule.status == ObjectStatus.ACTIVE + assert Workflow.objects.filter(id=workflow.id).exists() + class GetProjectRuleDetailsDeltaTest(ProjectRuleDetailsBaseTestCase): def test_dual_written_rule_parity(self) -> None: diff --git a/tests/sentry/deletions/test_alert_rule.py b/tests/sentry/deletions/test_alert_rule.py index 5089b8c5fd1ffe..d07a79f1350837 100644 --- a/tests/sentry/deletions/test_alert_rule.py +++ b/tests/sentry/deletions/test_alert_rule.py @@ -21,7 +21,9 @@ from sentry.workflow_engine.models import ( AlertRuleDetector, AlertRuleWorkflow, + Detector, IncidentGroupOpenPeriod, + Workflow, ) from tests.sentry.workflow_engine.test_base import BaseWorkflowTest @@ -75,6 +77,9 @@ def test_simple(self) -> None: assert not GroupOpenPeriod.objects.filter(project=self.project, group=group) assert not AlertRuleDetector.objects.filter(alert_rule_id=alert_rule.id).exists() assert not AlertRuleWorkflow.objects.filter(alert_rule_id=alert_rule.id).exists() + # Detector and Workflow survive — they are now the primary entities. + assert Detector.objects.filter(id=detector.id).exists() + assert Workflow.objects.filter(id=workflow.id).exists() @with_feature("organizations:anomaly-detection-alerts") @patch( diff --git a/tests/sentry/deletions/test_rule.py b/tests/sentry/deletions/test_rule.py index a14508b63e5d13..7e3b280c1ed87f 100644 --- a/tests/sentry/deletions/test_rule.py +++ b/tests/sentry/deletions/test_rule.py @@ -3,16 +3,12 @@ from sentry.models.grouprulestatus import GroupRuleStatus from sentry.models.rule import Rule, RuleActivity, RuleActivityType from sentry.testutils.hybrid_cloud import HybridCloudTestMixin -from sentry.workflow_engine.models import AlertRuleDetector, AlertRuleWorkflow, Workflow +from sentry.workflow_engine.models import AlertRuleDetector, AlertRuleWorkflow, Detector, Workflow +from sentry.workflow_engine.models.detector_workflow import DetectorWorkflow from tests.sentry.workflow_engine.test_base import BaseWorkflowTest class DeleteRuleTest(HybridCloudTestMixin, BaseWorkflowTest): - def _assert_both_deleted(self, rule: Rule, workflow: Workflow) -> None: - assert not Rule.objects.filter(id=rule.id).exists() - assert not Workflow.objects_for_deletion.filter(id=workflow.id).exists() - assert not AlertRuleWorkflow.objects.filter(rule_id=rule.id).exists() - def test_both_rule_and_workflow_scheduled_rule_first(self) -> None: project = self.create_project() rule = self.create_project_rule(project) @@ -25,7 +21,9 @@ def test_both_rule_and_workflow_scheduled_rule_first(self) -> None: with self.tasks(): run_scheduled_deletions() - self._assert_both_deleted(rule, workflow) + assert not Rule.objects.filter(id=rule.id).exists() + assert not Workflow.objects_for_deletion.filter(id=workflow.id).exists() + assert not AlertRuleWorkflow.objects.filter(rule_id=rule.id).exists() def test_both_rule_and_workflow_scheduled_workflow_first(self) -> None: project = self.create_project() @@ -39,7 +37,9 @@ def test_both_rule_and_workflow_scheduled_workflow_first(self) -> None: with self.tasks(): run_scheduled_deletions() - self._assert_both_deleted(rule, workflow) + assert not Rule.objects.filter(id=rule.id).exists() + assert not Workflow.objects_for_deletion.filter(id=workflow.id).exists() + assert not AlertRuleWorkflow.objects.filter(rule_id=rule.id).exists() def test_simple(self) -> None: project = self.create_project() @@ -67,5 +67,25 @@ def test_simple(self) -> None: # Link rows are cleaned up assert not AlertRuleDetector.objects.filter(rule_id=rule.id).exists() assert not AlertRuleWorkflow.objects.filter(rule_id=rule.id).exists() - # Org-scoped Workflow survives Rule deletion + # Workflow and Detector survive — they are now the primary entities. + assert Workflow.objects.filter(id=workflow.id).exists() + assert Detector.objects.filter(id=detector.id).exists() + + def test_deleting_rule_does_not_delete_workflow_with_detector_links(self) -> None: + project = self.create_project() + rule = self.create_project_rule(project) + detector = self.create_detector() + workflow = self.create_workflow() + AlertRuleWorkflow.objects.create(rule_id=rule.id, workflow=workflow) + DetectorWorkflow.objects.create(detector=detector, workflow=workflow) + + self.ScheduledDeletion.schedule(instance=rule, days=0) + + with self.tasks(): + run_scheduled_deletions() + + assert not Rule.objects.filter(id=rule.id).exists() + assert not AlertRuleWorkflow.objects.filter(rule_id=rule.id).exists() + # Workflow stays alive — it's still connected to a Detector. assert Workflow.objects.filter(id=workflow.id).exists() + assert DetectorWorkflow.objects.filter(detector=detector, workflow=workflow).exists() diff --git a/tests/sentry/incidents/endpoints/test_organization_alert_rule_details.py b/tests/sentry/incidents/endpoints/test_organization_alert_rule_details.py index a8666ec55804ba..16fc712af98ef5 100644 --- a/tests/sentry/incidents/endpoints/test_organization_alert_rule_details.py +++ b/tests/sentry/incidents/endpoints/test_organization_alert_rule_details.py @@ -2757,3 +2757,24 @@ def test_dual_delete_detector_id_passed(self) -> None: assert not AlertRule.objects_with_snapshots.filter(name=self.alert_rule.name).exists() assert not AlertRule.objects_with_snapshots.filter(id=self.alert_rule.id).exists() assert not Detector.objects.filter(id=ard.detector_id).exists() + + @with_feature("organizations:workflow-engine-rule-serializers") + def test_delete_shared_detector_returns_400(self) -> None: + self.create_member( + user=self.user, organization=self.organization, role="owner", teams=[self.team] + ) + self.login_as(self.user) + + ard = AlertRuleDetector.objects.get(alert_rule_id=self.alert_rule.id) + # Create another Rule linked to the same Detector. + AlertRuleDetector.objects.create(rule_id=999999, detector_id=ard.detector_id) + + with self.feature("organizations:incidents"): + resp = self.get_error_response( + self.organization.slug, self.alert_rule.id, status_code=400 + ) + + assert "Detector" in resp.data["detail"] + assert str(ard.detector_id) in resp.data["detail"] + # AlertRule was not deleted. + assert AlertRule.objects.filter(id=self.alert_rule.id).exists() From c22f460ffb3e208f8b43dd1f1b0500ef68aec00e Mon Sep 17 00:00:00 2001 From: Kyle Consalus Date: Fri, 29 May 2026 17:09:40 -0700 Subject: [PATCH 2/4] test fixes --- tests/sentry/deletions/test_rule.py | 23 ++----------------- .../test_issue_alert_dual_write.py | 4 ++++ 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/tests/sentry/deletions/test_rule.py b/tests/sentry/deletions/test_rule.py index 7e3b280c1ed87f..6c7bb534f55cfb 100644 --- a/tests/sentry/deletions/test_rule.py +++ b/tests/sentry/deletions/test_rule.py @@ -4,7 +4,6 @@ from sentry.models.rule import Rule, RuleActivity, RuleActivityType from sentry.testutils.hybrid_cloud import HybridCloudTestMixin from sentry.workflow_engine.models import AlertRuleDetector, AlertRuleWorkflow, Detector, Workflow -from sentry.workflow_engine.models.detector_workflow import DetectorWorkflow from tests.sentry.workflow_engine.test_base import BaseWorkflowTest @@ -67,25 +66,7 @@ def test_simple(self) -> None: # Link rows are cleaned up assert not AlertRuleDetector.objects.filter(rule_id=rule.id).exists() assert not AlertRuleWorkflow.objects.filter(rule_id=rule.id).exists() - # Workflow and Detector survive — they are now the primary entities. + # Workflow and Detector survive — Workflow is org-scoped and cleaned + # up by the API or OrganizationDeletionTask, not by RuleDeletionTask. assert Workflow.objects.filter(id=workflow.id).exists() assert Detector.objects.filter(id=detector.id).exists() - - def test_deleting_rule_does_not_delete_workflow_with_detector_links(self) -> None: - project = self.create_project() - rule = self.create_project_rule(project) - detector = self.create_detector() - workflow = self.create_workflow() - AlertRuleWorkflow.objects.create(rule_id=rule.id, workflow=workflow) - DetectorWorkflow.objects.create(detector=detector, workflow=workflow) - - self.ScheduledDeletion.schedule(instance=rule, days=0) - - with self.tasks(): - run_scheduled_deletions() - - assert not Rule.objects.filter(id=rule.id).exists() - assert not AlertRuleWorkflow.objects.filter(rule_id=rule.id).exists() - # Workflow stays alive — it's still connected to a Detector. - assert Workflow.objects.filter(id=workflow.id).exists() - assert DetectorWorkflow.objects.filter(detector=detector, workflow=workflow).exists() diff --git a/tests/sentry/workflow_engine/migration_helpers/test_issue_alert_dual_write.py b/tests/sentry/workflow_engine/migration_helpers/test_issue_alert_dual_write.py index 2c08be4c286efc..69ac2fb075ac90 100644 --- a/tests/sentry/workflow_engine/migration_helpers/test_issue_alert_dual_write.py +++ b/tests/sentry/workflow_engine/migration_helpers/test_issue_alert_dual_write.py @@ -374,6 +374,8 @@ def assert_everything_deleted( assert not Action.objects.all().exists() def test_delete_issue_alert__rule_deletion_task(self) -> None: + # RuleDeletionTask no longer cascades to Workflow — Workflow is + # org-scoped and deleted by the API or OrganizationDeletionTask. self.issue_alert.update(status=ObjectStatus.PENDING_DELETION) CellScheduledDeletion.schedule(self.issue_alert, days=0) @@ -383,6 +385,8 @@ def test_delete_issue_alert__rule_deletion_task(self) -> None: self.assert_rule_deleted_workflow_survives(self.workflow) def test_delete_issue_alert__project_deletion_task(self) -> None: + # Project deletion cleans up project-scoped Rules but not + # org-scoped Workflows. Workflows are deleted by OrganizationDeletionTask. self.project.update(status=ObjectStatus.PENDING_DELETION) CellScheduledDeletion.schedule(self.project, days=0) From c44c1b3c19bd0ee8fa4ef15cd73c60a1040d1215 Mon Sep 17 00:00:00 2001 From: Kyle Consalus Date: Fri, 29 May 2026 17:20:22 -0700 Subject: [PATCH 3/4] cleanup --- tests/sentry/deletions/test_alert_rule.py | 5 ----- tests/sentry/deletions/test_rule.py | 19 +++++++++---------- .../test_issue_alert_dual_write.py | 4 ---- 3 files changed, 9 insertions(+), 19 deletions(-) diff --git a/tests/sentry/deletions/test_alert_rule.py b/tests/sentry/deletions/test_alert_rule.py index d07a79f1350837..5089b8c5fd1ffe 100644 --- a/tests/sentry/deletions/test_alert_rule.py +++ b/tests/sentry/deletions/test_alert_rule.py @@ -21,9 +21,7 @@ from sentry.workflow_engine.models import ( AlertRuleDetector, AlertRuleWorkflow, - Detector, IncidentGroupOpenPeriod, - Workflow, ) from tests.sentry.workflow_engine.test_base import BaseWorkflowTest @@ -77,9 +75,6 @@ def test_simple(self) -> None: assert not GroupOpenPeriod.objects.filter(project=self.project, group=group) assert not AlertRuleDetector.objects.filter(alert_rule_id=alert_rule.id).exists() assert not AlertRuleWorkflow.objects.filter(alert_rule_id=alert_rule.id).exists() - # Detector and Workflow survive — they are now the primary entities. - assert Detector.objects.filter(id=detector.id).exists() - assert Workflow.objects.filter(id=workflow.id).exists() @with_feature("organizations:anomaly-detection-alerts") @patch( diff --git a/tests/sentry/deletions/test_rule.py b/tests/sentry/deletions/test_rule.py index 6c7bb534f55cfb..a14508b63e5d13 100644 --- a/tests/sentry/deletions/test_rule.py +++ b/tests/sentry/deletions/test_rule.py @@ -3,11 +3,16 @@ from sentry.models.grouprulestatus import GroupRuleStatus from sentry.models.rule import Rule, RuleActivity, RuleActivityType from sentry.testutils.hybrid_cloud import HybridCloudTestMixin -from sentry.workflow_engine.models import AlertRuleDetector, AlertRuleWorkflow, Detector, Workflow +from sentry.workflow_engine.models import AlertRuleDetector, AlertRuleWorkflow, Workflow from tests.sentry.workflow_engine.test_base import BaseWorkflowTest class DeleteRuleTest(HybridCloudTestMixin, BaseWorkflowTest): + def _assert_both_deleted(self, rule: Rule, workflow: Workflow) -> None: + assert not Rule.objects.filter(id=rule.id).exists() + assert not Workflow.objects_for_deletion.filter(id=workflow.id).exists() + assert not AlertRuleWorkflow.objects.filter(rule_id=rule.id).exists() + def test_both_rule_and_workflow_scheduled_rule_first(self) -> None: project = self.create_project() rule = self.create_project_rule(project) @@ -20,9 +25,7 @@ def test_both_rule_and_workflow_scheduled_rule_first(self) -> None: with self.tasks(): run_scheduled_deletions() - assert not Rule.objects.filter(id=rule.id).exists() - assert not Workflow.objects_for_deletion.filter(id=workflow.id).exists() - assert not AlertRuleWorkflow.objects.filter(rule_id=rule.id).exists() + self._assert_both_deleted(rule, workflow) def test_both_rule_and_workflow_scheduled_workflow_first(self) -> None: project = self.create_project() @@ -36,9 +39,7 @@ def test_both_rule_and_workflow_scheduled_workflow_first(self) -> None: with self.tasks(): run_scheduled_deletions() - assert not Rule.objects.filter(id=rule.id).exists() - assert not Workflow.objects_for_deletion.filter(id=workflow.id).exists() - assert not AlertRuleWorkflow.objects.filter(rule_id=rule.id).exists() + self._assert_both_deleted(rule, workflow) def test_simple(self) -> None: project = self.create_project() @@ -66,7 +67,5 @@ def test_simple(self) -> None: # Link rows are cleaned up assert not AlertRuleDetector.objects.filter(rule_id=rule.id).exists() assert not AlertRuleWorkflow.objects.filter(rule_id=rule.id).exists() - # Workflow and Detector survive — Workflow is org-scoped and cleaned - # up by the API or OrganizationDeletionTask, not by RuleDeletionTask. + # Org-scoped Workflow survives Rule deletion assert Workflow.objects.filter(id=workflow.id).exists() - assert Detector.objects.filter(id=detector.id).exists() diff --git a/tests/sentry/workflow_engine/migration_helpers/test_issue_alert_dual_write.py b/tests/sentry/workflow_engine/migration_helpers/test_issue_alert_dual_write.py index 69ac2fb075ac90..2c08be4c286efc 100644 --- a/tests/sentry/workflow_engine/migration_helpers/test_issue_alert_dual_write.py +++ b/tests/sentry/workflow_engine/migration_helpers/test_issue_alert_dual_write.py @@ -374,8 +374,6 @@ def assert_everything_deleted( assert not Action.objects.all().exists() def test_delete_issue_alert__rule_deletion_task(self) -> None: - # RuleDeletionTask no longer cascades to Workflow — Workflow is - # org-scoped and deleted by the API or OrganizationDeletionTask. self.issue_alert.update(status=ObjectStatus.PENDING_DELETION) CellScheduledDeletion.schedule(self.issue_alert, days=0) @@ -385,8 +383,6 @@ def test_delete_issue_alert__rule_deletion_task(self) -> None: self.assert_rule_deleted_workflow_survives(self.workflow) def test_delete_issue_alert__project_deletion_task(self) -> None: - # Project deletion cleans up project-scoped Rules but not - # org-scoped Workflows. Workflows are deleted by OrganizationDeletionTask. self.project.update(status=ObjectStatus.PENDING_DELETION) CellScheduledDeletion.schedule(self.project, days=0) From 5709508a1b48d949a9fc7231285003ece28ebe6c Mon Sep 17 00:00:00 2001 From: Kyle Consalus Date: Fri, 29 May 2026 17:26:30 -0700 Subject: [PATCH 4/4] simplify --- src/sentry/api/bases/rule.py | 3 --- src/sentry/incidents/endpoints/bases.py | 33 +++++++++++-------------- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/src/sentry/api/bases/rule.py b/src/sentry/api/bases/rule.py index dc788d48215317..4c6889e1cd8db8 100644 --- a/src/sentry/api/bases/rule.py +++ b/src/sentry/api/bases/rule.py @@ -18,9 +18,6 @@ class SharedWorkflowError(APIException): status_code = status.HTTP_400_BAD_REQUEST - default_detail = ( - "This rule's workflow is shared with other rules. Use the workflow API to manage it." - ) def __init__(self, workflow_id: int) -> None: detail = ( diff --git a/src/sentry/incidents/endpoints/bases.py b/src/sentry/incidents/endpoints/bases.py index 93301120382b62..17df173ad1e320 100644 --- a/src/sentry/incidents/endpoints/bases.py +++ b/src/sentry/incidents/endpoints/bases.py @@ -19,9 +19,6 @@ class SharedDetectorError(APIException): status_code = status.HTTP_400_BAD_REQUEST - default_detail = ( - "This alert rule's detector is shared with other rules. Use the detector API to manage it." - ) def __init__(self, detector_id: int) -> None: detail = ( @@ -30,6 +27,18 @@ def __init__(self, detector_id: int) -> None: super().__init__(detail=detail) +def _check_shared_detector(request: Request, ard: AlertRuleDetector) -> None: + """Reject PUT/DELETE on a Detector that is shared with other rules.""" + if request.method in ("PUT", "DELETE"): + has_other_links = ( + AlertRuleDetector.objects.filter(detector_id=ard.detector_id) + .exclude(id=ard.id) + .exists() + ) + if has_other_links: + raise SharedDetectorError(ard.detector_id) + + class OrganizationAlertRuleBaseEndpoint(OrganizationEndpoint): """ Base endpoint for organization-scoped alert rule creation. @@ -145,14 +154,7 @@ def convert_args( alert_rule_id=validated_alert_rule_id, detector__project=project, ) - if request.method in ("PUT", "DELETE"): - has_other_links = ( - AlertRuleDetector.objects.filter(detector_id=ard.detector_id) - .exclude(id=ard.id) - .exists() - ) - if has_other_links: - raise SharedDetectorError(ard.detector_id) + _check_shared_detector(request, ard) kwargs["alert_rule"] = ard.detector except AlertRuleDetector.DoesNotExist: # XXX: this means the detector was single written and has no ARD or related AlertRule object @@ -201,14 +203,7 @@ def convert_args( alert_rule_id=validated_alert_rule_id, detector__project__organization=organization, ) - if request.method in ("PUT", "DELETE"): - has_other_links = ( - AlertRuleDetector.objects.filter(detector_id=ard.detector_id) - .exclude(id=ard.id) - .exists() - ) - if has_other_links: - raise SharedDetectorError(ard.detector_id) + _check_shared_detector(request, ard) kwargs["alert_rule"] = ard.detector except AlertRuleDetector.DoesNotExist: # XXX: this means the detector was single written and has no ARD or related AlertRule object