diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 5a2182dfe95fb9..7847c555cb9732 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -163,7 +163,10 @@ from sentry.monitors.endpoints.project_processing_errors_details import ( ProjectProcessingErrorsDetailsEndpoint, ) -from sentry.remote_config.endpoints import ProjectConfigurationEndpoint +from sentry.remote_config.endpoints import ( + ProjectConfigurationEndpoint, + ProjectConfigurationProxyEndpoint, +) from sentry.replays.endpoints.organization_replay_count import OrganizationReplayCountEndpoint from sentry.replays.endpoints.organization_replay_details import OrganizationReplayDetailsEndpoint from sentry.replays.endpoints.organization_replay_events_meta import ( @@ -3256,6 +3259,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: SetupWizard.as_view(), name="sentry-api-0-project-wizard", ), + re_path( + r"^remote-config/projects/(?P[^\/]+)$", + ProjectConfigurationProxyEndpoint.as_view(), + name="sentry-api-0-project-remote-configuration", + ), # Internal re_path( r"^internal/", diff --git a/src/sentry/remote_config/docs/api.md b/src/sentry/remote_config/docs/api.md index fb285220b970b5..fc05ee85c44d6d 100644 --- a/src/sentry/remote_config/docs/api.md +++ b/src/sentry/remote_config/docs/api.md @@ -10,7 +10,7 @@ Host: https://sentry.io/api/0 ### Get Configuration [GET] -Retrieve the DSN's configuration. +Retrieve the project's configuration. **Attributes** @@ -64,7 +64,7 @@ Retrieve the DSN's configuration. ### Set Configuration [POST] -Set the DSN's configuration. +Set the project's configuration. - Request @@ -114,6 +114,44 @@ Set the DSN's configuration. ### Delete Configuration [DELETE] -Delete the DSN's configuration. +Delete the project's configuration. - Response 204 + +## Configuration Proxy [/remote-config/projects//] + +Temporary configuration proxy resource. + +### Get Configuration [GET] + +Fetch a project's configuration. Responses should be proxied exactly to the SDK. + +- Response 200 + + - Headers + + Cache-Control: public, max-age=3600 + Content-Type: application/json + ETag: a7966bf58e23583c9a5a4059383ff850 + + - Body + + ```json + { + "features": [ + { + "key": "hello", + "value": "world" + }, + { + "key": "has_access", + "value": true + } + ], + "options": { + "sample_rate": 1.0, + "traces_sample_rate": 0.5 + }, + "version": 1 + } + ``` diff --git a/src/sentry/remote_config/endpoints.py b/src/sentry/remote_config/endpoints.py index 6ac1029ecf531d..86c1f3549d8a7d 100644 --- a/src/sentry/remote_config/endpoints.py +++ b/src/sentry/remote_config/endpoints.py @@ -1,4 +1,8 @@ +import hashlib + +from django.contrib.auth.models import AnonymousUser from rest_framework import serializers +from rest_framework.authentication import BasicAuthentication from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import Serializer @@ -6,10 +10,14 @@ from sentry import features 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.authentication import AuthenticationSiloLimit +from sentry.api.base import Endpoint, region_silo_endpoint from sentry.api.bases.project import ProjectEndpoint, ProjectEventPermission +from sentry.api.permissions import RelayPermission from sentry.models.project import Project -from sentry.remote_config.storage import make_storage +from sentry.remote_config.storage import BlobDriver, make_storage +from sentry.silo.base import SiloMode +from sentry.utils import json, metrics class OptionsValidator(Serializer): @@ -49,7 +57,7 @@ def get(self, request: Request, project: Project) -> Response: if not features.has( "organizations:remote-config", project.organization, actor=request.user ): - return Response(status=404) + return Response("Disabled", status=404) remote_config = make_storage(project).get() if remote_config is None: @@ -62,7 +70,7 @@ def post(self, request: Request, project: Project) -> Response: if not features.has( "organizations:remote-config", project.organization, actor=request.user ): - return Response(status=404) + return Response("Disabled", status=404) validator = ConfigurationContainerValidator(data=request.data) if not validator.is_valid(): @@ -71,6 +79,7 @@ def post(self, request: Request, project: Project) -> Response: result = validator.validated_data["data"] make_storage(project).set(result) + metrics.incr("remote_config.configuration.write") return Response({"data": result}, status=201) def delete(self, request: Request, project: Project) -> Response: @@ -78,7 +87,61 @@ def delete(self, request: Request, project: Project) -> Response: if not features.has( "organizations:remote-config", project.organization, actor=request.user ): - return Response(status=404) + return Response("Disabled", status=404) make_storage(project).pop() + metrics.incr("remote_config.configuration.delete") return Response("", status=204) + + +@AuthenticationSiloLimit(SiloMode.REGION) +class RelayAuthentication(BasicAuthentication): + """Same as default Relay authentication except without body signing.""" + + def authenticate(self, request: Request): + return (AnonymousUser(), None) + + +class RemoteConfigRelayPermission(RelayPermission): + def has_permission(self, request: Request, view: object) -> bool: + # Relay has permission to do everything! Except the only thing we expose is a simple + # read endpoint full of public data... + return True + + +@region_silo_endpoint +class ProjectConfigurationProxyEndpoint(Endpoint): + publish_status = { + "GET": ApiPublishStatus.EXPERIMENTAL, + } + owner = ApiOwner.REMOTE_CONFIG + authentication_classes = (RelayAuthentication,) + permission_classes = (RemoteConfigRelayPermission,) + enforce_rate_limit = False + + def get(self, request: Request, project_id: int) -> Response: + metrics.incr("remote_config.configuration.requested") + + project = Project.objects.select_related("organization").get(pk=project_id) + if not features.has("organizations:remote-config", project.organization, actor=None): + metrics.incr("remote_config.configuration.flag_disabled") + return Response("Disabled", status=404) + + result = BlobDriver(project).get() + if result is None: + metrics.incr("remote_config.configuration.not_found") + return Response("Not found", status=404) + + result_str = json.dumps(result) + metrics.incr("remote_config.configuration.returned") + metrics.distribution("remote_config.configuration.size", value=len(result_str)) + + # Emulating cache headers just because. + return Response( + result, + status=200, + headers={ + "Cache-Control": "public, max-age=3600", + "ETag": hashlib.sha1(result_str.encode()).hexdigest(), + }, + ) diff --git a/tests/sentry/remote_config/endpoints/test_configuration.py b/tests/sentry/remote_config/endpoints/test_configuration.py index 9e29f9bab77c75..743779565b39df 100644 --- a/tests/sentry/remote_config/endpoints/test_configuration.py +++ b/tests/sentry/remote_config/endpoints/test_configuration.py @@ -1,14 +1,17 @@ from typing import Any +from uuid import uuid4 from django.urls import reverse +from sentry_relay.auth import generate_key_pair +from sentry.models.relay import Relay from sentry.remote_config.storage import StorageBackend from sentry.testutils.cases import APITestCase REMOTE_CONFIG_FEATURES = {"organizations:remote-config": True} -class ConfiguratioAPITestCase(APITestCase): +class ConfigurationAPITestCase(APITestCase): endpoint = "sentry-api-0-project-key-configuration" def setUp(self): @@ -174,3 +177,67 @@ def test_delete_configuration_not_found(self): def test_delete_configuration_not_enabled(self): response = self.client.delete(self.url) assert response.status_code == 404 + + +class ConfigurationProxyAPITestCase(APITestCase): + endpoint = "sentry-api-0-project-remote-configuration" + + def setUp(self): + super().setUp() + self.url = reverse(self.endpoint, args=(self.project.id,)) + + @property + def storage(self): + return StorageBackend(self.project) + + def test_remote_config_proxy(self): + """Assert configurations are returned successfully.""" + self.storage.set( + { + "features": [{"key": "abc", "value": "def"}], + "options": {"sample_rate": 0.5, "traces_sample_rate": 0}, + }, + ) + + keys = generate_key_pair() + relay = Relay.objects.create( + relay_id=str(uuid4()), public_key=str(keys[1]), is_internal=True + ) + + with self.feature(REMOTE_CONFIG_FEATURES): + response = self.client.get( + self.url, content_type="application/json", HTTP_X_SENTRY_RELAY_ID=relay.relay_id + ) + assert response.status_code == 200 + assert response["ETag"] is not None + assert response["Cache-Control"] == "public, max-age=3600" + assert response["Content-Type"] == "application/json" + + def test_remote_config_proxy_not_found(self): + """Assert missing configurations 404.""" + self.storage.pop() + + keys = generate_key_pair() + relay = Relay.objects.create( + relay_id=str(uuid4()), public_key=str(keys[1]), is_internal=True + ) + + with self.feature(REMOTE_CONFIG_FEATURES): + response = self.client.get( + self.url, content_type="application/json", HTTP_X_SENTRY_RELAY_ID=relay.relay_id + ) + assert response.status_code == 404 + + def test_remote_config_proxy_feature_disabled(self): + """Assert access is gated by feature flag.""" + self.storage.pop() + + keys = generate_key_pair() + relay = Relay.objects.create( + relay_id=str(uuid4()), public_key=str(keys[1]), is_internal=True + ) + + response = self.client.get( + self.url, content_type="application/json", HTTP_X_SENTRY_RELAY_ID=relay.relay_id + ) + assert response.status_code == 404