Skip to content

Commit 4eb4e79

Browse files
committed
feat(cells): Add GET path for /organization list on control silo
- 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.
1 parent 2ce924b commit 4eb4e79

File tree

3 files changed

+183
-2
lines changed

3 files changed

+183
-2
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: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@
1616
)
1717
from sentry.analytics.events.organization_created import OrganizationCreatedEvent
1818
from sentry.api.api_publish_status import ApiPublishStatus
19-
from sentry.api.base import Endpoint, cell_silo_endpoint
19+
from sentry.api.base import Endpoint, all_silo_endpoint
2020
from sentry.api.bases.organization import OrganizationPermission
2121
from sentry.api.paginator import DateTimePaginator, OffsetPaginator
2222
from sentry.api.serializers import serialize
2323
from sentry.api.serializers.models.organization import (
2424
BaseOrganizationSerializer,
25+
ControlSiloOrganizationMappingSerializer,
2526
OrganizationSummarySerializerResponse,
2627
)
2728
from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED
@@ -33,7 +34,9 @@
3334
from sentry.demo_mode.utils import is_demo_user
3435
from sentry.hybridcloud.rpc import IDEMPOTENCY_KEY_LENGTH
3536
from sentry.models.organization import Organization, OrganizationStatus
37+
from sentry.models.organizationmapping import OrganizationMapping
3638
from sentry.models.organizationmember import OrganizationMember
39+
from sentry.models.organizationmembermapping import OrganizationMemberMapping
3740
from sentry.models.projectplatform import ProjectPlatform
3841
from sentry.search.utils import tokenize_query
3942
from sentry.services.organization import (
@@ -43,6 +46,7 @@
4346
)
4447
from sentry.services.organization.provisioning import organization_provisioning_service
4548
from sentry.signals import org_setup_complete, terms_accepted
49+
from sentry.silo.base import SiloMode
4650
from sentry.users.services.user.service import user_service
4751
from sentry.utils.pagination_factory import PaginatorLike
4852

@@ -69,7 +73,7 @@ def validate_agreeTerms(self, value):
6973

7074

7175
@extend_schema(tags=["Users"])
72-
@cell_silo_endpoint
76+
@all_silo_endpoint
7377
class OrganizationIndexEndpoint(Endpoint):
7478
publish_status = {
7579
"GET": ApiPublishStatus.PUBLIC,
@@ -101,6 +105,12 @@ def get(self, request: Request) -> Response:
101105
Return a list of organizations available to the authenticated session in a region.
102106
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.
103107
"""
108+
if SiloMode.get_current_mode() == SiloMode.CONTROL:
109+
return self._get_from_control(request)
110+
111+
return self._get_from_cell(request)
112+
113+
def _get_from_cell(self, request: Request) -> Response:
104114
owner_only = request.GET.get("owner") in ("1", "true")
105115

106116
queryset = Organization.objects.distinct()
@@ -163,6 +173,9 @@ def get(self, request: Request) -> Response:
163173
}
164174
queryset = queryset.filter(Q(member_set__user_id__in=user_ids))
165175
elif key == "platform":
176+
# Note: platform filtering is kept here but is not present in the control version
177+
# of this endpoint, since the data is not in control and our UI isn't
178+
# passing this anymore.
166179
queryset = queryset.filter(
167180
project__in=ProjectPlatform.objects.filter(platform__in=value).values(
168181
"project_id"
@@ -208,6 +221,110 @@ def get(self, request: Request) -> Response:
208221
paginator_cls=paginator_cls,
209222
)
210223

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

tests/sentry/core/endpoints/test_organization_index.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,45 @@ 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+
139178
class OrganizationsCreateTest(OrganizationIndexTest, HybridCloudTestMixin):
140179
method = "post"
141180

0 commit comments

Comments
 (0)