Skip to content

Commit 3326f75

Browse files
committed
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`.
1 parent 6958b17 commit 3326f75

36 files changed

Lines changed: 246 additions & 68 deletions

‎src/sentry/api/base.py‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -782,9 +782,7 @@ def track_set_commits_local(self, request: Request, organization_id=None, projec
782782
try:
783783
analytics.record(
784784
ReleaseSetCommitsLocalEvent(
785-
user_id=acting_user_id(request.user)
786-
if request.user and request.user.id
787-
else None,
785+
user_id=acting_user_id(request.user),
788786
organization_id=organization_id,
789787
project_ids=project_ids,
790788
user_agent=request.META.get("HTTP_USER_AGENT", ""),

‎src/sentry/api/bases/organization.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
)
3232
from sentry.api.permissions import DemoSafePermission, StaffPermissionMixin
3333
from sentry.api.utils import get_date_range_from_params, is_member_disabled_from_limit
34-
from sentry.auth.principal import acting_user_id
34+
from sentry.auth.principal import acting_user_id, require_user
3535
from sentry.auth.services.auth import AuthenticatedToken
3636
from sentry.auth.superuser import is_active_superuser
3737
from sentry.constants import ALL_ACCESS_PROJECT_ID, ALL_ACCESS_PROJECTS_SLUG, ObjectStatus
@@ -99,7 +99,7 @@ def is_not_2fa_compliant(
9999
if not organization.flags.require_2fa:
100100
return False
101101

102-
if request.user.is_authenticated and request.user.has_2fa():
102+
if request.user.is_authenticated and require_user(request).has_2fa():
103103
return False
104104

105105
if request.user.is_authenticated and request.user.is_sentry_app:
@@ -932,7 +932,7 @@ def has_release_permission(
932932
key = None
933933
if not is_agent_auth(request.auth) and not is_service_account_auth(request.auth):
934934
if request.user.is_authenticated:
935-
actor_id = "user:%s" % request.user.id
935+
actor_id = "user:%s" % require_user(request).id
936936
elif request.auth is not None:
937937
actor_id = "apikey:%s" % request.auth.entity_id
938938
if actor_id is not None:

‎src/sentry/api/bases/organization_events.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
from sentry.search.events.constants import DURATION_UNITS, SIZE_UNITS
5757
from sentry.search.events.fields import get_function_alias, is_function
5858
from sentry.search.events.types import SAMPLING_MODES, SnubaParams
59+
from sentry.service_accounts.principal import ServiceAccountPrincipal
5960
from sentry.snuba import discover, metrics_enhanced_performance, metrics_performance
6061
from sentry.snuba.dataset import Dataset
6162
from sentry.snuba.metrics.extraction import MetricSpecType
@@ -290,7 +291,7 @@ def save_split_decision(
290291
has_errors: bool,
291292
has_transactions_data: bool,
292293
organization: Organization,
293-
user: User | AnonymousUser,
294+
user: User | AnonymousUser | ServiceAccountPrincipal,
294295
) -> int | None:
295296
"""This can be removed once the discover dataset has been fully split"""
296297
source = DashboardDatasetSourcesTypes.INFERRED.value

‎src/sentry/api/decorators.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from rest_framework.request import Request
88

99
from sentry.api.exceptions import PrimaryEmailVerificationRequired, SudoRequired, UserRequired
10-
from sentry.auth.principal import has_user
10+
from sentry.auth.principal import has_user, require_user
1111
from sentry.models.apikey import is_api_key_auth
1212
from sentry.models.apitoken import is_api_token_auth
1313
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
5858
def primary_email_verification_required[T, **P](func: _RFMethod[T, P]) -> _RFMethod[T, P]:
5959
@wraps(func)
6060
def wrapped(self: T, request: Request, *args: P.args, **kwargs: P.kwargs) -> HttpResponseBase:
61-
if isinstance(request.user, AnonymousUser) or not request.user.has_verified_primary_email():
61+
if (
62+
isinstance(request.user, AnonymousUser)
63+
or not require_user(request).has_verified_primary_email()
64+
):
6265
raise PrimaryEmailVerificationRequired(request.user)
6366
return func(self, request, *args, **kwargs)
6467

‎src/sentry/api/endpoints/organization_agentic_onboarding.py‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
AgenticOnboardingRunSerializer,
2323
)
2424
from sentry.api.serializers.rest_framework import CamelSnakeSerializer
25+
from sentry.auth.principal import require_user
2526
from sentry.models.organization import Organization
2627
from sentry.onboarding.agentic_progress.model import (
2728
MAX_EVENT_NOTE_LENGTH,
@@ -168,8 +169,7 @@ class OrganizationAgenticOnboardingRunIndexEndpoint(OrganizationEndpoint):
168169
def post(self, request: Request, organization: Organization) -> Response:
169170
serializer = AgenticOnboardingRunRequestSerializer(data=request.data)
170171
serializer.is_valid(raise_exception=True)
171-
user_id = request.user.id
172-
assert user_id is not None
172+
user_id = require_user(request).id
173173
try:
174174
run, onboarding_code = get_onboarding_progress_service().create_or_resume(
175175
user_id=user_id,
@@ -207,8 +207,7 @@ def post(self, request: Request, organization: Organization) -> Response:
207207
serializer = AgenticOnboardingStatusRequestSerializer(data=request.data)
208208
serializer.is_valid(raise_exception=True)
209209
values = serializer.validated_data
210-
user_id = request.user.id
211-
assert user_id is not None
210+
user_id = require_user(request).id
212211

213212
try:
214213
run, changed, stage_status_changed = get_onboarding_progress_service().update(

‎src/sentry/api/endpoints/organization_agentic_onboarding_run_details.py‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from sentry.api.serializers.models.agentic_onboarding import (
1212
AgenticOnboardingRunSerializer,
1313
)
14+
from sentry.auth.principal import require_user
1415
from sentry.models.organization import Organization
1516
from sentry.onboarding.agentic_progress.model import OnboardingRunTerminal
1617
from sentry.onboarding.agentic_progress.service import (
@@ -27,8 +28,7 @@ class OrganizationAgenticOnboardingRunDetailsEndpoint(OrganizationEndpoint):
2728

2829
@extend_schema(responses={200: AgenticOnboardingRunSerializer})
2930
def get(self, request: Request, organization: Organization, run_id: str) -> Response:
30-
user_id = request.user.id
31-
assert user_id is not None
31+
user_id = require_user(request).id
3232
run = get_onboarding_progress_service().get(
3333
run_id=run_id, user_id=user_id, organization_id=organization.id
3434
)
@@ -39,8 +39,7 @@ def get(self, request: Request, organization: Organization, run_id: str) -> Resp
3939

4040
@extend_schema(responses={200: AgenticOnboardingRunSerializer})
4141
def delete(self, request: Request, organization: Organization, run_id: str) -> Response:
42-
user_id = request.user.id
43-
assert user_id is not None
42+
user_id = require_user(request).id
4443
try:
4544
run = get_onboarding_progress_service().cancel(
4645
run_id=run_id, user_id=user_id, organization_id=organization.id

‎src/sentry/api/endpoints/organization_auth_tokens.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ def post(
161161
name=name,
162162
organization_id=organization.id,
163163
scope_list=sorted(set(scopes)),
164-
created_by_id=request.user.id,
164+
created_by_id=acting_user_id(request.user),
165165
token_last_characters=token_str[-4:],
166166
token_hashed=token_hashed,
167167
)

‎src/sentry/api/endpoints/organization_fork.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from sentry.api.base import Endpoint, cell_silo_endpoint
1515
from sentry.api.permissions import SuperuserOrStaffFeatureFlaggedPermission
1616
from sentry.api.serializers import serialize
17+
from sentry.auth.principal import acting_user_id, require_user
1718
from sentry.hybridcloud.services.organization_mapping import organization_mapping_service
1819
from sentry.models.organization import OrganizationStatus
1920
from sentry.relocation.api.endpoints.index import (
@@ -69,10 +70,11 @@ def post(self, request: Request, organization_id_or_slug) -> Response:
6970
:auth: required
7071
"""
7172

72-
logger.info("relocations.fork.post.start", extra={"caller": request.user.id})
73+
logger.info("relocations.fork.post.start", extra={"caller": acting_user_id(request.user)})
7374

7475
if not request.user.is_authenticated:
7576
return Response(status=status.HTTP_400_BAD_REQUEST)
77+
user = require_user(request)
7678

7779
org_mapping = (
7880
organization_mapping_service.get(organization_id=organization_id_or_slug)
@@ -151,7 +153,7 @@ def post(self, request: Request, organization_id_or_slug) -> Response:
151153
# Identify who will be the owner of the newly forked organization, and ensure that they
152154
# don't already have relocations in flight.
153155
owners = organization_mapping_service.get_owners(organization_id=org_mapping.id)
154-
owner = owners[0] if len(owners) > 0 else request.user
156+
owner = owners[0] if len(owners) > 0 else user
155157
err = validate_relocation_uniqueness(owner)
156158
if err is not None:
157159
return err
@@ -162,7 +164,7 @@ def post(self, request: Request, organization_id_or_slug) -> Response:
162164
provenance = Relocation.Provenance.SAAS_TO_SAAS
163165
with atomic_transaction(using=(router.db_for_write(Relocation))):
164166
new_relocation = Relocation.objects.create(
165-
creator_id=request.user.id,
167+
creator_id=user.id,
166168
owner_id=owner.id,
167169
step=Relocation.Step.UPLOADING.value,
168170
scheduled_pause_at_step=get_autopause_value(provenance),
@@ -180,7 +182,7 @@ def post(self, request: Request, organization_id_or_slug) -> Response:
180182
try:
181183
analytics.record(
182184
RelocationForkedEvent(
183-
creator_id=request.user.id,
185+
creator_id=user.id,
184186
owner_id=owner.id,
185187
uuid=str(new_relocation.uuid),
186188
from_org_slug=org_mapping.slug,

‎src/sentry/api/endpoints/organization_intercom_jwt.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from sentry.api.api_publish_status import ApiPublishStatus
1212
from sentry.api.base import control_silo_endpoint
1313
from sentry.api.bases.organization import ControlSiloOrganizationEndpoint
14+
from sentry.auth.principal import require_user
1415
from sentry.organizations.services.organization.model import (
1516
RpcOrganization,
1617
RpcUserOrganizationContext,
@@ -60,7 +61,7 @@ def get(
6061
status=status.HTTP_503_SERVICE_UNAVAILABLE,
6162
)
6263

63-
user = request.user
64+
user = require_user(request)
6465
now = int(datetime.datetime.now(datetime.UTC).timestamp())
6566
exp = now + JWT_VALIDITY_WINDOW_SECONDS
6667

‎src/sentry/api/endpoints/organization_monitoring_provider_details.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
MonitoringProviderPermission,
2020
)
2121
from sentry.auth.exceptions import IdentityNotValid
22+
from sentry.auth.principal import owned_by_acting_user
2223
from sentry.identity import default_manager as identity_manager
2324
from sentry.identity.base import Provider
2425
from sentry.identity.oauth2 import OAuth2Provider
@@ -86,8 +87,8 @@ def put(
8687
# Reuse the stored site from the existing connection.
8788
org_identity = (
8889
OrganizationIdentity.objects.filter(
90+
owned_by_acting_user(request.user, "identity__user_id"),
8991
organization_id=organization.id,
90-
identity__user_id=request.user.id, # type: ignore[misc]
9192
identity__idp__type=provider_key,
9293
)
9394
.select_related("identity")
@@ -186,8 +187,8 @@ def delete(
186187

187188
org_identities: list[OrganizationIdentity] = list(
188189
OrganizationIdentity.objects.filter(
190+
owned_by_acting_user(request.user, "identity__user_id"),
189191
organization_id=organization.id,
190-
identity__user_id=request.user.id, # type: ignore[misc]
191192
identity__idp__type=provider_key,
192193
).select_related("identity")
193194
)

0 commit comments

Comments
 (0)