From 1adfd7a52c040f7fac8e989f2bbe31d6696b2c8e Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:41:00 -0700 Subject: [PATCH 1/3] fix(viewer-context): Populate org after request resolution Set the viewer context organization id at the same points where API request handlers resolve and bind the request organization. This keeps the early middleware behavior intact while making the resolved organization available to code that runs after convert_args on organization, project, team, group, and monitor endpoints. Add focused regression tests for the shared helper and the main endpoint resolution paths. Co-Authored-By: Codex --- src/sentry/api/bases/organization.py | 3 +++ src/sentry/api/bases/project.py | 2 ++ src/sentry/api/bases/team.py | 2 ++ src/sentry/issues/endpoints/bases/group.py | 3 +++ src/sentry/monitors/endpoints/base.py | 2 ++ src/sentry/viewer_context.py | 9 +++++++ tests/sentry/api/bases/test_organization.py | 15 ++++++++++++ tests/sentry/api/bases/test_project.py | 26 +++++++++++++++++++- tests/sentry/api/bases/test_team.py | 27 ++++++++++++++++++++- tests/sentry/issues/endpoints/test_group.py | 23 ++++++++++++++++++ tests/sentry/test_viewer_context.py | 20 +++++++++++++++ 11 files changed, 130 insertions(+), 2 deletions(-) diff --git a/src/sentry/api/bases/organization.py b/src/sentry/api/bases/organization.py index 24bc36b1ed0a3d..dfdcc8084d780f 100644 --- a/src/sentry/api/bases/organization.py +++ b/src/sentry/api/bases/organization.py @@ -39,6 +39,7 @@ from sentry.utils.hashlib import hash_values from sentry.utils.numbers import format_grouped_length from sentry.utils.sdk import bind_organization_context, set_span_attribute +from sentry.viewer_context import set_viewer_context_organization class NoProjects(Exception): @@ -317,6 +318,7 @@ def convert_args( # Used for API access logs request._request.organization = organization_context.organization + set_viewer_context_organization(organization_context.organization.id) return (args, kwargs) @@ -638,6 +640,7 @@ def convert_args( bind_organization_context(organization) request._request.organization = organization + set_viewer_context_organization(organization.id) # Track the 'active' organization when the request came from # a cookie based agent (react app) diff --git a/src/sentry/api/bases/project.py b/src/sentry/api/bases/project.py index 58d5133030fef7..594eb159974223 100644 --- a/src/sentry/api/bases/project.py +++ b/src/sentry/api/bases/project.py @@ -20,6 +20,7 @@ from sentry.models.project import Project from sentry.models.projectredirect import ProjectRedirect from sentry.utils.sdk import Scope, bind_organization_context +from sentry.viewer_context import set_viewer_context_organization from .organization import OrganizationPermission @@ -211,6 +212,7 @@ def convert_args( request._request.organization = ( project.organization ) # XXX: we should not be stuffing random attributes into HttpRequest + set_viewer_context_organization(project.organization.id) kwargs["project"] = project return (args, kwargs) diff --git a/src/sentry/api/bases/team.py b/src/sentry/api/bases/team.py index 1b7abcadf6158d..920d9c1e5aa1e3 100644 --- a/src/sentry/api/bases/team.py +++ b/src/sentry/api/bases/team.py @@ -9,6 +9,7 @@ from sentry.api.exceptions import ResourceDoesNotExist from sentry.models.team import Team, TeamStatus from sentry.utils.sdk import bind_organization_context +from sentry.viewer_context import set_viewer_context_organization from .organization import OrganizationPermission @@ -67,6 +68,7 @@ def convert_args( bind_organization_context(team.organization) request._request.organization = team.organization + set_viewer_context_organization(team.organization.id) kwargs["team"] = team return (args, kwargs) diff --git a/src/sentry/issues/endpoints/bases/group.py b/src/sentry/issues/endpoints/bases/group.py index a5a20fa7088e9a..e5648fce6bd713 100644 --- a/src/sentry/issues/endpoints/bases/group.py +++ b/src/sentry/issues/endpoints/bases/group.py @@ -20,6 +20,7 @@ from sentry.models.grouplink import GroupLink from sentry.models.organization import Organization from sentry.utils.sdk import bind_organization_context +from sentry.viewer_context import set_viewer_context_organization logger = logging.getLogger(__name__) @@ -77,6 +78,7 @@ def convert_args( bind_organization_context(organization) request._request.organization = organization + set_viewer_context_organization(organization.id) else: organization = None @@ -101,6 +103,7 @@ def convert_args( raise ResourceDoesNotExist request._request.organization = group.project.organization + set_viewer_context_organization(group.project.organization.id) kwargs["group"] = group diff --git a/src/sentry/monitors/endpoints/base.py b/src/sentry/monitors/endpoints/base.py index f933e0556e1615..09c99e6842d053 100644 --- a/src/sentry/monitors/endpoints/base.py +++ b/src/sentry/monitors/endpoints/base.py @@ -14,6 +14,7 @@ from sentry.models.project import Project from sentry.monitors.models import CheckInStatus, Monitor, MonitorCheckIn, MonitorEnvironment from sentry.utils.sdk import Scope, bind_organization_context +from sentry.viewer_context import set_viewer_context_organization DEPRECATED_INGEST_API_MESSAGE = "We have removed this deprecated API. Please migrate to using DSN instead: https://docs.sentry.io/product/crons/legacy-endpoint-migration/#am-i-using-legacy-endpoints" @@ -71,6 +72,7 @@ def convert_args( bind_organization_context(project.organization) request._request.organization = project.organization + set_viewer_context_organization(project.organization.id) kwargs["organization"] = organization kwargs["project"] = project diff --git a/src/sentry/viewer_context.py b/src/sentry/viewer_context.py index 02029dd2c3700b..80bed8c7a21fc5 100644 --- a/src/sentry/viewer_context.py +++ b/src/sentry/viewer_context.py @@ -103,6 +103,15 @@ def get_viewer_context() -> ViewerContext | None: return _viewer_context_var.get() +def set_viewer_context_organization(organization_id: int) -> None: + """Update the current ``ViewerContext`` with a resolved organization id.""" + ctx = get_viewer_context() + if ctx is None or ctx.organization_id == organization_id: + return + + _viewer_context_var.set(dataclasses.replace(ctx, organization_id=organization_id)) + + # --------------------------------------------------------------------------- # JWT encoding / decoding for cross-service propagation # --------------------------------------------------------------------------- diff --git a/tests/sentry/api/bases/test_organization.py b/tests/sentry/api/bases/test_organization.py index 5fbaed64c9002e..2cffd5a8631f87 100644 --- a/tests/sentry/api/bases/test_organization.py +++ b/tests/sentry/api/bases/test_organization.py @@ -42,6 +42,7 @@ from sentry.users.services.user.serial import serialize_rpc_user from sentry.users.services.user.service import user_service from sentry.utils.security.orgauthtoken_token import hash_token +from sentry.viewer_context import ViewerContext, get_viewer_context, viewer_context_scope class MockSuperUser: @@ -377,6 +378,20 @@ def build_request(self, user=None, active_superuser=False, **params): return request +class OrganizationEndpointViewerContextTest(BaseOrganizationEndpointTest): + def test_convert_args_enriches_viewer_context_with_organization(self) -> None: + request = drf_request_from_request(self.build_request(user=self.owner)) + request._request.organization = None + + with viewer_context_scope(ViewerContext(user_id=self.owner.id)): + self.endpoint.convert_args(request, self.org.slug) + ctx = get_viewer_context() + + assert ctx is not None + assert ctx.user_id == self.owner.id + assert ctx.organization_id == self.org.id + + class GetProjectIdsTest(BaseOrganizationEndpointTest): def setUp(self) -> None: self.team_1 = self.create_team(organization=self.org) diff --git a/tests/sentry/api/bases/test_project.py b/tests/sentry/api/bases/test_project.py index 582d62a7f3d596..ff6108f16730bc 100644 --- a/tests/sentry/api/bases/test_project.py +++ b/tests/sentry/api/bases/test_project.py @@ -1,6 +1,9 @@ +from django.contrib.sessions.backends.base import SessionBase +from django.test import RequestFactory from rest_framework.views import APIView -from sentry.api.bases.project import ProjectAndStaffPermission, ProjectPermission +from sentry.api.bases.project import ProjectAndStaffPermission, ProjectEndpoint, ProjectPermission +from sentry.auth.access import from_request from sentry.models.apitoken import ApiToken from sentry.models.project import Project from sentry.testutils.cases import TestCase @@ -8,6 +11,7 @@ from sentry.testutils.requests import drf_request_from_request from sentry.users.models.user import User from sentry.users.services.user.serial import serialize_rpc_user +from sentry.viewer_context import ViewerContext, get_viewer_context, viewer_context_scope class ProjectPermissionBase(TestCase): @@ -34,6 +38,26 @@ def has_object_perm( ) +class ProjectEndpointViewerContextTest(TestCase): + def test_convert_args_enriches_viewer_context_with_organization(self) -> None: + endpoint = ProjectEndpoint() + request = RequestFactory().get("/") + request.session = SessionBase() + request.user = self.user + request.auth = None + request.access = from_request(drf_request_from_request(request), self.organization) + request = drf_request_from_request(request) + request._request.organization = None + + with viewer_context_scope(ViewerContext(user_id=self.user.id)): + endpoint.convert_args(request, self.organization.slug, self.project.slug) + ctx = get_viewer_context() + + assert ctx is not None + assert ctx.user_id == self.user.id + assert ctx.organization_id == self.organization.id + + class ProjectPermissionTest(ProjectPermissionBase): def test_regular_user(self) -> None: user = self.create_user(is_superuser=False) diff --git a/tests/sentry/api/bases/test_team.py b/tests/sentry/api/bases/test_team.py index 822a8f5d0f6c3d..9630e195a74931 100644 --- a/tests/sentry/api/bases/test_team.py +++ b/tests/sentry/api/bases/test_team.py @@ -1,12 +1,16 @@ +from django.contrib.sessions.backends.base import SessionBase +from django.test import RequestFactory from rest_framework.views import APIView -from sentry.api.bases.team import TeamPermission +from sentry.api.bases.team import TeamEndpoint, TeamPermission +from sentry.auth.access import from_request from sentry.models.apitoken import ApiToken from sentry.models.team import Team from sentry.testutils.cases import TestCase from sentry.testutils.helpers import with_feature from sentry.testutils.requests import drf_request_from_request from sentry.users.models.user import User +from sentry.viewer_context import ViewerContext, get_viewer_context, viewer_context_scope class TeamPermissionBase(TestCase): @@ -33,6 +37,27 @@ def has_object_perm( ) +class TeamEndpointViewerContextTest(TeamPermissionBase): + def test_convert_args_enriches_viewer_context_with_organization(self) -> None: + endpoint = TeamEndpoint() + self.create_member(user=self.user, organization=self.org, role="member", teams=[self.team]) + request = RequestFactory().get("/") + request.session = SessionBase() + request.user = self.user + request.auth = None + request.access = from_request(drf_request_from_request(request), self.org) + request = drf_request_from_request(request) + request._request.organization = None + + with viewer_context_scope(ViewerContext(user_id=self.user.id)): + endpoint.convert_args(request, self.org.slug, self.team.slug) + ctx = get_viewer_context() + + assert ctx is not None + assert ctx.user_id == self.user.id + assert ctx.organization_id == self.org.id + + class TeamPermissionTest(TeamPermissionBase): def test_get_regular_user(self) -> None: user = self.create_user() diff --git a/tests/sentry/issues/endpoints/test_group.py b/tests/sentry/issues/endpoints/test_group.py index 1cf4eae09c39cd..9bc229883ac4f1 100644 --- a/tests/sentry/issues/endpoints/test_group.py +++ b/tests/sentry/issues/endpoints/test_group.py @@ -2,6 +2,7 @@ from rest_framework.views import APIView +from sentry.auth.access import from_request from sentry.issues.endpoints.bases.group import GroupAiEndpoint, GroupAiPermission from sentry.models.apitoken import ApiToken from sentry.models.group import Group @@ -9,6 +10,7 @@ from sentry.testutils.helpers.options import override_options from sentry.testutils.requests import drf_request_from_request from sentry.users.models.user import User +from sentry.viewer_context import ViewerContext, get_viewer_context, viewer_context_scope class GroupAiPermissionTest(TestCase): @@ -125,3 +127,24 @@ def setUp(self) -> None: def test_permission_classes(self) -> None: assert hasattr(self.endpoint, "permission_classes") assert self.endpoint.permission_classes == (GroupAiPermission,) + + def test_convert_args_enriches_viewer_context_with_organization(self) -> None: + user = self.create_user() + self.create_member( + user=user, + organization=self.project.organization, + role="member", + teams=[self.project.teams.first()], + ) + request = self.make_request(user=user, method="GET") + request.access = from_request(drf_request_from_request(request), self.project.organization) + request = drf_request_from_request(request) + request._request.organization = None + + with viewer_context_scope(ViewerContext(user_id=user.id)): + self.endpoint.convert_args(request, str(self.group.id), self.project.organization.slug) + ctx = get_viewer_context() + + assert ctx is not None + assert ctx.user_id == user.id + assert ctx.organization_id == self.project.organization.id diff --git a/tests/sentry/test_viewer_context.py b/tests/sentry/test_viewer_context.py index 9ac2458c7dd921..e1c3ecb56dbf4e 100644 --- a/tests/sentry/test_viewer_context.py +++ b/tests/sentry/test_viewer_context.py @@ -10,6 +10,7 @@ ActorType, ViewerContext, get_viewer_context, + set_viewer_context_organization, viewer_context_scope, ) @@ -125,3 +126,22 @@ def test_context_propagating_executor_does_not_leak_across_submissions(self): assert inside is ctx assert outside is None + + def test_set_organization_updates_current_context(self): + ctx = ViewerContext(user_id=1, actor_type=ActorType.USER) + + with viewer_context_scope(ctx): + set_viewer_context_organization(42) + updated = get_viewer_context() + + assert updated is not None + assert updated.organization_id == 42 + assert updated.user_id == ctx.user_id + assert updated.actor_type is ctx.actor_type + + assert get_viewer_context() is None + + def test_set_organization_is_noop_without_context(self): + set_viewer_context_organization(42) + + assert get_viewer_context() is None From aba99361c85f34cc6bec9834da6f7b1399952d8d Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:45:02 -0700 Subject: [PATCH 2/3] ref(viewer-context): Centralize org binding hook Update the viewer context organization id inside bind_organization_context instead of repeating the same call at each binding site. This keeps request-scoped enrichment behavior the same while making the canonical organization binding hook responsible for both SDK scope tags and ambient viewer context updates. Co-Authored-By: Codex --- src/sentry/api/bases/organization.py | 3 --- src/sentry/api/bases/project.py | 2 -- src/sentry/api/bases/team.py | 2 -- src/sentry/issues/endpoints/bases/group.py | 3 --- src/sentry/monitors/endpoints/base.py | 2 -- src/sentry/utils/sdk.py | 2 ++ 6 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/sentry/api/bases/organization.py b/src/sentry/api/bases/organization.py index dfdcc8084d780f..24bc36b1ed0a3d 100644 --- a/src/sentry/api/bases/organization.py +++ b/src/sentry/api/bases/organization.py @@ -39,7 +39,6 @@ from sentry.utils.hashlib import hash_values from sentry.utils.numbers import format_grouped_length from sentry.utils.sdk import bind_organization_context, set_span_attribute -from sentry.viewer_context import set_viewer_context_organization class NoProjects(Exception): @@ -318,7 +317,6 @@ def convert_args( # Used for API access logs request._request.organization = organization_context.organization - set_viewer_context_organization(organization_context.organization.id) return (args, kwargs) @@ -640,7 +638,6 @@ def convert_args( bind_organization_context(organization) request._request.organization = organization - set_viewer_context_organization(organization.id) # Track the 'active' organization when the request came from # a cookie based agent (react app) diff --git a/src/sentry/api/bases/project.py b/src/sentry/api/bases/project.py index 594eb159974223..58d5133030fef7 100644 --- a/src/sentry/api/bases/project.py +++ b/src/sentry/api/bases/project.py @@ -20,7 +20,6 @@ from sentry.models.project import Project from sentry.models.projectredirect import ProjectRedirect from sentry.utils.sdk import Scope, bind_organization_context -from sentry.viewer_context import set_viewer_context_organization from .organization import OrganizationPermission @@ -212,7 +211,6 @@ def convert_args( request._request.organization = ( project.organization ) # XXX: we should not be stuffing random attributes into HttpRequest - set_viewer_context_organization(project.organization.id) kwargs["project"] = project return (args, kwargs) diff --git a/src/sentry/api/bases/team.py b/src/sentry/api/bases/team.py index 920d9c1e5aa1e3..1b7abcadf6158d 100644 --- a/src/sentry/api/bases/team.py +++ b/src/sentry/api/bases/team.py @@ -9,7 +9,6 @@ from sentry.api.exceptions import ResourceDoesNotExist from sentry.models.team import Team, TeamStatus from sentry.utils.sdk import bind_organization_context -from sentry.viewer_context import set_viewer_context_organization from .organization import OrganizationPermission @@ -68,7 +67,6 @@ def convert_args( bind_organization_context(team.organization) request._request.organization = team.organization - set_viewer_context_organization(team.organization.id) kwargs["team"] = team return (args, kwargs) diff --git a/src/sentry/issues/endpoints/bases/group.py b/src/sentry/issues/endpoints/bases/group.py index e5648fce6bd713..a5a20fa7088e9a 100644 --- a/src/sentry/issues/endpoints/bases/group.py +++ b/src/sentry/issues/endpoints/bases/group.py @@ -20,7 +20,6 @@ from sentry.models.grouplink import GroupLink from sentry.models.organization import Organization from sentry.utils.sdk import bind_organization_context -from sentry.viewer_context import set_viewer_context_organization logger = logging.getLogger(__name__) @@ -78,7 +77,6 @@ def convert_args( bind_organization_context(organization) request._request.organization = organization - set_viewer_context_organization(organization.id) else: organization = None @@ -103,7 +101,6 @@ def convert_args( raise ResourceDoesNotExist request._request.organization = group.project.organization - set_viewer_context_organization(group.project.organization.id) kwargs["group"] = group diff --git a/src/sentry/monitors/endpoints/base.py b/src/sentry/monitors/endpoints/base.py index 09c99e6842d053..f933e0556e1615 100644 --- a/src/sentry/monitors/endpoints/base.py +++ b/src/sentry/monitors/endpoints/base.py @@ -14,7 +14,6 @@ from sentry.models.project import Project from sentry.monitors.models import CheckInStatus, Monitor, MonitorCheckIn, MonitorEnvironment from sentry.utils.sdk import Scope, bind_organization_context -from sentry.viewer_context import set_viewer_context_organization DEPRECATED_INGEST_API_MESSAGE = "We have removed this deprecated API. Please migrate to using DSN instead: https://docs.sentry.io/product/crons/legacy-endpoint-migration/#am-i-using-legacy-endpoints" @@ -72,7 +71,6 @@ def convert_args( bind_organization_context(project.organization) request._request.organization = project.organization - set_viewer_context_organization(project.organization.id) kwargs["organization"] = organization kwargs["project"] = project diff --git a/src/sentry/utils/sdk.py b/src/sentry/utils/sdk.py index e825d54e31dc31..e549cfd8b62dc2 100644 --- a/src/sentry/utils/sdk.py +++ b/src/sentry/utils/sdk.py @@ -30,6 +30,7 @@ from sentry.utils import metrics from sentry.utils.db import DjangoAtomicIntegration from sentry.utils.rust import RustInfoIntegration +from sentry.viewer_context import set_viewer_context_organization # Can't import models in utils because utils should be the bottom of the food chain if TYPE_CHECKING: @@ -683,6 +684,7 @@ def bind_organization_context(organization: Organization | RpcOrganization) -> N helper = settings.SENTRY_ORGANIZATION_CONTEXT_HELPER scope = sentry_sdk.get_isolation_scope() + set_viewer_context_organization(organization.id) # XXX(dcramer): this is duplicated in organizationContext.jsx on the frontend with sentry_sdk.start_span(op="other", name="bind_organization_context"): From f86a1c311db97358e765876b2aa6fbc1317d40e0 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:47:56 -0700 Subject: [PATCH 3/3] test(viewer-context): Fix DRF request typing in endpoint tests Keep the endpoint viewer-context regression tests type-safe by separating the raw WSGI request from the DRF request wrapper used for convert_args. This matches the actual request shapes under test and fixes the backend typing job without changing runtime behavior. Co-Authored-By: Codex --- tests/sentry/api/bases/test_project.py | 12 ++++++------ tests/sentry/api/bases/test_team.py | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/sentry/api/bases/test_project.py b/tests/sentry/api/bases/test_project.py index ff6108f16730bc..1fb2aa27f8aa19 100644 --- a/tests/sentry/api/bases/test_project.py +++ b/tests/sentry/api/bases/test_project.py @@ -41,12 +41,12 @@ def has_object_perm( class ProjectEndpointViewerContextTest(TestCase): def test_convert_args_enriches_viewer_context_with_organization(self) -> None: endpoint = ProjectEndpoint() - request = RequestFactory().get("/") - request.session = SessionBase() - request.user = self.user - request.auth = None - request.access = from_request(drf_request_from_request(request), self.organization) - request = drf_request_from_request(request) + raw_request = RequestFactory().get("/") + raw_request.session = SessionBase() + raw_request.user = self.user + raw_request.auth = None + raw_request.access = from_request(drf_request_from_request(raw_request), self.organization) + request = drf_request_from_request(raw_request) request._request.organization = None with viewer_context_scope(ViewerContext(user_id=self.user.id)): diff --git a/tests/sentry/api/bases/test_team.py b/tests/sentry/api/bases/test_team.py index 9630e195a74931..771d54a47911d4 100644 --- a/tests/sentry/api/bases/test_team.py +++ b/tests/sentry/api/bases/test_team.py @@ -41,12 +41,12 @@ class TeamEndpointViewerContextTest(TeamPermissionBase): def test_convert_args_enriches_viewer_context_with_organization(self) -> None: endpoint = TeamEndpoint() self.create_member(user=self.user, organization=self.org, role="member", teams=[self.team]) - request = RequestFactory().get("/") - request.session = SessionBase() - request.user = self.user - request.auth = None - request.access = from_request(drf_request_from_request(request), self.org) - request = drf_request_from_request(request) + raw_request = RequestFactory().get("/") + raw_request.session = SessionBase() + raw_request.user = self.user + raw_request.auth = None + raw_request.access = from_request(drf_request_from_request(raw_request), self.org) + request = drf_request_from_request(raw_request) request._request.organization = None with viewer_context_scope(ViewerContext(user_id=self.user.id)):