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 800484faac8c..1f59458ef1a4 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 fac6fecc5af6..86697f4437c5 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) -> 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) -> 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) -> 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) -> 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..93137784ca05 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 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( + 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( + 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..1dd265a429c7 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 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( + 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 8351444da7ce..c730e151dca4 100644 --- a/src/sentry/api/endpoints/organization_onboarding_continuation_email.py +++ b/src/sentry/api/endpoints/organization_onboarding_continuation_email.py @@ -1,4 +1,3 @@ -from django.contrib.auth.models import AnonymousUser from rest_framework import serializers from rest_framework.request import Request @@ -9,6 +8,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 @@ -57,19 +57,15 @@ def post(self, request: Request, organization: Organization): 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 4a49058abd67..36b7001628ac 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 ( @@ -127,7 +128,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 15fd0b3d592c..7f4c7c2620a6 100644 --- a/src/sentry/api/endpoints/organization_profiling_functions.py +++ b/src/sentry/api/endpoints/organization_profiling_functions.py @@ -16,6 +16,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 @@ -82,7 +83,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 0f9e837e089c..37e80383aa8d 100644 --- a/src/sentry/api/endpoints/organization_recent_searches.py +++ b/src/sentry/api/endpoints/organization_recent_searches.py @@ -8,6 +8,7 @@ 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 owned_by_acting_user, require_user from sentry.models.recentsearch import RecentSearch, remove_excess_recent_searches from sentry.models.search_common import SearchType @@ -54,7 +55,6 @@ def get(self, request: Request, organization) -> Response: query_kwargs = { "organization": organization, - "user_id": request.user.id, "type": search_type, } @@ -62,7 +62,9 @@ def get(self, request: Request, 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( + owned_by_acting_user(request.user), **query_kwargs + ).order_by("-last_seen")[:limit] ) return Response(serialize(recent_searches, request.user)) @@ -72,16 +74,17 @@ def post(self, request: Request, 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, 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 a333456092c2..24ce1fceba1e 100644 --- a/src/sentry/api/endpoints/organization_releases.py +++ b/src/sentry/api/endpoints/organization_releases.py @@ -927,7 +927,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 d668e94e8cd5..bf54829e643f 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes_ranked.py @@ -35,6 +35,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 @@ -196,7 +197,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=cohort_2_distribution, outliers=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 17e3031c6082..ce7d54a26d84 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 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 + 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): 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 + 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): 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 044ed446f10d..21718c2defb6 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.sentry_apps.api.parsers.servicehook import ServiceHookValidator from sentry.sentry_apps.api.serializers.servicehook import ( @@ -113,8 +114,7 @@ def post(self, request: Request, project) -> Response[ServiceHookSerializerRespo 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( @@ -137,7 +137,7 @@ def post(self, request: Request, project) -> Response[ServiceHookSerializerRespo 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 550b99c2cd51..163626887be0 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.transaction_threshold import ( TRANSACTION_METRICS, ProjectTransactionThreshold, @@ -95,7 +96,7 @@ def post(self, request: Request, 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: @@ -107,7 +108,7 @@ def post(self, request: Request, 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 1b8b084cca6d..10cc4cb04298 100644 --- a/src/sentry/api/endpoints/project_transaction_threshold_override.py +++ b/src/sentry/api/endpoints/project_transaction_threshold_override.py @@ -9,6 +9,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.transaction_threshold import ( TRANSACTION_METRICS, @@ -130,7 +131,7 @@ def post(self, request: Request, 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 2497cb306a4d..5b213aee48d9 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 owned_by_acting_user from sentry.models.organization import Organization from sentry.models.project import Project from sentry.models.promptsactivity import PromptsActivity @@ -150,7 +151,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, 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 4f538045e9e3..cefc1084fd34 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 5e11dac4d88b..f8cd0fe2b07f 100644 --- a/tests/sentry/api/endpoints/test_organization_recent_searches.py +++ b/tests/sentry/api/endpoints/test_organization_recent_searches.py @@ -4,6 +4,8 @@ from functools import cached_property 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 @@ -14,6 +16,7 @@ from sentry.testutils.helpers.datetime import freeze_time 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 RecentSearchesListTest(APITestCase): @@ -188,6 +191,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 e23914522662..110a8d1c51f2 100644 --- a/tests/sentry/api/endpoints/test_prompts_activity.py +++ b/tests/sentry/api/endpoints/test_prompts_activity.py @@ -1,9 +1,12 @@ +from django.test import override_settings from django.urls import reverse +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): @@ -53,6 +56,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 cbb9ccf9df5f..45ffac475969 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: