Skip to content

Commit 8295fe9

Browse files
authored
feat(cells): Add GET path for /organization list on control silo (#112622)
- org listing needs to move to the control silo as it returns data relating to orgs that can be spread across multiple cells - this change introduces the new path, while keeping the existing cell-based org listing in place - the new implementation uses the control silo variants `OrganizationMapping` and `OrganizationMemberMapping` in place of Organization and `OrganizationMember` - `owner=1` is not supported currently. This is used for account closing and can be moved to a separate (control-based) api - platform filtering exists on the cell but is not ported to control - this appears to be older, unused functionality and is not worth porting since this information is not be available in control - added a TODO for avatar functionality: this should also be available in the control endpoint, but we need to sync avatars to control as a prerequisite. This will be tackled separately. - member sorting functionality (used in _admin) had to be implemented a bit differently since there are no FK relations but works the same
1 parent fe3ba93 commit 8295fe9

File tree

3 files changed

+212
-3
lines changed

3 files changed

+212
-3
lines changed

src/sentry/api/serializers/models/organization.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
from sentry.models.options.project_option import ProjectOption
7979
from sentry.models.organization import Organization, OrganizationStatus
8080
from sentry.models.organizationaccessrequest import OrganizationAccessRequest
81+
from sentry.models.organizationmapping import OrganizationMapping
8182
from sentry.models.organizationonboardingtask import OrganizationOnboardingTask
8283
from sentry.models.project import Project
8384
from sentry.models.team import Team, TeamStatus
@@ -285,6 +286,23 @@ def serialize(
285286
)
286287

287288

289+
class ControlSiloOrganizationMappingSerializer(Serializer):
290+
# TODO(cells): Add the `avatar` to this serializer
291+
# once it is available in the control silo
292+
def serialize(
293+
self,
294+
obj: OrganizationMapping,
295+
attrs: Mapping[str, Any],
296+
user: User | RpcUser | AnonymousUser,
297+
**kwargs: Any,
298+
) -> ControlSiloOrganizationSerializerResponse:
299+
return dict(
300+
id=str(obj.organization_id),
301+
slug=obj.slug,
302+
name=obj.name,
303+
)
304+
305+
288306
@register(Organization)
289307
class OrganizationSummarySerializer(Serializer):
290308
def get_attrs(

src/sentry/core/endpoints/organization_index.py

Lines changed: 139 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import sentry_sdk
44
from django.conf import settings
55
from django.db import IntegrityError
6-
from django.db.models import Count, Q
6+
from django.db.models import Count, OuterRef, Q, Subquery
7+
from django.db.models.functions import Coalesce
78
from drf_spectacular.utils import extend_schema
89
from rest_framework import serializers, status
910
from rest_framework.request import Request
@@ -16,12 +17,13 @@
1617
)
1718
from sentry.analytics.events.organization_created import OrganizationCreatedEvent
1819
from sentry.api.api_publish_status import ApiPublishStatus
19-
from sentry.api.base import Endpoint, cell_silo_endpoint
20+
from sentry.api.base import Endpoint, all_silo_endpoint
2021
from sentry.api.bases.organization import OrganizationPermission
2122
from sentry.api.paginator import DateTimePaginator, OffsetPaginator
2223
from sentry.api.serializers import serialize
2324
from sentry.api.serializers.models.organization import (
2425
BaseOrganizationSerializer,
26+
ControlSiloOrganizationMappingSerializer,
2527
OrganizationSummarySerializerResponse,
2628
)
2729
from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED
@@ -33,7 +35,9 @@
3335
from sentry.demo_mode.utils import is_demo_user
3436
from sentry.hybridcloud.rpc import IDEMPOTENCY_KEY_LENGTH
3537
from sentry.models.organization import Organization, OrganizationStatus
38+
from sentry.models.organizationmapping import OrganizationMapping
3639
from sentry.models.organizationmember import OrganizationMember
40+
from sentry.models.organizationmembermapping import OrganizationMemberMapping
3741
from sentry.models.projectplatform import ProjectPlatform
3842
from sentry.search.utils import tokenize_query
3943
from sentry.services.organization import (
@@ -43,6 +47,7 @@
4347
)
4448
from sentry.services.organization.provisioning import organization_provisioning_service
4549
from sentry.signals import org_setup_complete, terms_accepted
50+
from sentry.silo.base import SiloMode
4651
from sentry.users.services.user.service import user_service
4752
from sentry.utils.pagination_factory import PaginatorLike
4853

@@ -69,7 +74,7 @@ def validate_agreeTerms(self, value):
6974

7075

7176
@extend_schema(tags=["Users"])
72-
@cell_silo_endpoint
77+
@all_silo_endpoint
7378
class OrganizationIndexEndpoint(Endpoint):
7479
publish_status = {
7580
"GET": ApiPublishStatus.PUBLIC,
@@ -101,6 +106,12 @@ def get(self, request: Request) -> Response:
101106
Return a list of organizations available to the authenticated session in a region.
102107
This is particularly useful for requests with a user bound context. For API key-based requests this will only return the organization that belongs to the key.
103108
"""
109+
if SiloMode.get_current_mode() == SiloMode.CONTROL:
110+
return self._get_from_control(request)
111+
112+
return self._get_from_cell(request)
113+
114+
def _get_from_cell(self, request: Request) -> Response:
104115
owner_only = request.GET.get("owner") in ("1", "true")
105116

106117
queryset = Organization.objects.distinct()
@@ -163,6 +174,10 @@ def get(self, request: Request) -> Response:
163174
}
164175
queryset = queryset.filter(Q(member_set__user_id__in=user_ids))
165176
elif key == "platform":
177+
# Note: platform filtering is kept here but is not present in the control version
178+
# of this endpoint, since the data is not in control and our UI isn't
179+
# passing this anymore.
180+
sentry_sdk.capture_message("organization_index.platform_filter_used")
166181
queryset = queryset.filter(
167182
project__in=ProjectPlatform.objects.filter(platform__in=value).values(
168183
"project_id"
@@ -193,6 +208,7 @@ def get(self, request: Request) -> Response:
193208
order_by = "-member_count"
194209
paginator_cls = OffsetPaginator
195210
elif sort_by == "projects":
211+
sentry_sdk.capture_message("organization_index.sort_by_projects_used")
196212
queryset = queryset.annotate(project_count=Count("project"))
197213
order_by = "-project_count"
198214
paginator_cls = OffsetPaginator
@@ -208,6 +224,119 @@ def get(self, request: Request) -> Response:
208224
paginator_cls=paginator_cls,
209225
)
210226

227+
def _get_from_control(self, request: Request) -> Response:
228+
owner_only = request.GET.get("owner") in ("1", "true")
229+
230+
if owner_only:
231+
return Response(
232+
{"detail": "The control-silo organizations endpoint does not support owner=1."},
233+
status=status.HTTP_400_BAD_REQUEST,
234+
)
235+
236+
queryset = OrganizationMapping.objects.distinct()
237+
238+
if request.auth and not request.user.is_authenticated:
239+
if hasattr(request.auth, "project"):
240+
queryset = queryset.filter(organization_id=request.auth.project.organization_id)
241+
elif request.auth.organization_id is not None:
242+
queryset = queryset.filter(organization_id=request.auth.organization_id)
243+
elif not (is_active_superuser(request) and request.GET.get("show") == "all"):
244+
assert request.user.id is not None
245+
queryset = queryset.filter(
246+
organization_id__in=OrganizationMemberMapping.objects.filter(
247+
user_id=request.user.id
248+
).values("organization_id")
249+
)
250+
if request.auth and request.auth.organization_id is not None and queryset.count() > 1:
251+
# If a token is limited to one organization, this endpoint should only return that one organization
252+
queryset = queryset.filter(organization_id=request.auth.organization_id)
253+
254+
query = request.GET.get("query")
255+
if query:
256+
tokens = tokenize_query(query)
257+
for key, value in tokens.items():
258+
if key == "query":
259+
query_value = " ".join(value)
260+
user_ids = {
261+
u.id
262+
for u in user_service.get_many_by_email(
263+
emails=[query_value], is_verified=False
264+
)
265+
}
266+
queryset = queryset.filter(
267+
Q(name__icontains=query_value)
268+
| Q(slug__icontains=query_value)
269+
# Control-side equivalent of `Q(member_set__user_id__in=user_ids)`
270+
| Q(
271+
organization_id__in=OrganizationMemberMapping.objects.filter(
272+
user_id__in=user_ids
273+
).values("organization_id")
274+
)
275+
)
276+
elif key == "slug":
277+
queryset = queryset.filter(in_iexact("slug", value))
278+
elif key == "email":
279+
user_ids = {
280+
u.id
281+
for u in user_service.get_many_by_email(emails=value, is_verified=False)
282+
}
283+
queryset = queryset.filter(
284+
organization_id__in=OrganizationMemberMapping.objects.filter(
285+
user_id__in=user_ids
286+
).values("organization_id")
287+
)
288+
elif key == "id":
289+
queryset = queryset.filter(organization_id__in=value)
290+
elif key == "status":
291+
try:
292+
queryset = queryset.filter(
293+
status__in=[OrganizationStatus[v.upper()] for v in value]
294+
)
295+
except KeyError:
296+
queryset = queryset.none()
297+
elif key == "member_id":
298+
queryset = queryset.filter(
299+
organization_id__in=OrganizationMemberMapping.objects.filter(
300+
organizationmember_id__in=value
301+
).values("organization_id")
302+
)
303+
else:
304+
queryset = queryset.none()
305+
306+
sort_by = request.GET.get("sortBy")
307+
paginator_cls: type[PaginatorLike]
308+
if sort_by == "members":
309+
member_count_subquery = (
310+
OrganizationMemberMapping.objects.filter(
311+
organization_id=OuterRef("organization_id")
312+
)
313+
.values("organization_id")
314+
.annotate(member_count=Count("id"))
315+
.values("member_count")[:1]
316+
)
317+
queryset = queryset.annotate(member_count=Coalesce(Subquery(member_count_subquery), 0))
318+
order_by = "-member_count"
319+
paginator_cls = OffsetPaginator
320+
elif sort_by == "projects":
321+
queryset = queryset.none()
322+
order_by = "-date_created"
323+
paginator_cls = OffsetPaginator
324+
else:
325+
order_by = "-date_created"
326+
paginator_cls = DateTimePaginator
327+
328+
return self.paginate(
329+
request=request,
330+
queryset=queryset,
331+
order_by=order_by,
332+
on_results=lambda x: serialize(
333+
x,
334+
request.user,
335+
serializer=ControlSiloOrganizationMappingSerializer(),
336+
),
337+
paginator_cls=paginator_cls,
338+
)
339+
211340
# XXX: endpoint useless for end-users as it needs user context.
212341
def post(self, request: Request) -> Response:
213342
"""
@@ -225,6 +354,13 @@ def post(self, request: Request) -> Response:
225354
terms of service and privacy policy.
226355
:auth: required, user-context-needed
227356
"""
357+
# TODO(cells): Move org creation to control as part of the broader
358+
# org-listing/org-provisioning cutover. Since POST is private, the
359+
# legacy cell-side path can be removed once the control implementation
360+
# is ready.
361+
if SiloMode.get_current_mode() == SiloMode.CONTROL:
362+
return Response(status=404)
363+
228364
if not request.user.is_authenticated:
229365
return Response({"detail": "This endpoint requires user info"}, status=401)
230366

tests/sentry/core/endpoints/test_organization_index.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,61 @@ def test_show_only_token_organization(self) -> None:
136136
assert response.data[0]["id"] == str(org1.id)
137137

138138

139+
@control_silo_test(cells=create_test_cells("us", "de"))
140+
class OrganizationsControlListTest(OrganizationIndexTest):
141+
endpoint = "sentry-api-0-organizations"
142+
143+
def test_membership_across_cells(self) -> None:
144+
us_org = self.create_organization(cell="us", owner=self.user, name="US Org", slug="us-org")
145+
de_org = self.create_organization(cell="de", owner=self.user, name="DE Org", slug="de-org")
146+
147+
response = self.get_success_response()
148+
149+
assert {item["id"] for item in response.data} == {str(us_org.id), str(de_org.id)}
150+
assert {item["slug"] for item in response.data} == {"us-org", "de-org"}
151+
152+
def test_show_only_token_organization(self) -> None:
153+
org1 = self.create_organization(cell="us", owner=self.user)
154+
self.create_organization(cell="de", owner=self.user)
155+
156+
with assume_test_silo_mode(SiloMode.CONTROL):
157+
org_scoped_token = ApiToken.objects.create(
158+
user=self.user, scoping_organization_id=org1.id, scope_list=["org:read"]
159+
)
160+
161+
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {org_scoped_token.plaintext_token}")
162+
response = self.client.get(reverse(self.endpoint))
163+
164+
assert len(response.data) == 1
165+
assert response.data[0]["id"] == str(org1.id)
166+
167+
def test_owner_not_supported(self) -> None:
168+
self.create_organization(cell="us", owner=self.user)
169+
170+
response = self.get_error_response(status_code=400, owner="1")
171+
172+
assert (
173+
response.data["detail"]
174+
== "The control-silo organizations endpoint does not support owner=1."
175+
)
176+
177+
def test_sort_by_members(self) -> None:
178+
smaller_org = self.create_organization(
179+
cell="us", owner=self.user, name="Smaller Org", slug="smaller-org"
180+
)
181+
larger_org = self.create_organization(
182+
cell="de", owner=self.user, name="Larger Org", slug="larger-org"
183+
)
184+
185+
self.create_member(user=self.create_user(), organization=smaller_org)
186+
self.create_member(user=self.create_user(), organization=larger_org)
187+
self.create_member(user=self.create_user(), organization=larger_org)
188+
189+
response = self.get_success_response(sortBy="members")
190+
191+
assert [item["id"] for item in response.data] == [str(larger_org.id), str(smaller_org.id)]
192+
193+
139194
class OrganizationsCreateTest(OrganizationIndexTest, HybridCloudTestMixin):
140195
method = "post"
141196

0 commit comments

Comments
 (0)