Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/sentry/api/serializers/models/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
from sentry.models.options.project_option import ProjectOption
from sentry.models.organization import Organization, OrganizationStatus
from sentry.models.organizationaccessrequest import OrganizationAccessRequest
from sentry.models.organizationmapping import OrganizationMapping
from sentry.models.organizationonboardingtask import OrganizationOnboardingTask
from sentry.models.project import Project
from sentry.models.team import Team, TeamStatus
Expand Down Expand Up @@ -285,6 +286,23 @@ def serialize(
)


class ControlSiloOrganizationMappingSerializer(Serializer):
# TODO(cells): Add the `avatar` to this serializer
# once it is available in the control silo
def serialize(
self,
obj: OrganizationMapping,
attrs: Mapping[str, Any],
user: User | RpcUser | AnonymousUser,
**kwargs: Any,
) -> ControlSiloOrganizationSerializerResponse:
return dict(
id=str(obj.organization_id),
slug=obj.slug,
name=obj.name,
)


@register(Organization)
class OrganizationSummarySerializer(Serializer):
def get_attrs(
Expand Down
142 changes: 139 additions & 3 deletions src/sentry/core/endpoints/organization_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import sentry_sdk
from django.conf import settings
from django.db import IntegrityError
from django.db.models import Count, Q
from django.db.models import Count, OuterRef, Q, Subquery
from django.db.models.functions import Coalesce
from drf_spectacular.utils import extend_schema
from rest_framework import serializers, status
from rest_framework.request import Request
Expand All @@ -16,12 +17,13 @@
)
from sentry.analytics.events.organization_created import OrganizationCreatedEvent
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import Endpoint, cell_silo_endpoint
from sentry.api.base import Endpoint, all_silo_endpoint
from sentry.api.bases.organization import OrganizationPermission
from sentry.api.paginator import DateTimePaginator, OffsetPaginator
from sentry.api.serializers import serialize
from sentry.api.serializers.models.organization import (
BaseOrganizationSerializer,
ControlSiloOrganizationMappingSerializer,
OrganizationSummarySerializerResponse,
)
from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED
Expand All @@ -33,7 +35,9 @@
from sentry.demo_mode.utils import is_demo_user
from sentry.hybridcloud.rpc import IDEMPOTENCY_KEY_LENGTH
from sentry.models.organization import Organization, OrganizationStatus
from sentry.models.organizationmapping import OrganizationMapping
from sentry.models.organizationmember import OrganizationMember
from sentry.models.organizationmembermapping import OrganizationMemberMapping
from sentry.models.projectplatform import ProjectPlatform
from sentry.search.utils import tokenize_query
from sentry.services.organization import (
Expand All @@ -43,6 +47,7 @@
)
from sentry.services.organization.provisioning import organization_provisioning_service
from sentry.signals import org_setup_complete, terms_accepted
from sentry.silo.base import SiloMode
from sentry.users.services.user.service import user_service
from sentry.utils.pagination_factory import PaginatorLike

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


@extend_schema(tags=["Users"])
@cell_silo_endpoint
@all_silo_endpoint
class OrganizationIndexEndpoint(Endpoint):
publish_status = {
"GET": ApiPublishStatus.PUBLIC,
Expand Down Expand Up @@ -101,6 +106,12 @@ def get(self, request: Request) -> Response:
Return a list of organizations available to the authenticated session in a region.
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.
"""
if SiloMode.get_current_mode() == SiloMode.CONTROL:
return self._get_from_control(request)

return self._get_from_cell(request)

def _get_from_cell(self, request: Request) -> Response:
owner_only = request.GET.get("owner") in ("1", "true")

queryset = Organization.objects.distinct()
Expand Down Expand Up @@ -163,6 +174,10 @@ def get(self, request: Request) -> Response:
}
queryset = queryset.filter(Q(member_set__user_id__in=user_ids))
elif key == "platform":
# Note: platform filtering is kept here but is not present in the control version
# of this endpoint, since the data is not in control and our UI isn't
# passing this anymore.
Comment on lines +177 to +179
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add a metric/log here to see if customers are actively using this filter? If, this filter gets high usage, we'll need to find a solution for it when we move GET /organizations to control.

Copy link
Copy Markdown
Member Author

@lynnagara lynnagara Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, i'll add one Added logging to sentry

sentry_sdk.capture_message("organization_index.platform_filter_used")
queryset = queryset.filter(
project__in=ProjectPlatform.objects.filter(platform__in=value).values(
"project_id"
Expand Down Expand Up @@ -193,6 +208,7 @@ def get(self, request: Request) -> Response:
order_by = "-member_count"
paginator_cls = OffsetPaginator
elif sort_by == "projects":
sentry_sdk.capture_message("organization_index.sort_by_projects_used")
queryset = queryset.annotate(project_count=Count("project"))
order_by = "-project_count"
paginator_cls = OffsetPaginator
Expand All @@ -208,6 +224,119 @@ def get(self, request: Request) -> Response:
paginator_cls=paginator_cls,
)

def _get_from_control(self, request: Request) -> Response:
owner_only = request.GET.get("owner") in ("1", "true")

if owner_only:
return Response(
{"detail": "The control-silo organizations endpoint does not support owner=1."},
status=status.HTTP_400_BAD_REQUEST,
)

queryset = OrganizationMapping.objects.distinct()

if request.auth and not request.user.is_authenticated:
if hasattr(request.auth, "project"):
queryset = queryset.filter(organization_id=request.auth.project.organization_id)
elif request.auth.organization_id is not None:
queryset = queryset.filter(organization_id=request.auth.organization_id)
elif not (is_active_superuser(request) and request.GET.get("show") == "all"):
assert request.user.id is not None
Comment thread
sentry[bot] marked this conversation as resolved.
queryset = queryset.filter(
organization_id__in=OrganizationMemberMapping.objects.filter(
user_id=request.user.id
).values("organization_id")
)
if request.auth and request.auth.organization_id is not None and queryset.count() > 1:
# If a token is limited to one organization, this endpoint should only return that one organization
queryset = queryset.filter(organization_id=request.auth.organization_id)

query = request.GET.get("query")
if query:
tokens = tokenize_query(query)
for key, value in tokens.items():
if key == "query":
query_value = " ".join(value)
user_ids = {
u.id
for u in user_service.get_many_by_email(
emails=[query_value], is_verified=False
)
}
queryset = queryset.filter(
Q(name__icontains=query_value)
| Q(slug__icontains=query_value)
# Control-side equivalent of `Q(member_set__user_id__in=user_ids)`
| Q(
organization_id__in=OrganizationMemberMapping.objects.filter(
user_id__in=user_ids
).values("organization_id")
)
)
elif key == "slug":
queryset = queryset.filter(in_iexact("slug", value))
elif key == "email":
user_ids = {
u.id
for u in user_service.get_many_by_email(emails=value, is_verified=False)
}
queryset = queryset.filter(
organization_id__in=OrganizationMemberMapping.objects.filter(
user_id__in=user_ids
).values("organization_id")
)
elif key == "id":
queryset = queryset.filter(organization_id__in=value)
elif key == "status":
try:
queryset = queryset.filter(
status__in=[OrganizationStatus[v.upper()] for v in value]
)
except KeyError:
queryset = queryset.none()
elif key == "member_id":
queryset = queryset.filter(
organization_id__in=OrganizationMemberMapping.objects.filter(
organizationmember_id__in=value
).values("organization_id")
)
else:
queryset = queryset.none()

sort_by = request.GET.get("sortBy")
paginator_cls: type[PaginatorLike]
if sort_by == "members":
member_count_subquery = (
OrganizationMemberMapping.objects.filter(
organization_id=OuterRef("organization_id")
)
.values("organization_id")
.annotate(member_count=Count("id"))
.values("member_count")[:1]
)
queryset = queryset.annotate(member_count=Coalesce(Subquery(member_count_subquery), 0))
order_by = "-member_count"
paginator_cls = OffsetPaginator
elif sort_by == "projects":
queryset = queryset.none()
order_by = "-date_created"
paginator_cls = OffsetPaginator
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorting by projects silently returns empty results

Medium Severity

When sortBy=projects is requested on the control silo, queryset.none() silently drops all organizations and returns an empty list. This is a documented public API parameter (see OrganizationParams.SORT_BY), so callers will see zero results with no error indication. This is inconsistent with the owner=1 handling, which properly returns HTTP 400 with a descriptive message. A user or frontend sorting by projects would see no organizations listed at all.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4eb4e79. Configure here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we raise an error here instead of silently returning no records? This is another filter option that we should collect metrics from to gauge whether or not we'll need to find a solution to make it work in the control based implementation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added logging here too

Comment on lines +320 to +323
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: When sorting by projects in the control silo, the queryset is unconditionally cleared with queryset.none(), causing the endpoint to silently return an empty list.
Severity: MEDIUM

Suggested Fix

The logic for handling sorting by projects in the control silo should be implemented correctly instead of calling queryset.none(). This likely involves adding the appropriate annotation and ordering to the queryset, similar to how other sort fields are handled.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/sentry/core/endpoints/organization_index.py#L320-L323

Potential issue: When sorting by `projects` in the control silo, the queryset is
unconditionally cleared by calling `queryset.none()`. This causes the endpoint to
silently return an empty list of organizations instead of a sorted list. Any clients or
admin tools that attempt to sort organizations by their project count will receive no
results, without any error or warning indicating that the operation failed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added logging on the existing cell silo paths. I think this is fine for now since it's unused - will decide the path forward here after collecting logs for a few days.

else:
order_by = "-date_created"
paginator_cls = DateTimePaginator

return self.paginate(
request=request,
queryset=queryset,
order_by=order_by,
on_results=lambda x: serialize(
x,
request.user,
serializer=ControlSiloOrganizationMappingSerializer(),
),
paginator_cls=paginator_cls,
)

# XXX: endpoint useless for end-users as it needs user context.
def post(self, request: Request) -> Response:
"""
Expand All @@ -225,6 +354,13 @@ def post(self, request: Request) -> Response:
terms of service and privacy policy.
:auth: required, user-context-needed
"""
# TODO(cells): Move org creation to control as part of the broader
# org-listing/org-provisioning cutover. Since POST is private, the
# legacy cell-side path can be removed once the control implementation
# is ready.
if SiloMode.get_current_mode() == SiloMode.CONTROL:
return Response(status=404)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can continue from here with provisioning changes.


if not request.user.is_authenticated:
return Response({"detail": "This endpoint requires user info"}, status=401)

Expand Down
55 changes: 55 additions & 0 deletions tests/sentry/core/endpoints/test_organization_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,61 @@ def test_show_only_token_organization(self) -> None:
assert response.data[0]["id"] == str(org1.id)


@control_silo_test(cells=create_test_cells("us", "de"))
class OrganizationsControlListTest(OrganizationIndexTest):
endpoint = "sentry-api-0-organizations"

def test_membership_across_cells(self) -> None:
us_org = self.create_organization(cell="us", owner=self.user, name="US Org", slug="us-org")
de_org = self.create_organization(cell="de", owner=self.user, name="DE Org", slug="de-org")

response = self.get_success_response()

assert {item["id"] for item in response.data} == {str(us_org.id), str(de_org.id)}
assert {item["slug"] for item in response.data} == {"us-org", "de-org"}

def test_show_only_token_organization(self) -> None:
org1 = self.create_organization(cell="us", owner=self.user)
self.create_organization(cell="de", owner=self.user)

with assume_test_silo_mode(SiloMode.CONTROL):
org_scoped_token = ApiToken.objects.create(
user=self.user, scoping_organization_id=org1.id, scope_list=["org:read"]
)

self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {org_scoped_token.plaintext_token}")
response = self.client.get(reverse(self.endpoint))

assert len(response.data) == 1
assert response.data[0]["id"] == str(org1.id)

def test_owner_not_supported(self) -> None:
self.create_organization(cell="us", owner=self.user)

response = self.get_error_response(status_code=400, owner="1")

assert (
response.data["detail"]
== "The control-silo organizations endpoint does not support owner=1."
)

def test_sort_by_members(self) -> None:
smaller_org = self.create_organization(
cell="us", owner=self.user, name="Smaller Org", slug="smaller-org"
)
larger_org = self.create_organization(
cell="de", owner=self.user, name="Larger Org", slug="larger-org"
)

self.create_member(user=self.create_user(), organization=smaller_org)
self.create_member(user=self.create_user(), organization=larger_org)
self.create_member(user=self.create_user(), organization=larger_org)

response = self.get_success_response(sortBy="members")

assert [item["id"] for item in response.data] == [str(larger_org.id), str(smaller_org.id)]


class OrganizationsCreateTest(OrganizationIndexTest, HybridCloudTestMixin):
method = "post"

Expand Down
Loading