Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/sentry/api/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions src/sentry/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions src/sentry/utils/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
41 changes: 40 additions & 1 deletion tests/sentry/api/bases/test_organization.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading