From b0b2e63787bf45ceb8d4299d402fa848df7d382e Mon Sep 17 00:00:00 2001 From: lcian <17258265+lcian@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:59:05 +0200 Subject: [PATCH 1/2] ref(objectstore): Make ObjectstoreEndpoint unauthenticated and remove feature gate Drops the `organizations:objectstore-endpoint` feature flag, renames `OrganizationObjectstoreEndpoint` to `ObjectstoreEndpoint`, and switches the base class from `OrganizationEndpoint` to `Endpoint` with no DRF auth or permission classes. Authentication is performed by Objectstore via the `Authorization` or `X-Os-Auth` header. The `organization_id_or_slug` URL kwarg remains for API Gateway cell routing only. --- src/sentry/api/urls.py | 4 +- src/sentry/features/temporary.py | 2 - .../objectstore/endpoints/organization.py | 45 +++++++------------ .../endpoints/test_organization.py | 11 +---- 4 files changed, 20 insertions(+), 42 deletions(-) diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index c801d9304bdda0..780805e9ae0a99 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -467,7 +467,7 @@ UserNotificationSettingsProvidersEndpoint, ) from sentry.notifications.platform.api.endpoints import urls as notification_platform_urls -from sentry.objectstore.endpoints.organization import OrganizationObjectstoreEndpoint +from sentry.objectstore.endpoints.organization import ObjectstoreEndpoint from sentry.preprod.api.endpoints import urls as preprod_urls from sentry.releases.endpoints.organization_release_assemble import ( OrganizationReleaseAssembleEndpoint, @@ -2775,7 +2775,7 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: ), re_path( r"^(?P[^/]+)/objectstore/(?P.*)$", - OrganizationObjectstoreEndpoint.as_view(), + ObjectstoreEndpoint.as_view(), name="sentry-api-0-organization-objectstore", ), *preprod_urls.preprod_organization_urlpatterns, diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 367fa06cba6f6f..80fb703be2322b 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -195,8 +195,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:on-demand-metrics-query-spec-version-two", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Use the new OrganizationMemberInvite endpoints manager.add("organizations:new-organization-member-invite", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Use the OrganizationObjectstoreEndpoint - manager.add("organizations:objectstore-endpoint", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Display on demand metrics related UI elements manager.add("organizations:on-demand-metrics-ui", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Display on demand metrics related UI elements, for dashboards and widgets. The other flag is for alerts. diff --git a/src/sentry/objectstore/endpoints/organization.py b/src/sentry/objectstore/endpoints/organization.py index e4a70691e51d49..bda60bb10f2f0a 100644 --- a/src/sentry/objectstore/endpoints/organization.py +++ b/src/sentry/objectstore/endpoints/organization.py @@ -21,19 +21,23 @@ except ImportError: pass -from sentry import features, options +from sentry import options from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus -from sentry.api.base import cell_silo_endpoint -from sentry.api.bases import OrganizationEndpoint -from sentry.api.bases.organization import OrganizationReleasePermission -from sentry.models.organization import Organization +from sentry.api.base import Endpoint, cell_silo_endpoint from sentry.objectstore import parse_accept_encoding from sentry.ratelimits.config import RateLimitConfig @cell_silo_endpoint -class OrganizationObjectstoreEndpoint(OrganizationEndpoint): +class ObjectstoreEndpoint(Endpoint): + """ + Transparent proxy to Objectstore. + + This endpoint is unauthenticated at the Django level, as authentication is performed by Objectstore via the `Authorization` or the `X-Os-Auth` header. + The `organization_id_or_slug` parameter in the view path and URL kwarg is needed by the API Gateway for cell routing, even though this endpoint does not validate it. + """ + publish_status = { "GET": ApiPublishStatus.EXPERIMENTAL, "PUT": ApiPublishStatus.EXPERIMENTAL, @@ -41,46 +45,29 @@ class OrganizationObjectstoreEndpoint(OrganizationEndpoint): "DELETE": ApiPublishStatus.EXPERIMENTAL, } owner = ApiOwner.FOUNDATIONAL_STORAGE - permission_classes = (OrganizationReleasePermission,) + authentication_classes = () + permission_classes = () rate_limits = RateLimitConfig(group="CLI") parser_classes = () # don't attempt to parse request data, so we can access the raw body in wsgi.input - def _check_flag(self, request: Request, organization: Organization) -> Response | None: - if not features.has("organizations:objectstore-endpoint", organization, actor=request.user): - return Response( - { - "error": "This endpoint requires the organizations:objectstore-endpoint feature flag." - }, - status=403, - ) - return None - def get( - self, request: Request, organization: Organization, path: str + self, request: Request, organization_id_or_slug: str, path: str ) -> Response | StreamingHttpResponse: - if response := self._check_flag(request, organization): - return response return self._proxy(request, path) def put( - self, request: Request, organization: Organization, path: str + self, request: Request, organization_id_or_slug: str, path: str ) -> Response | StreamingHttpResponse: - if response := self._check_flag(request, organization): - return response return self._proxy(request, path) def post( - self, request: Request, organization: Organization, path: str + self, request: Request, organization_id_or_slug: str, path: str ) -> Response | StreamingHttpResponse: - if response := self._check_flag(request, organization): - return response return self._proxy(request, path) def delete( - self, request: Request, organization: Organization, path: str + self, request: Request, organization_id_or_slug: str, path: str ) -> Response | StreamingHttpResponse: - if response := self._check_flag(request, organization): - return response return self._proxy(request, path) def _proxy( diff --git a/tests/sentry/objectstore/endpoints/test_organization.py b/tests/sentry/objectstore/endpoints/test_organization.py index 7a586c095bd646..485687db201019 100644 --- a/tests/sentry/objectstore/endpoints/test_organization.py +++ b/tests/sentry/objectstore/endpoints/test_organization.py @@ -17,7 +17,6 @@ from sentry.testutils.asserts import assert_status_code from sentry.testutils.cases import TransactionTestCase from sentry.testutils.cell import override_cells -from sentry.testutils.helpers.features import with_feature from sentry.testutils.silo import cell_silo_test, create_test_cells from sentry.testutils.skips import requires_objectstore from sentry.types.cell import Cell @@ -34,7 +33,7 @@ def local_live_server(request: pytest.FixtureRequest, live_server: LiveServer) - @cell_silo_test @requires_objectstore @pytest.mark.usefixtures("local_live_server") -class OrganizationObjectstoreEndpointTest(TransactionTestCase): +class ObjectstoreEndpointTest(TransactionTestCase): endpoint = "sentry-api-0-organization-objectstore" live_server: LiveServer @@ -68,13 +67,11 @@ def get_session(self) -> Session: session = client.session(Usecase("test"), org=self.organization.id) return session - @with_feature("organizations:objectstore-endpoint") def test_health(self) -> None: url = self.get_endpoint_url() + "health" res = requests.get(url, headers=self.get_auth_headers()) res.raise_for_status() - @with_feature("organizations:objectstore-endpoint") def test_full_cycle(self) -> None: session = self.get_session() @@ -95,7 +92,6 @@ def test_full_cycle(self) -> None: with pytest.raises(RequestError): session.get(object_key) - @with_feature("organizations:objectstore-endpoint") def test_uncompressed(self) -> None: session = self.get_session() @@ -105,7 +101,6 @@ def test_uncompressed(self) -> None: retrieved = session.get(object_key) assert retrieved.payload.read() == b"test data" - @with_feature("organizations:objectstore-endpoint") def test_accept_encoding_passthrough(self) -> None: data = os.urandom(10 * 1024) ctx = zstandard.ZstdCompressor() @@ -152,7 +147,6 @@ def test_accept_encoding_passthrough(self) -> None: with dctx.stream_reader(raw_body) as reader: assert reader.read() == data - @with_feature("organizations:objectstore-endpoint") def test_large_payload(self) -> None: session = self.get_session() data = b"A" * 1_000_000 @@ -169,9 +163,8 @@ def test_large_payload(self) -> None: @cell_silo_test(cells=(test_region,)) @requires_objectstore -@with_feature("organizations:objectstore-endpoint") @pytest.mark.usefixtures("local_live_server") -class OrganizationObjectstoreEndpointWithControlSiloTest(TransactionTestCase): +class ObjectstoreEndpointWithControlSiloTest(TransactionTestCase): endpoint = "sentry-api-0-organization-objectstore" live_server: LiveServer From 3cf1ca2ff316707bff9592d1acf480a504f8011f Mon Sep 17 00:00:00 2001 From: lcian <17258265+lcian@users.noreply.github.com> Date: Mon, 4 May 2026 14:05:54 +0200 Subject: [PATCH 2/2] ref(objectstore): Add concurrency params to ObjectstoreUploadOptions Add maxIndividualConcurrency and maxBatchConcurrency to the upload options response so clients can respect server-side concurrency limits. --- src/sentry/objectstore/types.py | 2 ++ .../preprod/api/endpoints/project_preprod_upload_options.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/sentry/objectstore/types.py b/src/sentry/objectstore/types.py index 478c6d8f3a12c3..df3c0b75ce478c 100644 --- a/src/sentry/objectstore/types.py +++ b/src/sentry/objectstore/types.py @@ -8,3 +8,5 @@ class ObjectstoreUploadOptions(TypedDict): scopes: list[tuple[str, str]] authToken: str | None expirationPolicy: str + maxIndividualConcurrency: int + maxBatchConcurrency: int diff --git a/src/sentry/preprod/api/endpoints/project_preprod_upload_options.py b/src/sentry/preprod/api/endpoints/project_preprod_upload_options.py index 930cbb9ae417ec..52c6f7f0131166 100644 --- a/src/sentry/preprod/api/endpoints/project_preprod_upload_options.py +++ b/src/sentry/preprod/api/endpoints/project_preprod_upload_options.py @@ -59,6 +59,8 @@ def get(self, request: Request, project: Project) -> Response: expirationPolicy=format_expiration( TimeToLive(timedelta(days=30)) ), # Hardcoded for now, check with Objectstore before increasing + maxIndividualConcurrency=16, + maxBatchConcurrency=16, ) return Response({"objectstore": options})