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
8 changes: 6 additions & 2 deletions src/sentry/api/bases/organizationmember.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from sentry.api.exceptions import ResourceDoesNotExist
from sentry.api.permissions import StaffPermissionMixin
from sentry.auth.principal import has_user
from sentry.db.models.fields.bounded import BoundedAutoField
from sentry.models.organization import Organization
from sentry.models.organizationmember import InviteStatus, OrganizationMember
Expand All @@ -18,6 +19,7 @@
RpcUserOrganizationContext,
)
from sentry.users.models.user import User
from sentry.users.services.user.model import RpcUser

from .organization import OrganizationEndpoint, OrganizationPermission

Expand Down Expand Up @@ -85,6 +87,8 @@ class _FilterKwargs(TypedDict):


class OrganizationMemberEndpoint(OrganizationEndpoint):
requires_user = True

def convert_args(
self,
request: Request,
Expand All @@ -96,7 +100,7 @@ def convert_args(
args, kwargs = super().convert_args(request, organization_id_or_slug, *args, **kwargs)

serializer = MemberSerializer(data={"id": member_id})
if request.user.is_authenticated and serializer.is_valid():
if has_user(request.user) and serializer.is_valid():
result = serializer.validated_data
try:
kwargs["member"] = self._get_member(
Expand All @@ -111,7 +115,7 @@ def convert_args(

def _get_member(
self,
request_user: User,
request_user: User | RpcUser,
organization: Organization,
member_id: int | Literal["me"],
invite_status: InviteStatus | None = None,
Expand Down
15 changes: 9 additions & 6 deletions src/sentry/core/endpoints/organization_member_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
DetailResponse,
ValidationErrorResponse,
)
from sentry.auth.principal import has_user, require_user
from sentry.auth.services.auth import auth_service
from sentry.auth.superuser import is_active_superuser
from sentry.core.endpoints.organization_member_index import OrganizationMemberRequestSerializer
Expand All @@ -48,6 +49,7 @@
from sentry.models.project import Project
from sentry.roles import organization_roles, team_roles
from sentry.users.models.user import User
from sentry.users.services.user.model import RpcUser
from sentry.users.services.user_option import user_option_service
from sentry.utils import metrics

Expand Down Expand Up @@ -134,7 +136,7 @@ class OrganizationMemberDetailsEndpoint(OrganizationMemberEndpoint):

def _get_member(
self,
request_user: User,
request_user: User | RpcUser,
organization: Organization,
member_id: int | Literal["me"],
invite_status: InviteStatus | None = None,
Expand Down Expand Up @@ -220,6 +222,7 @@ def put(
"""
if not request.user.is_authenticated:
return Response(status=status.HTTP_400_BAD_REQUEST)
request_user = require_user(request)

allowed_roles = get_allowed_org_roles(request, organization)
serializer = UpdateOrgMemberRolesSerializer(
Expand Down Expand Up @@ -253,7 +256,7 @@ def put(
# Members can only resend invites
is_reinvite_request_only = set(result.keys()).issubset({"reinvite", "regenerate"})
# Members can only resend invites that they sent
is_invite_from_user = member.inviter_id == request.user.id
is_invite_from_user = member.inviter_id == request_user.id

if is_member:
if not (members_can_invite and member.is_pending and is_reinvite_request_only):
Expand All @@ -271,7 +274,7 @@ def put(
if ratelimits.for_organization_member_invite(
organization=organization,
email=member.email,
user=request.user,
user=request_user,
auth=request.auth,
):
metrics.incr(
Expand All @@ -293,7 +296,7 @@ def put(
return Response({"detail": ERR_EXPIRED}, status=400)
member.send_invite_email()
elif auth_provider and not getattr(member.flags, "sso:linked"):
member.send_sso_link_email(request.user.email, auth_provider)
member.send_sso_link_email(request_user.email, auth_provider)
else:
# TODO(dcramer): proper error message
return Response({"detail": ERR_UNINVITABLE}, status=400)
Expand Down Expand Up @@ -383,7 +386,7 @@ def put(
status=403,
)

if member.user_id == request.user.id and (assigned_org_role != member.role):
if member.user_id == request_user.id and (assigned_org_role != member.role):
return Response({"detail": "You cannot make changes to your own role."}, status=400)

if (
Expand Down Expand Up @@ -502,7 +505,7 @@ def delete(
"""
# with superuser read write separation, superuser read cannot hit this endpoint
# so we can keep this as is_active_superuser
if request.user.is_authenticated and not is_active_superuser(request):
if has_user(request.user) and not is_active_superuser(request):
try:
acting_member = OrganizationMember.objects.get(
organization=organization, user_id=request.user.id
Expand Down
10 changes: 6 additions & 4 deletions src/sentry/core/endpoints/organization_member_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
)
from sentry.apidocs.utils import inline_sentry_response_serializer
from sentry.auth.authenticators import available_authenticators
from sentry.auth.principal import require_user
from sentry.core.endpoints.organization_member_utils import (
ERR_RATE_LIMITED,
ROLE_CHOICES,
Expand Down Expand Up @@ -353,6 +354,7 @@ def post(
"""
Add or invite a member to an organization.
"""
inviter = require_user(request)
assigned_org_role = (
request.data.get("orgRole")
or request.data.get("role")
Expand Down Expand Up @@ -397,7 +399,7 @@ def post(
if ratelimits.for_organization_member_invite(
organization=organization,
email=result["email"],
user=request.user,
user=inviter,
auth=request.auth,
):
metrics.incr(
Expand All @@ -421,7 +423,7 @@ def post(
requester_teams = set(
OrganizationMember.objects.filter(
organization=organization,
user_id=request.user.id,
user_id=inviter.id,
user_is_active=True,
).values_list("teams__slug", flat=True)
)
Expand Down Expand Up @@ -461,7 +463,7 @@ def post(
organization=organization,
email=result["email"],
role=assigned_org_role,
inviter_id=request.user.id,
inviter_id=inviter.id,
)

if settings.SENTRY_ENABLE_INVITES:
Expand All @@ -481,7 +483,7 @@ def post(
referrer = request.query_params.get("referrer")
om.send_invite_email(referrer)
member_invited.send_robust(
member=om, user=request.user, sender=self, referrer=request.data.get("referrer")
member=om, user=inviter, sender=self, referrer=request.data.get("referrer")
)

self.create_audit_entry(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from sentry.models.organizationmember import InviteStatus, OrganizationMember
from sentry.roles import organization_roles
from sentry.users.models.user import User
from sentry.users.services.user.model import RpcUser
from sentry.utils.audit import get_api_key_for_audit_log


Expand Down Expand Up @@ -64,7 +65,7 @@ class OrganizationInviteRequestDetailsEndpoint(OrganizationMemberEndpoint):

def _get_member(
self,
request_user: User,
request_user: User | RpcUser,
organization: Organization,
member_id: int | Literal["me"],
invite_status: InviteStatus | None = None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
as_validation_errors,
)
from sentry.apidocs.utils import inline_sentry_response_serializer
from sentry.auth.principal import require_user
from sentry.core.endpoints.organization_member_index import OrganizationMemberRequestSerializer
from sentry.core.endpoints.organization_member_utils import get_allowed_org_roles
from sentry.hybridcloud.models.outbox import outbox_context
Expand Down Expand Up @@ -114,6 +115,7 @@ def post(
Create an invite request for an organization given an email and suggested
role / teams.
"""
inviter = require_user(request)
allowed_roles = get_allowed_org_roles(request, organization, creating_org_invite=True)
serializer = OrganizationMemberRequestSerializer(
data=request.data,
Expand All @@ -140,7 +142,7 @@ def post(
organization_id=organization.id,
role=result["role"] or organization.default_role,
email=result["email"],
inviter_id=request.user.id,
inviter_id=inviter.id,
invite_status=InviteStatus.REQUESTED_TO_BE_INVITED.value,
)

Expand All @@ -160,7 +162,7 @@ def post(
event=audit_log.get_event_id("INVITE_REQUEST_ADD"),
)

async_send_notification(InviteRequestNotification, om, request.user)
async_send_notification(InviteRequestNotification, om, inviter)

data: OrganizationMemberResponse = serialize(om)
return Response(data, status=201)
1 change: 1 addition & 0 deletions src/sentry/core/endpoints/scim/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ class SCIMMetaResponse(TypedDict):
@extend_schema(tags=["SCIM"])
class SCIMEndpoint(OrganizationEndpoint):
owner = ApiOwner.FOUNDATIONS
requires_user = True
content_negotiation_class = SCIMClientNegotiation
cursor_name = "startIndex"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@

import orjson
from django.core import mail
from django.test import override_settings
from django.urls import reverse

from sentry.models.options.organization_option import OrganizationOption
from sentry.models.organization import Organization
from sentry.models.organizationmember import InviteStatus, OrganizationMember
from sentry.models.organizationmemberteam import OrganizationMemberTeam
from sentry.testutils.cases import APITestCase, SlackActivityNotificationTest
from sentry.testutils.helpers.features import with_feature
from sentry.testutils.hybrid_cloud import HybridCloudTestMixin
from sentry.testutils.outbox import outbox_runner
from sentry.viewer_context import Actor, ActorType, ViewerContext, encode_viewer_context


class OrganizationInviteRequestListTest(APITestCase):
Expand Down Expand Up @@ -88,6 +91,30 @@ def setUp(self) -> None:
kwargs={"organization_id_or_slug": self.organization.slug},
)

@override_settings(SEER_API_SHARED_SECRET="service-account-secret")
@with_feature("organizations:service-accounts")
def test_service_account_cannot_request_invite(self) -> None:
service_account = self.create_service_account(
organization=self.organization, scope_list=["member:read", "member:invite"]
)
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",
)

response = self.client.post(
self.url,
{"email": "eric@localhost", "role": "member"},
HTTP_X_VIEWER_CONTEXT=viewer_context,
)

assert response.status_code == 403, response.content
assert response.data["detail"]["code"] == "user-required"
assert not OrganizationMember.objects.filter(email="eric@localhost").exists()

def test_simple(self) -> None:
self.login_as(user=self.user)

Expand Down
4 changes: 2 additions & 2 deletions tests/sentry/auth/test_principal.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,9 +275,9 @@ 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 = 129
REQUEST_USER_ID_BASELINE = 124
REQUEST_USER_TYPE_CHECK_BASELINE = 29
REQUEST_USER_ALLOWLIST_BASELINE = 5
REQUEST_USER_ALLOWLIST_BASELINE = 1


def test_request_user_id_uses_only_go_down() -> None:
Expand Down
23 changes: 23 additions & 0 deletions tests/sentry/core/endpoints/scim/test_scim_user_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import Any
from unittest.mock import MagicMock, call, patch

from django.test import override_settings
from django.urls import reverse
from django.utils import timezone

Expand All @@ -12,9 +13,11 @@
from sentry.models.organizationmember import InviteStatus, OrganizationMember
from sentry.silo.base import SiloMode
from sentry.testutils.cases import SCIMAzureTestCase, SCIMTestCase
from sentry.testutils.helpers.features import with_feature
from sentry.testutils.hybrid_cloud import HybridCloudTestMixin
from sentry.testutils.outbox import outbox_runner
from sentry.testutils.silo import all_silo_test, assume_test_silo_mode
from sentry.viewer_context import Actor, ActorType, ViewerContext, encode_viewer_context


def post_data() -> dict[str, Any]:
Expand Down Expand Up @@ -49,6 +52,26 @@ def test_get_users_index_empty(self) -> None:
assert response.status_code == 200, response.content
assert response.data == correct_get_data

@override_settings(SEER_API_SHARED_SECRET="service-account-secret")
@with_feature("organizations:service-accounts")
def test_service_account_cannot_provision_users(self) -> None:
service_account = self.create_service_account(
organization=self.organization, scope_list=["member:read", "member:write"]
)
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("sentry-api-0-organization-scim-member-index", args=[self.organization.slug])

response = self.client.post(url, post_data(), HTTP_X_VIEWER_CONTEXT=viewer_context)

assert response.status_code == 403, response.content
assert not OrganizationMember.objects.filter(email="test.user@okta.local").exists()

@patch("sentry.core.endpoints.scim.members.metrics")
def test_post_users_successful(self, mock_metrics: MagicMock) -> None:
url = reverse("sentry-api-0-organization-scim-member-index", args=[self.organization.slug])
Expand Down
Loading
Loading