From adf379c931e7b99eb868b61b50577af3230d9768 Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Mon, 13 Apr 2026 16:32:27 -0700 Subject: [PATCH 1/4] feat(cells): Compare locality not cell name in fork locality restriction CANNOT_FORK_FROM_REGION contained locality names (e.g. "de") but was compared against cell names. This works now while cells and localities are 1:1 named, but will break as a locality grows to contain multiple cells (e.g. "us1", "us2"). Now we resolve the replying cell name to its locality via get_global_directory().get_locality_for_cell() before comparing, with a fallback to the cell name for test environments without locality config. Rename CANNOT_FORK_FROM_REGION -> CANNOT_FORK_FROM_LOCALITY and the error constant accordingly. --- src/sentry/api/endpoints/organization_fork.py | 38 +++++++++++-------- .../api/endpoints/test_organization_fork.py | 9 +++-- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/sentry/api/endpoints/organization_fork.py b/src/sentry/api/endpoints/organization_fork.py index 863a0e98f16f00..4f06dd1abc0df0 100644 --- a/src/sentry/api/endpoints/organization_fork.py +++ b/src/sentry/api/endpoints/organization_fork.py @@ -22,7 +22,7 @@ ) from sentry.relocation.models.relocation import Relocation from sentry.relocation.tasks.process import uploading_start -from sentry.types.cell import get_local_cell +from sentry.types.cell import get_global_directory, get_local_cell from sentry.utils.db import atomic_transaction ERR_DUPLICATE_ORGANIZATION_FORK = Template( @@ -35,12 +35,12 @@ ERR_CANNOT_FORK_INTO_SAME_REGION = Template( "The organization already lives in region `$region`, so it cannot be forked into that region." ) -ERR_CANNOT_FORK_FROM_REGION = Template( - "Forking an organization from region `$region` is forbidden." +ERR_CANNOT_FORK_FROM_LOCALITY = Template( + "Forking an organization from locality `$locality` is forbidden." ) -# For legal reasons, there are certain regions from which forking is disallowed. -CANNOT_FORK_FROM_REGION = {"de"} +# For legal reasons, there are certain localities from which forking is disallowed. +CANNOT_FORK_FROM_LOCALITY = {"de"} logger = logging.getLogger(__name__) @@ -99,23 +99,29 @@ def post(self, request: Request, organization_id_or_slug) -> Response: status=status.HTTP_400_BAD_REQUEST, ) - # Figure out which region the organization being forked lives in. - requesting_region_name = get_local_cell().name - replying_region_name = org_mapping.cell_name - if replying_region_name in CANNOT_FORK_FROM_REGION: + # Figure out which cell the organization being forked lives in. + requesting_cell_name = get_local_cell().name + replying_cell_name = org_mapping.cell_name + + # Resolve the locality for the exporting cell. In environments without locality + # config (e.g. monolith, tests), falls back to the cell name. + replying_locality = get_global_directory().get_locality_for_cell(replying_cell_name) + replying_locality_name = replying_locality.name if replying_locality else replying_cell_name + + if replying_locality_name in CANNOT_FORK_FROM_LOCALITY: return Response( { - "detail": ERR_CANNOT_FORK_FROM_REGION.substitute( - region=replying_region_name, + "detail": ERR_CANNOT_FORK_FROM_LOCALITY.substitute( + locality=replying_locality_name, ) }, status=status.HTTP_403_FORBIDDEN, ) - if replying_region_name == requesting_region_name: + if replying_cell_name == requesting_cell_name: return Response( { "detail": ERR_CANNOT_FORK_INTO_SAME_REGION.substitute( - region=requesting_region_name, + region=requesting_cell_name, ) }, status=status.HTTP_400_BAD_REQUEST, @@ -164,7 +170,7 @@ def post(self, request: Request, organization_id_or_slug) -> Response: # When we received this back (via RPC call), we'll be able to continue with the usual # relocation flow, picking up from the `uploading_complete` task. uploading_start.apply_async( - args=[new_relocation.uuid, replying_region_name, org_mapping.slug] + args=[new_relocation.uuid, replying_cell_name, org_mapping.slug] ) try: @@ -174,8 +180,8 @@ def post(self, request: Request, organization_id_or_slug) -> Response: owner_id=owner.id, uuid=str(new_relocation.uuid), from_org_slug=org_mapping.slug, - requesting_region_name=requesting_region_name, - replying_region_name=replying_region_name, + requesting_region_name=requesting_cell_name, + replying_region_name=replying_cell_name, ) ) except Exception as e: diff --git a/tests/sentry/api/endpoints/test_organization_fork.py b/tests/sentry/api/endpoints/test_organization_fork.py index 4115540715e9d2..a68e3e1de7c0ea 100644 --- a/tests/sentry/api/endpoints/test_organization_fork.py +++ b/tests/sentry/api/endpoints/test_organization_fork.py @@ -3,7 +3,7 @@ from sentry.analytics.events.relocation_forked import RelocationForkedEvent from sentry.api.endpoints.organization_fork import ( - ERR_CANNOT_FORK_FROM_REGION, + ERR_CANNOT_FORK_FROM_LOCALITY, ERR_CANNOT_FORK_INTO_SAME_REGION, ERR_DUPLICATE_ORGANIZATION_FORK, ERR_ORGANIZATION_INACTIVE, @@ -394,7 +394,8 @@ def test_bad_cannot_fork_deleted_organization( @override_options({"relocation.enabled": True, "relocation.daily-limit.small": 1}) @assume_test_silo_mode(SiloMode.CELL, cell_name=REQUESTING_TEST_REGION) @patch( - "sentry.api.endpoints.organization_fork.CANNOT_FORK_FROM_REGION", {EXPORTING_TEST_REGION} + "sentry.api.endpoints.organization_fork.CANNOT_FORK_FROM_LOCALITY", + {EXPORTING_TEST_REGION}, ) def test_bad_organization_in_forbidden_region( self, @@ -408,8 +409,8 @@ def test_bad_organization_in_forbidden_region( response = self.get_error_response(self.existing_org.slug, status_code=403) assert response.data.get("detail") is not None - assert response.data.get("detail") == ERR_CANNOT_FORK_FROM_REGION.substitute( - region=EXPORTING_TEST_REGION, + assert response.data.get("detail") == ERR_CANNOT_FORK_FROM_LOCALITY.substitute( + locality=EXPORTING_TEST_REGION, ) assert uploading_start_mock.call_count == 0 assert analytics_record_mock.call_count == 0 From fc865a6b834ce7f4088ee7be958ff31a0c99788f Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Tue, 14 Apr 2026 14:27:37 -0700 Subject: [PATCH 2/4] use get_locality_name_for_cell --- src/sentry/api/endpoints/organization_fork.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/sentry/api/endpoints/organization_fork.py b/src/sentry/api/endpoints/organization_fork.py index 4f06dd1abc0df0..2feeddf8dcfa52 100644 --- a/src/sentry/api/endpoints/organization_fork.py +++ b/src/sentry/api/endpoints/organization_fork.py @@ -22,7 +22,7 @@ ) from sentry.relocation.models.relocation import Relocation from sentry.relocation.tasks.process import uploading_start -from sentry.types.cell import get_global_directory, get_local_cell +from sentry.types.cell import get_local_cell, get_locality_name_for_cell from sentry.utils.db import atomic_transaction ERR_DUPLICATE_ORGANIZATION_FORK = Template( @@ -105,8 +105,7 @@ def post(self, request: Request, organization_id_or_slug) -> Response: # Resolve the locality for the exporting cell. In environments without locality # config (e.g. monolith, tests), falls back to the cell name. - replying_locality = get_global_directory().get_locality_for_cell(replying_cell_name) - replying_locality_name = replying_locality.name if replying_locality else replying_cell_name + replying_locality_name = get_locality_name_for_cell(replying_cell_name) if replying_locality_name in CANNOT_FORK_FROM_LOCALITY: return Response( From 2d220599d3117687442e7aa95d56cac2488f1ef1 Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Tue, 14 Apr 2026 15:49:13 -0700 Subject: [PATCH 3/4] handle cell resolution error --- src/sentry/api/endpoints/organization_fork.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/sentry/api/endpoints/organization_fork.py b/src/sentry/api/endpoints/organization_fork.py index 2feeddf8dcfa52..d70ec363993071 100644 --- a/src/sentry/api/endpoints/organization_fork.py +++ b/src/sentry/api/endpoints/organization_fork.py @@ -22,7 +22,7 @@ ) from sentry.relocation.models.relocation import Relocation from sentry.relocation.tasks.process import uploading_start -from sentry.types.cell import get_local_cell, get_locality_name_for_cell +from sentry.types.cell import CellResolutionError, get_local_cell, get_locality_name_for_cell from sentry.utils.db import atomic_transaction ERR_DUPLICATE_ORGANIZATION_FORK = Template( @@ -105,7 +105,13 @@ def post(self, request: Request, organization_id_or_slug) -> Response: # Resolve the locality for the exporting cell. In environments without locality # config (e.g. monolith, tests), falls back to the cell name. - replying_locality_name = get_locality_name_for_cell(replying_cell_name) + try: + replying_locality_name = get_locality_name_for_cell(replying_cell_name) + except CellResolutionError: + return Response( + {"detail": "Could not resolve the locality for the target cell."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) if replying_locality_name in CANNOT_FORK_FROM_LOCALITY: return Response( From 86c31b0591526b03162af0bf0d84d50cb86b5fee Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Tue, 14 Apr 2026 15:57:38 -0700 Subject: [PATCH 4/4] remove invalid comments, all envs have locality now even if 1:1 with cell --- src/sentry/api/endpoints/organization_fork.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/sentry/api/endpoints/organization_fork.py b/src/sentry/api/endpoints/organization_fork.py index d70ec363993071..c30e7597810830 100644 --- a/src/sentry/api/endpoints/organization_fork.py +++ b/src/sentry/api/endpoints/organization_fork.py @@ -103,8 +103,7 @@ def post(self, request: Request, organization_id_or_slug) -> Response: requesting_cell_name = get_local_cell().name replying_cell_name = org_mapping.cell_name - # Resolve the locality for the exporting cell. In environments without locality - # config (e.g. monolith, tests), falls back to the cell name. + # Resolve the locality for the exporting cell. try: replying_locality_name = get_locality_name_for_cell(replying_cell_name) except CellResolutionError: