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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 18 additions & 57 deletions src/sentry/api/endpoints/organization_invite_request_details.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Comment on lines +46 to +47
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense at all for organization to be passed as a parameter to get_member_invite_query?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wedamija I purposely left it out because I have a query where I don't have the organization. See: https://github.com/getsentry/sentry/pull/29708/files#diff-38bebb13f20409b4ea44e977a5c976b696c2e8f3e1ec02855b2581e5d422f4e5R378-R384

The alternative was to add the organization_id to the callback_id but I figured it was better to have a more minimalist approach for what data I need to send.

)
except ValueError:
raise OrganizationMember.DoesNotExist()
Expand Down Expand Up @@ -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(
Expand All @@ -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)
4 changes: 4 additions & 0 deletions src/sentry/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,7 @@ class ApiTokenLimitError(Exception):

class InvalidSearchQuery(Exception):
pass


class UnableToAcceptMemberInvitationException(Exception):
pass
104 changes: 103 additions & 1 deletion src/sentry/models/organizationmember.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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."""
Expand All @@ -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):
"""
Expand Down Expand Up @@ -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]
18 changes: 13 additions & 5 deletions src/sentry/utils/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
Loading