diff --git a/src/sentry/api/endpoints/organization_member_invite/details.py b/src/sentry/api/endpoints/organization_member_invite/details.py index e2c6b1db75699b..b45c09629ed5fd 100644 --- a/src/sentry/api/endpoints/organization_member_invite/details.py +++ b/src/sentry/api/endpoints/organization_member_invite/details.py @@ -1,18 +1,37 @@ from typing import Any +from django.db import router, transaction +from rest_framework.exceptions import PermissionDenied from rest_framework.request import Request from rest_framework.response import Response -from sentry import features +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 region_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint +from sentry.api.endpoints.organization_member import get_allowed_org_roles from sentry.api.endpoints.organization_member.utils import RelaxedMemberPermission from sentry.api.exceptions import ResourceDoesNotExist from sentry.api.serializers import serialize +from sentry.api.serializers.rest_framework.organizationmemberinvite import ( + ApproveInviteRequestValidator, + OrganizationMemberInviteRequestValidator, +) from sentry.models.organization import Organization from sentry.models.organizationmemberinvite import OrganizationMemberInvite +from sentry.utils import metrics +from sentry.utils.audit import get_api_key_for_audit_log + +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." +ERR_WRONG_METHOD = "You cannot reject an invite request via this method." +ERR_INVITE_UNAPPROVED = "You cannot resend an invitation that has not been approved." MISSING_FEATURE_MESSAGE = "Your organization does not have access to this feature." @@ -45,6 +64,48 @@ def convert_args( raise ResourceDoesNotExist return args, kwargs + def _reinvite( + self, + request: Request, + organization: Organization, + invited_member: OrganizationMemberInvite, + regenerate: 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 regenerate: + if request.access.has_scope("member:admin"): + with transaction.atomic(router.db_for_write(OrganizationMemberInvite)): + invited_member.regenerate_token() + invited_member.save() + else: + return Response({"detail": ERR_INSUFFICIENT_SCOPE}, status=400) + 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 get( self, request: Request, @@ -61,9 +122,99 @@ def get( return Response(serialize(invited_member, request.user)) def put( - self, request: Request, organization: Organization, invited_member: OrganizationMemberInvite + self, + request: Request, + organization: Organization, + invited_member: OrganizationMemberInvite, ) -> Response: - raise NotImplementedError + """ + 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 member_id: the member 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 the requesting user doesn't have >= member:admin perms, then they cannot approve invite + # members can reinvite users they invited, but they cannot edit invite requests + 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 + + is_member = not request.access.has_scope("member:admin") and ( + request.access.has_scope("member:invite") + ) + members_can_invite = not organization.flags.disable_member_invite + # Members can only resend invites + is_reinvite_request_only = ( + set(result.keys()).issubset({"reinvite", "regenerate"}) + and "approve" not in request.data + ) + + # Members can only resend invites that they sent + is_invite_from_user = invited_member.inviter_id == request.user.id + + if is_member: + if not (members_can_invite and is_reinvite_request_only): + # this check blocks members from doing anything but reinviting + raise PermissionDenied + if not is_invite_from_user: + return Response({"detail": ERR_MEMBER_INVITE}, status=403) + + if result.get("reinvite"): + if not is_reinvite_request_only: + return Response({"detail": ERR_EDIT_WHEN_REINVITING}, status=403) + return self._reinvite(request, organization, invited_member, result.get("regenerate")) + + 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: + # you can't reject an invite request via a PUT request + if request.data["approve"] is False: + return Response({"detail": ERR_WRONG_METHOD}, status=400) + + 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 delete( self, request: Request, organization: Organization, invited_member: OrganizationMemberInvite diff --git a/src/sentry/api/endpoints/organization_member_invite/index.py b/src/sentry/api/endpoints/organization_member_invite/index.py index ba3c1ca2fc4d03..846a8a5a505ebd 100644 --- a/src/sentry/api/endpoints/organization_member_invite/index.py +++ b/src/sentry/api/endpoints/organization_member_invite/index.py @@ -155,7 +155,10 @@ def _invite_member(self, request, organization) -> Response: 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") + invited_member=omi, + user=request.user, + sender=self, + referrer=request.data.get("referrer"), ) return Response(serialize(omi), status=201) diff --git a/src/sentry/api/serializers/rest_framework/organizationmemberinvite.py b/src/sentry/api/serializers/rest_framework/organizationmemberinvite.py index 4063899368e405..1733b08c52eeed 100644 --- a/src/sentry/api/serializers/rest_framework/organizationmemberinvite.py +++ b/src/sentry/api/serializers/rest_framework/organizationmemberinvite.py @@ -6,6 +6,7 @@ ROLE_CHOICES, MemberConflictValidationError, ) +from sentry.exceptions import UnableToAcceptMemberInvitationException from sentry.models.organizationmember import OrganizationMember from sentry.models.organizationmemberinvite import InviteStatus, OrganizationMemberInvite from sentry.models.team import Team, TeamStatus @@ -26,6 +27,13 @@ class OrganizationMemberInviteRequestValidator(serializers.Serializer): ) teams = serializers.ListField(required=False, allow_null=False, default=[]) + reinvite = serializers.BooleanField( + required=False, + help_text="Whether or not to re-invite a user who has already been invited to the organization. Defaults to True.", + ) + + regenerate = serializers.BooleanField(required=False) + def validate_email(self, email): users = user_service.get_many_by_email( emails=[email], @@ -60,6 +68,13 @@ def validate_email(self, email): return email def validate_orgRole(self, role): + # if the user is making a PUT request and updating the org role to one that can't have teams + # assignments, but the existing invite has team assignments, raise an error + if self.context.get("teams", []) and not organization_roles.get(role).is_team_roles_allowed: + raise serializers.ValidationError( + f"The '{role}' role cannot be set on an invited user with team assignments." + ) + if role == "billing" and features.has( "organizations:invite-billing", self.context["organization"] ): @@ -125,12 +140,30 @@ def validate_teams(self, teams): "You cannot assign members to teams you are not a member of." ) - if ( - has_teams - and not organization_roles.get(self.initial_data.get("orgRole")).is_team_roles_allowed - ): + # if we're making a PUT request and not changing the org role, then orgRole will be None in the initial data + org_role = ( + self.initial_data.get("orgRole") + if self.initial_data.get("orgRole") is not None + else self.context["org_role"] + ) + if has_teams and not organization_roles.get(org_role).is_team_roles_allowed: raise serializers.ValidationError( - f"The user with a '{self.initial_data.get("orgRole")}' role cannot have team-level permissions." + f"The user with a '{org_role}' role cannot have team-level permissions." ) return valid_teams + + +class ApproveInviteRequestValidator(serializers.Serializer): + approve = serializers.BooleanField(required=True, write_only=True) + + def validate_approve(self, approve): + invited_member = self.context["invited_member"] + allowed_roles = self.context["allowed_roles"] + + try: + invited_member.validate_invitation(allowed_roles) + except UnableToAcceptMemberInvitationException as err: + raise serializers.ValidationError(str(err)) + + return approve diff --git a/src/sentry/models/organizationmemberinvite.py b/src/sentry/models/organizationmemberinvite.py index 64225f4671b650..b04a341d8d36bb 100644 --- a/src/sentry/models/organizationmemberinvite.py +++ b/src/sentry/models/organizationmemberinvite.py @@ -4,17 +4,21 @@ from typing import TypedDict from django.conf import settings -from django.db import models +from django.db import models, router, transaction from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from sentry import features from sentry.backup.dependencies import ImportKind from sentry.backup.helpers import ImportFlags from sentry.backup.scopes import ImportScope, RelocationScope from sentry.db.models import FlexibleForeignKey, region_silo_model, sane_repr from sentry.db.models.base import DefaultFieldsModel from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey +from sentry.exceptions import UnableToAcceptMemberInvitationException +from sentry.models.team import Team from sentry.roles import organization_roles +from sentry.signals import member_invited INVITE_DAYS_VALID = 30 @@ -42,6 +46,9 @@ def as_choices(cls): InviteStatus.REQUESTED_TO_JOIN.value: "requested_to_join", } +ERR_CANNOT_INVITE = "Your organization is not allowed to invite members." +ERR_JOIN_REQUESTS_DISABLED = "Your organization does not allow requests to join." + def default_expiration(): return timezone.now() + timedelta(days=INVITE_DAYS_VALID) @@ -132,6 +139,64 @@ def approve_invite(self): def get_invite_status_name(self): return invite_status_names[self.invite_status] + def set_org_role(self, orgRole: str): + self.role = orgRole + self.save() + + def set_teams(self, teams: list[Team]): + team_data = [] + for team in teams: + team_data.append({"id": team.id, "slug": team.slug, "role": None}) + self.organization_member_team_data = team_data + self.save() + + def validate_invitation(self, allowed_roles): + """ + Validates whether an org has the options to invite members, handle join requests, + and that the member role doesn't exceed the allowed roles to invite. + """ + organization = self.organization + if not features.has("organizations:invite-members", organization): + raise UnableToAcceptMemberInvitationException(ERR_CANNOT_INVITE) + + if ( + organization.get_option("sentry:join_requests") is False + and self.invite_status == InviteStatus.REQUESTED_TO_JOIN.value + ): + raise UnableToAcceptMemberInvitationException(ERR_JOIN_REQUESTS_DISABLED) + + # members cannot invite roles higher than their own + if not {self.role} & {r.id for r in allowed_roles}: + raise UnableToAcceptMemberInvitationException( + f"You do not have permission to approve a member invitation with the role {self.role}." + ) + return True + + def approve_invite_request(self, approving_user, api_key=None, ip_address=None, referrer=None): + """ + Approve a member invite/join request and send an audit log entry + """ + from sentry import audit_log + from sentry.utils.audit import create_audit_entry_from_user + + with transaction.atomic(using=router.db_for_write(OrganizationMemberInvite)): + self.approve_invite() + self.save() + + self.send_invite_email(referrer) + member_invited.send_robust( + invited_member=self, user=approving_user, sender=self, referrer=referrer + ) + create_audit_entry_from_user( + approving_user, + api_key, + ip_address, + organization_id=self.organization_id, + target_object=self.id, + data=self.get_audit_log_data(), + event=(audit_log.get_event_id("MEMBER_INVITE")), + ) + @property def invite_approved(self): return self.invite_status == InviteStatus.APPROVED.value diff --git a/src/sentry/receivers/onboarding.py b/src/sentry/receivers/onboarding.py index 49e8dc9eb5a8ce..5fd9515b483863 100644 --- a/src/sentry/receivers/onboarding.py +++ b/src/sentry/receivers/onboarding.py @@ -405,6 +405,7 @@ def record_first_insight_span(project, module, **kwargs): first_insight_span_received.connect(record_first_insight_span, weak=False) +# TODO (mifu67): update this to use the new org member invite model @member_invited.connect(weak=False, dispatch_uid="onboarding.record_member_invited") def record_member_invited(member, user, **kwargs): OrganizationOnboardingTask.objects.record( diff --git a/tests/sentry/api/endpoints/test_organization_member_invite_details.py b/tests/sentry/api/endpoints/test_organization_member_invite_details.py index 3b34c453320e46..48979d1330d8f5 100644 --- a/tests/sentry/api/endpoints/test_organization_member_invite_details.py +++ b/tests/sentry/api/endpoints/test_organization_member_invite_details.py @@ -1,8 +1,28 @@ -from sentry.models.organizationmemberinvite import InviteStatus +from dataclasses import replace +from unittest.mock import patch + +from sentry import audit_log +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.features import apply_feature_flag_on_cls +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 +@apply_feature_flag_on_cls("organizations:new-organization-member-invite") class OrganizationMemberInviteTestBase(APITestCase): endpoint = "sentry-api-0-organization-member-invite-details" @@ -35,3 +55,409 @@ def test_invite_request(self): def test_get_by_garbage(self): self.get_error_response(self.organization.slug, "-1", status_code=404) + + +@apply_feature_flag_on_cls("organizations:new-organization-member-invite") +class UpdateOrganizationMemberInviteTest(OrganizationMemberInviteTestBase): + method = "put" + + def setUp(self): + 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): + 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): + 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 can_update_from_retired_role_with_flag(self): + 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 can_update_from_retired_role_without_flag(self): + 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 can_update_to_retired_role_without_flag(self): + 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 cannot_update_to_retired_role_with_flag(self): + 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): + 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__a(self, mock_get): + """ + 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__b(self, mock_get): + """ + 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): + 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 + + @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, reinvite=True) + mock_send_invite_email.assert_called_once() + + @patch("sentry.models.OrganizationMemberInvite.send_invite_email") + def test_member_resend_invite(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, reinvite=1, 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, reinvite=1, 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 + + self.organization.flags.disable_member_invite = False + self.organization.save() + + with outbox_runner(): + self.get_success_response( + self.organization.slug, self.approved_invite.id, reinvite=True + ) + 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, reinvite=1, status_code=403 + ) + assert response.data.get("detail") == "You cannot modify invitations sent by someone else." + assert not mock_send_invite_email.mock_calls + + @patch("sentry.models.OrganizationMemberInvite.send_invite_email") + def test_member_can_only_reinvite(self, mock_send_invite_email): + self.login_as(self.regular_user) + team = self.create_team(organization=self.organization, name="team-croissant") + + self.organization.flags.disable_member_invite = True + self.organization.save() + response = self.get_error_response( + self.organization.slug, + self.approved_invite.id, + teams=[team.slug], + 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 + + self.organization.flags.disable_member_invite = False + self.organization.save() + response = self.get_error_response( + self.organization.slug, + self.approved_invite.id, + teams=[team.slug], + 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_cannot_reinvite_and_modify_member(self, mock_send_invite_email): + response = self.get_error_response( + self.organization.slug, + self.approved_invite.id, + reinvite=1, + orgRole="manager", + status_code=403, + ) + assert ( + response.data.get("detail") + == "You cannot modify member details when resending an invitation. Separate requests are required." + ) + assert not mock_send_invite_email.mock_calls + + @patch("sentry.models.OrganizationMemberInvite.send_invite_email") + def test_member_details_not_modified_after_reinviting(self, mock_send_invite_email): + team = self.create_team(organization=self.organization, name="Moo Deng's Team") + + invite = self.create_member_invite( + organization=self.organization, + email="foo@example.com", + role="member", + organization_member_team_data=[{"id": team.id, "slug": team.slug, "role": None}], + ) + assert invite.role == "member" + + with outbox_runner(): + self.get_success_response(self.organization.slug, invite.id, reinvite=1) + + assert_org_audit_log_exists( + organization=self.organization, + event=audit_log.get_event_id("MEMBER_REINVITE"), + ) + + assert invite.role == "member" + assert invite.organization_member_team_data == [ + {"id": team.id, "slug": team.slug, "role": None} + ] + + @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, reinvite=1, status_code=429 + ) + + assert not mock_send_invite_email.mock_calls + + def test_member_cannot_regenerate_pending_invite(self): + 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, + reinvite=1, + regenerate=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, + reinvite=1, + regenerate=1, + status_code=400, + ) + assert response.data.get("detail") == "You are missing the member:admin scope." + + @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, + reinvite=1, + regenerate=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, reinvite=1, 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, reinvite=1, regenerate=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): + 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, reinvite=1, status_code=400) + + invite.update(invite_status=InviteStatus.REQUESTED_TO_JOIN.value) + self.get_error_response(self.organization.slug, invite.id, reinvite=1, status_code=400) + + def test_cannot_regenerate_unapproved_invite(self): + 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, reinvite=1, regenerate=1, status_code=400 + ) + + invite.update(invite_status=InviteStatus.REQUESTED_TO_JOIN.value) + self.get_error_response( + self.organization.slug, invite.id, reinvite=1, regenerate=1, status_code=400 + ) + + def test_member_cannot_approve_invite(self): + self.login_as(self.regular_user) + response = self.get_error_response( + self.organization.slug, self.invite_request.id, approve=1 + ) + assert response.data.get("detail") == "You do not have permission to perform this action." + + def test_cannot_approve_invite_above_self(self): + 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}) + # idk wtf is going on tbh. why is this feature still enabled. + def test_cannot_approve_if_invite_requests_disabled(self): + 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." diff --git a/tests/sentry/api/endpoints/test_organization_member_invite_index.py b/tests/sentry/api/endpoints/test_organization_member_invite_index.py index 12f4a260a4188e..db0f8f990da5e4 100644 --- a/tests/sentry/api/endpoints/test_organization_member_invite_index.py +++ b/tests/sentry/api/endpoints/test_organization_member_invite_index.py @@ -303,7 +303,7 @@ def test_internal_integration_token_can_only_invite_member_role(self, mock_send_ def test_rate_limited(self, mock_rate_limit): mock_rate_limit.return_value = True - data = {"email": "mifu@email.com", "role": "member"} + 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()