From 1c7e55952d815b20905f7550b705b0278d181b47 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:43:55 -0800 Subject: [PATCH] ref(api): Narrow request.user in API plumbing and org endpoints `api/base.py`, `api/bases/organization.py`, `api/decorators.py`, the group-index helpers and 22 org/project endpoints read the caller through `sentry.auth.principal`: who-did-this columns, analytics and Seer context use `acting_user_id()`; "mine" lookups (recent searches, prompts, monitoring identities) use `owned_by_acting_user()`; personal writes, user-only attributes and NOT NULL actors use `require_user()`. Per-request paths: the 2FA check and the release cache key call `require_user()` only after checks a service account never passes (it has no member row; service-account auth is excluded), and `primary_email_verification_required` now answers a service account with 403 instead of an `AttributeError`. - A service account gets empty prompts and recent searches, and can save transaction thresholds; recent-search POST, weekly-report exclusions, agentic onboarding and service hooks are 403 `user-required`. New tests cover five of these. - Token callers get 401 instead of a 500 on recent-search POST and weekly-report exclusions. - Users and agents see no change. Left: three `features.batch_has(actor=request.user)` calls (widening it reaches getsentry's handlers), `_get_member`, `organization_releases` serialize, `prompts_activity` PUT and `organization_auth_tokens`. --- src/sentry/api/bases/organization.py | 6 ++-- src/sentry/api/bases/organization_events.py | 3 +- src/sentry/api/decorators.py | 7 ++-- .../organization_agentic_onboarding.py | 7 ++-- ...nization_agentic_onboarding_run_details.py | 7 ++-- .../api/endpoints/organization_auth_tokens.py | 2 +- src/sentry/api/endpoints/organization_fork.py | 10 +++--- .../endpoints/organization_intercom_jwt.py | 6 ++-- ...rganization_monitoring_provider_details.py | 5 +-- .../organization_monitoring_provider_index.py | 3 +- ...n_monitoring_provider_verify_connection.py | 3 +- ...anization_onboarding_continuation_email.py | 14 +++----- .../api/endpoints/organization_pipeline.py | 3 +- .../organization_profiling_functions.py | 5 ++- .../endpoints/organization_recent_searches.py | 16 ++++----- .../api/endpoints/organization_releases.py | 2 +- ...ganization_trace_item_attribute_context.py | 5 +-- ...ganization_trace_item_attributes_ranked.py | 5 ++- .../organization_trace_item_metric_context.py | 5 +-- .../api/endpoints/organization_unsubscribe.py | 9 ++--- ...weekly_report_project_exclusion_details.py | 5 +-- ...zation_weekly_report_project_exclusions.py | 11 ++++--- .../project_custom_inbound_filter_validate.py | 3 +- .../api/endpoints/project_servicehooks.py | 6 ++-- .../project_transaction_threshold.py | 5 +-- .../project_transaction_threshold_override.py | 3 +- src/sentry/api/endpoints/prompts_activity.py | 3 +- src/sentry/api/helpers/group_index/update.py | 5 +-- ...st_organization_onboarding_continuation.py | 28 ++++++++++++++++ .../test_organization_recent_searches.py | 29 ++++++++++++++++ .../test_project_transaction_threshold.py | 30 +++++++++++++++++ ..._project_transaction_threshold_override.py | 33 +++++++++++++++++++ .../api/endpoints/test_prompts_activity.py | 32 ++++++++++++++++++ tests/sentry/auth/test_principal.py | 4 +-- 34 files changed, 244 insertions(+), 76 deletions(-) diff --git a/src/sentry/api/bases/organization.py b/src/sentry/api/bases/organization.py index c898b9225489..17582852db27 100644 --- a/src/sentry/api/bases/organization.py +++ b/src/sentry/api/bases/organization.py @@ -33,7 +33,7 @@ ) from sentry.api.permissions import DemoSafePermission, StaffPermissionMixin from sentry.api.utils import get_date_range_from_params, is_member_disabled_from_limit -from sentry.auth.principal import acting_user_id +from sentry.auth.principal import acting_user_id, require_user from sentry.auth.services.auth import AuthenticatedToken from sentry.auth.superuser import is_active_superuser from sentry.constants import ALL_ACCESS_PROJECT_ID, ALL_ACCESS_PROJECTS_SLUG, ObjectStatus @@ -107,7 +107,7 @@ def is_not_2fa_compliant( if not organization.flags.require_2fa: return False - if request.user.is_authenticated and request.user.has_2fa(): + if request.user.is_authenticated and require_user(request).has_2fa(): return False if request.user.is_authenticated and request.user.is_sentry_app: @@ -962,7 +962,7 @@ def has_release_permission( key = None if not is_agent_auth(request.auth) and not is_service_account_auth(request.auth): if request.user.is_authenticated: - actor_id = "user:%s" % request.user.id + actor_id = "user:%s" % require_user(request).id elif request.auth is not None: actor_id = "apikey:%s" % request.auth.entity_id if actor_id is not None: diff --git a/src/sentry/api/bases/organization_events.py b/src/sentry/api/bases/organization_events.py index b6167525565d..9a21f38d34a2 100644 --- a/src/sentry/api/bases/organization_events.py +++ b/src/sentry/api/bases/organization_events.py @@ -56,6 +56,7 @@ from sentry.search.events.constants import DURATION_UNITS, SIZE_UNITS from sentry.search.events.fields import get_function_alias, is_function from sentry.search.events.types import SAMPLING_MODES, SnubaParams +from sentry.service_accounts.principal import ServiceAccountPrincipal from sentry.snuba import discover, metrics_enhanced_performance, metrics_performance from sentry.snuba.dataset import Dataset from sentry.snuba.metrics.extraction import MetricSpecType @@ -290,7 +291,7 @@ def save_split_decision( has_errors: bool, has_transactions_data: bool, organization: Organization, - user: User | AnonymousUser, + user: User | AnonymousUser | ServiceAccountPrincipal, ) -> int | None: """This can be removed once the discover dataset has been fully split""" source = DashboardDatasetSourcesTypes.INFERRED.value diff --git a/src/sentry/api/decorators.py b/src/sentry/api/decorators.py index d21354a04768..3d69196eafce 100644 --- a/src/sentry/api/decorators.py +++ b/src/sentry/api/decorators.py @@ -7,7 +7,7 @@ from rest_framework.request import Request from sentry.api.exceptions import PrimaryEmailVerificationRequired, SudoRequired, UserRequired -from sentry.auth.principal import has_user +from sentry.auth.principal import has_user, require_user from sentry.models.apikey import is_api_key_auth from sentry.models.apitoken import is_api_token_auth from sentry.models.orgauthtoken import is_org_auth_token_auth @@ -58,7 +58,10 @@ def wrapped(self: T, request: Request, *args: P.args, **kwargs: P.kwargs) -> Htt def primary_email_verification_required[T, **P](func: _RFMethod[T, P]) -> _RFMethod[T, P]: @wraps(func) def wrapped(self: T, request: Request, *args: P.args, **kwargs: P.kwargs) -> HttpResponseBase: - if isinstance(request.user, AnonymousUser) or not request.user.has_verified_primary_email(): + if ( + isinstance(request.user, AnonymousUser) + or not require_user(request).has_verified_primary_email() + ): raise PrimaryEmailVerificationRequired(request.user) return func(self, request, *args, **kwargs) diff --git a/src/sentry/api/endpoints/organization_agentic_onboarding.py b/src/sentry/api/endpoints/organization_agentic_onboarding.py index a4d968cc1df3..d197d03acb53 100644 --- a/src/sentry/api/endpoints/organization_agentic_onboarding.py +++ b/src/sentry/api/endpoints/organization_agentic_onboarding.py @@ -22,6 +22,7 @@ AgenticOnboardingRunSerializer, ) from sentry.api.serializers.rest_framework import CamelSnakeSerializer +from sentry.auth.principal import require_user from sentry.models.organization import Organization from sentry.onboarding.agentic_progress.model import ( MAX_EVENT_NOTE_LENGTH, @@ -168,8 +169,7 @@ class OrganizationAgenticOnboardingRunIndexEndpoint(OrganizationEndpoint): def post(self, request: Request, organization: Organization) -> Response: serializer = AgenticOnboardingRunRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) - user_id = request.user.id - assert user_id is not None + user_id = require_user(request).id try: run, onboarding_code = get_onboarding_progress_service().create_or_resume( user_id=user_id, @@ -207,8 +207,7 @@ def post(self, request: Request, organization: Organization) -> Response: serializer = AgenticOnboardingStatusRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) values = serializer.validated_data - user_id = request.user.id - assert user_id is not None + user_id = require_user(request).id try: run, changed, stage_status_changed = get_onboarding_progress_service().update( diff --git a/src/sentry/api/endpoints/organization_agentic_onboarding_run_details.py b/src/sentry/api/endpoints/organization_agentic_onboarding_run_details.py index 1fb490cb2b95..b26661a84a67 100644 --- a/src/sentry/api/endpoints/organization_agentic_onboarding_run_details.py +++ b/src/sentry/api/endpoints/organization_agentic_onboarding_run_details.py @@ -11,6 +11,7 @@ from sentry.api.serializers.models.agentic_onboarding import ( AgenticOnboardingRunSerializer, ) +from sentry.auth.principal import require_user from sentry.models.organization import Organization from sentry.onboarding.agentic_progress.model import OnboardingRunTerminal from sentry.onboarding.agentic_progress.service import ( @@ -27,8 +28,7 @@ class OrganizationAgenticOnboardingRunDetailsEndpoint(OrganizationEndpoint): @extend_schema(responses={200: AgenticOnboardingRunSerializer}) def get(self, request: Request, organization: Organization, run_id: str) -> Response: - user_id = request.user.id - assert user_id is not None + user_id = require_user(request).id run = get_onboarding_progress_service().get( run_id=run_id, user_id=user_id, organization_id=organization.id ) @@ -39,8 +39,7 @@ def get(self, request: Request, organization: Organization, run_id: str) -> Resp @extend_schema(responses={200: AgenticOnboardingRunSerializer}) def delete(self, request: Request, organization: Organization, run_id: str) -> Response: - user_id = request.user.id - assert user_id is not None + user_id = require_user(request).id try: run = get_onboarding_progress_service().cancel( run_id=run_id, user_id=user_id, organization_id=organization.id diff --git a/src/sentry/api/endpoints/organization_auth_tokens.py b/src/sentry/api/endpoints/organization_auth_tokens.py index 2fa2c70511bb..447b4601560e 100644 --- a/src/sentry/api/endpoints/organization_auth_tokens.py +++ b/src/sentry/api/endpoints/organization_auth_tokens.py @@ -161,7 +161,7 @@ def post( name=name, organization_id=organization.id, scope_list=sorted(set(scopes)), - created_by_id=request.user.id, + created_by_id=acting_user_id(request.user), token_last_characters=token_str[-4:], token_hashed=token_hashed, ) diff --git a/src/sentry/api/endpoints/organization_fork.py b/src/sentry/api/endpoints/organization_fork.py index 5803463688cf..cfa164113d3e 100644 --- a/src/sentry/api/endpoints/organization_fork.py +++ b/src/sentry/api/endpoints/organization_fork.py @@ -14,6 +14,7 @@ from sentry.api.base import Endpoint, cell_silo_endpoint from sentry.api.permissions import SuperuserOrStaffFeatureFlaggedPermission from sentry.api.serializers import serialize +from sentry.auth.principal import acting_user_id, require_user from sentry.hybridcloud.services.organization_mapping import organization_mapping_service from sentry.models.organization import OrganizationStatus from sentry.relocation.api.endpoints.index import ( @@ -69,10 +70,11 @@ def post(self, request: Request, organization_id_or_slug: str) -> Response: :auth: required """ - logger.info("relocations.fork.post.start", extra={"caller": request.user.id}) + logger.info("relocations.fork.post.start", extra={"caller": acting_user_id(request.user)}) if not request.user.is_authenticated: return Response(status=status.HTTP_400_BAD_REQUEST) + user = require_user(request) org_mapping = ( organization_mapping_service.get(organization_id=organization_id_or_slug) @@ -151,7 +153,7 @@ def post(self, request: Request, organization_id_or_slug: str) -> Response: # Identify who will be the owner of the newly forked organization, and ensure that they # don't already have relocations in flight. owners = organization_mapping_service.get_owners(organization_id=org_mapping.id) - owner = owners[0] if len(owners) > 0 else request.user + owner = owners[0] if len(owners) > 0 else user err = validate_relocation_uniqueness(owner) if err is not None: return err @@ -162,7 +164,7 @@ def post(self, request: Request, organization_id_or_slug: str) -> Response: provenance = Relocation.Provenance.SAAS_TO_SAAS with atomic_transaction(using=(router.db_for_write(Relocation))): new_relocation = Relocation.objects.create( - creator_id=request.user.id, + creator_id=user.id, owner_id=owner.id, step=Relocation.Step.UPLOADING.value, scheduled_pause_at_step=get_autopause_value(provenance), @@ -180,7 +182,7 @@ def post(self, request: Request, organization_id_or_slug: str) -> Response: try: analytics.record( RelocationForkedEvent( - creator_id=request.user.id, + creator_id=user.id, owner_id=owner.id, uuid=str(new_relocation.uuid), from_org_slug=org_mapping.slug, diff --git a/src/sentry/api/endpoints/organization_intercom_jwt.py b/src/sentry/api/endpoints/organization_intercom_jwt.py index c830260ea41a..1509ba9cae07 100644 --- a/src/sentry/api/endpoints/organization_intercom_jwt.py +++ b/src/sentry/api/endpoints/organization_intercom_jwt.py @@ -2,7 +2,6 @@ import logging from django.conf import settings -from django.contrib.auth.models import AnonymousUser from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response @@ -11,6 +10,7 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import control_silo_endpoint from sentry.api.bases.organization import ControlSiloOrganizationEndpoint +from sentry.auth.principal import require_user from sentry.organizations.services.organization.model import ( RpcOrganization, RpcUserOrganizationContext, @@ -49,8 +49,7 @@ def get( Returns a JWT signed with HS256 containing user identity claims, along with user data for the frontend to pass to Intercom. """ - if isinstance(request.user, AnonymousUser): - return Response(status=status.HTTP_401_UNAUTHORIZED) + user = require_user(request) signing_secret = settings.SENTRY_INTERCOM_API_SECRET if not signing_secret: @@ -60,7 +59,6 @@ def get( status=status.HTTP_503_SERVICE_UNAVAILABLE, ) - user = request.user now = int(datetime.datetime.now(datetime.UTC).timestamp()) exp = now + JWT_VALIDITY_WINDOW_SECONDS diff --git a/src/sentry/api/endpoints/organization_monitoring_provider_details.py b/src/sentry/api/endpoints/organization_monitoring_provider_details.py index d481ef09315c..e8ef89a5483b 100644 --- a/src/sentry/api/endpoints/organization_monitoring_provider_details.py +++ b/src/sentry/api/endpoints/organization_monitoring_provider_details.py @@ -19,6 +19,7 @@ MonitoringProviderPermission, ) from sentry.auth.exceptions import IdentityNotValid +from sentry.auth.principal import is_owned_by_acting_user from sentry.identity import default_manager as identity_manager from sentry.identity.base import Provider from sentry.identity.oauth2 import OAuth2Provider @@ -86,8 +87,8 @@ def put( # Reuse the stored site from the existing connection. org_identity = ( OrganizationIdentity.objects.filter( + is_owned_by_acting_user(request.user, "identity__user_id"), organization_id=organization.id, - identity__user_id=request.user.id, # type: ignore[misc] identity__idp__type=provider_key, ) .select_related("identity") @@ -186,8 +187,8 @@ def delete( org_identities: list[OrganizationIdentity] = list( OrganizationIdentity.objects.filter( + is_owned_by_acting_user(request.user, "identity__user_id"), organization_id=organization.id, - identity__user_id=request.user.id, # type: ignore[misc] identity__idp__type=provider_key, ).select_related("identity") ) diff --git a/src/sentry/api/endpoints/organization_monitoring_provider_index.py b/src/sentry/api/endpoints/organization_monitoring_provider_index.py index 5f84954392cf..a25b51e985b0 100644 --- a/src/sentry/api/endpoints/organization_monitoring_provider_index.py +++ b/src/sentry/api/endpoints/organization_monitoring_provider_index.py @@ -11,6 +11,7 @@ ControlSiloOrganizationEndpoint, OrganizationPermission, ) +from sentry.auth.principal import is_owned_by_acting_user from sentry.organizations.services.organization.model import RpcOrganization from sentry.users.models.identity import OrganizationIdentity @@ -48,8 +49,8 @@ def get(self, request: Request, organization: RpcOrganization, **kwargs: object) connected_providers = set( OrganizationIdentity.objects.filter( + is_owned_by_acting_user(request.user, "identity__user_id"), organization_id=organization.id, - identity__user_id=request.user.id, # type: ignore[misc] identity__idp__type__in=MONITORING_PROVIDERS.keys(), ).values_list("identity__idp__type", flat=True) ) diff --git a/src/sentry/api/endpoints/organization_monitoring_provider_verify_connection.py b/src/sentry/api/endpoints/organization_monitoring_provider_verify_connection.py index 1fd0b1c01c1a..81a3e38efe53 100644 --- a/src/sentry/api/endpoints/organization_monitoring_provider_verify_connection.py +++ b/src/sentry/api/endpoints/organization_monitoring_provider_verify_connection.py @@ -23,6 +23,7 @@ convert_dict_key_case, snake_to_camel_case, ) +from sentry.auth.principal import acting_user_id from sentry.constants import ObjectStatus from sentry.integrations.gcp.client import verify_gcp_connection from sentry.integrations.gcp.utils import parse_customer_sa_email, validate_gcp_project_id @@ -180,7 +181,7 @@ def post(self, request: Request, organization: Organization, **kwargs: object) - mark_investigation_telemetry_connected.delay( organization_id=organization.id, provider="gcp", - user_id=request.user.id, + user_id=acting_user_id(request.user), retry=False, ) diff --git a/src/sentry/api/endpoints/organization_onboarding_continuation_email.py b/src/sentry/api/endpoints/organization_onboarding_continuation_email.py index 0c50698ffcec..19ec2de0ffab 100644 --- a/src/sentry/api/endpoints/organization_onboarding_continuation_email.py +++ b/src/sentry/api/endpoints/organization_onboarding_continuation_email.py @@ -1,6 +1,5 @@ from typing import TypedDict -from django.contrib.auth.models import AnonymousUser from rest_framework import serializers from rest_framework.request import Request from rest_framework.response import Response @@ -12,6 +11,7 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer +from sentry.auth.principal import require_user from sentry.models.organization import Organization from sentry.users.models.user import User from sentry.utils.email import MessageBuilder @@ -70,19 +70,15 @@ def post(self, request: Request, organization: Organization) -> Response: serializer = OnboardingContinuationSerializer(data=request.data) if not serializer.is_valid(): return self.respond(serializer.errors, status=400) - if isinstance(request.user, AnonymousUser): - return self.respond(status=401) - + user = require_user(request) msg = MessageBuilder( - **get_request_builder_args( - request.user, organization, serializer.validated_data["platforms"] - ) + **get_request_builder_args(user, organization, serializer.validated_data["platforms"]) ) - msg.send_async([request.user.email]) + msg.send_async([user.email]) analytics.record( OnboardingContinuationSent( organization_id=organization.id, - user_id=request.user.id, + user_id=user.id, providers="email", ) ) diff --git a/src/sentry/api/endpoints/organization_pipeline.py b/src/sentry/api/endpoints/organization_pipeline.py index 834e34fcbd74..2c45eaa5d0c7 100644 --- a/src/sentry/api/endpoints/organization_pipeline.py +++ b/src/sentry/api/endpoints/organization_pipeline.py @@ -12,6 +12,7 @@ ControlSiloOrganizationEndpoint, OrganizationPermission, ) +from sentry.auth.principal import acting_user_id from sentry.exceptions import NotRegistered from sentry.identity.pipeline import IdentityPipeline from sentry.integrations.pipeline import ( @@ -126,7 +127,7 @@ def _initialize_pipeline( return Response({"detail": "Pipeline does not support API mode."}, status=400) pipeline.set_api_mode() - pipeline.bind_state("user_id", request.user.id) + pipeline.bind_state("user_id", acting_user_id(request.user)) metrics.incr( "integrations.pipeline_api.initialize", diff --git a/src/sentry/api/endpoints/organization_profiling_functions.py b/src/sentry/api/endpoints/organization_profiling_functions.py index 7dc201ce260f..19258a0d5111 100644 --- a/src/sentry/api/endpoints/organization_profiling_functions.py +++ b/src/sentry/api/endpoints/organization_profiling_functions.py @@ -17,6 +17,7 @@ from sentry.api.bases import NoProjects, OrganizationEventsEndpointBase from sentry.api.paginator import GenericOffsetPaginator from sentry.api.utils import handle_query_errors +from sentry.auth.principal import acting_user_id from sentry.exceptions import InvalidSearchQuery from sentry.models.organization import Organization from sentry.search.events.builder.profile_functions import ProfileTopFunctionsTimeseriesQueryBuilder @@ -83,7 +84,9 @@ def get(self, request: Request, organization: Organization) -> Response: if not self.has_feature(organization, request): return Response(status=404) - viewer_context = SeerViewerContext(organization_id=organization.id, user_id=request.user.id) + viewer_context = SeerViewerContext( + organization_id=organization.id, user_id=acting_user_id(request.user) + ) try: snuba_params = self.get_snuba_params(request, organization) diff --git a/src/sentry/api/endpoints/organization_recent_searches.py b/src/sentry/api/endpoints/organization_recent_searches.py index 20c710602777..0a25ff3004a9 100644 --- a/src/sentry/api/endpoints/organization_recent_searches.py +++ b/src/sentry/api/endpoints/organization_recent_searches.py @@ -1,5 +1,3 @@ -from typing import cast - from django.utils import timezone from rest_framework import serializers from rest_framework.request import Request @@ -10,10 +8,10 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission from sentry.api.serializers import serialize +from sentry.auth.principal import is_owned_by_acting_user, require_user from sentry.models.organization import Organization from sentry.models.recentsearch import RecentSearch, remove_excess_recent_searches from sentry.models.search_common import SearchType -from sentry.users.models.user import User class RecentSearchSerializer(serializers.Serializer): @@ -61,7 +59,6 @@ def get(self, request: Request, organization: Organization) -> Response: query_kwargs: dict[str, object] = { "organization": organization, - "user_id": request.user.id, "type": search_type, } @@ -69,7 +66,9 @@ def get(self, request: Request, organization: Organization) -> Response: query_kwargs["query__icontains"] = request.GET["query"] recent_searches = list( - RecentSearch.objects.filter(**query_kwargs).order_by("-last_seen")[:limit] + RecentSearch.objects.filter( + is_owned_by_acting_user(request.user), **query_kwargs + ).order_by("-last_seen")[:limit] ) return Response(serialize(recent_searches, request.user)) @@ -79,18 +78,17 @@ def post(self, request: Request, organization: Organization) -> Response: if serializer.is_valid(): result = serializer.validated_data + user = require_user(request) _, created = RecentSearch.objects.update_or_create( organization_id=organization.id, - user_id=request.user.id, + user_id=user.id, type=result["type"], query=result["query"], defaults={"last_seen": timezone.now()}, ) if created: - remove_excess_recent_searches( - organization, cast(User, request.user), result["type"] - ) + remove_excess_recent_searches(organization, user, result["type"]) status = 201 if created else 204 return Response(status=status) diff --git a/src/sentry/api/endpoints/organization_releases.py b/src/sentry/api/endpoints/organization_releases.py index 0ac7ca29d7c1..a9299165c05d 100644 --- a/src/sentry/api/endpoints/organization_releases.py +++ b/src/sentry/api/endpoints/organization_releases.py @@ -941,7 +941,7 @@ def post( ) fetch_commits = not commit_list try: - release.set_refs(refs, request.user.id, fetch=fetch_commits) + release.set_refs(refs, acting_user_id(request.user), fetch=fetch_commits) except InvalidRepository as e: scope.set_tag("failure_reason", "InvalidRepository") scope.set_attribute("failure_reason", "InvalidRepository") diff --git a/src/sentry/api/endpoints/organization_trace_item_attribute_context.py b/src/sentry/api/endpoints/organization_trace_item_attribute_context.py index a8a05d2bf254..8649245b203f 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attribute_context.py +++ b/src/sentry/api/endpoints/organization_trace_item_attribute_context.py @@ -42,6 +42,7 @@ as_validation_errors, ) from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.auth.principal import acting_user_id from sentry.exceptions import InvalidSearchQuery from sentry.explore.models import ( TraceItemAttributeContext, @@ -251,7 +252,7 @@ def put( } defaults = { "brief": data["brief"], - "updated_by_id": request.user.id, + "updated_by_id": acting_user_id(request.user), **optional_fields, } # Race-safe: the lookup kwargs match the unique constraints, so a losing @@ -267,7 +268,7 @@ def put( "additional_context": None, "examples": [], **defaults, - "created_by_id": request.user.id, + "created_by_id": acting_user_id(request.user), }, ) diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py b/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py index 09509180d922..a6cbcd795d85 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py @@ -38,6 +38,7 @@ from sentry.apidocs.examples.trace_item_attribute_examples import TraceItemAttributeExamples from sentry.apidocs.parameters import GlobalParams, OrganizationParams from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.auth.principal import acting_user_id from sentry.exceptions import InvalidSearchQuery from sentry.models.organization import Organization from sentry.search.eap.resolver import SearchResolver @@ -200,7 +201,9 @@ def get( {"referrer": Referrer.API_TRACE_EXPLORER_STATS.value}, ) - viewer_context = SeerViewerContext(organization_id=organization.id, user_id=request.user.id) + viewer_context = SeerViewerContext( + organization_id=organization.id, user_id=acting_user_id(request.user) + ) scored_attrs_rrr = compare_distributions( baseline=cast("list[dict[str, Any]]", cohort_2_distribution), outliers=cast("list[dict[str, Any]]", cohort_1_distribution), diff --git a/src/sentry/api/endpoints/organization_trace_item_metric_context.py b/src/sentry/api/endpoints/organization_trace_item_metric_context.py index 178b7717790c..0a2a48f2fdec 100644 --- a/src/sentry/api/endpoints/organization_trace_item_metric_context.py +++ b/src/sentry/api/endpoints/organization_trace_item_metric_context.py @@ -36,6 +36,7 @@ as_validation_errors, ) from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.auth.principal import acting_user_id from sentry.explore.models import ( TraceItemAttributeValueContext, TraceItemTypes, @@ -210,7 +211,7 @@ def put( optional_fields = {field: data[field] for field in ("additional_context",) if field in data} defaults = { "brief": data["brief"], - "updated_by_id": request.user.id, + "updated_by_id": acting_user_id(request.user), **optional_fields, } # Race-safe: the lookup kwargs match the unique constraints, so a losing @@ -228,7 +229,7 @@ def put( create_defaults={ "additional_context": None, **defaults, - "created_by_id": request.user.id, + "created_by_id": acting_user_id(request.user), }, ) diff --git a/src/sentry/api/endpoints/organization_unsubscribe.py b/src/sentry/api/endpoints/organization_unsubscribe.py index 1877fed88ed1..6487dc56522b 100644 --- a/src/sentry/api/endpoints/organization_unsubscribe.py +++ b/src/sentry/api/endpoints/organization_unsubscribe.py @@ -10,6 +10,7 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.authentication import SignedRequestAuthentication from sentry.api.base import Endpoint, cell_silo_endpoint +from sentry.auth.principal import is_owned_by_acting_user, require_user from sentry.db.models.base import BaseModel from sentry.models.group import Group from sentry.models.groupsubscription import GroupSubscription @@ -98,7 +99,7 @@ def fetch_instance( if project.organization.slug != organization_id_or_slug: raise NotFound() if not OrganizationMember.objects.filter( - user_id=request.user.pk, organization_id=project.organization_id + is_owned_by_acting_user(request.user), organization_id=project.organization_id ).exists(): raise NotFound() return project @@ -110,7 +111,7 @@ def add_instance_data(self, data: dict[str, Any], instance: Project) -> dict[str def unsubscribe(self, request: Request, instance: Project) -> None: notifications_service.update_notification_options( - actor=Actor(id=request.user.pk, actor_type=ActorType.USER), + actor=Actor(id=require_user(request).id, actor_type=ActorType.USER), type=NotificationSettingEnum.ISSUE_ALERTS, scope_type=NotificationScopeEnum.PROJECT, scope_identifier=instance.id, @@ -137,7 +138,7 @@ def fetch_instance( raise NotFound() if not OrganizationMember.objects.filter( - user_id=request.user.pk, organization=issue.organization + is_owned_by_acting_user(request.user), organization=issue.organization ).exists(): raise NotFound() return issue @@ -146,6 +147,6 @@ def unsubscribe(self, request: Request, instance: Group) -> None: GroupSubscription.objects.update_or_create( group=instance, project_id=instance.project_id, - user_id=request.user.pk, + user_id=require_user(request).id, defaults={"is_active": False}, ) diff --git a/src/sentry/api/endpoints/organization_weekly_report_project_exclusion_details.py b/src/sentry/api/endpoints/organization_weekly_report_project_exclusion_details.py index c8db3cdaf3e4..7f6bc6d7d937 100644 --- a/src/sentry/api/endpoints/organization_weekly_report_project_exclusion_details.py +++ b/src/sentry/api/endpoints/organization_weekly_report_project_exclusion_details.py @@ -7,6 +7,7 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission from sentry.api.helpers.projects import parse_id_or_slug_params +from sentry.auth.principal import require_user from sentry.models.organization import Organization from sentry.models.weeklyreportprojectexclusion import WeeklyReportProjectExclusion @@ -28,7 +29,7 @@ class OrganizationWeeklyReportProjectExclusionDetailsEndpoint(OrganizationEndpoi def delete( self, request: Request, organization: Organization, project_id_or_slug: str ) -> Response: - assert request.user and request.user.id + user = require_user(request) parsed = parse_id_or_slug_params([project_id_or_slug]) if any(pid <= 0 for pid in parsed.ids): @@ -43,7 +44,7 @@ def delete( return Response(status=status.HTTP_404_NOT_FOUND) deleted, _ = WeeklyReportProjectExclusion.objects.filter( - user_id=request.user.id, + user_id=user.id, project=projects[0], ).delete() diff --git a/src/sentry/api/endpoints/organization_weekly_report_project_exclusions.py b/src/sentry/api/endpoints/organization_weekly_report_project_exclusions.py index 0c18ec9f07d2..745070685416 100644 --- a/src/sentry/api/endpoints/organization_weekly_report_project_exclusions.py +++ b/src/sentry/api/endpoints/organization_weekly_report_project_exclusions.py @@ -9,6 +9,7 @@ from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission from sentry.api.paginator import OffsetPaginator from sentry.api.serializers import serialize +from sentry.auth.principal import require_user from sentry.models.organization import Organization from sentry.models.weeklyreportprojectexclusion import WeeklyReportProjectExclusion @@ -30,10 +31,10 @@ class OrganizationWeeklyReportProjectExclusionsEndpoint(OrganizationEndpoint): owner = ApiOwner.ISSUE_DETECTION_BACKEND def get(self, request: Request, organization: Organization) -> Response: - assert request.user and request.user.id + user = require_user(request) queryset = WeeklyReportProjectExclusion.objects.filter( - user_id=request.user.id, + user_id=user.id, project__organization_id=organization.id, ).select_related("project") @@ -46,7 +47,7 @@ def get(self, request: Request, organization: Organization) -> Response: ) def put(self, request: Request, organization: Organization) -> Response: - assert request.user and request.user.id + user = require_user(request) project_ids = request.data.get("projectIds", []) if not isinstance(project_ids, list) or not all( @@ -71,7 +72,7 @@ def put(self, request: Request, organization: Organization) -> Response: with transaction.atomic(using=router.db_for_write(WeeklyReportProjectExclusion)): WeeklyReportProjectExclusion.objects.filter( - user_id=request.user.id, + user_id=user.id, project__organization_id=organization.id, ).delete() @@ -80,7 +81,7 @@ def put(self, request: Request, organization: Organization) -> Response: [ WeeklyReportProjectExclusion( project_id=pid, - user_id=request.user.id, + user_id=user.id, ) for pid in validated_project_ids ], diff --git a/src/sentry/api/endpoints/project_custom_inbound_filter_validate.py b/src/sentry/api/endpoints/project_custom_inbound_filter_validate.py index 3692cf480e0f..cb3f595e3655 100644 --- a/src/sentry/api/endpoints/project_custom_inbound_filter_validate.py +++ b/src/sentry/api/endpoints/project_custom_inbound_filter_validate.py @@ -15,6 +15,7 @@ ProjectCustomInboundFilterEndpoint, ) from sentry.apidocs.response_types import DetailResponse, as_validation_errors +from sentry.auth.principal import acting_user_id from sentry.models.custominboundfilter import CustomInboundFilter from sentry.models.organization import Organization from sentry.models.project import Project @@ -108,6 +109,6 @@ def post( serializer.validated_data["data_type"], serializer.validated_data["conditions"], organization, - user_id=request.user.id, + user_id=acting_user_id(request.user), ) return Response({"errors": {}, "suggestedName": suggested_name}) diff --git a/src/sentry/api/endpoints/project_servicehooks.py b/src/sentry/api/endpoints/project_servicehooks.py index d43d6fa100e5..4cd3d1c4d4ea 100644 --- a/src/sentry/api/endpoints/project_servicehooks.py +++ b/src/sentry/api/endpoints/project_servicehooks.py @@ -19,6 +19,7 @@ ) from sentry.apidocs.parameters import CursorQueryParam, GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.auth.principal import require_user from sentry.constants import ObjectStatus from sentry.models.project import Project from sentry.sentry_apps.api.parsers.servicehook import ServiceHookValidator @@ -116,8 +117,7 @@ def post(self, request: Request, project: Project) -> Response[ServiceHookSerial This endpoint requires the `servicehooks` feature to be enabled for your project. """ - if not request.user.is_authenticated: - return self.respond(status=401) + user = require_user(request) if not self.has_feature(request, project): return self.respond( @@ -140,7 +140,7 @@ def post(self, request: Request, project: Project) -> Response[ServiceHookSerial project_ids=[project.id], organization_id=project.organization.id, url=result["url"], - actor_id=request.user.id, + actor_id=user.id, events=result.get("events"), application_id=app_id, installation_id=None, # Just being explicit here. diff --git a/src/sentry/api/endpoints/project_transaction_threshold.py b/src/sentry/api/endpoints/project_transaction_threshold.py index faabce9d0c5b..0114016950b7 100644 --- a/src/sentry/api/endpoints/project_transaction_threshold.py +++ b/src/sentry/api/endpoints/project_transaction_threshold.py @@ -8,6 +8,7 @@ from sentry.api.base import cell_silo_endpoint from sentry.api.bases.project import ProjectEndpoint, ProjectSettingPermission from sentry.api.serializers import serialize +from sentry.auth.principal import acting_user_id from sentry.models.project import Project from sentry.models.transaction_threshold import ( TRANSACTION_METRICS, @@ -96,7 +97,7 @@ def post(self, request: Request, project: Project) -> Response: data = serializer.validated_data - defaults = {"edited_by_id": request.user.id} + defaults = {"edited_by_id": acting_user_id(request.user)} if data.get("threshold") is not None: defaults["threshold"] = data.get("threshold") if data.get("metric") is not None: @@ -108,7 +109,7 @@ def post(self, request: Request, project: Project) -> Response: create_defaults={ "threshold": data.get("threshold", 300), "metric": data.get("metric", TransactionMetric.DURATION.value), - "edited_by_id": request.user.id, + "edited_by_id": acting_user_id(request.user), }, defaults=defaults, ) diff --git a/src/sentry/api/endpoints/project_transaction_threshold_override.py b/src/sentry/api/endpoints/project_transaction_threshold_override.py index aceb5be56ed6..1f9b6f7224c0 100644 --- a/src/sentry/api/endpoints/project_transaction_threshold_override.py +++ b/src/sentry/api/endpoints/project_transaction_threshold_override.py @@ -11,6 +11,7 @@ from sentry.api.bases import ProjectTransactionThresholdOverridePermission from sentry.api.bases.organization_events import OrganizationEventsEndpointBase from sentry.api.serializers import serialize +from sentry.auth.principal import acting_user_id from sentry.models.organization import Organization from sentry.models.project import Project from sentry.models.transaction_threshold import ( @@ -133,7 +134,7 @@ def post(self, request: Request, organization: Organization) -> Response: defaults={ "threshold": data["threshold"], "metric": data["metric"], - "edited_by_id": request.user.id, + "edited_by_id": acting_user_id(request.user), }, ) diff --git a/src/sentry/api/endpoints/prompts_activity.py b/src/sentry/api/endpoints/prompts_activity.py index 2cfe1a2a0d0d..24659dbc7669 100644 --- a/src/sentry/api/endpoints/prompts_activity.py +++ b/src/sentry/api/endpoints/prompts_activity.py @@ -23,6 +23,7 @@ from sentry.apidocs.parameters import GlobalParams from sentry.apidocs.response_types import DetailResponse from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry.auth.principal import is_owned_by_acting_user from sentry.models.organization import Organization from sentry.models.project import Project from sentry.models.promptsactivity import PromptsActivity @@ -153,7 +154,7 @@ def get( # Always scope by organization from URL - passed directly to filter() to prevent override result_qs = PromptsActivity.objects.filter( - conditions, user_id=request.user.id, organization_id=organization.id + conditions, is_owned_by_acting_user(request.user), organization_id=organization.id ) featuredata = {k.feature: k.data for k in result_qs} if len(features) == 1: diff --git a/src/sentry/api/helpers/group_index/update.py b/src/sentry/api/helpers/group_index/update.py index ec4d0e4efaa8..28f7e5ed3622 100644 --- a/src/sentry/api/helpers/group_index/update.py +++ b/src/sentry/api/helpers/group_index/update.py @@ -61,6 +61,7 @@ from sentry.models.project import Project from sentry.models.release import Release, ReleaseStatus, follows_semver_versioning_scheme from sentry.notifications.types import SUBSCRIPTION_REASON_MAP, GroupSubscriptionReason +from sentry.service_accounts.principal import ServiceAccountPrincipal from sentry.signals import issue_resolved from sentry.types.activity import ActivityType from sentry.types.actor import Actor, ActorType @@ -199,7 +200,7 @@ def get_current_release_version_of_group(group: Group, follows_semver: bool = Fa def update_groups( request: Request, groups: Sequence[Group], - user: RpcUser | User | AnonymousUser | None = None, + user: RpcUser | User | AnonymousUser | ServiceAccountPrincipal | None = None, data: Mapping[str, Any] | None = None, ) -> Response: # If `user` and `data` are passed as parameters then they should override @@ -355,7 +356,7 @@ def validate_request( request: Request, projects: Sequence[Project], data: Mapping[str, Any], - user: RpcUser | User | AnonymousUser | None = None, + user: RpcUser | User | AnonymousUser | ServiceAccountPrincipal | None = None, ) -> GroupValidator | None: serializer = None # TODO(jess): We may want to look into refactoring GroupValidator diff --git a/tests/sentry/api/endpoints/test_organization_onboarding_continuation.py b/tests/sentry/api/endpoints/test_organization_onboarding_continuation.py index 820b998cc53d..08d1f2971779 100644 --- a/tests/sentry/api/endpoints/test_organization_onboarding_continuation.py +++ b/tests/sentry/api/endpoints/test_organization_onboarding_continuation.py @@ -1,6 +1,11 @@ from unittest import mock +from django.test import override_settings +from django.urls import reverse + from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.features import with_feature +from sentry.viewer_context import Actor, ActorType, ViewerContext, encode_viewer_context class OrganizationOnboardingContinuation(APITestCase): @@ -50,3 +55,26 @@ def test_non_member_rejected(self, builder: mock.MagicMock) -> None: data = {"platforms": ["javascript"]} self.get_error_response(other_org.slug, status_code=403, **data) builder.assert_not_called() + + @override_settings(SEER_API_SHARED_SECRET="service-account-secret") + @with_feature("organizations:service-accounts") + @mock.patch("sentry.api.endpoints.organization_onboarding_continuation_email.MessageBuilder") + def test_service_account_is_user_required(self, builder: mock.MagicMock) -> None: + service_account = self.create_service_account( + organization=self.organization, scope_list=["org:read"] + ) + viewer_context = encode_viewer_context( + ViewerContext( + actor=Actor(type=ActorType.SERVICE_ACCOUNT, actor_id=service_account.id), + organization_id=self.organization.id, + ), + key="service-account-secret", + ) + url = reverse(self.endpoint, args=[self.organization.slug]) + + resp = self.client.post( + url, {"platforms": ["javascript"]}, format="json", HTTP_X_VIEWER_CONTEXT=viewer_context + ) + assert resp.status_code == 403, resp.content + assert resp.data["detail"]["code"] == "user-required" + builder.assert_not_called() diff --git a/tests/sentry/api/endpoints/test_organization_recent_searches.py b/tests/sentry/api/endpoints/test_organization_recent_searches.py index 4063cb32054f..ea96be769a8c 100644 --- a/tests/sentry/api/endpoints/test_organization_recent_searches.py +++ b/tests/sentry/api/endpoints/test_organization_recent_searches.py @@ -5,6 +5,7 @@ from typing import Any from django.test import override_settings +from django.urls import reverse from django.utils import timezone from sentry.api.serializers import serialize @@ -18,6 +19,7 @@ from sentry.testutils.helpers.options import override_options from sentry.testutils.silo import assume_test_silo_mode from sentry.users.models.user import User +from sentry.viewer_context import Actor, ActorType, ViewerContext, encode_viewer_context class RecentSearchesListTest(APITestCase): @@ -194,6 +196,33 @@ def test_query(self) -> None: ] self.check_results(issue_recent_searches[1:], search_type=SearchType.ISSUE, query="lde") + @override_settings(SEER_API_SHARED_SECRET="service-account-secret") + @with_feature("organizations:service-accounts") + def test_service_account_sees_no_recent_searches(self) -> None: + RecentSearch.objects.create( + organization=self.organization, + user_id=self.user.id, + type=SearchType.ISSUE.value, + query="some test", + ) + service_account = self.create_service_account( + organization=self.organization, scope_list=["org:read"] + ) + viewer_context = encode_viewer_context( + ViewerContext( + actor=Actor(type=ActorType.SERVICE_ACCOUNT, actor_id=service_account.id), + organization_id=self.organization.id, + ), + key="service-account-secret", + ) + url = reverse(self.endpoint, args=[self.organization.slug]) + + response = self.client.get( + url, {"type": SearchType.ISSUE.value}, HTTP_X_VIEWER_CONTEXT=viewer_context + ) + assert response.status_code == 200, response.content + assert response.data == [] + class RecentSearchesCreateTest(APITestCase): endpoint = "sentry-api-0-organization-recent-searches" diff --git a/tests/sentry/api/endpoints/test_project_transaction_threshold.py b/tests/sentry/api/endpoints/test_project_transaction_threshold.py index 30c83bbf3c89..fc9d4b09f7c1 100644 --- a/tests/sentry/api/endpoints/test_project_transaction_threshold.py +++ b/tests/sentry/api/endpoints/test_project_transaction_threshold.py @@ -1,7 +1,10 @@ +from django.test import override_settings from django.urls import reverse from sentry.models.transaction_threshold import ProjectTransactionThreshold, TransactionMetric from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.features import with_feature +from sentry.viewer_context import Actor, ActorType, ViewerContext, encode_viewer_context class ProjectTransactionThresholdTest(APITestCase): @@ -75,6 +78,33 @@ def test_create_project_threshold(self) -> None: project=self.project, organization=self.project.organization ).exists() + @override_settings(SEER_API_SHARED_SECRET="service-account-secret") + @with_feature("organizations:service-accounts") + def test_service_account_creates_threshold_without_editor(self) -> None: + service_account = self.create_service_account( + organization=self.project.organization, + scope_list=["project:write"], + project_ids=[self.project.id], + ) + viewer_context = encode_viewer_context( + ViewerContext( + actor=Actor(type=ActorType.SERVICE_ACCOUNT, actor_id=service_account.id), + organization_id=self.project.organization.id, + ), + key="service-account-secret", + ) + + with self.feature(self.feature_name): + response = self.client.post( + self.url, + data={"metric": "duration", "threshold": "300"}, + HTTP_X_VIEWER_CONTEXT=viewer_context, + ) + + assert response.status_code == 201, response.content + threshold = ProjectTransactionThreshold.objects.get(project=self.project) + assert threshold.edited_by_id is None + def test_update_single_field_project_threshold(self) -> None: with self.feature(self.feature_name): response = self.client.post( diff --git a/tests/sentry/api/endpoints/test_project_transaction_threshold_override.py b/tests/sentry/api/endpoints/test_project_transaction_threshold_override.py index 2f23313753ff..6b9d6f325abf 100644 --- a/tests/sentry/api/endpoints/test_project_transaction_threshold_override.py +++ b/tests/sentry/api/endpoints/test_project_transaction_threshold_override.py @@ -1,5 +1,6 @@ from unittest import mock +from django.test import override_settings from django.urls import reverse from sentry.models.transaction_threshold import ( @@ -8,8 +9,10 @@ ) from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.datetime import before_now +from sentry.testutils.helpers.features import with_feature from sentry.testutils.skips import requires_snuba from sentry.utils.samples import load_data +from sentry.viewer_context import Actor, ActorType, ViewerContext, encode_viewer_context pytestmark = [requires_snuba] @@ -124,6 +127,36 @@ def test_create_project_threshold(self) -> None: organization=self.org, ).exists() + @override_settings(SEER_API_SHARED_SECRET="service-account-secret") + @with_feature("organizations:service-accounts") + def test_service_account_creates_override_without_editor(self) -> None: + service_account = self.create_service_account( + organization=self.org, scope_list=["project:write"], project_ids=[self.project.id] + ) + viewer_context = encode_viewer_context( + ViewerContext( + actor=Actor(type=ActorType.SERVICE_ACCOUNT, actor_id=service_account.id), + organization_id=self.org.id, + ), + key="service-account-secret", + ) + + with self.feature(self.feature_name): + response = self.client.post( + self.url, + data={ + "transaction": self.data["transaction"], + "project": [self.project.id], + "metric": "duration", + "threshold": "600", + }, + HTTP_X_VIEWER_CONTEXT=viewer_context, + ) + + assert response.status_code == 201, response.content + override = ProjectTransactionThresholdOverride.objects.get(project=self.project) + assert override.edited_by_id is None + def test_creating_too_many_project_thresholds_raises_error(self) -> None: ProjectTransactionThresholdOverride.objects.create( project=self.project, diff --git a/tests/sentry/api/endpoints/test_prompts_activity.py b/tests/sentry/api/endpoints/test_prompts_activity.py index cc29f5f59a0c..f0c2db75832d 100644 --- a/tests/sentry/api/endpoints/test_prompts_activity.py +++ b/tests/sentry/api/endpoints/test_prompts_activity.py @@ -1,10 +1,13 @@ +from django.test import override_settings from django.urls import reverse from rest_framework.response import Response +from sentry.models.promptsactivity import PromptsActivity from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.features import with_feature from sentry.testutils.silo import assume_test_silo_mode +from sentry.viewer_context import Actor, ActorType, ViewerContext, encode_viewer_context class PromptsActivityTestBase(APITestCase): @@ -54,6 +57,35 @@ def test_get_empty_project_id(self) -> None: assert resp.status_code == 400 assert resp.data["detail"] == 'Missing required field "project_id"' + @override_settings(SEER_API_SHARED_SECRET="service-account-secret") + @with_feature("organizations:service-accounts") + def test_service_account_sees_no_prompt_state(self) -> None: + PromptsActivity.objects.create( + user_id=self.user.id, + feature="releases", + organization_id=self.org.id, + project_id=self.project.id, + data={"dismissed_ts": 1}, + ) + service_account = self.create_service_account( + organization=self.org, scope_list=["org:read"] + ) + viewer_context = encode_viewer_context( + ViewerContext( + actor=Actor(type=ActorType.SERVICE_ACCOUNT, actor_id=service_account.id), + organization_id=self.org.id, + ), + key="service-account-secret", + ) + + resp = self.client.get( + self.path, + {"project_id": self.project.id, "feature": "releases"}, + HTTP_X_VIEWER_CONTEXT=viewer_context, + ) + assert resp.status_code == 200, resp.content + assert resp.data == {"data": None, "features": {}} + class PutPromptsActivityTest(PromptsActivityTestBase): method = "put" diff --git a/tests/sentry/auth/test_principal.py b/tests/sentry/auth/test_principal.py index b4bdd7b8b6c6..634d35f512e2 100644 --- a/tests/sentry/auth/test_principal.py +++ b/tests/sentry/auth/test_principal.py @@ -274,8 +274,8 @@ 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 = 176 -REQUEST_USER_TYPE_CHECK_BASELINE = 31 +REQUEST_USER_ID_BASELINE = 135 +REQUEST_USER_TYPE_CHECK_BASELINE = 29 def test_request_user_id_uses_only_go_down() -> None: