From d419a9b256dd92d4851a519c05bc4086eb009a02 Mon Sep 17 00:00:00 2001 From: Irene Liu Date: Wed, 1 Oct 2025 13:58:24 -0700 Subject: [PATCH 1/7] initial commit --- .../api/endpoints/data_forwarding_details.py | 89 +++++++++++++++ .../endpoints/data_forwarding_index copy.py | 102 ++++++++++++++++++ .../api/endpoints/data_forwarding_index.py | 26 +++++ 3 files changed, 217 insertions(+) create mode 100644 src/sentry/integrations/api/endpoints/data_forwarding_details.py create mode 100644 src/sentry/integrations/api/endpoints/data_forwarding_index copy.py create mode 100644 src/sentry/integrations/api/endpoints/data_forwarding_index.py diff --git a/src/sentry/integrations/api/endpoints/data_forwarding_details.py b/src/sentry/integrations/api/endpoints/data_forwarding_details.py new file mode 100644 index 00000000000000..7616d5b98bad70 --- /dev/null +++ b/src/sentry/integrations/api/endpoints/data_forwarding_details.py @@ -0,0 +1,89 @@ +from django.utils.decorators import method_decorator +from django.views.decorators.cache import never_cache +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry import audit_log +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import region_silo_endpoint +from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.serializers import serialize +from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN +from sentry.apidocs.parameters import GlobalParams +from sentry.integrations.api.serializers.rest_framework.data_forwarder import ( + DataForwarderSerializer, +) +from sentry.integrations.models.data_forwarder import DataForwarder +from sentry.web.decorators import set_referrer_policy + + +class OrganizationDataForwardingDetailsPermission(OrganizationPermission): + scope_map = { + "GET": ["org:read"], + "POST": ["org:write"], + } + + +@region_silo_endpoint +@extend_schema(tags=["Integrations"]) +class DataForwardingDetailsEndpoint(OrganizationEndpoint): + owner = ApiOwner.INTEGRATIONS + publish_status = { + "GET": ApiPublishStatus.PRIVATE, # TODO: might need to change + "POST": ApiPublishStatus.PRIVATE, + } + permission_classes = (OrganizationDataForwardingDetailsPermission,) + + @extend_schema( + operation_id="Retrieve a Data Forwarding Configuration for an Organization", + parameters=[GlobalParams.ORG_ID_OR_SLUG], + responses={ + 200: DataForwarderSerializer, + }, + ) + @set_referrer_policy("strict-origin-when-cross-origin") + @method_decorator(never_cache) + def get(self, request: Request, organization_context) -> Response: + data_forwarders = DataForwarder.objects.filter( + organization_id=organization_context.organization.id + ) + return self.respond(serialize(data_forwarders, request.user)) + + @extend_schema( + operation_id="Create a Data Forwarding Configuration for an Organization", + parameters=[GlobalParams.ORG_ID_OR_SLUG], + request=DataForwarderSerializer, + responses={ + 201: DataForwarderSerializer, + 400: RESPONSE_BAD_REQUEST, + 403: RESPONSE_FORBIDDEN, + }, + ) + @set_referrer_policy("strict-origin-when-cross-origin") + @method_decorator(never_cache) + def post(self, request: Request, organization_context) -> Response: + data = request.data.copy() + data["organization_id"] = organization_context.organization.id + + serializer = DataForwarderSerializer(data=data) + if serializer.is_valid(): + data_forwarder = serializer.save() + + self.create_audit_entry( + request=request, + organization=organization_context.organization, + target_object=data_forwarder.id, + event=audit_log.get_event_id("DATA_FORWARDER_ADD"), + data={ + "provider": data_forwarder.provider, + "organization_id": data_forwarder.organization_id, + }, + ) + + return self.respond( + serialize(data_forwarder, request.user), status=status.HTTP_201_CREATED + ) + return self.respond(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/src/sentry/integrations/api/endpoints/data_forwarding_index copy.py b/src/sentry/integrations/api/endpoints/data_forwarding_index copy.py new file mode 100644 index 00000000000000..13852d3b6c271e --- /dev/null +++ b/src/sentry/integrations/api/endpoints/data_forwarding_index copy.py @@ -0,0 +1,102 @@ +from django.utils.decorators import method_decorator +from django.views.decorators.cache import never_cache +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry import audit_log +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import region_silo_endpoint +from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.serializers import serialize +from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN +from sentry.apidocs.parameters import GlobalParams +from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.integrations.api.serializers.rest_framework.data_forwarder import ( + DataForwarderSerializer, +) +from sentry.integrations.models.data_forwarder import DataForwarder +from sentry.utils.audit import create_audit_entry +from sentry.web.decorators import set_referrer_policy + + +class OrganizationDataForwardingPermission(OrganizationPermission): + scope_map = { + "GET": ["org:read", "org:write", "org:admin", "org:integrations"], + "POST": ["org:write", "org:admin", "org:integrations"], + } + + +@extend_schema(tags=["Integrations"]) +@region_silo_endpoint +class OrganizationDataForwardingEndpoint(OrganizationEndpoint): + permission_classes = (OrganizationDataForwardingPermission,) + owner = ApiOwner.INTEGRATIONS + publish_status = { + "GET": ApiPublishStatus.PRIVATE, + "POST": ApiPublishStatus.PRIVATE, + } + + @extend_schema( + operation_id="List an Organization's Data Forwarding Configurations", + parameters=[GlobalParams.ORG_ID_OR_SLUG], + responses={ + 200: inline_sentry_response_serializer("ListDataForwarderResponse", list[dict]), + }, + ) + @set_referrer_policy("strict-origin-when-cross-origin") + @method_decorator(never_cache) + def get(self, request: Request, organization_context) -> Response: + """ + Retrieve the forwarding configs for an organization. + Also retrieves project overrides for the given configs. + """ + data_forwarders = DataForwarder.objects.filter( + organization_id=organization_context.organization.id + ).prefetch_related("projects__project") + + return self.respond(serialize(data_forwarders, request.user)) + + @extend_schema( + operation_id="Create a Data Forwarding Configuration", + parameters=[GlobalParams.ORG_ID_OR_SLUG], + request=DataForwarderSerializer, + responses={ + 201: inline_sentry_response_serializer("DataForwarderResponse", dict), + 400: RESPONSE_BAD_REQUEST, + 403: RESPONSE_FORBIDDEN, + }, + ) + @set_referrer_policy("strict-origin-when-cross-origin") + @method_decorator(never_cache) + def post(self, request: Request, organization_context) -> Response: + """ + Create a forwarding config for an organization. + """ + data = request.data.copy() + data["organization_id"] = organization_context.organization.id + + serializer = DataForwarderSerializer(data=data) + if serializer.is_valid(): + data_forwarder = serializer.save() + + # Create audit log entry + create_audit_entry( + request=request, + organization_id=organization_context.organization.id, + target_object=data_forwarder.id, + event=audit_log.get_event_id("DATA_FORWARDER_ADD"), + data={ + "provider": data_forwarder.provider, + "organization_id": data_forwarder.organization_id, + }, + ) + + return self.respond( + serialize(data_forwarder, request.user), + status=status.HTTP_201_CREATED, + ) + + return self.respond(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/src/sentry/integrations/api/endpoints/data_forwarding_index.py b/src/sentry/integrations/api/endpoints/data_forwarding_index.py new file mode 100644 index 00000000000000..9398cf71413398 --- /dev/null +++ b/src/sentry/integrations/api/endpoints/data_forwarding_index.py @@ -0,0 +1,26 @@ +# PUT and DELETE specific data forwarder configs + +# from rest_framework.request import Request +# from rest_framework.response import Response + +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import region_silo_endpoint +from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission + + +class OrganizationDataForwardingPermission(OrganizationPermission): + scope_map = { + "PUT": ["org:write"], + "DELETE": ["org:write"], + } + + +@region_silo_endpoint +class DataForwardingIndexEndpoint(OrganizationEndpoint): + permission_classes = (OrganizationDataForwardingPermission,) + owner = ApiOwner.INTEGRATIONS + publish_status = { + "PUT": ApiPublishStatus.PRIVATE, + "DELETE": ApiPublishStatus.PRIVATE, + } From c9432c618b22a8be9a3952b58ad8436a51fcf092 Mon Sep 17 00:00:00 2001 From: Irene Liu Date: Thu, 2 Oct 2025 12:55:23 -0700 Subject: [PATCH 2/7] switch names --- .../api/endpoints/data_forwarding_details.py | 91 ++++++------------ .../api/endpoints/data_forwarding_index.py | 93 ++++++++++++++++--- 2 files changed, 111 insertions(+), 73 deletions(-) diff --git a/src/sentry/integrations/api/endpoints/data_forwarding_details.py b/src/sentry/integrations/api/endpoints/data_forwarding_details.py index 7616d5b98bad70..3d8b7d767a87a3 100644 --- a/src/sentry/integrations/api/endpoints/data_forwarding_details.py +++ b/src/sentry/integrations/api/endpoints/data_forwarding_details.py @@ -1,89 +1,56 @@ -from django.utils.decorators import method_decorator -from django.views.decorators.cache import never_cache -from drf_spectacular.utils import extend_schema -from rest_framework import status +# PUT and DELETE specific data forwarder configs + +# from rest_framework.request import Request +# from rest_framework.response import Response + from rest_framework.request import Request from rest_framework.response import Response -from sentry import audit_log from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission from sentry.api.serializers import serialize -from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN -from sentry.apidocs.parameters import GlobalParams +from sentry.hybridcloud.rpc import RpcUserOrganizationContext from sentry.integrations.api.serializers.rest_framework.data_forwarder import ( DataForwarderSerializer, ) -from sentry.integrations.models.data_forwarder import DataForwarder -from sentry.web.decorators import set_referrer_policy -class OrganizationDataForwardingDetailsPermission(OrganizationPermission): +class OrganizationDataForwardingPermission(OrganizationPermission): scope_map = { - "GET": ["org:read"], - "POST": ["org:write"], + "PUT": ["org:write"], + "DELETE": ["org:write"], } @region_silo_endpoint -@extend_schema(tags=["Integrations"]) class DataForwardingDetailsEndpoint(OrganizationEndpoint): + permission_classes = (OrganizationDataForwardingPermission,) owner = ApiOwner.INTEGRATIONS publish_status = { - "GET": ApiPublishStatus.PRIVATE, # TODO: might need to change - "POST": ApiPublishStatus.PRIVATE, + "DELETE": ApiPublishStatus.PRIVATE, + "PUT": ApiPublishStatus.PRIVATE, } - permission_classes = (OrganizationDataForwardingDetailsPermission,) - - @extend_schema( - operation_id="Retrieve a Data Forwarding Configuration for an Organization", - parameters=[GlobalParams.ORG_ID_OR_SLUG], - responses={ - 200: DataForwarderSerializer, - }, - ) - @set_referrer_policy("strict-origin-when-cross-origin") - @method_decorator(never_cache) - def get(self, request: Request, organization_context) -> Response: - data_forwarders = DataForwarder.objects.filter( - organization_id=organization_context.organization.id - ) - return self.respond(serialize(data_forwarders, request.user)) - @extend_schema( - operation_id="Create a Data Forwarding Configuration for an Organization", - parameters=[GlobalParams.ORG_ID_OR_SLUG], - request=DataForwarderSerializer, - responses={ - 201: DataForwarderSerializer, - 400: RESPONSE_BAD_REQUEST, - 403: RESPONSE_FORBIDDEN, - }, - ) - @set_referrer_policy("strict-origin-when-cross-origin") - @method_decorator(never_cache) - def post(self, request: Request, organization_context) -> Response: - data = request.data.copy() + def put( + self, + request: Request, + organization_context: RpcUserOrganizationContext, + data_forwarder_id: int, + ) -> Response: + data = request.data data["organization_id"] = organization_context.organization.id - serializer = DataForwarderSerializer(data=data) if serializer.is_valid(): data_forwarder = serializer.save() - - self.create_audit_entry( - request=request, - organization=organization_context.organization, - target_object=data_forwarder.id, - event=audit_log.get_event_id("DATA_FORWARDER_ADD"), - data={ - "provider": data_forwarder.provider, - "organization_id": data_forwarder.organization_id, - }, - ) - - return self.respond( - serialize(data_forwarder, request.user), status=status.HTTP_201_CREATED - ) - return self.respond(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return self.respond(serialize(data_forwarder, request.user)) + return self.respond(serializer.errors, status=400) + + def delete( + self, + request: Request, + organization_context: RpcUserOrganizationContext, + data_forwarder_id: int, + ) -> Response: + return self.respond(serialize(organization_context.organization, request.user)) diff --git a/src/sentry/integrations/api/endpoints/data_forwarding_index.py b/src/sentry/integrations/api/endpoints/data_forwarding_index.py index 9398cf71413398..6dfd91b5548eb8 100644 --- a/src/sentry/integrations/api/endpoints/data_forwarding_index.py +++ b/src/sentry/integrations/api/endpoints/data_forwarding_index.py @@ -1,26 +1,97 @@ -# PUT and DELETE specific data forwarder configs - -# from rest_framework.request import Request -# from rest_framework.response import Response +from django.utils.decorators import method_decorator +from django.views.decorators.cache import never_cache +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.request import Request +from rest_framework.response import Response +from sentry import audit_log from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.serializers import serialize +from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN +from sentry.apidocs.parameters import GlobalParams +from sentry.integrations.api.serializers.rest_framework.data_forwarder import ( + DataForwarderSerializer, +) +from sentry.integrations.models.data_forwarder import DataForwarder +from sentry.integrations.models.data_forwarder_project import DataForwarderProject +from sentry.web.decorators import set_referrer_policy -class OrganizationDataForwardingPermission(OrganizationPermission): +class OrganizationDataForwardingDetailsPermission(OrganizationPermission): scope_map = { - "PUT": ["org:write"], - "DELETE": ["org:write"], + "GET": ["org:read"], + "POST": ["org:write"], } @region_silo_endpoint -class DataForwardingIndexEndpoint(OrganizationEndpoint): - permission_classes = (OrganizationDataForwardingPermission,) +@extend_schema(tags=["Integrations"]) +class DataForwardingDetailsEndpoint(OrganizationEndpoint): owner = ApiOwner.INTEGRATIONS publish_status = { - "PUT": ApiPublishStatus.PRIVATE, - "DELETE": ApiPublishStatus.PRIVATE, + "GET": ApiPublishStatus.PRIVATE, # TODO: might need to change + "POST": ApiPublishStatus.PRIVATE, } + permission_classes = (OrganizationDataForwardingDetailsPermission,) + + @extend_schema( + operation_id="Retrieve a Data Forwarding Configuration for an Organization", + parameters=[GlobalParams.ORG_ID_OR_SLUG], + responses={ + 200: DataForwarderSerializer, + }, + ) + @set_referrer_policy("strict-origin-when-cross-origin") + @method_decorator(never_cache) + def get(self, request: Request, organization_context) -> Response: + data_forwarders = DataForwarder.objects.filter( + organization_id=organization_context.organization.id + ) + # retrieve project configs for each data forwarder + for data_forwarder in data_forwarders: + data_forwarder.project_configs = DataForwarderProject.objects.filter( + data_forwarder=data_forwarder + ) + data_forwarder.project_configs = serialize(data_forwarder.project_configs, request.user) + + return self.respond(serialize(data_forwarders, request.user)) + + @extend_schema( + operation_id="Create a Data Forwarding Configuration for an Organization", + parameters=[GlobalParams.ORG_ID_OR_SLUG], + request=DataForwarderSerializer, + responses={ + 201: DataForwarderSerializer, + 400: RESPONSE_BAD_REQUEST, + 403: RESPONSE_FORBIDDEN, + }, + ) + @set_referrer_policy("strict-origin-when-cross-origin") + @method_decorator(never_cache) + def post(self, request: Request, organization_context) -> Response: + data = request.data.copy() + data["organization_id"] = organization_context.organization.id + + serializer = DataForwarderSerializer(data=data) + if serializer.is_valid(): + data_forwarder = serializer.save() + + self.create_audit_entry( + request=request, + organization=organization_context.organization, + target_object=data_forwarder.id, + event=audit_log.get_event_id("DATA_FORWARDER_ADD"), + data={ + "provider": data_forwarder.provider, + "organization_id": data_forwarder.organization_id, + }, + ) + + return self.respond( + serialize(data_forwarder, request.user), status=status.HTTP_201_CREATED + ) + return self.respond(serializer.errors, status=status.HTTP_400_BAD_REQUEST) From 9462d12ebb046345efad5619e2bf6a7eb8269ed3 Mon Sep 17 00:00:00 2001 From: Irene Liu Date: Thu, 2 Oct 2025 12:56:31 -0700 Subject: [PATCH 3/7] switch names --- .../api/endpoints/data_forwarding_details.py | 21 ++++++++++++++++++- .../api/endpoints/data_forwarding_index.py | 2 +- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/sentry/integrations/api/endpoints/data_forwarding_details.py b/src/sentry/integrations/api/endpoints/data_forwarding_details.py index 3d8b7d767a87a3..be88364285a19e 100644 --- a/src/sentry/integrations/api/endpoints/data_forwarding_details.py +++ b/src/sentry/integrations/api/endpoints/data_forwarding_details.py @@ -3,6 +3,7 @@ # from rest_framework.request import Request # from rest_framework.response import Response +from django.db import router, transaction from rest_framework.request import Request from rest_framework.response import Response @@ -15,6 +16,8 @@ from sentry.integrations.api.serializers.rest_framework.data_forwarder import ( DataForwarderSerializer, ) +from sentry.integrations.models.data_forwarder import DataForwarder +from sentry.integrations.models.data_forwarder_project import DataForwarderProject class OrganizationDataForwardingPermission(OrganizationPermission): @@ -53,4 +56,20 @@ def delete( organization_context: RpcUserOrganizationContext, data_forwarder_id: int, ) -> Response: - return self.respond(serialize(organization_context.organization, request.user)) + # also removes project overrides associated with the data forwarder + + if not request.user.is_authenticated: + return Response(status=401) + + try: + data_forwarder = DataForwarder.objects.get( + id=data_forwarder_id, organization_id=organization_context.organization.id + ) + except DataForwarder.DoesNotExist: + return self.respond(status=404) + + with transaction.atomic(router.db_for_write(DataForwarder)): + DataForwarderProject.objects.filter(data_forwarder=data_forwarder).delete() + data_forwarder.delete() + + return Response(serialize(data_forwarder, request.user), status=202) diff --git a/src/sentry/integrations/api/endpoints/data_forwarding_index.py b/src/sentry/integrations/api/endpoints/data_forwarding_index.py index 6dfd91b5548eb8..e85c2484f0925f 100644 --- a/src/sentry/integrations/api/endpoints/data_forwarding_index.py +++ b/src/sentry/integrations/api/endpoints/data_forwarding_index.py @@ -30,7 +30,7 @@ class OrganizationDataForwardingDetailsPermission(OrganizationPermission): @region_silo_endpoint @extend_schema(tags=["Integrations"]) -class DataForwardingDetailsEndpoint(OrganizationEndpoint): +class DataForwardingIndexEndpoint(OrganizationEndpoint): owner = ApiOwner.INTEGRATIONS publish_status = { "GET": ApiPublishStatus.PRIVATE, # TODO: might need to change From da121da5af3dded6d85d1041e09b3c3904c0a870 Mon Sep 17 00:00:00 2001 From: Irene Liu Date: Mon, 6 Oct 2025 16:18:40 -0700 Subject: [PATCH 4/7] initial commit --- .../api/endpoints/data_forwarding_index.py | 26 +- .../api/endpoints/test_data_forwarding.py | 306 ++++++++++++++++++ 2 files changed, 319 insertions(+), 13 deletions(-) create mode 100644 tests/sentry/integrations/api/endpoints/test_data_forwarding.py diff --git a/src/sentry/integrations/api/endpoints/data_forwarding_index.py b/src/sentry/integrations/api/endpoints/data_forwarding_index.py index e85c2484f0925f..fba296938a85b3 100644 --- a/src/sentry/integrations/api/endpoints/data_forwarding_index.py +++ b/src/sentry/integrations/api/endpoints/data_forwarding_index.py @@ -10,6 +10,7 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.paginator import OffsetPaginator from sentry.api.serializers import serialize from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN from sentry.apidocs.parameters import GlobalParams @@ -17,7 +18,7 @@ DataForwarderSerializer, ) from sentry.integrations.models.data_forwarder import DataForwarder -from sentry.integrations.models.data_forwarder_project import DataForwarderProject +from sentry.organizations.services.organization.model import RpcUserOrganizationContext from sentry.web.decorators import set_referrer_policy @@ -33,13 +34,13 @@ class OrganizationDataForwardingDetailsPermission(OrganizationPermission): class DataForwardingIndexEndpoint(OrganizationEndpoint): owner = ApiOwner.INTEGRATIONS publish_status = { - "GET": ApiPublishStatus.PRIVATE, # TODO: might need to change + "GET": ApiPublishStatus.PRIVATE, "POST": ApiPublishStatus.PRIVATE, } permission_classes = (OrganizationDataForwardingDetailsPermission,) @extend_schema( - operation_id="Retrieve a Data Forwarding Configuration for an Organization", + operation_id="Retrieve Data Forwarding Configurations for an Organization", parameters=[GlobalParams.ORG_ID_OR_SLUG], responses={ 200: DataForwarderSerializer, @@ -47,18 +48,17 @@ class DataForwardingIndexEndpoint(OrganizationEndpoint): ) @set_referrer_policy("strict-origin-when-cross-origin") @method_decorator(never_cache) - def get(self, request: Request, organization_context) -> Response: - data_forwarders = DataForwarder.objects.filter( + def get(self, request: Request, organization_context: RpcUserOrganizationContext) -> Response: + queryset = DataForwarder.objects.filter( organization_id=organization_context.organization.id ) - # retrieve project configs for each data forwarder - for data_forwarder in data_forwarders: - data_forwarder.project_configs = DataForwarderProject.objects.filter( - data_forwarder=data_forwarder - ) - data_forwarder.project_configs = serialize(data_forwarder.project_configs, request.user) - return self.respond(serialize(data_forwarders, request.user)) + return self.paginate( + request=request, + queryset=queryset, + on_results=lambda x: serialize(x, request.user), + paginator_cls=OffsetPaginator, + ) @extend_schema( operation_id="Create a Data Forwarding Configuration for an Organization", @@ -72,7 +72,7 @@ def get(self, request: Request, organization_context) -> Response: ) @set_referrer_policy("strict-origin-when-cross-origin") @method_decorator(never_cache) - def post(self, request: Request, organization_context) -> Response: + def post(self, request: Request, organization_context: RpcUserOrganizationContext) -> Response: data = request.data.copy() data["organization_id"] = organization_context.organization.id diff --git a/tests/sentry/integrations/api/endpoints/test_data_forwarding.py b/tests/sentry/integrations/api/endpoints/test_data_forwarding.py new file mode 100644 index 00000000000000..f34e904faa9ffb --- /dev/null +++ b/tests/sentry/integrations/api/endpoints/test_data_forwarding.py @@ -0,0 +1,306 @@ +from sentry import audit_log +from sentry.integrations.models.data_forwarder import DataForwarder +from sentry.integrations.models.data_forwarder_project import DataForwarderProject +from sentry.integrations.types import DataForwarderProviderSlug +from sentry.models.auditlogentry import AuditLogEntry +from sentry.testutils.cases import APITestCase +from sentry.testutils.silo import region_silo_test + + +@region_silo_test +class DataForwardingIndexEndpointTest(APITestCase): + endpoint = "sentry-api-0-organization-data-forwarding-index" + + def setUp(self) -> None: + super().setUp() + self.login_as(user=self.user) + + +@region_silo_test +class DataForwardingIndexGetTest(DataForwardingIndexEndpointTest): + def test_get_single_data_forwarder(self) -> None: + data_forwarder = DataForwarder.objects.create( + organization=self.organization, + provider=DataForwarderProviderSlug.SEGMENT, + config={"write_key": "test_key"}, + is_enabled=True, + ) + + response = self.get_success_response(self.organization.slug) + assert len(response.data) == 1 + assert response.data[0]["id"] == str(data_forwarder.id) + assert response.data[0]["provider"] == DataForwarderProviderSlug.SEGMENT + assert response.data[0]["config"] == {"write_key": "test_key"} + assert response.data[0]["isEnabled"] is True + + def test_get_multiple_data_forwarders(self) -> None: + segment_forwarder = DataForwarder.objects.create( + organization=self.organization, + provider=DataForwarderProviderSlug.SEGMENT, + config={"write_key": "segment_key"}, + ) + sqs_forwarder = DataForwarder.objects.create( + organization=self.organization, + provider=DataForwarderProviderSlug.SQS, + config={ + "queue_url": "https://sqs.us-east-1.amazonaws.com/123456789012/test-queue", + "region": "us-east-1", + "access_key": "AKIAIOSFODNN7EXAMPLE", + "secret_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }, + ) + + response = self.get_success_response(self.organization.slug) + assert len(response.data) == 2 + + forwarder_ids = [f["id"] for f in response.data] + assert str(segment_forwarder.id) in forwarder_ids + assert str(sqs_forwarder.id) in forwarder_ids + + def test_get_data_forwarder_with_project_configs(self) -> None: + data_forwarder = DataForwarder.objects.create( + organization=self.organization, + provider=DataForwarderProviderSlug.SEGMENT, + config={"write_key": "test_key"}, + ) + + project1 = self.create_project(organization=self.organization) + project2 = self.create_project(organization=self.organization) + + project_config1 = DataForwarderProject.objects.create( + data_forwarder=data_forwarder, + project=project1, + is_enabled=True, + overrides={"custom": "value1"}, + ) + project_config2 = DataForwarderProject.objects.create( + data_forwarder=data_forwarder, + project=project2, + is_enabled=False, + overrides={"custom": "value2"}, + ) + + response = self.get_success_response(self.organization.slug) + assert len(response.data) == 1 + + project_configs = response.data[0]["projectConfigs"] + assert len(project_configs) == 2 + + project_config_ids = [pc["id"] for pc in project_configs] + assert str(project_config1.id) in project_config_ids + assert str(project_config2.id) in project_config_ids + + def test_get_only_returns_organization_data_forwarders(self) -> None: + my_forwarder = DataForwarder.objects.create( + organization=self.organization, + provider=DataForwarderProviderSlug.SEGMENT, + config={"write_key": "my_key"}, + ) + + other_org = self.create_organization() + DataForwarder.objects.create( + organization=other_org, + provider=DataForwarderProviderSlug.SEGMENT, + config={"write_key": "other_key"}, + ) + + response = self.get_success_response(self.organization.slug) + assert len(response.data) == 1 + assert response.data[0]["id"] == str(my_forwarder.id) + + def test_get_requires_read_permission(self) -> None: + user_without_permission = self.create_user() + self.login_as(user=user_without_permission) + + self.get_error_response(self.organization.slug, status_code=403) + + def test_get_with_disabled_data_forwarder(self) -> None: + data_forwarder = DataForwarder.objects.create( + organization=self.organization, + provider=DataForwarderProviderSlug.SEGMENT, + config={"write_key": "test_key"}, + is_enabled=False, + ) + + response = self.get_success_response(self.organization.slug) + assert len(response.data) == 1 + assert response.data[0]["id"] == str(data_forwarder.id) + assert response.data[0]["isEnabled"] is False + + +@region_silo_test +class DataForwardingIndexPostTest(DataForwardingIndexEndpointTest): + method = "POST" + + def test_create_segment_data_forwarder(self) -> None: + payload = { + "provider": DataForwarderProviderSlug.SEGMENT, + "config": {"write_key": "test_segment_key"}, + "is_enabled": True, + "enroll_new_projects": False, + } + + response = self.get_success_response(self.organization.slug, status_code=201, **payload) + + assert response.data["provider"] == DataForwarderProviderSlug.SEGMENT + assert response.data["config"] == {"write_key": "test_segment_key"} + assert response.data["isEnabled"] is True + assert response.data["enrollNewProjects"] is False + + data_forwarder = DataForwarder.objects.get(id=response.data["id"]) + assert data_forwarder.organization_id == self.organization.id + assert data_forwarder.provider == DataForwarderProviderSlug.SEGMENT + assert data_forwarder.config == {"write_key": "test_segment_key"} + + assert AuditLogEntry.objects.filter( + organization_id=self.organization.id, + event=audit_log.get_event_id("DATA_FORWARDER_ADD"), + target_object=data_forwarder.id, + data={ + "provider": DataForwarderProviderSlug.SEGMENT, + "organization_id": self.organization.id, + }, + ).exists() + + def test_create_sqs_data_forwarder(self) -> None: + payload = { + "provider": DataForwarderProviderSlug.SQS, + "config": { + "queue_url": "https://sqs.us-east-1.amazonaws.com/123456789012/test-queue", + "region": "us-east-1", + "access_key": "AKIAIOSFODNN7EXAMPLE", + "secret_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }, + } + + response = self.get_success_response(self.organization.slug, status_code=201, **payload) + + assert response.data["provider"] == DataForwarderProviderSlug.SQS + assert response.data["config"]["queue_url"] == payload["config"]["queue_url"] + + data_forwarder = DataForwarder.objects.get(id=response.data["id"]) + assert data_forwarder.provider == DataForwarderProviderSlug.SQS + + def test_create_splunk_data_forwarder(self) -> None: + payload = { + "provider": DataForwarderProviderSlug.SPLUNK, + "config": { + "instance_url": "https://splunk.example.com:8089", + "index": "main", + "source": "sentry", + "token": "12345678-1234-1234-1234-123456789abc", + }, + } + + response = self.get_success_response(self.organization.slug, status_code=201, **payload) + + assert response.data["provider"] == DataForwarderProviderSlug.SPLUNK + assert response.data["config"]["instance_url"] == payload["config"]["instance_url"] + + data_forwarder = DataForwarder.objects.get(id=response.data["id"]) + assert data_forwarder.provider == DataForwarderProviderSlug.SPLUNK + + def test_create_with_default_values(self) -> None: + payload = { + "provider": DataForwarderProviderSlug.SEGMENT, + "config": {"write_key": "test_key"}, + } + + response = self.get_success_response(self.organization.slug, status_code=201, **payload) + + assert response.data["isEnabled"] is True + assert response.data["enrollNewProjects"] is False + + def test_create_duplicate_provider_fails(self) -> None: + DataForwarder.objects.create( + organization=self.organization, + provider=DataForwarderProviderSlug.SEGMENT, + config={"write_key": "existing_key"}, + ) + + payload = { + "provider": DataForwarderProviderSlug.SEGMENT, + "config": {"write_key": "new_key"}, + } + + response = self.get_error_response(self.organization.slug, status_code=400, **payload) + + assert "already exists" in str(response.data).lower() + + def test_create_different_providers_succeeds(self) -> None: + DataForwarder.objects.create( + organization=self.organization, + provider=DataForwarderProviderSlug.SEGMENT, + config={"write_key": "segment_key"}, + ) + + payload = { + "provider": DataForwarderProviderSlug.SQS, + "config": { + "queue_url": "https://sqs.us-east-1.amazonaws.com/123456789012/test-queue", + "region": "us-east-1", + "access_key": "AKIAIOSFODNN7EXAMPLE", + "secret_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }, + } + + response = self.get_success_response(self.organization.slug, status_code=201, **payload) + + assert response.data["provider"] == DataForwarderProviderSlug.SQS + + def test_create_missing_required_fields(self) -> None: + payload = { + "config": {"write_key": "test_key"}, + } + response = self.get_error_response(self.organization.slug, status_code=400, **payload) + assert "provider" in str(response.data).lower() + + def test_create_invalid_config(self) -> None: + payload = { + "provider": DataForwarderProviderSlug.SEGMENT, + "config": {"write_key": "invalid key"}, + } + response = self.get_error_response(self.organization.slug, status_code=400, **payload) + assert "config" in str(response.data).lower() + + def test_create_requires_write_permission(self) -> None: + user_without_permission = self.create_user() + self.login_as(user=user_without_permission) + + payload = { + "provider": DataForwarderProviderSlug.SEGMENT, + "config": {"write_key": "test_key"}, + } + + self.get_error_response(self.organization.slug, status_code=403, **payload) + + def test_create_invalid_provider(self) -> None: + payload = { + "provider": "invalid_provider", + "config": {"write_key": "test_key"}, + } + + response = self.get_error_response(self.organization.slug, status_code=400, **payload) + assert "provider" in str(response.data).lower() + + def test_create_missing_config(self) -> None: + payload = { + "provider": DataForwarderProviderSlug.SEGMENT, + } + + response = self.get_error_response(self.organization.slug, status_code=400, **payload) + assert "config" in str(response.data).lower() + + def test_create_sqs_fifo_queue_validation(self) -> None: + payload = { + "provider": DataForwarderProviderSlug.SQS, + "config": { + "queue_url": "https://sqs.us-east-1.amazonaws.com/123456789012/test-queue.fifo", + "region": "us-east-1", + "access_key": "AKIAIOSFODNN7EXAMPLE", + "secret_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }, + } + + response = self.get_error_response(self.organization.slug, status_code=400, **payload) + assert "message_group_id" in str(response.data).lower() From 22565848ad06ebe729f06700fd1a95c73ea58c33 Mon Sep 17 00:00:00 2001 From: Irene Liu Date: Mon, 6 Oct 2025 16:22:18 -0700 Subject: [PATCH 5/7] shld not have been included --- .../api/endpoints/data_forwarding_details.py | 75 ------------- .../endpoints/data_forwarding_index copy.py | 102 ------------------ 2 files changed, 177 deletions(-) delete mode 100644 src/sentry/integrations/api/endpoints/data_forwarding_details.py delete mode 100644 src/sentry/integrations/api/endpoints/data_forwarding_index copy.py diff --git a/src/sentry/integrations/api/endpoints/data_forwarding_details.py b/src/sentry/integrations/api/endpoints/data_forwarding_details.py deleted file mode 100644 index be88364285a19e..00000000000000 --- a/src/sentry/integrations/api/endpoints/data_forwarding_details.py +++ /dev/null @@ -1,75 +0,0 @@ -# PUT and DELETE specific data forwarder configs - -# from rest_framework.request import Request -# from rest_framework.response import Response - -from django.db import router, transaction -from rest_framework.request import Request -from rest_framework.response import Response - -from sentry.api.api_owners import ApiOwner -from sentry.api.api_publish_status import ApiPublishStatus -from sentry.api.base import region_silo_endpoint -from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission -from sentry.api.serializers import serialize -from sentry.hybridcloud.rpc import RpcUserOrganizationContext -from sentry.integrations.api.serializers.rest_framework.data_forwarder import ( - DataForwarderSerializer, -) -from sentry.integrations.models.data_forwarder import DataForwarder -from sentry.integrations.models.data_forwarder_project import DataForwarderProject - - -class OrganizationDataForwardingPermission(OrganizationPermission): - scope_map = { - "PUT": ["org:write"], - "DELETE": ["org:write"], - } - - -@region_silo_endpoint -class DataForwardingDetailsEndpoint(OrganizationEndpoint): - permission_classes = (OrganizationDataForwardingPermission,) - owner = ApiOwner.INTEGRATIONS - publish_status = { - "DELETE": ApiPublishStatus.PRIVATE, - "PUT": ApiPublishStatus.PRIVATE, - } - - def put( - self, - request: Request, - organization_context: RpcUserOrganizationContext, - data_forwarder_id: int, - ) -> Response: - data = request.data - data["organization_id"] = organization_context.organization.id - serializer = DataForwarderSerializer(data=data) - if serializer.is_valid(): - data_forwarder = serializer.save() - return self.respond(serialize(data_forwarder, request.user)) - return self.respond(serializer.errors, status=400) - - def delete( - self, - request: Request, - organization_context: RpcUserOrganizationContext, - data_forwarder_id: int, - ) -> Response: - # also removes project overrides associated with the data forwarder - - if not request.user.is_authenticated: - return Response(status=401) - - try: - data_forwarder = DataForwarder.objects.get( - id=data_forwarder_id, organization_id=organization_context.organization.id - ) - except DataForwarder.DoesNotExist: - return self.respond(status=404) - - with transaction.atomic(router.db_for_write(DataForwarder)): - DataForwarderProject.objects.filter(data_forwarder=data_forwarder).delete() - data_forwarder.delete() - - return Response(serialize(data_forwarder, request.user), status=202) diff --git a/src/sentry/integrations/api/endpoints/data_forwarding_index copy.py b/src/sentry/integrations/api/endpoints/data_forwarding_index copy.py deleted file mode 100644 index 13852d3b6c271e..00000000000000 --- a/src/sentry/integrations/api/endpoints/data_forwarding_index copy.py +++ /dev/null @@ -1,102 +0,0 @@ -from django.utils.decorators import method_decorator -from django.views.decorators.cache import never_cache -from drf_spectacular.utils import extend_schema -from rest_framework import status -from rest_framework.request import Request -from rest_framework.response import Response - -from sentry import audit_log -from sentry.api.api_owners import ApiOwner -from sentry.api.api_publish_status import ApiPublishStatus -from sentry.api.base import region_silo_endpoint -from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission -from sentry.api.serializers import serialize -from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN -from sentry.apidocs.parameters import GlobalParams -from sentry.apidocs.utils import inline_sentry_response_serializer -from sentry.integrations.api.serializers.rest_framework.data_forwarder import ( - DataForwarderSerializer, -) -from sentry.integrations.models.data_forwarder import DataForwarder -from sentry.utils.audit import create_audit_entry -from sentry.web.decorators import set_referrer_policy - - -class OrganizationDataForwardingPermission(OrganizationPermission): - scope_map = { - "GET": ["org:read", "org:write", "org:admin", "org:integrations"], - "POST": ["org:write", "org:admin", "org:integrations"], - } - - -@extend_schema(tags=["Integrations"]) -@region_silo_endpoint -class OrganizationDataForwardingEndpoint(OrganizationEndpoint): - permission_classes = (OrganizationDataForwardingPermission,) - owner = ApiOwner.INTEGRATIONS - publish_status = { - "GET": ApiPublishStatus.PRIVATE, - "POST": ApiPublishStatus.PRIVATE, - } - - @extend_schema( - operation_id="List an Organization's Data Forwarding Configurations", - parameters=[GlobalParams.ORG_ID_OR_SLUG], - responses={ - 200: inline_sentry_response_serializer("ListDataForwarderResponse", list[dict]), - }, - ) - @set_referrer_policy("strict-origin-when-cross-origin") - @method_decorator(never_cache) - def get(self, request: Request, organization_context) -> Response: - """ - Retrieve the forwarding configs for an organization. - Also retrieves project overrides for the given configs. - """ - data_forwarders = DataForwarder.objects.filter( - organization_id=organization_context.organization.id - ).prefetch_related("projects__project") - - return self.respond(serialize(data_forwarders, request.user)) - - @extend_schema( - operation_id="Create a Data Forwarding Configuration", - parameters=[GlobalParams.ORG_ID_OR_SLUG], - request=DataForwarderSerializer, - responses={ - 201: inline_sentry_response_serializer("DataForwarderResponse", dict), - 400: RESPONSE_BAD_REQUEST, - 403: RESPONSE_FORBIDDEN, - }, - ) - @set_referrer_policy("strict-origin-when-cross-origin") - @method_decorator(never_cache) - def post(self, request: Request, organization_context) -> Response: - """ - Create a forwarding config for an organization. - """ - data = request.data.copy() - data["organization_id"] = organization_context.organization.id - - serializer = DataForwarderSerializer(data=data) - if serializer.is_valid(): - data_forwarder = serializer.save() - - # Create audit log entry - create_audit_entry( - request=request, - organization_id=organization_context.organization.id, - target_object=data_forwarder.id, - event=audit_log.get_event_id("DATA_FORWARDER_ADD"), - data={ - "provider": data_forwarder.provider, - "organization_id": data_forwarder.organization_id, - }, - ) - - return self.respond( - serialize(data_forwarder, request.user), - status=status.HTTP_201_CREATED, - ) - - return self.respond(serializer.errors, status=status.HTTP_400_BAD_REQUEST) From 29c99cc5d9fe39b590ca3def1e7a14bfb27858d2 Mon Sep 17 00:00:00 2001 From: Irene Liu Date: Mon, 6 Oct 2025 16:33:41 -0700 Subject: [PATCH 6/7] fix typing --- tests/sentry/integrations/api/endpoints/test_data_forwarding.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/sentry/integrations/api/endpoints/test_data_forwarding.py b/tests/sentry/integrations/api/endpoints/test_data_forwarding.py index f34e904faa9ffb..c99074717994aa 100644 --- a/tests/sentry/integrations/api/endpoints/test_data_forwarding.py +++ b/tests/sentry/integrations/api/endpoints/test_data_forwarding.py @@ -176,7 +176,6 @@ def test_create_sqs_data_forwarder(self) -> None: response = self.get_success_response(self.organization.slug, status_code=201, **payload) assert response.data["provider"] == DataForwarderProviderSlug.SQS - assert response.data["config"]["queue_url"] == payload["config"]["queue_url"] data_forwarder = DataForwarder.objects.get(id=response.data["id"]) assert data_forwarder.provider == DataForwarderProviderSlug.SQS @@ -195,7 +194,6 @@ def test_create_splunk_data_forwarder(self) -> None: response = self.get_success_response(self.organization.slug, status_code=201, **payload) assert response.data["provider"] == DataForwarderProviderSlug.SPLUNK - assert response.data["config"]["instance_url"] == payload["config"]["instance_url"] data_forwarder = DataForwarder.objects.get(id=response.data["id"]) assert data_forwarder.provider == DataForwarderProviderSlug.SPLUNK From 27ac3388ff6d26e37642b41752f15239f5ee176d Mon Sep 17 00:00:00 2001 From: Irene Liu Date: Mon, 6 Oct 2025 16:57:27 -0700 Subject: [PATCH 7/7] add url --- src/sentry/api/urls.py | 7 +++++++ .../integrations/api/endpoints/test_data_forwarding.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 6a214bd1b7b470..eb4e768e5b6aa7 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -218,6 +218,7 @@ TeamAlertsTriggeredTotalsEndpoint, ) from sentry.insights.endpoints.starred_segments import InsightsStarredSegmentsEndpoint +from sentry.integrations.api.endpoints.data_forwarding_index import DataForwardingIndexEndpoint from sentry.integrations.api.endpoints.doc_integration_avatar import DocIntegrationAvatarEndpoint from sentry.integrations.api.endpoints.doc_integration_details import DocIntegrationDetailsEndpoint from sentry.integrations.api.endpoints.doc_integrations_index import DocIntegrationsEndpoint @@ -1403,6 +1404,12 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: OrganizationCodeMappingCodeOwnersEndpoint.as_view(), name="sentry-api-0-organization-code-mapping-codeowners", ), + # Data Forwarding + re_path( + r"^(?P[^/]+)/forwarding/$", + DataForwardingIndexEndpoint.as_view(), + name="sentry-api-0-organization-forwarding", + ), re_path( r"^(?P[^/]+)/codeowners-associations/$", OrganizationCodeOwnersAssociationsEndpoint.as_view(), diff --git a/tests/sentry/integrations/api/endpoints/test_data_forwarding.py b/tests/sentry/integrations/api/endpoints/test_data_forwarding.py index c99074717994aa..95a64fad072efe 100644 --- a/tests/sentry/integrations/api/endpoints/test_data_forwarding.py +++ b/tests/sentry/integrations/api/endpoints/test_data_forwarding.py @@ -9,7 +9,7 @@ @region_silo_test class DataForwardingIndexEndpointTest(APITestCase): - endpoint = "sentry-api-0-organization-data-forwarding-index" + endpoint = "sentry-api-0-organization-forwarding" def setUp(self) -> None: super().setUp()