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/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..fba296938a85b3 --- /dev/null +++ b/src/sentry/integrations/api/endpoints/data_forwarding_index.py @@ -0,0 +1,97 @@ +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.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 +from sentry.integrations.api.serializers.rest_framework.data_forwarder import ( + DataForwarderSerializer, +) +from sentry.integrations.models.data_forwarder import DataForwarder +from sentry.organizations.services.organization.model import RpcUserOrganizationContext +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 DataForwardingIndexEndpoint(OrganizationEndpoint): + owner = ApiOwner.INTEGRATIONS + publish_status = { + "GET": ApiPublishStatus.PRIVATE, + "POST": ApiPublishStatus.PRIVATE, + } + permission_classes = (OrganizationDataForwardingDetailsPermission,) + + @extend_schema( + operation_id="Retrieve Data Forwarding Configurations 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: RpcUserOrganizationContext) -> Response: + queryset = DataForwarder.objects.filter( + organization_id=organization_context.organization.id + ) + + 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", + 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: RpcUserOrganizationContext) -> 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/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..95a64fad072efe --- /dev/null +++ b/tests/sentry/integrations/api/endpoints/test_data_forwarding.py @@ -0,0 +1,304 @@ +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-forwarding" + + 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 + + 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 + + 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()