From 050744464d3fb29364fed3c5963bb6779ce054f5 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:43:59 -0800 Subject: [PATCH] fix(replays): Don't match pending invites for replay access with no user get_org_member looked the caller up by user_id=request.user.id. For an API key that's None, which matched pending invites: a pending invite on the replay allowlist granted the key access, and several pending invites raised MultipleObjectsReturned. --- src/sentry/replays/permissions.py | 5 ++- tests/sentry/auth/test_principal.py | 2 +- .../test_replay_granular_permissions.py | 37 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/sentry/replays/permissions.py b/src/sentry/replays/permissions.py index 16e690840823..bda844f8bb08 100644 --- a/src/sentry/replays/permissions.py +++ b/src/sentry/replays/permissions.py @@ -4,6 +4,7 @@ from django.http import HttpRequest +from sentry.auth.principal import is_owned_by_acting_user from sentry.auth.superuser import superuser_has_permission from sentry.models.options.organization_option import OrganizationOption from sentry.models.organizationmember import OrganizationMember @@ -85,6 +86,8 @@ def granular_permissions_turned_off(organization: Organization) -> bool: def get_org_member(organization: Organization, request: HttpRequest) -> OrganizationMember | None: """Get the OrganizationMember for the request user, or None if not a member.""" try: - return OrganizationMember.objects.get(organization=organization, user_id=request.user.id) + return OrganizationMember.objects.get( + is_owned_by_acting_user(request.user), organization=organization + ) except OrganizationMember.DoesNotExist: return None diff --git a/tests/sentry/auth/test_principal.py b/tests/sentry/auth/test_principal.py index 8ab38167d823..b36d7147802f 100644 --- a/tests/sentry/auth/test_principal.py +++ b/tests/sentry/auth/test_principal.py @@ -275,7 +275,7 @@ 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 = 134 +REQUEST_USER_ID_BASELINE = 133 REQUEST_USER_TYPE_CHECK_BASELINE = 29 REQUEST_USER_ALLOWLIST_BASELINE = 22 diff --git a/tests/sentry/replays/endpoints/test_replay_granular_permissions.py b/tests/sentry/replays/endpoints/test_replay_granular_permissions.py index 9eebea2da002..cc2c562e93b8 100644 --- a/tests/sentry/replays/endpoints/test_replay_granular_permissions.py +++ b/tests/sentry/replays/endpoints/test_replay_granular_permissions.py @@ -1,3 +1,6 @@ +from rest_framework.response import Response + +from sentry.models.apikey import ApiKey from sentry.models.apitoken import ApiToken from sentry.models.options.organization_option import OrganizationOption from sentry.models.orgauthtoken import OrgAuthToken @@ -445,3 +448,37 @@ def test_sentry_app_with_event_read_empty_allowlist_has_access(self) -> None: ) # SentryApp with event:read bypasses member allowlist assert response.status_code == 200 + + def _api_key_get_replays(self) -> Response: + with assume_test_silo_mode(SiloMode.CONTROL): + key = ApiKey.objects.create( + organization_id=self.organization.id, scope_list=["org:read", "event:read"] + ) + url = f"/api/0/organizations/{self.organization.slug}/replays/" + return self.client.get(url, HTTP_AUTHORIZATION=self.create_basic_auth_header(key.key)) + + def test_api_key_does_not_get_a_pending_invites_access(self) -> None: + with self.feature("organizations:session-replay"): + self._enable_granular_permissions() + OrganizationMemberReplayAccess.objects.create( + organizationmember=self.member_with_access + ) + # An org requiring 2FA turns non-compliant members back into pending invites, + # keeping their allowlist entry. + self.member_with_access.remove_user() + self.member_with_access.save() + + response = self._api_key_get_replays() + assert response.status_code == 403 + + def test_api_key_with_several_pending_invites_denied(self) -> None: + with self.feature("organizations:session-replay"): + self._enable_granular_permissions() + OrganizationMemberReplayAccess.objects.create( + organizationmember=self.member_with_access + ) + self.create_member(organization=self.organization, email="a@example.com") + self.create_member(organization=self.organization, email="b@example.com") + + response = self._api_key_get_replays() + assert response.status_code == 403