Browse Source

feat(hybridcloud) Add an API for getting a user's regions (#60036)

In order to make the docs support endpoints work with multi-region we
need a way to get the regions a user has membership in. Once a client
knows which regions a user has membership in, we can fetch docs support
data from each of these regions.

I've also ported the `ALLOWED_CREDENTIAL_ORIGINS` setting from getsentry
with the intent of removing the 'other' cors decorator from getsentry.

Refs HC-984
Mark Story 1 year ago
parent
commit
fe3563c984

+ 4 - 1
src/sentry/api/base.py

@@ -148,7 +148,10 @@ def apply_cors_headers(
     # to be sent.
     # to be sent.
     basehost = options.get("system.base-hostname")
     basehost = options.get("system.base-hostname")
     if basehost and origin:
     if basehost and origin:
-        if origin.endswith(("://" + basehost, "." + basehost)):
+        if (
+            origin.endswith(("://" + basehost, "." + basehost))
+            or origin in settings.ALLOWED_CREDENTIAL_ORIGINS
+        ):
             response["Access-Control-Allow-Credentials"] = "true"
             response["Access-Control-Allow-Credentials"] = "true"
 
 
     return response
     return response

+ 41 - 0
src/sentry/api/endpoints/user_regions.py

@@ -0,0 +1,41 @@
+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 control_silo_endpoint
+from sentry.api.bases.user import UserEndpoint
+from sentry.models.organizationmapping import OrganizationMapping
+from sentry.models.organizationmembermapping import OrganizationMemberMapping
+from sentry.types.region import get_region_by_name
+
+
+@control_silo_endpoint
+class UserRegionsEndpoint(UserEndpoint):
+    owner = ApiOwner.HYBRID_CLOUD
+    publish_status = {
+        "GET": ApiPublishStatus.PRIVATE,
+    }
+
+    def get(self, request: Request, **kwargs) -> Response:
+        """
+        Retrieve the Regions a User has membership in
+        `````````````````````````````````````````````
+
+        Returns a list of regions that the current user has membership in.
+
+        :auth: required
+        """
+        organization_ids = OrganizationMemberMapping.objects.filter(
+            user_id=request.user.id
+        ).values_list("organization_id", flat=True)
+        org_mappings = (
+            OrganizationMapping.objects.filter(organization_id__in=organization_ids)
+            .distinct("region_name")
+            .values_list("region_name", flat=True)
+        )
+        regions = [get_region_by_name(region_name).api_serialize() for region_name in org_mappings]
+        payload = {
+            "regions": regions,
+        }
+        return Response(payload)

+ 6 - 0
src/sentry/api/urls.py

@@ -596,6 +596,7 @@ from .endpoints.user_password import UserPasswordEndpoint
 from .endpoints.user_permission_details import UserPermissionDetailsEndpoint
 from .endpoints.user_permission_details import UserPermissionDetailsEndpoint
 from .endpoints.user_permissions import UserPermissionsEndpoint
 from .endpoints.user_permissions import UserPermissionsEndpoint
 from .endpoints.user_permissions_config import UserPermissionsConfigEndpoint
 from .endpoints.user_permissions_config import UserPermissionsConfigEndpoint
+from .endpoints.user_regions import UserRegionsEndpoint
 from .endpoints.user_role_details import UserUserRoleDetailsEndpoint
 from .endpoints.user_role_details import UserUserRoleDetailsEndpoint
 from .endpoints.user_roles import UserUserRolesEndpoint
 from .endpoints.user_roles import UserUserRolesEndpoint
 from .endpoints.user_social_identities_index import UserSocialIdentitiesIndexEndpoint
 from .endpoints.user_social_identities_index import UserSocialIdentitiesIndexEndpoint
@@ -808,6 +809,11 @@ USER_URLS = [
         UserDetailsEndpoint.as_view(),
         UserDetailsEndpoint.as_view(),
         name="sentry-api-0-user-details",
         name="sentry-api-0-user-details",
     ),
     ),
+    re_path(
+        r"^(?P<user_id>[^\/]+)/regions/$",
+        UserRegionsEndpoint.as_view(),
+        name="sentry-api-0-user-regions",
+    ),
     re_path(
     re_path(
         r"^(?P<user_id>[^\/]+)/avatar/$",
         r"^(?P<user_id>[^\/]+)/avatar/$",
         UserAvatarEndpoint.as_view(),
         UserAvatarEndpoint.as_view(),

+ 4 - 0
src/sentry/conf/server.py

@@ -2060,6 +2060,10 @@ SENTRY_ENABLE_INVITES = True
 # Origins allowed for session-based API access (via the Access-Control-Allow-Origin header)
 # Origins allowed for session-based API access (via the Access-Control-Allow-Origin header)
 SENTRY_ALLOW_ORIGIN: str | None = None
 SENTRY_ALLOW_ORIGIN: str | None = None
 
 
+# Origins that are allowed to use credentials. This list is in addition
+# to all subdomains of system.url-prefix
+ALLOWED_CREDENTIAL_ORIGINS: list[str] = []
+
 # Buffer backend
 # Buffer backend
 SENTRY_BUFFER = "sentry.buffer.Buffer"
 SENTRY_BUFFER = "sentry.buffer.Buffer"
 SENTRY_BUFFER_OPTIONS: dict[str, str] = {}
 SENTRY_BUFFER_OPTIONS: dict[str, str] = {}

+ 2 - 0
static/app/data/controlsiloUrlPatterns.ts

@@ -1,6 +1,7 @@
 // This is generated code.
 // This is generated code.
 // To update it run `getsentry django generate_controlsilo_urls --format=js --output=/path/to/thisfile.ts`
 // To update it run `getsentry django generate_controlsilo_urls --format=js --output=/path/to/thisfile.ts`
 const patterns: RegExp[] = [
 const patterns: RegExp[] = [
+  new RegExp('^remote/heroku/resources(?:/[^/]+)?$'),
   new RegExp('^remote/github/marketplace/purchase/$'),
   new RegExp('^remote/github/marketplace/purchase/$'),
   new RegExp('^docs/api/user/$'),
   new RegExp('^docs/api/user/$'),
   new RegExp('^_experiment/log_exposure/$'),
   new RegExp('^_experiment/log_exposure/$'),
@@ -48,6 +49,7 @@ const patterns: RegExp[] = [
   new RegExp('^api/0/organizations/[^/]+/broadcasts/$'),
   new RegExp('^api/0/organizations/[^/]+/broadcasts/$'),
   new RegExp('^api/0/users/$'),
   new RegExp('^api/0/users/$'),
   new RegExp('^api/0/users/[^/]+/$'),
   new RegExp('^api/0/users/[^/]+/$'),
+  new RegExp('^api/0/users/[^/]+/regions/$'),
   new RegExp('^api/0/users/[^/]+/avatar/$'),
   new RegExp('^api/0/users/[^/]+/avatar/$'),
   new RegExp('^api/0/users/[^/]+/authenticators/$'),
   new RegExp('^api/0/users/[^/]+/authenticators/$'),
   new RegExp('^api/0/users/[^/]+/authenticators/[^/]+/enroll/$'),
   new RegExp('^api/0/users/[^/]+/authenticators/[^/]+/enroll/$'),

+ 53 - 0
tests/sentry/api/endpoints/test_user_regions.py

@@ -0,0 +1,53 @@
+from sentry.testutils.cases import APITestCase
+from sentry.testutils.region import override_regions
+from sentry.testutils.silo import control_silo_test
+from sentry.types.region import Region, RegionCategory
+
+us = Region("us", 1, "https://us.testserver", RegionCategory.MULTI_TENANT)
+de = Region("de", 2, "https://de.testserver", RegionCategory.MULTI_TENANT)
+st = Region("acme", 3, "https://acme.testserver", RegionCategory.SINGLE_TENANT)
+region_config = (us, de, st)
+
+
+@control_silo_test(stable=True)
+class UserUserRolesTest(APITestCase):
+    endpoint = "sentry-api-0-user-regions"
+
+    def setUp(self):
+        super().setUp()
+        self.user = self.create_user()
+        self.login_as(user=self.user)
+
+    @override_regions(region_config)
+    def test_get(self):
+        self.create_organization(region="us", owner=self.user)
+        self.create_organization(region="de", owner=self.user)
+        self.create_organization(region="acme", owner=self.user)
+
+        response = self.get_response("me")
+        assert response.status_code == 200
+        assert "regions" in response.data
+        assert len(response.data["regions"]) == 3
+        assert us.api_serialize() in response.data["regions"]
+        assert de.api_serialize() in response.data["regions"]
+        assert st.api_serialize() in response.data["regions"]
+
+    @override_regions(region_config)
+    def test_get_only_memberships(self):
+        other = self.create_user()
+        self.create_organization(region="acme", owner=other)
+        self.create_organization(region="de", owner=self.user)
+
+        response = self.get_response("me")
+        assert response.status_code == 200
+        assert "regions" in response.data
+        assert len(response.data["regions"]) == 1
+        assert response.data["regions"][0] == de.api_serialize()
+
+    @override_regions(region_config)
+    def test_get_other_user_error(self):
+        other = self.create_user()
+        self.create_organization(region="acme", owner=other)
+
+        response = self.get_response(other.id)
+        assert response.status_code == 403

+ 29 - 0
tests/sentry/api/test_base.py

@@ -193,6 +193,35 @@ class EndpointTest(APITestCase):
         assert response["Access-Control-Allow-Methods"] == "GET, HEAD, OPTIONS"
         assert response["Access-Control-Allow-Methods"] == "GET, HEAD, OPTIONS"
         assert response["Access-Control-Allow-Credentials"] == "true"
         assert response["Access-Control-Allow-Credentials"] == "true"
 
 
+    @override_options({"system.base-hostname": "example.com"})
+    @override_settings(ALLOWED_CREDENTIAL_ORIGINS=["http://docs.example.org"])
+    def test_allow_credentials_allowed_domain(self):
+        org = self.create_organization()
+        with assume_test_silo_mode(SiloMode.CONTROL):
+            apikey = ApiKey.objects.create(organization_id=org.id, allowed_origins="*")
+
+        request = self.make_request(method="GET")
+        # Origin is an allowed domain
+        request.META["HTTP_ORIGIN"] = "http://docs.example.org"
+        request.META["HTTP_AUTHORIZATION"] = self.create_basic_auth_header(apikey.key)
+
+        response = _dummy_endpoint(request)
+        response.render()
+
+        assert response.status_code == 200, response.content
+        assert response["Access-Control-Allow-Origin"] == "http://docs.example.org"
+        assert response["Access-Control-Allow-Headers"] == (
+            "X-Sentry-Auth, X-Requested-With, Origin, Accept, "
+            "Content-Type, Authentication, Authorization, Content-Encoding, "
+            "sentry-trace, baggage, X-CSRFToken"
+        )
+        assert response["Access-Control-Expose-Headers"] == (
+            "X-Sentry-Error, X-Sentry-Direct-Hit, X-Hits, X-Max-Hits, "
+            "Endpoint, Retry-After, Link"
+        )
+        assert response["Access-Control-Allow-Methods"] == "GET, HEAD, OPTIONS"
+        assert response["Access-Control-Allow-Credentials"] == "true"
+
     @override_options({"system.base-hostname": "acme.com"})
     @override_options({"system.base-hostname": "acme.com"})
     def test_allow_credentials_incorrect(self):
     def test_allow_credentials_incorrect(self):
         org = self.create_organization()
         org = self.create_organization()