diff --git a/src/sentry/api/authentication.py b/src/sentry/api/authentication.py index cd926d5fb334cc..665aaf17c08d7d 100644 --- a/src/sentry/api/authentication.py +++ b/src/sentry/api/authentication.py @@ -833,6 +833,10 @@ def authenticate(self, request: Request) -> tuple[Any, Any] | None: return None sentry_sdk.get_isolation_scope().set_tag("viewer_context_auth", True) + # Viewer context comes from a trusted first-party service. Keep auth + # session-like for permission derivation, but mark it so org access can + # avoid requiring browser-session SSO state on service callbacks. + setattr(request, "user_from_viewer_context", True) # Return None for auth to match session behavior — # determine_access will derive scopes from org membership role. diff --git a/src/sentry/api/permissions.py b/src/sentry/api/permissions.py index fec09d3e594a28..e48f3425999ccf 100644 --- a/src/sentry/api/permissions.py +++ b/src/sentry/api/permissions.py @@ -203,9 +203,10 @@ def determine_access( rpc_user_org_context=org_context, ) - if auth.is_user_signed_request(request): - # if the user comes from a signed request - # we let them pass if sso is enabled + if auth.is_user_signed_request(request) or auth.is_user_from_viewer_context(request): + # Signed requests and viewer-context-authenticated service + # callbacks already carry a trusted assertion of user identity, so + # they should not depend on browser-session SSO completion. logger.info( "access.signed-sso-passthrough", extra=extra, diff --git a/src/sentry/utils/auth.py b/src/sentry/utils/auth.py index 8a118866922f35..9ca4d357420bba 100644 --- a/src/sentry/utils/auth.py +++ b/src/sentry/utils/auth.py @@ -404,6 +404,13 @@ def is_user_signed_request(request: Request) -> bool: return False +def is_user_from_viewer_context(request: Request) -> bool: + """ + This function returns True if the request was authenticated via viewer context. + """ + return bool(getattr(request, "user_from_viewer_context", False)) + + def set_active_org(request: HttpRequest, org_slug: str) -> None: # even if the value being set is the same this will trigger a session # modification and reset the users expiry, so check if they are different first. diff --git a/tests/sentry/api/bases/test_organization.py b/tests/sentry/api/bases/test_organization.py index 5fbaed64c9002e..7537fbb71c918c 100644 --- a/tests/sentry/api/bases/test_organization.py +++ b/tests/sentry/api/bases/test_organization.py @@ -1,16 +1,20 @@ +import hashlib +import hmac from datetime import timedelta from functools import cached_property from unittest import mock +import orjson import pytest from django.contrib.auth.models import AnonymousUser from django.contrib.sessions.backends.base import SessionBase from django.db.models import F -from django.test import RequestFactory +from django.test import RequestFactory, override_settings from django.utils import timezone from rest_framework.exceptions import PermissionDenied from rest_framework.views import APIView +from sentry.api.authentication import ViewerContextAuthentication from sentry.api.bases.organization import ( NoProjects, OrganizationAndStaffPermission, @@ -96,6 +100,8 @@ def has_object_perm( class OrganizationPermissionTest(PermissionBaseTestCase): + VIEWER_CONTEXT_SHARED_SECRET = "test-seer-api-shared-secret" + def org_require_2fa(self): self.org.update(flags=F("flags").bitor(Organization.flags.require_2fa)) assert self.org.flags.require_2fa.is_set is True @@ -311,6 +317,39 @@ def test_sso_required(self) -> None: with pytest.raises(SsoRequired): assert not self.has_object_perm("POST", self.org, user=user) + @override_settings(SEER_API_SHARED_SECRET=VIEWER_CONTEXT_SHARED_SECRET) + def test_viewer_context_auth_bypasses_sso_gate(self) -> None: + user = self.create_user() + self.create_member(user=user, organization=self.org, role="member") + + with assume_test_silo_mode(SiloMode.CONTROL): + auth_provider = AuthProvider.objects.create( + organization_id=self.org.id, provider="dummy" + ) + AuthIdentity.objects.create(auth_provider=auth_provider, user=user) + + context = orjson.dumps({"user_id": user.id, "actor_type": "user"}).decode() + signature = hmac.new( + self.VIEWER_CONTEXT_SHARED_SECRET.encode("utf-8"), + context.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + request = RequestFactory().get("/api/0/organizations/") + request.session = SessionBase() + request.META["HTTP_X_VIEWER_CONTEXT"] = context + request.META["HTTP_X_VIEWER_CONTEXT_SIGNATURE"] = signature + + drf_request = drf_request_from_request(request) + result = ViewerContextAuthentication().authenticate(drf_request) + + assert result is not None + drf_request.user, drf_request.auth = result + assert getattr(drf_request, "user_from_viewer_context", False) is True + + permission = self.permission_cls() + permission.determine_access(request=drf_request, organization=self.org) + class OrganizationAndStaffPermissionTest(PermissionBaseTestCase): def setUp(self) -> None: