Skip to content

Commit 794e1eb

Browse files
grichacodex
andauthored
fix(auth): bypass SSO for viewer-context requests (#113124)
Bypass the SSO gate for requests authenticated through viewer context. Seer code mode callbacks authenticate as the acting user via `X-Viewer-Context`, but Sentry was treating those requests like browser-session auth and enforcing completed SSO state from the Django session. That made callbacks fail with `sso-required` for orgs that require SSO even though the user identity had already been asserted by a trusted first-party service. This marks viewer-context-authenticated requests explicitly and lets them skip only the SSO gate while preserving the existing membership-based access checks. It also adds a regression test covering the SSO-required org path so the Seer callback behavior stays covered. Co-authored-by: OpenAI Codex <noreply@openai.com>
1 parent 753dc7b commit 794e1eb

File tree

4 files changed

+55
-4
lines changed

4 files changed

+55
-4
lines changed

src/sentry/api/authentication.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -833,6 +833,10 @@ def authenticate(self, request: Request) -> tuple[Any, Any] | None:
833833
return None
834834

835835
sentry_sdk.get_isolation_scope().set_tag("viewer_context_auth", True)
836+
# Viewer context comes from a trusted first-party service. Keep auth
837+
# session-like for permission derivation, but mark it so org access can
838+
# avoid requiring browser-session SSO state on service callbacks.
839+
setattr(request, "user_from_viewer_context", True)
836840

837841
# Return None for auth to match session behavior —
838842
# determine_access will derive scopes from org membership role.

src/sentry/api/permissions.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,9 +203,10 @@ def determine_access(
203203
rpc_user_org_context=org_context,
204204
)
205205

206-
if auth.is_user_signed_request(request):
207-
# if the user comes from a signed request
208-
# we let them pass if sso is enabled
206+
if auth.is_user_signed_request(request) or auth.is_user_from_viewer_context(request):
207+
# Signed requests and viewer-context-authenticated service
208+
# callbacks already carry a trusted assertion of user identity, so
209+
# they should not depend on browser-session SSO completion.
209210
logger.info(
210211
"access.signed-sso-passthrough",
211212
extra=extra,

src/sentry/utils/auth.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,13 @@ def is_user_signed_request(request: Request) -> bool:
404404
return False
405405

406406

407+
def is_user_from_viewer_context(request: Request) -> bool:
408+
"""
409+
This function returns True if the request was authenticated via viewer context.
410+
"""
411+
return bool(getattr(request, "user_from_viewer_context", False))
412+
413+
407414
def set_active_org(request: HttpRequest, org_slug: str) -> None:
408415
# even if the value being set is the same this will trigger a session
409416
# modification and reset the users expiry, so check if they are different first.

tests/sentry/api/bases/test_organization.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
1+
import hashlib
2+
import hmac
13
from datetime import timedelta
24
from functools import cached_property
35
from unittest import mock
46

7+
import orjson
58
import pytest
69
from django.contrib.auth.models import AnonymousUser
710
from django.contrib.sessions.backends.base import SessionBase
811
from django.db.models import F
9-
from django.test import RequestFactory
12+
from django.test import RequestFactory, override_settings
1013
from django.utils import timezone
1114
from rest_framework.exceptions import PermissionDenied
1215
from rest_framework.views import APIView
1316

17+
from sentry.api.authentication import ViewerContextAuthentication
1418
from sentry.api.bases.organization import (
1519
NoProjects,
1620
OrganizationAndStaffPermission,
@@ -96,6 +100,8 @@ def has_object_perm(
96100

97101

98102
class OrganizationPermissionTest(PermissionBaseTestCase):
103+
VIEWER_CONTEXT_SHARED_SECRET = "test-seer-api-shared-secret"
104+
99105
def org_require_2fa(self):
100106
self.org.update(flags=F("flags").bitor(Organization.flags.require_2fa))
101107
assert self.org.flags.require_2fa.is_set is True
@@ -311,6 +317,39 @@ def test_sso_required(self) -> None:
311317
with pytest.raises(SsoRequired):
312318
assert not self.has_object_perm("POST", self.org, user=user)
313319

320+
@override_settings(SEER_API_SHARED_SECRET=VIEWER_CONTEXT_SHARED_SECRET)
321+
def test_viewer_context_auth_bypasses_sso_gate(self) -> None:
322+
user = self.create_user()
323+
self.create_member(user=user, organization=self.org, role="member")
324+
325+
with assume_test_silo_mode(SiloMode.CONTROL):
326+
auth_provider = AuthProvider.objects.create(
327+
organization_id=self.org.id, provider="dummy"
328+
)
329+
AuthIdentity.objects.create(auth_provider=auth_provider, user=user)
330+
331+
context = orjson.dumps({"user_id": user.id, "actor_type": "user"}).decode()
332+
signature = hmac.new(
333+
self.VIEWER_CONTEXT_SHARED_SECRET.encode("utf-8"),
334+
context.encode("utf-8"),
335+
hashlib.sha256,
336+
).hexdigest()
337+
338+
request = RequestFactory().get("/api/0/organizations/")
339+
request.session = SessionBase()
340+
request.META["HTTP_X_VIEWER_CONTEXT"] = context
341+
request.META["HTTP_X_VIEWER_CONTEXT_SIGNATURE"] = signature
342+
343+
drf_request = drf_request_from_request(request)
344+
result = ViewerContextAuthentication().authenticate(drf_request)
345+
346+
assert result is not None
347+
drf_request.user, drf_request.auth = result
348+
assert getattr(drf_request, "user_from_viewer_context", False) is True
349+
350+
permission = self.permission_cls()
351+
permission.determine_access(request=drf_request, organization=self.org)
352+
314353

315354
class OrganizationAndStaffPermissionTest(PermissionBaseTestCase):
316355
def setUp(self) -> None:

0 commit comments

Comments
 (0)