diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 4cd2c97bbceb6c..c1a7019832f07c 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -93,15 +93,6 @@ from sentry.core.endpoints.organization_index import OrganizationIndexEndpoint from sentry.core.endpoints.organization_member_details import OrganizationMemberDetailsEndpoint from sentry.core.endpoints.organization_member_index import OrganizationMemberIndexEndpoint -from sentry.core.endpoints.organization_member_invite.details import ( - OrganizationMemberInviteDetailsEndpoint, -) -from sentry.core.endpoints.organization_member_invite.index import ( - OrganizationMemberInviteIndexEndpoint, -) -from sentry.core.endpoints.organization_member_invite.reinvite import ( - OrganizationMemberReinviteEndpoint, -) from sentry.core.endpoints.organization_member_requests_invite_details import ( OrganizationInviteRequestDetailsEndpoint, ) @@ -2055,21 +2046,6 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: OrganizationMemberIndexEndpoint.as_view(), name="sentry-api-0-organization-member-index", ), - re_path( - r"^(?P[^/]+)/invited-members/$", - OrganizationMemberInviteIndexEndpoint.as_view(), - name="sentry-api-0-organization-member-invite-index", - ), - re_path( - r"^(?P[^/]+)/invited-members/(?P[^/]+)/$", - OrganizationMemberInviteDetailsEndpoint.as_view(), - name="sentry-api-0-organization-member-invite-details", - ), - re_path( - r"^(?P[^/]+)/invited-members/(?P[^/]+)/reinvite/$", - OrganizationMemberReinviteEndpoint.as_view(), - name="sentry-api-0-organization-member-reinvite", - ), re_path( r"^(?P[^/]+)/external-users/$", ExternalUserEndpoint.as_view(), diff --git a/src/sentry/core/endpoints/organization_member_invite/__init__.py b/src/sentry/core/endpoints/organization_member_invite/__init__.py deleted file mode 100644 index e69de29bb2d1d6..00000000000000 diff --git a/src/sentry/core/endpoints/organization_member_invite/details.py b/src/sentry/core/endpoints/organization_member_invite/details.py deleted file mode 100644 index 93f6b31c6e9a7f..00000000000000 --- a/src/sentry/core/endpoints/organization_member_invite/details.py +++ /dev/null @@ -1,222 +0,0 @@ -from typing import Any - -from rest_framework import status -from rest_framework.request import Request -from rest_framework.response import Response - -from sentry import features, roles -from sentry.api.api_owners import ApiOwner -from sentry.api.api_publish_status import ApiPublishStatus -from sentry.api.base import cell_silo_endpoint -from sentry.api.bases.organization import OrganizationEndpoint -from sentry.api.exceptions import ResourceDoesNotExist -from sentry.api.serializers import serialize -from sentry.api.serializers.rest_framework.organizationmemberinvite import ( - ApproveInviteRequestValidator, - OrganizationMemberInviteRequestValidator, -) -from sentry.auth.superuser import is_active_superuser -from sentry.core.endpoints.organization_member_invite.utils import ( - MISSING_FEATURE_MESSAGE, - MemberInviteDetailsPermission, -) -from sentry.core.endpoints.organization_member_utils import get_allowed_org_roles -from sentry.models.organization import Organization -from sentry.models.organizationmember import OrganizationMember -from sentry.models.organizationmemberinvite import OrganizationMemberInvite -from sentry.utils.audit import get_api_key_for_audit_log - -ERR_INSUFFICIENT_ROLE = "You cannot remove an invite with a higher role assignment than your own." -ERR_INSUFFICIENT_SCOPE = "You are missing the member:admin scope." -ERR_MEMBER_INVITE = "You cannot modify invitations sent by someone else." -ERR_EDIT_WHEN_REINVITING = ( - "You cannot modify member details when resending an invitation. Separate requests are required." -) -ERR_EXPIRED = "You cannot resend an expired invitation without regenerating the token." -ERR_RATE_LIMITED = "You are being rate limited for too many invitations." - - -@cell_silo_endpoint -class OrganizationMemberInviteDetailsEndpoint(OrganizationEndpoint): - publish_status = { - "DELETE": ApiPublishStatus.EXPERIMENTAL, - "GET": ApiPublishStatus.EXPERIMENTAL, - "PUT": ApiPublishStatus.EXPERIMENTAL, - } - owner = ApiOwner.ENTERPRISE - permission_classes = (MemberInviteDetailsPermission,) - - def convert_args( - self, - request: Request, - member_invite_id: str, - organization_id_or_slug: str | int | None = None, - *args: Any, - **kwargs: Any, - ) -> tuple[tuple[Any, ...], dict[str, Any]]: - args, kwargs = super().convert_args(request, organization_id_or_slug, *args, **kwargs) - - try: - kwargs["invited_member"] = OrganizationMemberInvite.objects.get( - id=int(member_invite_id), - organization_id=kwargs["organization"].id, - ) - except OrganizationMemberInvite.DoesNotExist: - raise ResourceDoesNotExist - return args, kwargs - - def get( - self, - request: Request, - organization: Organization, - invited_member: OrganizationMemberInvite, - ) -> Response: - """ - Retrieve an invited organization member's details. - """ - if not features.has( - "organizations:new-organization-member-invite", organization, actor=request.user - ): - return Response({"detail": MISSING_FEATURE_MESSAGE}, status=403) - return Response(serialize(invited_member, request.user)) - - def put( - self, - request: Request, - organization: Organization, - invited_member: OrganizationMemberInvite, - ) -> Response: - """ - Update an invite request to Organization - ```````````````````````````````````````` - - Update and/or approve an invite request to an organization. - - :pparam string organization_id_or_slug: the id or slug of the organization the member will belong to - :param string invited_member_id: the invite ID - :param boolean approve: allows the member to be invited - :param string orgRole: the suggested org-role of the new member - :param array teams: the teams which the member should belong to. - :auth: required - """ - if not features.has( - "organizations:new-organization-member-invite", organization, actor=request.user - ): - return Response({"detail": MISSING_FEATURE_MESSAGE}, status=403) - - if invited_member.partnership_restricted: - return Response( - { - "detail": "This member is managed by an active partnership and cannot be modified until the end of the partnership." - }, - status=403, - ) - - allowed_roles = get_allowed_org_roles(request, organization) - validator = OrganizationMemberInviteRequestValidator( - data=request.data, - partial=True, - context={ - "organization": organization, - "allowed_roles": allowed_roles, - "org_role": invited_member.role, - "teams": invited_member.organization_member_team_data, - }, - ) - if not validator.is_valid(): - return Response(validator.errors, status=400) - - result = validator.validated_data - - if result.get("orgRole"): - invited_member.set_org_role(result["orgRole"]) - if result.get("teams"): - invited_member.set_teams(result["teams"]) - - if "approve" in request.data: - approval_validator = ApproveInviteRequestValidator( - data=request.data, - context={ - "organization": organization, - "invited_member": invited_member, - "allowed_roles": allowed_roles, - }, - ) - - if not approval_validator.is_valid(): - return Response(approval_validator.errors, status=400) - if not invited_member.invite_approved: - api_key = get_api_key_for_audit_log(request) - invited_member.approve_invite_request( - request.user, api_key, request.META["REMOTE_ADDR"], request.data.get("referrer") - ) - - return Response(serialize(invited_member, request.user), status=200) - - def _handle_deletion_by_member( - self, - request: Request, - invited_member: OrganizationMemberInvite, - acting_member: OrganizationMember, - ) -> Response: - # Members can only delete invitations that they sent - if invited_member.inviter_id != acting_member.user_id: - return Response({"detail": ERR_MEMBER_INVITE}, status=403) - - self._remove_invite_and_log(request, invited_member) - return Response(status=status.HTTP_204_NO_CONTENT) - - def _remove_invite_and_log( - self, - request: Request, - invited_member: OrganizationMemberInvite, - ) -> None: - api_key = get_api_key_for_audit_log(request) - event_name = "INVITE_REMOVE" if invited_member.invite_approved else "INVITE_REQUEST_REMOVE" - invited_member.remove_invite_from_db( - request.user, event_name, api_key, request.META["REMOTE_ADDR"] - ) - - def delete( - self, request: Request, organization: Organization, invited_member: OrganizationMemberInvite - ) -> Response: - if not features.has( - "organizations:new-organization-member-invite", organization, actor=request.user - ): - return Response({"detail": MISSING_FEATURE_MESSAGE}, status=403) - if invited_member.idp_provisioned: - return Response( - {"detail": "This invite is managed through your organization's identity provider."}, - status=403, - ) - if invited_member.partnership_restricted: - return Response( - { - "detail": "This invite is managed by an active partnership and cannot be modified until the end of the partnership." - }, - status=403, - ) - - if not is_active_superuser(request): - # acting_member exists, otherwise the user would have been prevented from accessing the endpoint - acting_member = OrganizationMember.objects.get( - organization=organization, user_id=request.user.id - ) - - has_member_admin_scope = request.access.has_scope("member:admin") - has_member_invite_scope = request.access.has_scope("member:invite") - - if not has_member_admin_scope: - if has_member_invite_scope: - return self._handle_deletion_by_member(request, invited_member, acting_member) - return Response({"detail": ERR_INSUFFICIENT_SCOPE}, status=403) - else: - can_manage = roles.can_manage(acting_member.role, invited_member.role) - - if not can_manage: - return Response({"detail": ERR_INSUFFICIENT_ROLE}, status=403) - - self._remove_invite_and_log(request, invited_member) - - # TODO(mifu67): replace all the magic numbers with status codes in a separate PR - return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/sentry/core/endpoints/organization_member_invite/index.py b/src/sentry/core/endpoints/organization_member_invite/index.py deleted file mode 100644 index 999c77af08ec80..00000000000000 --- a/src/sentry/core/endpoints/organization_member_invite/index.py +++ /dev/null @@ -1,227 +0,0 @@ -from django.db import router, transaction -from drf_spectacular.utils import extend_schema -from rest_framework.request import Request -from rest_framework.response import Response - -from sentry import audit_log, features, ratelimits, roles -from sentry.api.api_owners import ApiOwner -from sentry.api.api_publish_status import ApiPublishStatus -from sentry.api.base import cell_silo_endpoint -from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission -from sentry.api.paginator import OffsetPaginator -from sentry.api.permissions import StaffPermissionMixin -from sentry.api.serializers import serialize -from sentry.api.serializers.models.organizationmemberinvite import ( - OrganizationMemberInviteSerializer, -) -from sentry.api.serializers.rest_framework.organizationmemberinvite import ( - OrganizationMemberInviteRequestValidator, -) -from sentry.core.endpoints.organization_member_invite.utils import ( - ERR_RATE_LIMITED, - MISSING_FEATURE_MESSAGE, -) -from sentry.core.endpoints.organization_member_utils import get_allowed_org_roles -from sentry.models.organization import Organization -from sentry.models.organizationmember import OrganizationMember -from sentry.models.organizationmemberinvite import InviteStatus, OrganizationMemberInvite -from sentry.notifications.notifications.organization_request import InviteRequestNotification -from sentry.notifications.utils.tasks import async_send_notification -from sentry.organizations.services.organization.model import ( - RpcOrganization, - RpcUserOrganizationContext, -) -from sentry.roles import organization_roles -from sentry.signals import member_invited -from sentry.utils import metrics -from sentry.utils.audit import create_audit_entry - - -class MemberInvitePermission(OrganizationPermission): - scope_map = { - "GET": ["member:read", "member:write", "member:admin"], - # We will do an additional check to see if a user can invite members. If - # not, then POST creates an invite request, not an invite. - "POST": ["member:read", "member:write", "member:admin", "member:invite"], - } - - -class MemberInviteAndStaffPermission(StaffPermissionMixin, MemberInvitePermission): - pass - - -def _can_invite_member( - request: Request, - organization: Organization | RpcOrganization | RpcUserOrganizationContext, -) -> bool: - scopes = request.access.scopes - is_role_above_member = "member:admin" in scopes or "member:write" in scopes - if isinstance(organization, RpcUserOrganizationContext): - organization = organization.organization - - if is_role_above_member: - return True - if "member:invite" not in scopes: - return False - return not organization.flags.disable_member_invite - - -def _create_invite_object( - request, organization, result, is_request: bool -) -> OrganizationMemberInvite: - with transaction.atomic(router.db_for_write(OrganizationMemberInvite)): - teams = [] - for team in result.get("teams", []): - teams.append({"id": team.id, "slug": team.slug, "role": None}) - - om = OrganizationMember.objects.create(organization=organization) - omi = OrganizationMemberInvite( - organization=organization, - organization_member=om, - email=result["email"], - role=result["orgRole"], - inviter_id=request.user.id, - organization_member_team_data=teams, - invite_status=( - InviteStatus.REQUESTED_TO_BE_INVITED.value - if is_request - else InviteStatus.APPROVED.value - ), - ) - - omi.save() - - create_audit_entry( - request=request, - organization_id=organization.id, - target_object=omi.id, - data=omi.get_audit_log_data(), - event=( - (audit_log.get_event_id("INVITE_REQUEST_ADD")) - if is_request - else (audit_log.get_event_id("MEMBER_INVITE")) - ), - ) - return omi - - -@cell_silo_endpoint -@extend_schema(tags=["Organizations"]) -class OrganizationMemberInviteIndexEndpoint(OrganizationEndpoint): - # TODO (mifu67): make these PUBLIC once ready - publish_status = { - "GET": ApiPublishStatus.EXPERIMENTAL, - "POST": ApiPublishStatus.EXPERIMENTAL, - } - permission_classes = (MemberInviteAndStaffPermission,) - owner = ApiOwner.ENTERPRISE - - def _invite_member(self, request, organization) -> Response: - allowed_roles = get_allowed_org_roles(request, organization, creating_org_invite=True) - - is_member = not request.access.has_scope("member:admin") and ( - request.access.has_scope("member:invite") - ) - - validator = OrganizationMemberInviteRequestValidator( - data=request.data, - context={ - "organization": organization, - "allowed_roles": allowed_roles, - "is_integration_token": request.access.is_integration_token, - "is_member": is_member, - "actor": request.user, - }, - ) - - if not validator.is_valid(): - return Response(validator.errors, status=400) - - result = validator.validated_data - - if ratelimits.for_organization_member_invite( - organization=organization, - email=result["email"], - user=request.user, - auth=request.auth, - ): - metrics.incr( - "member-invite.attempt", - instance="rate_limited", - skip_internal=True, - sample_rate=1.0, - ) - return Response({"detail": ERR_RATE_LIMITED}, status=429) - - omi = _create_invite_object(request, organization, result, is_request=False) - - referrer = request.query_params.get("referrer") - omi.send_invite_email(referrer) - member_invited.send_robust( - member=omi, - user=request.user, - sender=self, - referrer=request.data.get("referrer"), - ) - - return Response(serialize(omi), status=201) - - def _request_to_invite_member(self, request: Request, organization) -> Response: - validator = OrganizationMemberInviteRequestValidator( - data=request.data, - context={ - "organization": organization, - "allowed_roles": roles.get_all(), - "actor": request.user, - }, - ) - if not validator.is_valid(): - return Response(validator.errors, status=400) - result = validator.validated_data - - omi = _create_invite_object(request, organization, result, is_request=True) - - async_send_notification(InviteRequestNotification, omi, request.user) - return Response(serialize(omi), status=201) - - def get(self, request: Request, organization: Organization) -> Response: - """ - List all organization member invites. - """ - if not features.has( - "organizations:new-organization-member-invite", organization, actor=request.user - ): - return Response({"detail": MISSING_FEATURE_MESSAGE}, status=403) - - queryset = OrganizationMemberInvite.objects.filter(organization=organization).order_by( - "invite_status", "email" - ) - - return self.paginate( - request=request, - queryset=queryset, - on_results=lambda x: serialize(x, request.user, OrganizationMemberInviteSerializer()), - paginator_cls=OffsetPaginator, - ) - - def post(self, request: Request, organization) -> Response: - if not features.has( - "organizations:new-organization-member-invite", organization, actor=request.user - ): - return Response({"detail": MISSING_FEATURE_MESSAGE}, status=403) - - assigned_org_role = request.data.get("orgRole") or organization_roles.get_default().id - billing_bypass = assigned_org_role == "billing" and features.has( - "organizations:invite-billing", organization - ) - if not billing_bypass and not features.has( - "organizations:invite-members", organization, actor=request.user - ): - return Response( - {"organization": "Your organization is not allowed to invite members"}, status=403 - ) - # Check to see if the requesting user can invite members. If not, create an invite - # request. - if not _can_invite_member(request, organization): - return self._request_to_invite_member(request, organization) - return self._invite_member(request, organization) diff --git a/src/sentry/core/endpoints/organization_member_invite/reinvite.py b/src/sentry/core/endpoints/organization_member_invite/reinvite.py deleted file mode 100644 index 21b02888622c55..00000000000000 --- a/src/sentry/core/endpoints/organization_member_invite/reinvite.py +++ /dev/null @@ -1,150 +0,0 @@ -from typing import Any - -from django.db import router, transaction -from rest_framework import status -from rest_framework.exceptions import PermissionDenied -from rest_framework.request import Request -from rest_framework.response import Response - -from sentry import audit_log, features, ratelimits -from sentry.api.api_owners import ApiOwner -from sentry.api.api_publish_status import ApiPublishStatus -from sentry.api.base import cell_silo_endpoint -from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission -from sentry.api.exceptions import ResourceDoesNotExist -from sentry.api.serializers import serialize -from sentry.api.serializers.rest_framework.organizationmemberinvite import ( - OrganizationMemberReinviteRequestValidator, -) -from sentry.core.endpoints.organization_member_invite.utils import MISSING_FEATURE_MESSAGE -from sentry.models.organization import Organization -from sentry.models.organizationmemberinvite import OrganizationMemberInvite -from sentry.utils import metrics - -ERR_EXPIRED = "You cannot resend an expired invitation without regenerating the token." -ERR_INSUFFICIENT_SCOPE = "You are missing the member:admin scope." -ERR_INVITE_UNAPPROVED = "You cannot resend an invitation that has not been approved." -ERR_MEMBER_INVITE = "You cannot resend an invitation that was sent by someone else." -ERR_RATE_LIMITED = "You are being rate limited for too many invitations." - - -class MemberReinvitePermission(OrganizationPermission): - scope_map = {"PUT": ["member:invite", "member:write", "member:admin"]} - - -@cell_silo_endpoint -class OrganizationMemberReinviteEndpoint(OrganizationEndpoint): - publish_status = { - "PUT": ApiPublishStatus.EXPERIMENTAL, - } - owner = ApiOwner.ENTERPRISE - permission_classes = (MemberReinvitePermission,) - - def convert_args( - self, - request: Request, - member_invite_id: str, - organization_id_or_slug: str | int | None = None, - *args: Any, - **kwargs: Any, - ) -> tuple[tuple[Any, ...], dict[str, Any]]: - args, kwargs = super().convert_args(request, organization_id_or_slug, *args, **kwargs) - - try: - kwargs["invited_member"] = OrganizationMemberInvite.objects.get( - id=int(member_invite_id), - organization_id=kwargs["organization"].id, - ) - except OrganizationMemberInvite.DoesNotExist: - raise ResourceDoesNotExist - return args, kwargs - - def _reinvite_and_send_email( - self, - request: Request, - organization: Organization, - invited_member: OrganizationMemberInvite, - trigger_regenerate_token: bool, - ) -> Response: - if not invited_member.invite_approved: - return Response({"detail": ERR_INVITE_UNAPPROVED}, status=400) - - if ratelimits.for_organization_member_invite( - organization=organization, - email=invited_member.email, - user=request.user, - auth=request.auth, - ): - metrics.incr( - "member-invite.attempt", - instance="rate_limited", - skip_internal=True, - sample_rate=1.0, - ) - return Response({"detail": ERR_RATE_LIMITED}, status=429) - - if trigger_regenerate_token: - if request.access.has_scope("member:admin"): - with transaction.atomic(router.db_for_write(OrganizationMemberInvite)): - invited_member.regenerate_token() - invited_member.save() - else: - raise PermissionDenied - - if invited_member.token_expired: - return Response({"detail": ERR_EXPIRED}, status=400) - - invited_member.send_invite_email() - - self.create_audit_entry( - request=request, - organization=organization, - target_object=invited_member.id, - event=audit_log.get_event_id("MEMBER_REINVITE"), - data=invited_member.get_audit_log_data(), - ) - return Response(serialize(invited_member, request.user), status=200) - - def put( - self, - request: Request, - organization: Organization, - invited_member: OrganizationMemberInvite, - ) -> Response: - """ - Resend a member invite to an Organization - ```````````````````````````````````````` - - Resend an invite to an organization. - - :pparam string organization_id_or_slug: the id or slug of the organization the member will belong to - :param string member_id: the member ID - :param boolean trigger_regenerate_token: whether to regenerate the member's invite token - :auth: required - """ - if not features.has( - "organizations:new-organization-member-invite", organization, actor=request.user - ): - return Response({"detail": MISSING_FEATURE_MESSAGE}, status=403) - - validator = OrganizationMemberReinviteRequestValidator( - data=request.data, - ) - if not validator.is_valid(): - return Response(validator.errors, status=status.HTTP_400_BAD_REQUEST) - - result = validator.validated_data - - actor_is_member = not request.access.has_scope("member:admin") and ( - request.access.has_scope("member:invite") - ) - # Members can only resend invites that they sent - is_invite_from_actor = invited_member.inviter_id == request.user.id - org_allows_invites_from_members = not organization.flags.disable_member_invite - - if actor_is_member and (not org_allows_invites_from_members or not is_invite_from_actor): - raise PermissionDenied - - return self._reinvite_and_send_email( - request, organization, invited_member, result["trigger_regenerate_token"] - ) diff --git a/src/sentry/core/endpoints/organization_member_invite/utils.py b/src/sentry/core/endpoints/organization_member_invite/utils.py deleted file mode 100644 index 6be047608e3795..00000000000000 --- a/src/sentry/core/endpoints/organization_member_invite/utils.py +++ /dev/null @@ -1,24 +0,0 @@ -from sentry.api.bases.organization import OrganizationPermission -from sentry.auth.superuser import is_active_superuser, superuser_has_permission - -MISSING_FEATURE_MESSAGE = "Your organization does not have access to this feature." -ERR_RATE_LIMITED = "You are being rate limited for too many invitations." - - -class MemberInviteDetailsPermission(OrganizationPermission): - scope_map = { - "GET": ["member:read", "member:write", "member:admin"], - "PUT": ["member:write", "member:admin"], - # DELETE checks for role comparison as you can either remove a member invite request - # you added, or any member invite / invite request if you have the required scopes - "DELETE": ["member:read", "member:write", "member:admin"], - } - - def has_object_permission(self, request, view, organization): - """ - Prevents superuser read from deleting an invite or invite request. - """ - has_perms = super().has_object_permission(request, view, organization) - if is_active_superuser(request) and not superuser_has_permission(request): - return False - return has_perms diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 83c6cedf3fe8c0..5849d513589dc2 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -193,8 +193,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:on-demand-metrics-extraction-widgets", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # This spec version includes the environment in the query hash manager.add("organizations:on-demand-metrics-query-spec-version-two", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) - # Use the new OrganizationMemberInvite endpoints - manager.add("organizations:new-organization-member-invite", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Display on demand metrics related UI elements manager.add("organizations:on-demand-metrics-ui", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Display on demand metrics related UI elements, for dashboards and widgets. The other flag is for alerts. diff --git a/static/app/utils/api/knownSentryApiUrls.generated.ts b/static/app/utils/api/knownSentryApiUrls.generated.ts index a25a807986fe0e..b38c7eb3ba46d3 100644 --- a/static/app/utils/api/knownSentryApiUrls.generated.ts +++ b/static/app/utils/api/knownSentryApiUrls.generated.ts @@ -371,9 +371,6 @@ export type KnownSentryApiUrls = | '/organizations/$organizationIdOrSlug/intercom-jwt/' | '/organizations/$organizationIdOrSlug/invite-requests/' | '/organizations/$organizationIdOrSlug/invite-requests/$memberId/' - | '/organizations/$organizationIdOrSlug/invited-members/' - | '/organizations/$organizationIdOrSlug/invited-members/$memberInviteId/' - | '/organizations/$organizationIdOrSlug/invited-members/$memberInviteId/reinvite/' | '/organizations/$organizationIdOrSlug/issue-view-title/generate/' | '/organizations/$organizationIdOrSlug/issues-count/' | '/organizations/$organizationIdOrSlug/issues-metrics/' diff --git a/tests/sentry/api/endpoints/test_organization_member_reinvite.py b/tests/sentry/api/endpoints/test_organization_member_reinvite.py deleted file mode 100644 index fd172ac69027b1..00000000000000 --- a/tests/sentry/api/endpoints/test_organization_member_reinvite.py +++ /dev/null @@ -1,210 +0,0 @@ -from unittest.mock import patch - -from sentry import audit_log -from sentry.models.organizationmemberinvite import InviteStatus, OrganizationMemberInvite -from sentry.testutils.asserts import assert_org_audit_log_exists -from sentry.testutils.cases import APITestCase -from sentry.testutils.helpers import with_feature -from sentry.testutils.outbox import outbox_runner - - -@with_feature("organizations:new-organization-member-invite") -class OrganizationMemberReinviteTest(APITestCase): - endpoint = "sentry-api-0-organization-member-reinvite" - method = "put" - - def setUp(self) -> None: - super().setUp() - self.login_as(self.user) - self.regular_user = self.create_user("member@email.com") - self.curr_member = self.create_member( - organization=self.organization, role="member", user=self.regular_user - ) - - self.approved_invite = self.create_member_invite( - organization=self.organization, - email="matcha@tea.com", - role="member", - inviter_id=self.regular_user.id, - ) - self.invite_request = self.create_member_invite( - organization=self.organization, - email="hojicha@tea.com", - role="member", - invite_status=InviteStatus.REQUESTED_TO_BE_INVITED.value, - inviter_id=self.regular_user.id, - ) - - @patch("sentry.models.OrganizationMemberInvite.send_invite_email") - def test_resend_invite(self, mock_send_invite_email): - self.get_success_response(self.organization.slug, self.approved_invite.id) - mock_send_invite_email.assert_called_once() - - @patch("sentry.models.OrganizationMemberInvite.send_invite_email") - def test_member_resend_invite__member_invite_disabled(self, mock_send_invite_email): - self.login_as(self.regular_user) - other_user_invite = self.create_member_invite( - organization=self.organization, - email="sencha@tea.com", - role="member", - inviter_id=self.user.id, - ) - self.organization.flags.disable_member_invite = True - self.organization.save() - response = self.get_error_response( - self.organization.slug, self.approved_invite.id, status_code=403 - ) - assert response.data.get("detail") == "You do not have permission to perform this action." - response = self.get_error_response( - self.organization.slug, other_user_invite.id, status_code=403 - ) - assert response.data.get("detail") == "You do not have permission to perform this action." - assert not mock_send_invite_email.mock_calls - - @patch("sentry.models.OrganizationMemberInvite.send_invite_email") - def test_member_resend_invite__member_invite_enabled(self, mock_send_invite_email): - self.login_as(self.regular_user) - other_user_invite = self.create_member_invite( - organization=self.organization, - email="sencha@tea.com", - role="member", - inviter_id=self.user.id, - ) - with outbox_runner(): - self.get_success_response(self.organization.slug, self.approved_invite.id) - mock_send_invite_email.assert_called_once() - assert_org_audit_log_exists( - organization=self.organization, - event=audit_log.get_event_id("MEMBER_REINVITE"), - ) - mock_send_invite_email.reset_mock() - - response = self.get_error_response( - self.organization.slug, other_user_invite.id, status_code=403 - ) - assert response.data.get("detail") == "You do not have permission to perform this action." - assert not mock_send_invite_email.mock_calls - - @patch("sentry.ratelimits.for_organization_member_invite") - @patch("sentry.models.OrganizationMemberInvite.send_invite_email") - def test_rate_limited(self, mock_send_invite_email, mock_rate_limit): - mock_rate_limit.return_value = True - - self.get_error_response(self.organization.slug, self.approved_invite.id, status_code=429) - - assert not mock_send_invite_email.mock_calls - - def test_member_cannot_regenerate_pending_invite(self) -> None: - self.login_as(self.regular_user) - self.organization.flags.disable_member_invite = True - self.organization.save() - response = self.get_error_response( - self.organization.slug, - self.approved_invite.id, - trigger_regenerate_token=1, - status_code=403, - ) - assert response.data.get("detail") == "You do not have permission to perform this action." - - self.organization.flags.disable_member_invite = False - self.organization.save() - response = self.get_error_response( - self.organization.slug, - self.approved_invite.id, - trigger_regenerate_token=1, - status_code=403, - ) - assert response.data.get("detail") == "You do not have permission to perform this action." - - @patch("sentry.models.OrganizationMemberInvite.send_invite_email") - def test_admin_can_regenerate_pending_invite(self, mock_send_invite_email): - invite = self.create_member_invite( - organization=self.organization, email="sencha@tea.com", role="member" - ) - old_token = invite.token - response = self.get_success_response( - self.organization.slug, - invite.id, - trigger_regenerate_token=1, - ) - invite = OrganizationMemberInvite.objects.get(id=invite.id) - assert old_token != invite.token - mock_send_invite_email.assert_called_once_with() - assert "invite_link" not in response.data - assert "token" not in response.data - - @patch("sentry.models.OrganizationMemberInvite.send_invite_email") - def test_reinvite_invite_expired_member(self, mock_send_invite_email): - invite = self.create_member_invite( - organization=self.organization, - email="sencha@tea.com", - role="member", - token_expires_at="2018-10-20 00:00:00+00:00", - ) - - self.get_error_response(self.organization.slug, invite.id, status_code=400) - assert mock_send_invite_email.called is False - - invite = OrganizationMemberInvite.objects.get(id=invite.id) - assert invite.token_expired - - @patch("sentry.models.OrganizationMemberInvite.send_invite_email") - def test_regenerate_invite_expired_member(self, mock_send_invite_email): - invite = self.create_member_invite( - organization=self.organization, - email="sencha@tea.com", - role="member", - token_expires_at="2018-10-20 00:00:00+00:00", - ) - - self.get_success_response(self.organization.slug, invite.id, trigger_regenerate_token=1) - mock_send_invite_email.assert_called_once() - - invite = OrganizationMemberInvite.objects.get(id=invite.id) - assert invite.token_expired is False - - def test_cannot_reinvite_unapproved_invite(self) -> None: - invite = self.create_member_invite( - organization=self.organization, - email="sencha@tea.com", - invite_status=InviteStatus.REQUESTED_TO_BE_INVITED.value, - ) - self.get_error_response(self.organization.slug, invite.id, status_code=400) - - invite.update(invite_status=InviteStatus.REQUESTED_TO_JOIN.value) - self.get_error_response(self.organization.slug, invite.id, status_code=400) - - def test_cannot_regenerate_unapproved_invite(self) -> None: - invite = self.create_member_invite( - organization=self.organization, - email="sencha@tea.com", - invite_status=InviteStatus.REQUESTED_TO_BE_INVITED.value, - ) - self.get_error_response( - self.organization.slug, invite.id, trigger_regenerate_token=1, status_code=400 - ) - - invite.update(invite_status=InviteStatus.REQUESTED_TO_JOIN.value) - self.get_error_response( - self.organization.slug, invite.id, trigger_regenerate_token=1, status_code=400 - ) - - def test_other_org_admin_cannot_resend_invite(self) -> None: - org = self.create_organization(slug="other-org") - other_admin_user = self.create_user("other-admin@email.com") - self.create_member(organization=org, role="owner", user=other_admin_user) - self.login_as(other_admin_user) - response = self.get_error_response( - self.organization.slug, self.approved_invite.id, status_code=403 - ) - assert response.data.get("detail") == "You do not have permission to perform this action." - - @patch("sentry.models.OrganizationMemberInvite.send_invite_email") - def test_cannot_reinvite_from_other_org(self, mock_send_invite_email): - """An org owner cannot reinvite a member invite that belongs to another org.""" - other_org = self.create_organization(slug="other-org", owner=self.user) - other_org_invite = self.create_member_invite( - organization=other_org, email="cross-org@test.com" - ) - self.get_error_response(self.organization.slug, other_org_invite.id, status_code=404) - assert not mock_send_invite_email.called diff --git a/tests/sentry/core/endpoints/test_organization_member_invite_details.py b/tests/sentry/core/endpoints/test_organization_member_invite_details.py deleted file mode 100644 index dd08aaae43c58c..00000000000000 --- a/tests/sentry/core/endpoints/test_organization_member_invite_details.py +++ /dev/null @@ -1,438 +0,0 @@ -from dataclasses import replace -from unittest.mock import MagicMock, patch - -from django.test import override_settings - -from sentry import audit_log -from sentry.models.organizationmember import OrganizationMember -from sentry.models.organizationmemberinvite import InviteStatus, OrganizationMemberInvite -from sentry.roles import organization_roles -from sentry.testutils.asserts import assert_org_audit_log_exists -from sentry.testutils.cases import APITestCase -from sentry.testutils.helpers import with_feature -from sentry.testutils.helpers.options import override_options -from sentry.testutils.outbox import outbox_runner - - -def mock_organization_roles_get_factory(original_organization_roles_get): - def wrapped_method(role): - # emulate the 'member' role not having team-level permissions - role_obj = original_organization_roles_get(role) - if role == "member": - return replace(role_obj, is_team_roles_allowed=False) - return role_obj - - return wrapped_method - - -@with_feature("organizations:new-organization-member-invite") -class OrganizationMemberInviteTestBase(APITestCase): - endpoint = "sentry-api-0-organization-member-invite-details" - - def setUp(self) -> None: - super().setUp() - self.login_as(self.user) - - -@with_feature("organizations:new-organization-member-invite") -class GetOrganizationMemberInviteTest(OrganizationMemberInviteTestBase): - def test_simple(self) -> None: - invited_member = self.create_member_invite( - organization=self.organization, email="matcha@latte.com" - ) - response = self.get_success_response(self.organization.slug, invited_member.id) - assert response.data["id"] == str(invited_member.id) - assert response.data["email"] == "matcha@latte.com" - - def test_invite_request(self) -> None: - # users can also hit this endpoint to view pending invite requests - invited_member = self.create_member_invite( - organization=self.organization, - email="matcha@latte.com", - invite_status=InviteStatus.REQUESTED_TO_BE_INVITED.value, - ) - response = self.get_success_response(self.organization.slug, invited_member.id) - assert response.data["id"] == str(invited_member.id) - assert response.data["email"] == "matcha@latte.com" - assert response.data["inviteStatus"] == "requested_to_be_invited" - - def test_get_by_garbage(self) -> None: - self.get_error_response(self.organization.slug, "-1", status_code=404) - - -@with_feature("organizations:new-organization-member-invite") -class UpdateOrganizationMemberInviteTest(OrganizationMemberInviteTestBase): - method = "put" - - def setUp(self) -> None: - super().setUp() - self.regular_user = self.create_user("member@email.com") - self.curr_member = self.create_member( - organization=self.organization, role="member", user=self.regular_user - ) - - self.approved_invite = self.create_member_invite( - organization=self.organization, - email="matcha@tea.com", - role="member", - inviter_id=self.regular_user.id, - ) - self.invite_request = self.create_member_invite( - organization=self.organization, - email="hojicha@tea.com", - role="member", - invite_status=InviteStatus.REQUESTED_TO_BE_INVITED.value, - inviter_id=self.regular_user.id, - ) - self.join_request = self.create_member_invite( - organization=self.organization, - email="oolong@tea.com", - role="member", - invite_status=InviteStatus.REQUESTED_TO_JOIN.value, - inviter_id=self.regular_user.id, - ) - - def test_update_org_role(self) -> None: - self.get_success_response( - self.organization.slug, self.approved_invite.id, orgRole="manager" - ) - self.approved_invite.refresh_from_db() - assert self.approved_invite.role == "manager" - - def test_cannot_update_with_invalid_role(self) -> None: - invalid_invite = self.create_member_invite( - organization=self.organization, email="chocolate@croissant.com" - ) - self.get_error_response( - self.organization.slug, invalid_invite.id, orgRole="invalid", status_code=400 - ) - - @with_feature("organizations:team-roles") - def test_can_update_from_retired_role_with_flag(self) -> None: - invite = self.create_member_invite( - organization=self.organization, - email="pistachio@croissant.com", - role="admin", - ) - - self.get_success_response(self.organization.slug, invite.id, orgRole="member") - invite.refresh_from_db() - assert invite.role == "member" - - @with_feature({"organizations:team-roles": False}) - def test_can_update_from_retired_role_without_flag(self) -> None: - invite = self.create_member_invite( - organization=self.organization, - email="pistachio@croissant.com", - role="admin", - ) - - self.get_success_response(self.organization.slug, invite.id, orgRole="member") - invite.refresh_from_db() - assert invite.role == "member" - - @with_feature({"organizations:team-roles": False}) - def test_can_update_to_retired_role_without_flag(self) -> None: - invite = self.create_member_invite( - organization=self.organization, - email="pistachio@croissant.com", - role="member", - ) - - self.get_success_response(self.organization.slug, invite.id, orgRole="admin") - invite.refresh_from_db() - assert invite.role == "admin" - - @with_feature("organizations:team-roles") - def test_cannot_update_to_retired_role_with_flag(self) -> None: - invite = self.create_member_invite( - organization=self.organization, - email="pistachio@croissant.com", - role="member", - ) - - self.get_error_response(self.organization.slug, invite.id, orgRole="admin", status_code=400) - - def test_update_teams(self) -> None: - team = self.create_team(organization=self.organization, name="cool-team") - self.get_success_response( - self.organization.slug, self.approved_invite.id, teams=[team.slug] - ) - self.approved_invite.refresh_from_db() - assert self.approved_invite.organization_member_team_data == [ - {"id": team.id, "slug": team.slug, "role": None} - ] - - @patch( - "sentry.roles.organization_roles.get", - wraps=mock_organization_roles_get_factory(organization_roles.get), - ) - def test_update_teams_invalid_new_teams(self, mock_get: MagicMock) -> None: - """ - If adding team assignments to an existing invite with orgRole that can't have team-level - permissions, then we should raise an error. - """ - team = self.create_team(organization=self.organization, name="cool-team") - invite = self.create_member_invite( - organization=self.organization, - email="mango-yuzu@almonds.com", - role="member", - ) - response = self.get_error_response(self.organization.slug, invite.id, teams=[team.slug]) - assert ( - response.data["teams"][0] - == "The user with a 'member' role cannot have team-level permissions." - ) - - @patch( - "sentry.roles.organization_roles.get", - wraps=mock_organization_roles_get_factory(organization_roles.get), - ) - def test_update_teams_invalid_new_role(self, mock_get: MagicMock) -> None: - """ - If updating an orgRole to one that can't have team-level assignments when the existing - invite has team assignments, then we should raise an error. - """ - team = self.create_team(organization=self.organization, name="cool-team") - invite = self.create_member_invite( - organization=self.organization, - email="mango-yuzu@almonds.com", - role="manager", - organization_member_team_data=[{"id": team.id, "slug": team.slug, "role": None}], - ) - response = self.get_error_response(self.organization.slug, invite.id, orgRole="member") - assert ( - response.data["orgRole"][0] - == "The 'member' role cannot be set on an invited user with team assignments." - ) - - def test_approve_invite(self) -> None: - self.get_success_response(self.organization.slug, self.invite_request.id, approve=True) - self.invite_request.refresh_from_db() - assert self.invite_request.invite_approved - - def test_cannot_approve_invite_above_self(self) -> None: - user = self.create_user("manager-mifu@email.com") - self.create_member(organization=self.organization, role="manager", user=user) - self.login_as(user) - - invite = self.create_member_invite( - organization=self.organization, - email="powerful-mifu@email.com", - role="owner", - invite_status=InviteStatus.REQUESTED_TO_BE_INVITED.value, - ) - response = self.get_error_response(self.organization.slug, invite.id, approve=1) - assert ( - response.data["approve"][0] - == "You do not have permission to approve a member invitation with the role owner." - ) - - @with_feature({"organizations:invite-members": False}) - def test_cannot_approve_if_invite_requests_disabled(self) -> None: - response = self.get_error_response( - self.organization.slug, self.invite_request.id, approve=1 - ) - assert response.data["approve"][0] == "Your organization is not allowed to invite members." - - def test_cannot_modify_partnership_managed_invite(self) -> None: - invite = self.create_member_invite( - organization=self.organization, - email="partnership-mifu@email.com", - partnership_restricted=True, - ) - response = self.get_error_response( - self.organization.slug, invite.id, orgRole="member", status_code=403 - ) - assert ( - response.data["detail"] - == "This member is managed by an active partnership and cannot be modified until the end of the partnership." - ) - - -@with_feature("organizations:new-organization-member-invite") -class DeleteOrganizationMemberInviteTest(OrganizationMemberInviteTestBase): - method = "delete" - - def setUp(self) -> None: - super().setUp() - self.regular_user = self.create_user("member@email.com") - self.curr_member = self.create_member( - organization=self.organization, role="member", user=self.regular_user - ) - - self.approved_invite = self.create_member_invite( - organization=self.organization, - email="matcha@tea.com", - role="member", - inviter_id=self.regular_user.id, - ) - self.placeholder_om = self.approved_invite.organization_member - - def test_simple(self) -> None: - with outbox_runner(): - self.get_success_response(self.organization.slug, self.approved_invite.id) - assert not OrganizationMember.objects.filter(id=self.placeholder_om.id).exists() - assert not OrganizationMemberInvite.objects.filter(id=self.approved_invite.id).exists() - assert_org_audit_log_exists( - organization=self.organization, - event=audit_log.get_event_id("INVITE_REMOVE"), - ) - - def test_reject_invite_request(self) -> None: - invite_request = self.create_member_invite( - organization=self.organization, - email="oolong@tea.com", - inviter_id=self.regular_user.id, - invite_status=InviteStatus.REQUESTED_TO_BE_INVITED.value, - ) - placeholder_om = invite_request.organization_member - with outbox_runner(): - self.get_success_response(self.organization.slug, invite_request.id) - assert not OrganizationMember.objects.filter(id=placeholder_om.id).exists() - assert not OrganizationMemberInvite.objects.filter(id=invite_request.id).exists() - assert_org_audit_log_exists( - organization=self.organization, - event=audit_log.get_event_id("INVITE_REQUEST_REMOVE"), - ) - - def test_member_can_remove_invite(self) -> None: - """ - Members can remove invites that they sent - """ - self.login_as(self.regular_user) - with outbox_runner(): - self.get_success_response(self.organization.slug, self.approved_invite.id) - assert not OrganizationMember.objects.filter(id=self.placeholder_om.id).exists() - assert not OrganizationMemberInvite.objects.filter(id=self.approved_invite.id).exists() - assert_org_audit_log_exists( - organization=self.organization, - event=audit_log.get_event_id("INVITE_REMOVE"), - ) - - def test_member_can_remove_invite_request(self) -> None: - """ - Members can remove invite requests that they sent - """ - self.login_as(self.regular_user) - invite_request = self.create_member_invite( - organization=self.organization, - email="oolong@tea.com", - inviter_id=self.regular_user.id, - invite_status=InviteStatus.REQUESTED_TO_BE_INVITED.value, - ) - placeholder_om = invite_request.organization_member - with outbox_runner(): - self.get_success_response(self.organization.slug, invite_request.id) - assert not OrganizationMember.objects.filter(id=placeholder_om.id).exists() - assert not OrganizationMemberInvite.objects.filter(id=invite_request.id).exists() - assert_org_audit_log_exists( - organization=self.organization, - event=audit_log.get_event_id("INVITE_REQUEST_REMOVE"), - ) - - def test_member_cannot_remove_other_invite(self) -> None: - """ - Members cannot remove invitations that they didn't send - """ - self.login_as(self.regular_user) - invite = self.create_member_invite( - organization=self.organization, - email="oolong@tea.com", - inviter_id=self.user.id, - ) - response = self.get_error_response(self.organization.slug, invite.id) - assert response.data["detail"] == "You cannot modify invitations sent by someone else." - assert OrganizationMemberInvite.objects.filter(id=invite.id).exists() - - def test_cannot_remove_idp_provisioned_invite(self) -> None: - invite = self.create_member_invite( - organization=self.organization, - email="oolong@tea.com", - inviter_id=self.user.id, - idp_provisioned=True, - ) - response = self.get_error_response(self.organization.slug, invite.id) - assert ( - response.data["detail"] - == "This invite is managed through your organization's identity provider." - ) - assert OrganizationMemberInvite.objects.filter(id=invite.id).exists() - - def test_cannot_remove_partnership_restricted_invite(self) -> None: - invite = self.create_member_invite( - organization=self.organization, - email="oolong@tea.com", - inviter_id=self.user.id, - partnership_restricted=True, - ) - response = self.get_error_response(self.organization.slug, invite.id) - assert ( - response.data["detail"] - == "This invite is managed by an active partnership and cannot be modified until the end of the partnership." - ) - assert OrganizationMemberInvite.objects.filter(id=invite.id).exists() - - @override_settings(SENTRY_SELF_HOSTED=False) - @override_options({"superuser.read-write.ga-rollout": True}) - def test_cannot_delete_as_superuser_read(self) -> None: - superuser = self.create_user(is_superuser=True) - self.login_as(superuser, superuser=True) - - self.get_error_response(self.organization.slug, self.approved_invite.id, status_code=403) - assert OrganizationMemberInvite.objects.filter(id=self.approved_invite.id).exists() - - @override_settings(SENTRY_SELF_HOSTED=False) - @override_options({"superuser.read-write.ga-rollout": True}) - def test_can_delete_as_superuser_write(self) -> None: - superuser = self.create_user(is_superuser=True) - self.add_user_permission(superuser, "superuser.write") - self.login_as(superuser, superuser=True) - - self.get_success_response(self.organization.slug, self.approved_invite.id) - - def test_non_member_user_cannot_hit_endpoint(self) -> None: - other_user = self.create_user(email="other@email.com") - self.login_as(other_user) - - response = self.get_error_response( - self.organization.slug, self.approved_invite.id, status_code=403 - ) - assert response.data["detail"] == "You do not have permission to perform this action." - assert OrganizationMemberInvite.objects.filter(id=self.approved_invite.id).exists() - - -@with_feature("organizations:new-organization-member-invite") -class CrossOrganizationMemberInviteTest(APITestCase): - endpoint = "sentry-api-0-organization-member-invite-details" - - def setUp(self) -> None: - super().setUp() - self.login_as(self.user) - self.other_org = self.create_organization(slug="other-org", owner=self.user) - self.invite_in_other_org = self.create_member_invite( - organization=self.other_org, email="cross-org@test.com" - ) - - def test_cannot_get_invite_from_other_org(self) -> None: - self.get_error_response( - self.organization.slug, self.invite_in_other_org.id, status_code=404 - ) - - def test_cannot_update_invite_from_other_org(self) -> None: - self.get_error_response( - self.organization.slug, - self.invite_in_other_org.id, - method="put", - orgRole="manager", - status_code=404, - ) - - def test_cannot_delete_invite_from_other_org(self) -> None: - self.get_error_response( - self.organization.slug, - self.invite_in_other_org.id, - method="delete", - status_code=404, - ) - assert OrganizationMemberInvite.objects.filter(id=self.invite_in_other_org.id).exists() diff --git a/tests/sentry/core/endpoints/test_organization_member_invite_index.py b/tests/sentry/core/endpoints/test_organization_member_invite_index.py deleted file mode 100644 index 7e6419b7deaf05..00000000000000 --- a/tests/sentry/core/endpoints/test_organization_member_invite_index.py +++ /dev/null @@ -1,331 +0,0 @@ -from dataclasses import replace -from unittest.mock import MagicMock, patch - -from sentry.models.organizationmemberinvite import InviteStatus, OrganizationMemberInvite -from sentry.roles import organization_roles -from sentry.testutils.cases import APITestCase -from sentry.testutils.helpers import Feature, with_feature - - -def mock_organization_roles_get_factory(original_organization_roles_get): - def wrapped_method(role): - # emulate the 'member' role not having team-level permissions - role_obj = original_organization_roles_get(role) - if role == "member": - return replace(role_obj, is_team_roles_allowed=False) - return role_obj - - return wrapped_method - - -@with_feature("organizations:new-organization-member-invite") -class OrganizationMemberInviteListTest(APITestCase): - endpoint = "sentry-api-0-organization-member-invite-index" - - def setUp(self) -> None: - self.approved_invite = self.create_member_invite( - organization=self.organization, email="user1@email.com" - ) - self.requested_invite = self.create_member_invite( - organization=self.organization, - email="user2@email.com", - invite_status=InviteStatus.REQUESTED_TO_BE_INVITED.value, - ) - - def test_simple(self) -> None: - self.login_as(self.user) - - response = self.get_success_response(self.organization.slug) - - assert len(response.data) == 2 - assert response.data[0]["email"] == self.approved_invite.email - assert response.data[0]["inviteStatus"] == "approved" - assert response.data[1]["email"] == self.requested_invite.email - assert response.data[1]["inviteStatus"] == "requested_to_be_invited" - - # make sure we don't serialize token - assert not response.data[0].get("token") - - -@with_feature("organizations:new-organization-member-invite") -class OrganizationMemberInvitePermissionRoleTest(APITestCase): - endpoint = "sentry-api-0-organization-member-invite-index" - method = "post" - - def setUp(self) -> None: - self.login_as(self.user) - - def invite_all_helper(self, role): - invite_roles = ["owner", "manager", "member"] - - user = self.create_user("user@localhost") - member = self.create_member(user=user, organization=self.organization, role=role) - self.login_as(user=user) - - # When this is set to True, only roles with the member:admin permission can invite members - self.organization.flags.disable_member_invite = True - self.organization.save() - - allowed_roles = member.get_allowed_org_roles_to_invite() - - for invite_role in invite_roles: - data = { - "email": f"{invite_role}_1@localhost", - "orgRole": invite_role, - "teams": [self.team.slug], - } - if role == "member" or role == "admin": - response = self.get_success_response( - self.organization.slug, **data, status_code=201 - ) - omi = OrganizationMemberInvite.objects.get(id=response.data["id"]) - assert omi.invite_status == InviteStatus.REQUESTED_TO_BE_INVITED.value - elif any(invite_role == allowed_role.id for allowed_role in allowed_roles): - self.get_success_response(self.organization.slug, **data, status_code=201) - else: - self.get_error_response(self.organization.slug, **data, status_code=400) - - self.organization.flags.disable_member_invite = False - self.organization.save() - - for invite_role in invite_roles: - data = { - "email": f"{invite_role}_2@localhost", - "orgRole": invite_role, - "teams": [self.team.slug], - } - if any(invite_role == allowed_role.id for allowed_role in allowed_roles): - self.get_success_response(self.organization.slug, **data, status_code=201) - else: - self.get_error_response(self.organization.slug, **data, status_code=400) - - def invite_to_other_team_helper(self, role): - user = self.create_user("inviter@localhost") - self.create_member(user=user, organization=self.organization, role=role, teams=[self.team]) - self.login_as(user=user) - - other_team = self.create_team(organization=self.organization, name="Moo Deng's Team") - - def get_data(email: str, other_team_invite: bool = False): - team_slug = other_team.slug if other_team_invite else self.team.slug - data: dict[str, str | list] = { - "email": f"{email}@localhost", - "orgRole": "member", - "teams": [team_slug], - } - return data - - # members can never invite members if disable_member_invite = True - # an invite request will be created instead of an invite - self.organization.flags.allow_joinleave = True - self.organization.flags.disable_member_invite = True - self.organization.save() - response = self.get_success_response( - self.organization.slug, **get_data("foo1"), status_code=201 - ) - omi = OrganizationMemberInvite.objects.get(id=response.data["id"]) - assert omi.invite_status == InviteStatus.REQUESTED_TO_BE_INVITED.value - - self.organization.flags.allow_joinleave = False - self.organization.flags.disable_member_invite = True - self.organization.save() - response = self.get_success_response( - self.organization.slug, **get_data("foo2"), status_code=201 - ) - omi = OrganizationMemberInvite.objects.get(id=response.data["id"]) - assert omi.invite_status == InviteStatus.REQUESTED_TO_BE_INVITED.value - - # members can only invite members to teams they are in if allow_joinleave = False - self.organization.flags.allow_joinleave = False - self.organization.flags.disable_member_invite = False - self.organization.save() - self.get_success_response(self.organization.slug, **get_data("foo3"), status_code=201) - response = self.get_error_response( - self.organization.slug, **get_data("foo4", True), status_code=400 - ) - assert ( - response.data["teams"][0] - == "You cannot assign members to teams you are not a member of." - ) - response = self.get_error_response( - self.organization.slug, - **get_data("foo5", other_team_invite=True), - status_code=400, - ) - assert ( - response.data["teams"][0] - == "You cannot assign members to teams you are not a member of." - ) - - # members can invite member to any team if allow_joinleave = True - self.organization.flags.allow_joinleave = True - self.organization.flags.disable_member_invite = False - self.organization.save() - self.get_success_response(self.organization.slug, **get_data("foo6"), status_code=201) - self.get_success_response(self.organization.slug, **get_data("foo7", True), status_code=201) - self.get_success_response( - self.organization.slug, - **get_data("foo8", other_team_invite=True), - status_code=201, - ) - - def test_owner_invites(self) -> None: - self.invite_all_helper("owner") - - def test_manager_invites(self) -> None: - self.invite_all_helper("manager") - - def test_admin_invites(self) -> None: - self.invite_all_helper("admin") - self.invite_to_other_team_helper("admin") - - def test_member_invites(self) -> None: - self.invite_all_helper("member") - self.invite_to_other_team_helper("member") - - def test_respects_feature_flag(self) -> None: - user = self.create_user("baz@example.com") - - with Feature({"organizations:invite-members": False}): - data = {"email": user.email, "orgRole": "member", "teams": [self.team.slug]} - self.get_error_response(self.organization.slug, **data, status_code=403) - - def test_no_team_invites(self) -> None: - data = {"email": "eric@localhost", "orgRole": "owner", "teams": []} - response = self.get_success_response(self.organization.slug, **data) - assert response.data["email"] == "eric@localhost" - - -@with_feature("organizations:new-organization-member-invite") -class OrganizationMemberInvitePostTest(APITestCase): - endpoint = "sentry-api-0-organization-member-invite-index" - method = "post" - - def setUp(self) -> None: - self.login_as(self.user) - - def test_forbid_qq(self) -> None: - data = {"email": "1234@qq.com", "orgRole": "member", "teams": [self.team.slug]} - response = self.get_error_response(self.organization.slug, **data, status_code=400) - assert response.data["email"][0] == "Enter a valid email address." - - @patch.object(OrganizationMemberInvite, "send_invite_email") - def test_simple(self, mock_send_invite_email: MagicMock) -> None: - data = {"email": "mifu@email.com", "orgRole": "member", "teams": [self.team.slug]} - response = self.get_success_response(self.organization.slug, **data) - - omi = OrganizationMemberInvite.objects.get(id=response.data["id"]) - assert omi.email == "mifu@email.com" - assert omi.role == "member" - assert omi.organization_member_team_data == [ - {"id": self.team.id, "role": None, "slug": self.team.slug} - ] - assert omi.inviter_id == self.user.id - - mock_send_invite_email.assert_called_once() - - def test_no_teams(self) -> None: - data = {"email": "mifu@email.com", "orgRole": "member", "teams": []} - response = self.get_success_response(self.organization.slug, **data) - - omi = OrganizationMemberInvite.objects.get(id=response.data["id"]) - assert omi.email == "mifu@email.com" - assert omi.role == "member" - assert omi.organization_member_team_data == [] - assert omi.inviter_id == self.user.id - - @patch.object(OrganizationMemberInvite, "send_invite_email") - def test_referrer_param(self, mock_send_invite_email: MagicMock) -> None: - data = {"email": "mifu@email.com", "orgRole": "member", "teams": [self.team.slug]} - response = self.get_success_response( - self.organization.slug, **data, qs_params={"referrer": "test_referrer"} - ) - - omi = OrganizationMemberInvite.objects.get(id=response.data["id"]) - assert omi.email == "mifu@email.com" - assert omi.role == "member" - assert omi.organization_member_team_data == [ - {"id": self.team.id, "role": None, "slug": self.team.slug} - ] - assert omi.inviter_id == self.user.id - - mock_send_invite_email.assert_called_with("test_referrer") - - @patch.object(OrganizationMemberInvite, "send_invite_email") - def test_internal_integration_token_can_only_invite_member_role( - self, mock_send_invite_email: MagicMock - ) -> None: - internal_integration = self.create_internal_integration( - name="Internal App", organization=self.organization, scopes=["member:write"] - ) - token = self.create_internal_integration_token( - user=self.user, internal_integration=internal_integration - ) - err_message = ( - "Integration tokens are restricted to inviting new members with the member role only." - ) - - data = {"email": "cat@meow.com", "orgRole": "owner", "teams": [self.team.slug]} - response = self.get_error_response( - self.organization.slug, - **data, - extra_headers={"HTTP_AUTHORIZATION": f"Bearer {token.token}"}, - status_code=400, - ) - assert response.data["orgRole"][0] == err_message - - data = {"email": "dog@woof.com", "orgRole": "manager", "teams": [self.team.slug]} - response = self.get_error_response( - self.organization.slug, - **data, - extra_headers={"HTTP_AUTHORIZATION": f"Bearer {token.token}"}, - status_code=400, - ) - assert response.data["orgRole"][0] == err_message - - data = {"email": "mifu@email.com", "orgRole": "member", "teams": [self.team.slug]} - response = self.get_success_response( - self.organization.slug, - **data, - extra_headers={"HTTP_AUTHORIZATION": f"Bearer {token.token}"}, - status_code=201, - ) - - omi = OrganizationMemberInvite.objects.get(id=response.data["id"]) - assert omi.email == "mifu@email.com" - assert omi.role == "member" - assert omi.organization_member_team_data == [ - {"id": self.team.id, "slug": self.team.slug, "role": None} - ] - - mock_send_invite_email.assert_called_once() - - @patch("sentry.ratelimits.for_organization_member_invite") - def test_rate_limited(self, mock_rate_limit: MagicMock) -> None: - mock_rate_limit.return_value = True - - data = {"email": "mifu@email.com", "orgRole": "member"} - self.get_error_response(self.organization.slug, **data, status_code=429) - assert not OrganizationMemberInvite.objects.filter(email="mifu@email.com").exists() - - @patch( - "sentry.roles.organization_roles.get", - wraps=mock_organization_roles_get_factory(organization_roles.get), - ) - def test_cannot_add_to_team_when_team_roles_disabled(self, mock_get: MagicMock) -> None: - owner_user = self.create_user("owner@localhost") - self.owner = self.create_member( - user=owner_user, organization=self.organization, role="owner" - ) - self.login_as(user=owner_user) - - data = { - "email": "mifu@email.com", - "orgRole": "member", - "teams": [self.team.slug], - } - response = self.get_error_response(self.organization.slug, **data, status_code=400) - assert ( - response.data["teams"][0] - == "The user with a 'member' role cannot have team-level permissions." - )