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
97 changes: 95 additions & 2 deletions src/sentry/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import sentry_sdk
from django.conf import settings
from django.db import IntegrityError, models, router, transaction
from django.db.models import Q, QuerySet, Subquery
from django.db.models import Count, Q, QuerySet, Subquery
from django.db.models.signals import pre_delete
from django.utils import timezone
from django.utils.http import urlencode
Expand Down Expand Up @@ -55,6 +55,7 @@
if TYPE_CHECKING:
from sentry.models.options.project_option import ProjectOptionManager
from sentry.models.options.project_template_option import ProjectTemplateOptionManager
from sentry.models.organization import Organization
from sentry.users.models.user import User

# NOTE:
Expand Down Expand Up @@ -499,7 +500,7 @@ def get_audit_log_data(self):
def get_full_name(self):
return self.slug

def transfer_to(self, organization):
def transfer_to(self, organization: Organization) -> None:
from sentry.deletions.models.scheduleddeletion import RegionScheduledDeletion
from sentry.incidents.models.alert_rule import AlertRule
from sentry.integrations.models.external_issue import ExternalIssue
Expand All @@ -514,6 +515,7 @@ def transfer_to(self, organization):
from sentry.models.rule import Rule
from sentry.monitors.models import Monitor, MonitorEnvironment, MonitorStatus
from sentry.snuba.models import SnubaQuery
from sentry.workflow_engine.models import DataConditionGroup, DataSource, Detector, Workflow

old_org_id = self.organization_id
org_changed = old_org_id != organization.id
Expand Down Expand Up @@ -637,6 +639,97 @@ def transfer_to(self, organization):

AlertRule.objects.fetch_for_project(self).update(organization=organization)

# Transfer DataSource, Workflow, and DataConditionGroup objects for Detectors attached to this project.
# * DataSources link detectors to their data sources (QuerySubscriptions, Monitors, etc.).
# * Workflows are connected to detectors and define what actions to take.
# * DataConditionGroups are connected to workflows (unique 1:1 via WorkflowDataConditionGroup).
# Since Detectors are project-scoped and their DataSources are project-specific,
# we need to update all related organization-scoped workflow_engine models.
#
# IMPORTANT: Workflows and DataConditionGroups can be shared across multiple projects
# in the same organization. We only transfer them if they're exclusively used by
# detectors in this project. Shared workflows remain in the original organization.
# There are certainly more correct ways to do this, but this should cover most cases.

detector_ids = Detector.objects.filter(project_id=self.id).values_list("id", flat=True)
if detector_ids:
# Update DataSources
# DataSources are 1:1 with their source (e.g., QuerySubscription) so they always transfer
data_source_ids = (
DataSource.objects.filter(detectors__id__in=detector_ids)
.distinct()
.values_list("id", flat=True)
)
DataSource.objects.filter(id__in=data_source_ids).update(
organization_id=organization.id
)

# Update Workflows connected to these detectors
# Only transfer workflows that are exclusively used by detectors in this project
all_workflow_ids = (
Workflow.objects.filter(detectorworkflow__detector_id__in=detector_ids)
.distinct()
.values_list("id", flat=True)
)

# Find workflows that are ONLY connected to detectors in this project
exclusive_workflow_ids = (
Workflow.objects.filter(id__in=all_workflow_ids)
.annotate(
detector_count=Count("detectorworkflow__detector"),
project_detector_count=Count(
"detectorworkflow__detector",
filter=Q(detectorworkflow__detector_id__in=detector_ids),
),
)
.filter(detector_count=models.F("project_detector_count"))
.values_list("id", flat=True)
)

Workflow.objects.filter(id__in=exclusive_workflow_ids).update(
organization_id=organization.id
)
Comment on lines +681 to +691
Copy link

Choose a reason for hiding this comment

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

Bug: Project.transfer_to() fails to update Workflow.when_condition_group during project transfer, causing an organization boundary violation.
Severity: CRITICAL | Confidence: High

🔍 Detailed Analysis

When a project is transferred using Project.transfer_to(), the when_condition_group field of Workflow objects is not updated. This field is a FlexibleForeignKey to DataConditionGroup and represents trigger conditions. Consequently, the transferred workflow's when_condition_group_id will still reference a DataConditionGroup in the old organization, leading to an organization boundary violation. This can cause errors when evaluate_trigger_conditions() is called or result in a dangling foreign key if the old organization is deleted.

💡 Suggested Fix

Modify the Project.transfer_to() method to ensure that the DataConditionGroup referenced by Workflow.when_condition_group is correctly transferred or re-associated with the new organization, similar to how other DataConditionGroup types are handled.

🤖 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/models/project.py#L669-L691

Potential issue: When a project is transferred using `Project.transfer_to()`, the
`when_condition_group` field of `Workflow` objects is not updated. This field is a
`FlexibleForeignKey` to `DataConditionGroup` and represents trigger conditions.
Consequently, the transferred workflow's `when_condition_group_id` will still reference
a `DataConditionGroup` in the old organization, leading to an organization boundary
violation. This can cause errors when `evaluate_trigger_conditions()` is called or
result in a dangling foreign key if the old organization is deleted.

Did we get this right? 👍 / 👎 to inform future reviews.
Reference_id: 2830139

Copy link
Member Author

Choose a reason for hiding this comment

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

good point.


# Update DataConditionGroups connected to the transferred workflows
# These are linked via WorkflowDataConditionGroup with a unique constraint on condition_group
workflow_condition_group_ids = (
DataConditionGroup.objects.filter(
workflowdataconditiongroup__workflow_id__in=exclusive_workflow_ids
)
.distinct()
.values_list("id", flat=True)
)
DataConditionGroup.objects.filter(id__in=workflow_condition_group_ids).update(
organization_id=organization.id
)

# Update DataConditionGroups that are directly owned by detectors
# These are linked via Detector.workflow_condition_group (unique FK)
# and are exclusively owned by the detector, so they always transfer
detector_condition_group_ids = (
Detector.objects.filter(
id__in=detector_ids, workflow_condition_group_id__isnull=False
)
.values_list("workflow_condition_group_id", flat=True)
.distinct()
)
DataConditionGroup.objects.filter(id__in=detector_condition_group_ids).update(
organization_id=organization.id
)
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: Cross-organization reference for shared DataConditionGroups

When transferring detector-owned DataConditionGroup objects, the code doesn't check if they're also referenced by workflows that aren't being transferred. If a DataConditionGroup is referenced by both a detector's workflow_condition_group (being transferred) and a workflow's when_condition_group or via WorkflowDataConditionGroup (not being transferred because the workflow is shared), transferring the DataConditionGroup creates a cross-organization reference where a workflow in the old organization points to a DataConditionGroup in the new organization.

Fix in Cursor Fix in Web

Copy link
Member

Choose a reason for hiding this comment

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

Is this a valid concern?

Copy link
Member Author

Choose a reason for hiding this comment

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

No. It's not forbidden by the data model, but we don't share DCGs.


# Update DataConditionGroups used as when_condition_group in transferred workflows
# DataConditionGroups are never shared, so transfer all when_condition_groups
when_condition_group_ids = (
Workflow.objects.filter(
id__in=exclusive_workflow_ids, when_condition_group_id__isnull=False
)
.values_list("when_condition_group_id", flat=True)
.distinct()
)
DataConditionGroup.objects.filter(id__in=when_condition_group_ids).update(
organization_id=organization.id
)
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: Shared when_condition_group incorrectly transferred between organizations

The code transfers when_condition_group DataConditionGroups to the new organization for all exclusive workflows, but when_condition_group can be shared between multiple workflows (no unique constraint on Workflow.when_condition_group). If a non-transferred workflow in the old organization shares a when_condition_group with a transferred workflow, that shared DataConditionGroup gets moved to the new organization, breaking the non-transferred workflow's reference.

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.

don't worry, I'm making it official that these can't be shared in another pr.


# Manually move over external issues to the new org
linked_groups = GroupLink.objects.filter(project_id=self.id).values_list(
"linked_id", flat=True
Expand Down
160 changes: 159 additions & 1 deletion tests/sentry/models/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
from sentry.types.actor import Actor
from sentry.users.models.user import User
from sentry.users.models.user_option import UserOption
from sentry.workflow_engine.models import Detector
from sentry.workflow_engine.models import Detector, DetectorWorkflow
from sentry.workflow_engine.typings.grouptype import IssueStreamGroupType


Expand Down Expand Up @@ -482,6 +482,164 @@ def test_project_detectors(self) -> None:
assert Detector.objects.filter(project=project, type=ErrorGroupType.slug).count() == 1
assert Detector.objects.filter(project=project, type=IssueStreamGroupType.slug).count() == 1

def test_transfer_to_organization_with_metric_issue_detector_and_workflow(self) -> None:
from_org = self.create_organization()
team = self.create_team(organization=from_org)
to_org = self.create_organization()
project = self.create_project(teams=[team])

detector = self.create_detector(project=project)
data_source = self.create_data_source(organization=from_org)
data_source.detectors.add(detector)
workflow = self.create_workflow(organization=from_org)
self.create_detector_workflow(detector=detector, workflow=workflow)

project.transfer_to(organization=to_org)

project.refresh_from_db()
detector.refresh_from_db()
data_source.refresh_from_db()
workflow.refresh_from_db()

assert project.organization_id == to_org.id
assert detector.project_id == project.id
assert data_source.organization_id == to_org.id
assert workflow.organization_id == to_org.id
assert DetectorWorkflow.objects.filter(detector=detector, workflow=workflow).exists()

def test_transfer_to_organization_with_workflow_data_condition_groups(self) -> None:
from_org = self.create_organization()
team = self.create_team(organization=from_org)
to_org = self.create_organization()
project = self.create_project(teams=[team])

detector = self.create_detector(project=project)
workflow = self.create_workflow(organization=from_org)
self.create_detector_workflow(detector=detector, workflow=workflow)
condition_group = self.create_data_condition_group(organization=from_org)
self.create_workflow_data_condition_group(
workflow=workflow, condition_group=condition_group
)

project.transfer_to(organization=to_org)

project.refresh_from_db()
detector.refresh_from_db()
workflow.refresh_from_db()
condition_group.refresh_from_db()

assert project.organization_id == to_org.id
assert detector.project_id == project.id
assert workflow.organization_id == to_org.id
assert condition_group.organization_id == to_org.id
wdcg = condition_group.workflowdataconditiongroup_set.first()
assert wdcg is not None
assert wdcg.workflow_id == workflow.id

def test_transfer_to_organization_does_not_transfer_shared_workflows(self) -> None:
from_org = self.create_organization()
team = self.create_team(organization=from_org)
to_org = self.create_organization()

project_a = self.create_project(teams=[team], name="Project A")
project_b = self.create_project(teams=[team], organization=from_org, name="Project B")

detector_a = self.create_detector(project=project_a)
detector_b = self.create_detector(project=project_b)

shared_workflow = self.create_workflow(organization=from_org, name="Shared Workflow")
self.create_detector_workflow(detector=detector_a, workflow=shared_workflow)
self.create_detector_workflow(detector=detector_b, workflow=shared_workflow)

exclusive_workflow = self.create_workflow(organization=from_org, name="Exclusive Workflow")
self.create_detector_workflow(detector=detector_a, workflow=exclusive_workflow)

shared_dcg = self.create_data_condition_group(organization=from_org)
self.create_workflow_data_condition_group(
workflow=shared_workflow, condition_group=shared_dcg
)

exclusive_dcg = self.create_data_condition_group(organization=from_org)
self.create_workflow_data_condition_group(
workflow=exclusive_workflow, condition_group=exclusive_dcg
)

project_a.transfer_to(organization=to_org)

project_a.refresh_from_db()
project_b.refresh_from_db()
detector_a.refresh_from_db()
detector_b.refresh_from_db()
shared_workflow.refresh_from_db()
exclusive_workflow.refresh_from_db()
shared_dcg.refresh_from_db()
exclusive_dcg.refresh_from_db()

assert project_a.organization_id == to_org.id
assert project_b.organization_id == from_org.id
assert detector_a.project_id == project_a.id
assert detector_b.project_id == project_b.id
assert shared_workflow.organization_id == from_org.id
assert exclusive_workflow.organization_id == to_org.id
assert shared_dcg.organization_id == from_org.id
assert exclusive_dcg.organization_id == to_org.id
assert DetectorWorkflow.objects.filter(
detector=detector_a, workflow=shared_workflow
).exists()
assert DetectorWorkflow.objects.filter(
detector=detector_b, workflow=shared_workflow
).exists()
assert DetectorWorkflow.objects.filter(
detector=detector_a, workflow=exclusive_workflow
).exists()

def test_transfer_to_organization_with_detector_workflow_condition_group(self) -> None:
from_org = self.create_organization()
team = self.create_team(organization=from_org)
to_org = self.create_organization()
project = self.create_project(teams=[team])

detector = self.create_detector(project=project)
workflow_condition_group = self.create_data_condition_group(organization=from_org)
detector.workflow_condition_group = workflow_condition_group
detector.save()

project.transfer_to(organization=to_org)

project.refresh_from_db()
detector.refresh_from_db()
workflow_condition_group.refresh_from_db()

assert project.organization_id == to_org.id
assert detector.project_id == project.id
assert workflow_condition_group.organization_id == to_org.id
assert detector.workflow_condition_group_id == workflow_condition_group.id

def test_transfer_to_organization_with_workflow_when_condition_groups(self) -> None:
from_org = self.create_organization()
to_org = self.create_organization()
team = self.create_team(organization=from_org)
project = self.create_project(teams=[team])

detector = self.create_detector(project=project)
when_condition_group = self.create_data_condition_group(organization=from_org)
workflow = self.create_workflow(
organization=from_org, when_condition_group=when_condition_group
)
self.create_detector_workflow(detector=detector, workflow=workflow)

project.transfer_to(organization=to_org)

project.refresh_from_db()
detector.refresh_from_db()
workflow.refresh_from_db()
when_condition_group.refresh_from_db()

assert project.organization_id == to_org.id
assert detector.project_id == project.id
assert workflow.organization_id == to_org.id
assert when_condition_group.organization_id == to_org.id


class ProjectOptionsTests(TestCase):
"""
Expand Down
Loading