diff --git a/src/sentry/api/endpoints/organization_invite_request_details.py b/src/sentry/api/endpoints/organization_invite_request_details.py index 9bdfc69aadaf2b..c5c6280e13d826 100644 --- a/src/sentry/api/endpoints/organization_invite_request_details.py +++ b/src/sentry/api/endpoints/organization_invite_request_details.py @@ -1,45 +1,31 @@ -from django.conf import settings -from django.db.models import Q from rest_framework import serializers, status from rest_framework.response import Response -from sentry import features, roles +from sentry import roles from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission from sentry.api.exceptions import ResourceDoesNotExist from sentry.api.serializers import serialize from sentry.api.serializers.models.organization_member import OrganizationMemberWithTeamsSerializer -from sentry.models import AuditLogEntryEvent, InviteStatus, OrganizationMember -from sentry.signals import member_invited +from sentry.exceptions import UnableToAcceptMemberInvitationException +from sentry.models import OrganizationMember +from sentry.utils.audit import get_api_key_for_audit_log from .organization_member_details import get_allowed_roles from .organization_member_index import OrganizationMemberSerializer, save_team_assignments -ERR_CANNOT_INVITE = "Your organization is not allowed to invite members." -ERR_INSUFFICIENT_ROLE = "You do not have permission to invite that role." -ERR_JOIN_REQUESTS_DISABLED = "Your organization does not allow requests to join." - class ApproveInviteRequestSerializer(serializers.Serializer): approve = serializers.BooleanField(required=True, write_only=True) def validate_approve(self, approve): request = self.context["request"] - organization = self.context["organization"] member = self.context["member"] allowed_roles = self.context["allowed_roles"] - if not features.has("organizations:invite-members", organization, actor=request.user): - raise serializers.ValidationError(ERR_CANNOT_INVITE) - - if ( - organization.get_option("sentry:join_requests") is False - and member.invite_status == InviteStatus.REQUESTED_TO_JOIN.value - ): - raise serializers.ValidationError(ERR_JOIN_REQUESTS_DISABLED) - - # members cannot invite roles higher than their own - if member.role not in {r.id for r in allowed_roles}: - raise serializers.ValidationError(ERR_INSUFFICIENT_ROLE) + try: + member.validate_invitation(request.user, allowed_roles) + except UnableToAcceptMemberInvitationException as err: + raise serializers.ValidationError(str(err)) return approve @@ -57,12 +43,8 @@ class OrganizationInviteRequestDetailsEndpoint(OrganizationEndpoint): def _get_member(self, organization, member_id): try: - return OrganizationMember.objects.get( - Q(invite_status=InviteStatus.REQUESTED_TO_BE_INVITED.value) - | Q(invite_status=InviteStatus.REQUESTED_TO_JOIN.value), - organization=organization, - user__isnull=True, - id=member_id, + return OrganizationMember.objects.get_member_invite_query(member_id).get( + organization=organization ) except ValueError: raise OrganizationMember.DoesNotExist() @@ -135,26 +117,12 @@ def put(self, request, organization, member_id): result = serializer.validated_data if result.get("approve") and not member.invite_approved: - member.approve_invite() - member.save() - - if settings.SENTRY_ENABLE_INVITES: - member.send_invite_email() - member_invited.send_robust( - member=member, - user=request.user, - sender=self, - referrer=request.data.get("referrer"), - ) - - self.create_audit_entry( - request=request, - organization_id=organization.id, - target_object=member.id, - data=member.get_audit_log_data(), - event=AuditLogEntryEvent.MEMBER_INVITE - if settings.SENTRY_ENABLE_INVITES - else AuditLogEntryEvent.MEMBER_ADD, + api_key = get_api_key_for_audit_log(request) + member.approve_member_invitation( + request.user, + api_key, + request.META["REMOTE_ADDR"], + request.data.get("referrer"), ) return Response( @@ -180,14 +148,7 @@ def delete(self, request, organization, member_id): except OrganizationMember.DoesNotExist: raise ResourceDoesNotExist - member.delete() - - self.create_audit_entry( - request=request, - organization_id=organization.id, - target_object=member.id, - data=member.get_audit_log_data(), - event=AuditLogEntryEvent.INVITE_REQUEST_REMOVE, - ) + api_key = get_api_key_for_audit_log(request) + member.reject_member_invitation(request.user, api_key, request.META["REMOTE_ADDR"]) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/sentry/exceptions.py b/src/sentry/exceptions.py index eb6b468cc1f0cd..2ca44e1c9cb6f3 100644 --- a/src/sentry/exceptions.py +++ b/src/sentry/exceptions.py @@ -65,3 +65,7 @@ class ApiTokenLimitError(Exception): class InvalidSearchQuery(Exception): pass + + +class UnableToAcceptMemberInvitationException(Exception): + pass diff --git a/src/sentry/models/organizationmember.py b/src/sentry/models/organizationmember.py index fb8be98cab87ae..e23590853d84a6 100644 --- a/src/sentry/models/organizationmember.py +++ b/src/sentry/models/organizationmember.py @@ -15,11 +15,13 @@ from structlog import get_logger from bitfield import BitField -from sentry import roles +from sentry import features, roles from sentry.constants import ALERTS_MEMBER_WRITE_DEFAULT, EVENTS_MEMBER_ADMIN_DEFAULT from sentry.db.models import BoundedPositiveIntegerField, FlexibleForeignKey, Model, sane_repr from sentry.db.models.manager import BaseManager +from sentry.exceptions import UnableToAcceptMemberInvitationException from sentry.models.team import TeamStatus +from sentry.signals import member_invited from sentry.utils.http import absolute_uri if TYPE_CHECKING: @@ -42,6 +44,10 @@ class InviteStatus(Enum): } +ERR_CANNOT_INVITE = "Your organization is not allowed to invite members." +ERR_JOIN_REQUESTS_DISABLED = "Your organization does not allow requests to join." + + class OrganizationMemberManager(BaseManager): def get_contactable_members_for_org(self, organization_id: int) -> QuerySet: """Get a list of members we can contact for an organization through email.""" @@ -64,6 +70,16 @@ def get_for_integration(self, integration: "Integration", actor: "User") -> Quer organization__organizationintegration__integration=integration, ).select_related("organization") + def get_member_invite_query(self, id: int) -> QuerySet: + return OrganizationMember.objects.filter( + invite_status__in=[ + InviteStatus.REQUESTED_TO_BE_INVITED.value, + InviteStatus.REQUESTED_TO_JOIN.value, + ], + user__isnull=True, + id=id, + ) + class OrganizationMember(Model): """ @@ -360,3 +376,89 @@ def get_scopes(self): scopes = frozenset(s for s in scopes if s not in disabled_scopes) return scopes + + def validate_invitation(self, user_to_approve, 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, actor=user_to_approve): + 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 self.role not in {r.id for r in allowed_roles}: + raise UnableToAcceptMemberInvitationException( + f"You do not have permission approve a member invitation with the role {self.role}." + ) + return True + + def approve_member_invitation( + self, user_to_approve, api_key=None, ip_address=None, referrer=None + ): + """ + Approve a member invite/join request and send an audit log entry + """ + from sentry.models.auditlogentry import AuditLogEntryEvent + from sentry.utils.audit import create_audit_entry_from_user + + self.approve_invite() + self.save() + + if settings.SENTRY_ENABLE_INVITES: + self.send_invite_email() + member_invited.send_robust( + member=self, + user=user_to_approve, + sender=self.approve_member_invitation, + referrer=referrer, + ) + + create_audit_entry_from_user( + user_to_approve, + api_key, + ip_address, + organization_id=self.organization_id, + target_object=self.id, + data=self.get_audit_log_data(), + event=AuditLogEntryEvent.MEMBER_INVITE + if settings.SENTRY_ENABLE_INVITES + else AuditLogEntryEvent.MEMBER_ADD, + ) + + def reject_member_invitation( + self, + user_to_approve, + api_key=None, + ip_address=None, + ): + """ + Reject a member invite/jin request and send an audit log entry + """ + from sentry.models.auditlogentry import AuditLogEntryEvent + from sentry.utils.audit import create_audit_entry_from_user + + self.delete() + + create_audit_entry_from_user( + user_to_approve, + api_key, + ip_address, + organization_id=self.organization_id, + target_object=self.id, + data=self.get_audit_log_data(), + event=AuditLogEntryEvent.INVITE_REQUEST_REMOVE, + ) + + def get_allowed_roles_to_invite(self): + """ + Return a list of roles which that member could invite + Must check if member member has member:admin first before checking + """ + return [r for r in roles.get_all() if r.priority <= roles.get(self.role).priority] diff --git a/src/sentry/utils/audit.py b/src/sentry/utils/audit.py index 326a9b2ac23bac..8887dee84357ff 100644 --- a/src/sentry/utils/audit.py +++ b/src/sentry/utils/audit.py @@ -13,14 +13,18 @@ def create_audit_entry(request, transaction_id=None, logger=None, **kwargs): user = kwargs.pop("actor", request.user if request.user.is_authenticated else None) - api_key = ( - request.auth if hasattr(request, "auth") and isinstance(request.auth, ApiKey) else None - ) + api_key = get_api_key_for_audit_log(request) - entry = AuditLogEntry( - actor=user, actor_key=api_key, ip_address=request.META["REMOTE_ADDR"], **kwargs + return create_audit_entry_from_user( + user, api_key, request.META["REMOTE_ADDR"], transaction_id, logger, **kwargs ) + +def create_audit_entry_from_user( + user, api_key=None, ip_address=None, transaction_id=None, logger=None, **kwargs +): + entry = AuditLogEntry(actor=user, actor_key=api_key, ip_address=ip_address, **kwargs) + # Only create a real AuditLogEntry record if we are passing an event type # otherwise, we want to still log to our actual logging if entry.event is not None: @@ -55,6 +59,10 @@ def create_audit_entry(request, transaction_id=None, logger=None, **kwargs): return entry +def get_api_key_for_audit_log(request): + return request.auth if hasattr(request, "auth") and isinstance(request.auth, ApiKey) else None + + def create_org_delete_log(entry): delete_log = DeletedOrganization() organization = Organization.objects.get(id=entry.target_object) diff --git a/tests/sentry/models/test_organizationmember.py b/tests/sentry/models/test_organizationmember.py index b4355daf426bff..fe75d41b10445b 100644 --- a/tests/sentry/models/test_organizationmember.py +++ b/tests/sentry/models/test_organizationmember.py @@ -1,12 +1,16 @@ from datetime import timedelta from unittest.mock import patch +import pytest from django.core import mail from django.utils import timezone +from sentry import roles from sentry.auth import manager -from sentry.models import INVITE_DAYS_VALID, InviteStatus, OrganizationMember +from sentry.exceptions import UnableToAcceptMemberInvitationException +from sentry.models import INVITE_DAYS_VALID, InviteStatus, OrganizationMember, OrganizationOption from sentry.testutils import TestCase +from sentry.testutils.helpers import with_feature class OrganizationMemberTest(TestCase): @@ -245,3 +249,91 @@ def test_get_contactable_members_for_org(self): results = OrganizationMember.objects.get_contactable_members_for_org(organization.id) assert results.count() == 1 assert results[0].user_id == member.user_id + + def test_validate_invitation_success(self): + member = self.create_member( + organization=self.organization, + invite_status=InviteStatus.REQUESTED_TO_BE_INVITED.value, + email="hello@sentry.io", + role="member", + ) + user = self.create_user() + assert member.validate_invitation(user, [roles.get("member")]) + + @with_feature({"organizations:invite-members": False}) + def test_validate_invitation_lack_feature(self): + member = self.create_member( + organization=self.organization, + invite_status=InviteStatus.REQUESTED_TO_BE_INVITED.value, + email="hello@sentry.io", + role="member", + ) + user = self.create_user() + with pytest.raises( + UnableToAcceptMemberInvitationException, + match="Your organization is not allowed to invite members.", + ): + member.validate_invitation(user, [roles.get("member")]) + + def test_validate_invitation_no_join_requests(self): + OrganizationOption.objects.create( + organization_id=self.organization.id, key="sentry:join_requests", value=False + ) + + member = self.create_member( + organization=self.organization, + invite_status=InviteStatus.REQUESTED_TO_JOIN.value, + email="hello@sentry.io", + role="member", + ) + user = self.create_user() + with pytest.raises( + UnableToAcceptMemberInvitationException, + match="Your organization does not allow requests to join.", + ): + member.validate_invitation(user, [roles.get("member")]) + + def test_validate_invitation_outside_allowed_role(self): + member = self.create_member( + organization=self.organization, + invite_status=InviteStatus.REQUESTED_TO_BE_INVITED.value, + email="hello@sentry.io", + role="admin", + ) + user = self.create_user() + with pytest.raises( + UnableToAcceptMemberInvitationException, + match="You do not have permission approve a member invitation with the role admin.", + ): + member.validate_invitation(user, [roles.get("member")]) + + def test_approve_member_invitation(self): + member = self.create_member( + organization=self.organization, + invite_status=InviteStatus.REQUESTED_TO_BE_INVITED.value, + email="hello@sentry.io", + role="member", + ) + user = self.create_user() + member.approve_member_invitation(user) + assert member.invite_status == InviteStatus.APPROVED.value + + def test_reject_member_invitation(self): + member = self.create_member( + organization=self.organization, + invite_status=InviteStatus.REQUESTED_TO_BE_INVITED.value, + email="hello@sentry.io", + role="member", + ) + user = self.create_user() + member.reject_member_invitation(user) + assert not OrganizationMember.objects.filter(id=member.id).exists() + + def test_get_allowed_roles_to_invite(self): + member = OrganizationMember.objects.get(user=self.user, organization=self.organization) + member.update(role="manager") + assert member.get_allowed_roles_to_invite() == [ + roles.get("member"), + roles.get("admin"), + roles.get("manager"), + ]