From 2d76232158835258af5b24265fac109f543ef458 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:43:58 -0800 Subject: [PATCH] fix(members): Don't use a pending invite's roles for a caller with no user get_allowed_org_roles looked up the caller's member row by user_id=request.user.id. For an API key that's None, which matched pending invites: with one, the key could invite with that invitee's role limits; with several, the lookup raised MultipleObjectsReturned. --- .../endpoints/organization_member_utils.py | 3 +- tests/sentry/auth/test_principal.py | 4 +-- .../test_organization_member_index.py | 31 +++++++++++++++++++ tools/mypy_helpers/request_user_allowlist.txt | 1 - 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/sentry/core/endpoints/organization_member_utils.py b/src/sentry/core/endpoints/organization_member_utils.py index 4240a331a002..35810d6b7722 100644 --- a/src/sentry/core/endpoints/organization_member_utils.py +++ b/src/sentry/core/endpoints/organization_member_utils.py @@ -10,6 +10,7 @@ from sentry import roles from sentry.api.bases.organization import OrganizationPermission from sentry.auth.access import Access +from sentry.auth.principal import is_owned_by_acting_user from sentry.auth.superuser import is_active_superuser, superuser_has_permission from sentry.locks import locks from sentry.models.organization import Organization @@ -213,7 +214,7 @@ def get_allowed_org_roles( if member is None: try: member = OrganizationMember.objects.get( - user_id=request.user.id, organization=organization + is_owned_by_acting_user(request.user), organization=organization ) except OrganizationMember.DoesNotExist: # This can happen if the request was authorized by an app integration diff --git a/tests/sentry/auth/test_principal.py b/tests/sentry/auth/test_principal.py index e8d9d7052904..8ab38167d823 100644 --- a/tests/sentry/auth/test_principal.py +++ b/tests/sentry/auth/test_principal.py @@ -275,9 +275,9 @@ def _is_request_user_type_check(node: ast.AST) -> bool: # Lower these as callers move off. Exact, so the baseline can't drift back up. -REQUEST_USER_ID_BASELINE = 135 +REQUEST_USER_ID_BASELINE = 134 REQUEST_USER_TYPE_CHECK_BASELINE = 29 -REQUEST_USER_ALLOWLIST_BASELINE = 23 +REQUEST_USER_ALLOWLIST_BASELINE = 22 def test_request_user_id_uses_only_go_down() -> None: diff --git a/tests/sentry/core/endpoints/test_organization_member_index.py b/tests/sentry/core/endpoints/test_organization_member_index.py index 56a47b4eb11c..e05d329a87e6 100644 --- a/tests/sentry/core/endpoints/test_organization_member_index.py +++ b/tests/sentry/core/endpoints/test_organization_member_index.py @@ -3,11 +3,14 @@ from unittest.mock import MagicMock, patch from django.core import mail +from django.urls import reverse +from rest_framework.response import Response from sentry import roles from sentry.api.endpoints.accept_organization_invite import get_invite_state from sentry.api.invite_helper import ApiInviteHelper from sentry.core.endpoints.organization_member_index import OrganizationMemberRequestSerializer +from sentry.models.apikey import ApiKey from sentry.models.organizationmember import InviteStatus, OrganizationMember from sentry.models.organizationmemberteam import OrganizationMemberTeam from sentry.roles import organization_roles @@ -975,6 +978,34 @@ def test_internal_integration_token_can_only_invite_member_role( mock_send_invite_email.assert_called_once() + def _api_key_invite(self, role: str) -> Response: + with assume_test_silo_mode(SiloMode.CONTROL): + key = ApiKey.objects.create( + organization_id=self.organization.id, scope_list=["member:admin"] + ) + return self.client.post( + reverse(self.endpoint, args=[self.organization.slug]), + {"email": "jane@gmail.com", "role": role}, + HTTP_AUTHORIZATION=self.create_basic_auth_header(key.key), + ) + + def test_api_key_does_not_get_a_pending_invites_roles(self) -> None: + self.create_member(organization=self.organization, email="owner@example.com", role="owner") + + response = self._api_key_invite("owner") + + assert response.status_code == 400, response.content + assert not OrganizationMember.objects.filter(email="jane@gmail.com").exists() + + def test_api_key_with_several_pending_invites(self) -> None: + self.create_member(organization=self.organization, email="a@example.com") + self.create_member(organization=self.organization, email="b@example.com") + + response = self._api_key_invite("member") + + assert response.status_code == 400, response.content + assert not OrganizationMember.objects.filter(email="jane@gmail.com").exists() + @patch("sentry.ratelimits.for_organization_member_invite") def test_rate_limited(self, mock_rate_limit: MagicMock) -> None: mock_rate_limit.return_value = True diff --git a/tools/mypy_helpers/request_user_allowlist.txt b/tools/mypy_helpers/request_user_allowlist.txt index 84c20ca6e6d2..11fc42541604 100644 --- a/tools/mypy_helpers/request_user_allowlist.txt +++ b/tools/mypy_helpers/request_user_allowlist.txt @@ -9,7 +9,6 @@ src/sentry/api/helpers/group_index/index.py src/sentry/core/endpoints/organization_member_details.py src/sentry/core/endpoints/organization_member_index.py src/sentry/core/endpoints/organization_member_requests_invite_index.py -src/sentry/core/endpoints/organization_member_utils.py src/sentry/dashboards/endpoints/organization_dashboards.py src/sentry/data_export/endpoints/data_export.py src/sentry/issues/endpoints/group_event_details.py