class OrganizationMemberSerializer(serializers.Serializer): email = AllowedEmailField(max_length=75, required=True) role = serializers.ChoiceField(choices=roles.get_choices(), required=True) teams = ListField(required=False, allow_null=False) sendInvite = serializers.BooleanField(required=False, default=True, write_only=True)
class OrganizationSettingsForm(forms.ModelForm): name = forms.CharField( help_text=_('The name of your organization. i.e. My Company')) slug = forms.SlugField( label=_('Short name'), help_text=_('A unique ID used to identify this organization.'), ) allow_joinleave = forms.BooleanField( label=_('Open Membership'), help_text=_( 'Allow organization members to freely join or leave any team.'), required=False, ) default_role = forms.ChoiceField( label=_('Default Role'), choices=roles.get_choices(), help_text=_('The default role new members will receive.'), ) enhanced_privacy = forms.BooleanField( label=_('Enhanced Privacy'), help_text= _('Enable enhanced privacy controls to limit personally identifiable information (PII) as well as source code in things like notifications.' ), required=False, ) class Meta: fields = ('name', 'slug', 'default_role') model = Organization
class OwnerOrganizationSerializer(OrganizationSerializer): defaultRole = serializers.ChoiceField(choices=roles.get_choices()) def save(self, *args, **kwargs): org = self.context['organization'] if 'defaultRole' in self.init_data: org.default_role = self.init_data['defaultRole'] return super(OwnerOrganizationSerializer, self).save(*args, **kwargs)
class AuthProviderSettingsForm(forms.Form): require_link = forms.BooleanField( label=_('Require SSO'), help_text=_('Require members use a valid linked SSO account to access this organization'), required=False, ) default_role = forms.ChoiceField( label=_('Default Role'), choices=roles.get_choices(), help_text=_('The default role new members will receive when logging in for the first time.'), )
class OwnerOrganizationSerializer(OrganizationSerializer): defaultRole = serializers.ChoiceField(choices=roles.get_choices()) cancelDeletion = serializers.BooleanField(required=False) def save(self, *args, **kwargs): org = self.context['organization'] cancel_deletion = 'cancelDeletion' in self.init_data and org.status in DELETION_STATUSES if 'defaultRole' in self.init_data: org.default_role = self.init_data['defaultRole'] if cancel_deletion: org.status = OrganizationStatus.VISIBLE return super(OwnerOrganizationSerializer, self).save(*args, **kwargs)
class OwnerOrganizationSerializer(OrganizationSerializer): defaultRole = serializers.ChoiceField(choices=roles.get_choices()) cancelDeletion = serializers.BooleanField(required=False) def save(self, *args, **kwargs): org = self.context["organization"] update_tracked_data(org) cancel_deletion = "cancelDeletion" in self.initial_data and org.status in DELETION_STATUSES if "defaultRole" in self.initial_data: org.default_role = self.initial_data["defaultRole"] if cancel_deletion: org.status = OrganizationStatus.VISIBLE return super().save(*args, **kwargs)
class OrganizationMemberSerializer(serializers.Serializer): email = AllowedEmailField(max_length=75, required=True) role = serializers.ChoiceField(choices=roles.get_choices(), required=True) teams = ListField(required=False, allow_null=False, default=[]) sendInvite = serializers.BooleanField(required=False, default=True, write_only=True) def validate_email(self, email): queryset = OrganizationMember.objects.filter( Q(email=email) | Q(user__email__iexact=email, user__is_active=True), organization=self.context["organization"], ) if queryset.filter(invite_status=InviteStatus.APPROVED.value).exists(): raise serializers.ValidationError( "The user %s is already a member" % email) if not self.context.get("allow_existing_invite_request"): if queryset.filter( Q(invite_status=InviteStatus.REQUESTED_TO_BE_INVITED.value) | Q(invite_status=InviteStatus.REQUESTED_TO_JOIN.value) ).exists(): raise serializers.ValidationError( "There is an existing invite request for %s" % email) return email def validate_teams(self, teams): valid_teams = list( Team.objects.filter(organization=self.context["organization"], status=TeamStatus.VISIBLE, slug__in=teams)) if len(valid_teams) != len(teams): raise serializers.ValidationError("Invalid teams") return valid_teams def validate_role(self, role): if role not in {r.id for r in self.context["allowed_roles"]}: raise serializers.ValidationError( "You do not have permission to invite that role.") return role
class OrganizationSettingsForm(forms.ModelForm): name = forms.CharField( help_text=_('The name of your organization. i.e. My Company')) slug = forms.SlugField( help_text=_('A unique ID used to identify this organization.')) allow_joinleave = forms.BooleanField( label=_('Open Membership'), help_text=_( 'Allow organization members to freely join or leave any team.'), required=False, ) default_role = forms.ChoiceField( label=_('Default Role'), choices=roles.get_choices(), help_text=_('The default role new members will receive.'), ) class Meta: fields = ('name', 'slug', 'default_role') model = Organization
class AuthProviderSettingsForm(forms.Form): require_link = forms.BooleanField( label=_("Require SSO"), help_text= _("Require members use a valid linked SSO account to access this organization" ), required=False, ) enable_scim = (forms.BooleanField( label=_("Enable SCIM"), help_text=_( "Enable SCIM to manage Memberships and Teams via your Provider" ), required=False, ) if provider.can_use_scim(organization, request.user) else None) default_role = forms.ChoiceField( label=_("Default Role"), choices=roles.get_choices(), help_text= _("The default role new members will receive when logging in for the first time." ), )
class OrganizationMember(Model): """ Identifies relationships between teams and users. Users listed as team members are considered to have access to all projects and could be thought of as team owners (though their access level may not) be set to ownership. """ __core__ = True organization = FlexibleForeignKey('sentry.Organization', related_name="member_set") user = FlexibleForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, related_name="sentry_orgmember_set" ) email = models.EmailField(null=True, blank=True) role = models.CharField( choices=roles.get_choices(), max_length=32, default=roles.get_default().id, ) flags = BitField( flags=(('sso:linked', 'sso:linked'), ('sso:invalid', 'sso:invalid'), ), default=0 ) token = models.CharField(max_length=64, null=True, blank=True, unique=True) date_added = models.DateTimeField(default=timezone.now) has_global_access = models.BooleanField(default=True) teams = models.ManyToManyField( 'sentry.Team', blank=True, through='sentry.OrganizationMemberTeam' ) # Deprecated -- no longer used type = BoundedPositiveIntegerField(default=50, blank=True) class Meta: app_label = 'sentry' db_table = 'sentry_organizationmember' unique_together = (('organization', 'user'), ('organization', 'email'), ) __repr__ = sane_repr( 'organization_id', 'user_id', 'role', ) @transaction.atomic def save(self, *args, **kwargs): assert self.user_id or self.email, \ 'Must set user or email' super(OrganizationMember, self).save(*args, **kwargs) @property def is_pending(self): return self.user_id is None @property def legacy_token(self): checksum = md5() checksum.update(six.text_type(self.organization_id).encode('utf-8')) checksum.update(self.get_email().encode('utf-8')) checksum.update(force_bytes(settings.SECRET_KEY)) return checksum.hexdigest() def generate_token(self): return uuid4().hex + uuid4().hex def get_invite_link(self): if not self.is_pending: return None return absolute_uri( reverse( 'sentry-accept-invite', kwargs={ 'member_id': self.id, 'token': self.token or self.legacy_token, } ) ) def send_invite_email(self): from sentry.utils.email import MessageBuilder context = { 'email': self.email, 'organization': self.organization, 'url': self.get_invite_link(), } msg = MessageBuilder( subject='Join %s in using Sentry' % self.organization.name, template='sentry/emails/member-invite.txt', html_template='sentry/emails/member-invite.html', type='organization.invite', context=context, ) try: msg.send_async([self.get_email()]) except Exception as e: logger = get_logger(name='sentry.mail') logger.exception(e) def send_sso_link_email(self, actor, provider): from sentry.utils.email import MessageBuilder link_args = {'organization_slug': self.organization.slug} context = { 'organization': self.organization, 'actor': actor, 'provider': provider, 'url': absolute_uri(reverse('sentry-auth-organization', kwargs=link_args)), } msg = MessageBuilder( subject='Action Required for %s' % (self.organization.name, ), template='sentry/emails/auth-link-identity.txt', html_template='sentry/emails/auth-link-identity.html', type='organization.auth_link', context=context, ) msg.send_async([self.get_email()]) def send_sso_unlink_email(self, actor, provider): from sentry.utils.email import MessageBuilder from sentry.models import LostPasswordHash email = self.get_email() recover_uri = '{path}?{query}'.format( path=reverse('sentry-account-recover'), query=urlencode({'email': email}), ) context = { 'email': email, 'recover_url': absolute_uri(recover_uri), 'has_password': self.user.password, 'organization': self.organization, 'actor': actor, 'provider': provider, } if not self.user.password: password_hash = LostPasswordHash.for_user(self.user) context['set_password_url'] = password_hash.get_absolute_url(mode='set_password') msg = MessageBuilder( subject='Action Required for %s' % (self.organization.name, ), template='sentry/emails/auth-sso-disabled.txt', html_template='sentry/emails/auth-sso-disabled.html', type='organization.auth_sso_disabled', context=context, ) msg.send_async([email]) def get_display_name(self): if self.user_id: return self.user.get_display_name() return self.email def get_label(self): if self.user_id: return self.user.get_label() return self.email or self.id def get_email(self): if self.user_id: return self.user.email return self.email def get_avatar_type(self): if self.user_id: return self.user.get_avatar_type() return 'letter_avatar' def get_audit_log_data(self): from sentry.models import Team return { 'email': self.email, 'user': self.user_id, 'teams': list( Team.objects.filter( id__in=OrganizationMemberTeam.objects.filter( organizationmember=self, is_active=True, ).values_list('team', flat=True) ).values_list('id', flat=True) ), 'has_global_access': self.has_global_access, 'role': self.role, } def get_teams(self): from sentry.models import Team if roles.get(self.role).is_global: return self.organization.team_set.all() return Team.objects.filter( id__in=OrganizationMemberTeam.objects.filter( organizationmember=self, is_active=True, ).values('team') ) def get_scopes(self): return roles.get(self.role).scopes
class OrganizationMember(Model): """ Identifies relationships between organizations and users. Users listed as team members are considered to have access to all projects and could be thought of as team owners (though their access level may not) be set to ownership. """ __core__ = True organization = FlexibleForeignKey("sentry.Organization", related_name="member_set") user = FlexibleForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, related_name="sentry_orgmember_set" ) email = models.EmailField(null=True, blank=True, max_length=75) role = models.CharField( choices=roles.get_choices(), max_length=32, default=roles.get_default().id ) flags = BitField( flags=(("sso:linked", "sso:linked"), ("sso:invalid", "sso:invalid")), default=0 ) token = models.CharField(max_length=64, null=True, blank=True, unique=True) date_added = models.DateTimeField(default=timezone.now) token_expires_at = models.DateTimeField(default=None, null=True) has_global_access = models.BooleanField(default=True) teams = models.ManyToManyField( "sentry.Team", blank=True, through="sentry.OrganizationMemberTeam" ) inviter = FlexibleForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, related_name="sentry_inviter_set" ) invite_status = models.PositiveSmallIntegerField( choices=( (InviteStatus.APPROVED.value, _("Approved")), ( InviteStatus.REQUESTED_TO_BE_INVITED.value, _("Organization member requested to invite user"), ), (InviteStatus.REQUESTED_TO_JOIN.value, _("User requested to join organization")), ), default=InviteStatus.APPROVED.value, null=True, ) # Deprecated -- no longer used type = BoundedPositiveIntegerField(default=50, blank=True) class Meta: app_label = "sentry" db_table = "sentry_organizationmember" unique_together = (("organization", "user"), ("organization", "email")) __repr__ = sane_repr("organization_id", "user_id", "role") @transaction.atomic def save(self, *args, **kwargs): assert self.user_id or self.email, "Must set user or email" if self.token and not self.token_expires_at: self.refresh_expires_at() super(OrganizationMember, self).save(*args, **kwargs) def set_user(self, user): self.user = user self.email = None self.token = None self.token_expires_at = None def remove_user(self): self.email = self.get_email() self.user = None self.token = self.generate_token() def regenerate_token(self): self.token = self.generate_token() self.refresh_expires_at() def refresh_expires_at(self): now = timezone.now() self.token_expires_at = now + timedelta(days=INVITE_DAYS_VALID) def approve_invite(self): self.invite_status = InviteStatus.APPROVED.value self.regenerate_token() def get_invite_status_name(self): if self.invite_status is None: return return invite_status_names[self.invite_status] @property def invite_approved(self): return self.invite_status == InviteStatus.APPROVED.value @property def requested_to_join(self): return self.invite_status == InviteStatus.REQUESTED_TO_JOIN.value @property def requested_to_be_invited(self): return self.invite_status == InviteStatus.REQUESTED_TO_BE_INVITED.value @property def is_pending(self): return self.user_id is None @property def token_expired(self): # Old tokens don't expire to preserve compatibility and not require # a backfill migration. if self.token_expires_at is None: return False if self.token_expires_at > timezone.now(): return False return True @property def legacy_token(self): checksum = md5() checksum.update(six.text_type(self.organization_id).encode("utf-8")) checksum.update(self.get_email().encode("utf-8")) checksum.update(force_bytes(settings.SECRET_KEY)) return checksum.hexdigest() def generate_token(self): return uuid4().hex + uuid4().hex def get_invite_link(self): if not self.is_pending or not self.invite_approved: return None return absolute_uri( reverse( "sentry-accept-invite", kwargs={"member_id": self.id, "token": self.token or self.legacy_token}, ) ) def send_invite_email(self): from sentry.utils.email import MessageBuilder context = { "email": self.email, "organization": self.organization, "url": self.get_invite_link(), } msg = MessageBuilder( subject="Join %s in using Sentry" % self.organization.name, template="sentry/emails/member-invite.txt", html_template="sentry/emails/member-invite.html", type="organization.invite", context=context, ) try: msg.send_async([self.get_email()]) except Exception as e: logger = get_logger(name="sentry.mail") logger.exception(e) def send_sso_link_email(self, actor, provider): from sentry.utils.email import MessageBuilder link_args = {"organization_slug": self.organization.slug} context = { "organization": self.organization, "actor": actor, "provider": provider, "url": absolute_uri(reverse("sentry-auth-organization", kwargs=link_args)), } msg = MessageBuilder( subject="Action Required for %s" % (self.organization.name,), template="sentry/emails/auth-link-identity.txt", html_template="sentry/emails/auth-link-identity.html", type="organization.auth_link", context=context, ) msg.send_async([self.get_email()]) def send_sso_unlink_email(self, actor, provider): from sentry.utils.email import MessageBuilder from sentry.models import LostPasswordHash email = self.get_email() recover_uri = u"{path}?{query}".format( path=reverse("sentry-account-recover"), query=urlencode({"email": email}) ) # Nothing to send if this member isn't associated to a user if not self.user_id: return context = { "email": email, "recover_url": absolute_uri(recover_uri), "has_password": self.user.password, "organization": self.organization, "actor": actor, "provider": provider, } if not self.user.password: password_hash = LostPasswordHash.for_user(self.user) context["set_password_url"] = password_hash.get_absolute_url(mode="set_password") msg = MessageBuilder( subject="Action Required for %s" % (self.organization.name,), template="sentry/emails/auth-sso-disabled.txt", html_template="sentry/emails/auth-sso-disabled.html", type="organization.auth_sso_disabled", context=context, ) msg.send_async([email]) def get_display_name(self): if self.user_id: return self.user.get_display_name() return self.email def get_label(self): if self.user_id: return self.user.get_label() return self.email or self.id def get_email(self): if self.user_id and self.user.email: return self.user.email return self.email def get_avatar_type(self): if self.user_id: return self.user.get_avatar_type() return "letter_avatar" def get_audit_log_data(self): from sentry.models import Team teams = list( Team.objects.filter( id__in=OrganizationMemberTeam.objects.filter( organizationmember=self, is_active=True ).values_list("team", flat=True) ).values("id", "slug") ) return { "email": self.get_email(), "user": self.user_id, "teams": [t["id"] for t in teams], "teams_slugs": [t["slug"] for t in teams], "has_global_access": self.has_global_access, "role": self.role, "invite_status": invite_status_names[self.invite_status], } def get_teams(self): from sentry.models import Team return Team.objects.filter( status=TeamStatus.VISIBLE, id__in=OrganizationMemberTeam.objects.filter( organizationmember=self, is_active=True ).values("team"), ) def get_scopes(self): return roles.get(self.role).scopes @classmethod def delete_expired(cls, threshold): """ Delete un-accepted member invitations that expired ``threshold`` days ago. """ cls.objects.filter(token_expires_at__lt=threshold, user_id__exact=None).exclude( email__exact=None ).delete()
class Organization(Model): """ An organization represents a group of individuals which maintain ownership of projects. """ __core__ = True name = models.CharField(max_length=64) slug = models.SlugField(unique=True) status = BoundedPositiveIntegerField( choices=OrganizationStatus.as_choices(), # south will generate a default value of `'<OrganizationStatus.ACTIVE: 0>'` # if `.value` is omitted default=OrganizationStatus.ACTIVE.value) date_added = models.DateTimeField(default=timezone.now) members = models.ManyToManyField(settings.AUTH_USER_MODEL, through='sentry.OrganizationMember', related_name='org_memberships') default_role = models.CharField( choices=roles.get_choices(), max_length=32, default=roles.get_default().id, ) flags = BitField(flags=( ('allow_joinleave', 'Allow members to join and leave teams without requiring approval.'), ('enhanced_privacy', 'Enable enhanced privacy controls to limit personally identifiable information (PII) as well as source code in things like notifications.' ), ('disable_shared_issues', 'Disable sharing of limited details on issues to anonymous users.'), ('early_adopter', 'Enable early adopter status, gaining access to features prior to public release.' ), ('require_2fa', 'Require and enforce two-factor authentication for all members.'), ( 'disable_new_visibility_features', 'Temporarily opt out of new visibility features and ui', ), ), default=1) objects = OrganizationManager(cache_fields=( 'pk', 'slug', )) class Meta: app_label = 'sentry' db_table = 'sentry_organization' __repr__ = sane_repr('owner_id', 'name', 'slug') @classmethod def get_default(cls): """ Return the organization used in single organization mode. """ return cls.objects.filter(status=OrganizationStatus.ACTIVE, )[0] def __unicode__(self): return u'%s (%s)' % (self.name, self.slug) def save(self, *args, **kwargs): if not self.slug: lock = locks.get('slug:organization', duration=5) with TimedRetryPolicy(10)(lock.acquire): slugify_instance(self, self.name, reserved=RESERVED_ORGANIZATION_SLUGS) super(Organization, self).save(*args, **kwargs) else: super(Organization, self).save(*args, **kwargs) def delete(self): if self.is_default: raise Exception('You cannot delete the the default organization.') return super(Organization, self).delete() @cached_property def is_default(self): if not settings.SENTRY_SINGLE_ORGANIZATION: return False return self == type(self).get_default() def has_access(self, user, access=None): queryset = self.member_set.filter(user=user) if access is not None: queryset = queryset.filter(type__lte=access) return queryset.exists() def get_audit_log_data(self): return { 'id': self.id, 'slug': self.slug, 'name': self.name, 'status': int(self.status), 'flags': int(self.flags), 'default_role': self.default_role, } def get_owners(self): from sentry.models import User return User.objects.filter( sentry_orgmember_set__role=roles.get_top_dog().id, sentry_orgmember_set__organization=self, is_active=True, ) def get_default_owner(self): if not hasattr(self, '_default_owner'): self._default_owner = self.get_owners()[0] return self._default_owner def has_single_owner(self): from sentry.models import OrganizationMember count = OrganizationMember.objects.filter( organization=self, role=roles.get_top_dog().id, user__isnull=False, user__is_active=True, )[:2].count() return count == 1 def merge_to(from_org, to_org): from sentry.models import ( ApiKey, AuditLogEntry, AuthProvider, Commit, OrganizationAvatar, OrganizationIntegration, OrganizationMember, OrganizationMemberTeam, Project, Release, ReleaseCommit, ReleaseEnvironment, ReleaseFile, ReleaseHeadCommit, Repository, Team, Environment, ) for from_member in OrganizationMember.objects.filter( organization=from_org, user__isnull=False): logger = logging.getLogger('sentry.merge') try: to_member = OrganizationMember.objects.get( organization=to_org, user=from_member.user, ) except OrganizationMember.DoesNotExist: from_member.update(organization=to_org) to_member = from_member else: qs = OrganizationMemberTeam.objects.filter( organizationmember=from_member, is_active=True, ).select_related() for omt in qs: OrganizationMemberTeam.objects.create_or_update( organizationmember=to_member, team=omt.team, defaults={ 'is_active': True, }, ) logger.info('user.migrate', extra={ 'instance_id': from_member.id, 'new_member_id': to_member.id, 'from_organization_id': from_org.id, 'to_organization_id': to_org.id, }) for from_team in Team.objects.filter(organization=from_org): try: with transaction.atomic(): from_team.update(organization=to_org) except IntegrityError: slugify_instance(from_team, from_team.name, organization=to_org) from_team.update( organization=to_org, slug=from_team.slug, ) logger.info('team.migrate', extra={ 'instance_id': from_team.id, 'new_slug': from_team.slug, 'from_organization_id': from_org.id, 'to_organization_id': to_org.id, }) for from_project in Project.objects.filter(organization=from_org): try: with transaction.atomic(): from_project.update(organization=to_org) except IntegrityError: slugify_instance(from_project, from_project.name, organization=to_org, reserved=RESERVED_PROJECT_SLUGS) from_project.update( organization=to_org, slug=from_project.slug, ) logger.info('project.migrate', extra={ 'instance_id': from_project.id, 'new_slug': from_project.slug, 'from_organization_id': from_org.id, 'to_organization_id': to_org.id, }) # TODO(jess): update this when adding unique constraint # on version, organization for releases for from_release in Release.objects.filter(organization=from_org): try: to_release = Release.objects.get(version=from_release.version, organization=to_org) except Release.DoesNotExist: Release.objects.filter(id=from_release.id).update( organization=to_org) else: Release.merge(to_release, [from_release]) logger.info('release.migrate', extra={ 'instance_id': from_release.id, 'from_organization_id': from_org.id, 'to_organization_id': to_org.id, }) def do_update(queryset, params): model_name = queryset.model.__name__.lower() try: with transaction.atomic(): queryset.update(**params) except IntegrityError: for instance in queryset: try: with transaction.atomic(): instance.update(**params) except IntegrityError: logger.info('{}.migrate-skipped'.format(model_name), extra={ 'from_organization_id': from_org.id, 'to_organization_id': to_org.id, }) else: logger.info('{}.migrate'.format(model_name), extra={ 'instance_id': instance.id, 'from_organization_id': from_org.id, 'to_organization_id': to_org.id, }) else: logger.info('{}.migrate'.format(model_name), extra={ 'from_organization_id': from_org.id, 'to_organization_id': to_org.id, }) INST_MODEL_LIST = ( AuthProvider, ApiKey, AuditLogEntry, OrganizationAvatar, OrganizationIntegration, ReleaseEnvironment, ReleaseFile, ) ATTR_MODEL_LIST = ( Commit, ReleaseCommit, ReleaseHeadCommit, Repository, Environment, ) for model in INST_MODEL_LIST: queryset = model.objects.filter(organization=from_org, ) do_update(queryset, {'organization': to_org}) for model in ATTR_MODEL_LIST: queryset = model.objects.filter(organization_id=from_org.id, ) do_update(queryset, {'organization_id': to_org.id}) # TODO: Make these a mixin def update_option(self, *args, **kwargs): from sentry.models import OrganizationOption return OrganizationOption.objects.set_value(self, *args, **kwargs) def get_option(self, *args, **kwargs): from sentry.models import OrganizationOption return OrganizationOption.objects.get_value(self, *args, **kwargs) def delete_option(self, *args, **kwargs): from sentry.models import OrganizationOption return OrganizationOption.objects.unset_value(self, *args, **kwargs) def send_delete_confirmation(self, audit_log_entry, countdown): from sentry import options from sentry.utils.email import MessageBuilder owners = self.get_owners() context = { 'organization': self, 'audit_log_entry': audit_log_entry, 'eta': timezone.now() + timedelta(seconds=countdown), 'url': absolute_uri( reverse( 'sentry-restore-organization', args=[self.slug], )), } MessageBuilder( subject='%sOrganization Queued for Deletion' % (options.get('mail.subject-prefix'), ), template='sentry/emails/org_delete_confirm.txt', html_template='sentry/emails/org_delete_confirm.html', type='org.confirm_delete', context=context, ).send_async([o.email for o in owners]) def flag_has_changed(self, flag_name): "Returns ``True`` if ``flag`` has changed since initialization." return getattr(self.old_value('flags'), flag_name, None) != getattr( self.flags, flag_name) def handle_2fa_required(self, request): from sentry.models import ApiKey from sentry.tasks.auth import remove_2fa_non_compliant_members actor_id = request.user.id if request.user and request.user.is_authenticated( ) else None api_key_id = request.auth.id if hasattr( request, 'auth') and isinstance(request.auth, ApiKey) else None ip_address = request.META['REMOTE_ADDR'] remove_2fa_non_compliant_members.delay(self.id, actor_id=actor_id, actor_key_id=api_key_id, ip_address=ip_address) def get_url_viewname(self): from sentry import features if features.has('organizations:sentry10', self): return 'sentry-organization-issue-list' else: return 'sentry-organization-home' def get_url(self): return reverse(self.get_url_viewname(), args=[self.slug])
class OrganizationMember(Model): """ Identifies relationships between teams and users. Users listed as team members are considered to have access to all projects and could be thought of as team owners (though their access level may not) be set to ownership. """ __core__ = True organization = FlexibleForeignKey('sentry.Organization', related_name="member_set") user = FlexibleForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, related_name="sentry_orgmember_set") email = models.EmailField(null=True, blank=True) role = models.CharField( choices=roles.get_choices(), max_length=32, default=roles.get_default().id, ) flags = BitField(flags=( ('sso:linked', 'sso:linked'), ('sso:invalid', 'sso:invalid'), ), default=0) date_added = models.DateTimeField(default=timezone.now) has_global_access = models.BooleanField(default=True) counter = BoundedPositiveIntegerField(null=True, blank=True) teams = models.ManyToManyField('sentry.Team', blank=True, through='sentry.OrganizationMemberTeam') # Deprecated -- no longer used type = BoundedPositiveIntegerField(default=50, blank=True) class Meta: app_label = 'sentry' db_table = 'sentry_organizationmember' unique_together = ( ('organization', 'user'), ('organization', 'email'), ) __repr__ = sane_repr('organization_id', 'user_id', 'role',) @transaction.atomic def save(self, *args, **kwargs): assert self.user_id or self.email, \ 'Must set user or email' super(OrganizationMember, self).save(*args, **kwargs) if not self.counter: self._set_counter() @transaction.atomic def delete(self, *args, **kwargs): super(OrganizationMember, self).delete(*args, **kwargs) if self.counter: self._unshift_counter() def _unshift_counter(self): assert self.counter OrganizationMember.objects.filter( organization=self.organization, counter__gt=self.counter, ).update( counter=F('counter') - 1, ) def _set_counter(self): assert self.id and not self.counter # XXX(dcramer): this isnt atomic, but unfortunately MySQL doesnt support # the subquery pattern we'd need self.update( counter=OrganizationMember.objects.filter( organization=self.organization, ).count(), ) @property def is_pending(self): return self.user_id is None @property def token(self): checksum = md5() for x in (str(self.organization_id), self.get_email(), settings.SECRET_KEY): checksum.update(x) return checksum.hexdigest() def send_invite_email(self): from sentry.utils.email import MessageBuilder context = { 'email': self.email, 'organization': self.organization, 'url': absolute_uri(reverse('sentry-accept-invite', kwargs={ 'member_id': self.id, 'token': self.token, })), } msg = MessageBuilder( subject='Join %s in using Sentry' % self.organization.name, template='sentry/emails/member-invite.txt', html_template='sentry/emails/member-invite.html', type='organization.invite', context=context, ) try: msg.send_async([self.get_email()]) except Exception as e: logger = get_logger(name='sentry.mail') logger.exception(e) def send_sso_link_email(self): from sentry.utils.email import MessageBuilder context = { 'email': self.email, 'organization_name': self.organization.name, 'url': absolute_uri(reverse('sentry-auth-organization', kwargs={ 'organization_slug': self.organization.slug, })), } msg = MessageBuilder( subject='Action Required for %s' % (self.organization.name,), template='sentry/emails/auth-link-identity.txt', html_template='sentry/emails/auth-link-identity.html', type='organization.auth_link', context=context, ) msg.send_async([self.get_email()]) def get_display_name(self): if self.user_id: return self.user.get_display_name() return self.email def get_label(self): if self.user_id: return self.user.get_label() return self.email or self.id def get_email(self): if self.user_id: return self.user.email return self.email def get_avatar_type(self): if self.user_id: return self.user.get_avatar_type() def get_audit_log_data(self): from sentry.models import Team return { 'email': self.email, 'user': self.user_id, 'teams': list(Team.objects.filter( id__in=OrganizationMemberTeam.objects.filter( organizationmember=self, is_active=True, ).values_list('team', flat=True) )), 'has_global_access': self.has_global_access, 'role': self.role, } def get_teams(self): from sentry.models import Team if roles.get(self.role).is_global: return self.organization.team_set.all() return Team.objects.filter( id__in=OrganizationMemberTeam.objects.filter( organizationmember=self, is_active=True, ).values('team') ) def get_scopes(self): return roles.get(self.role).scopes
class OrganizationMemberSerializer(serializers.Serializer): reinvite = serializers.BooleanField() regenerate = serializers.BooleanField() role = serializers.ChoiceField(choices=roles.get_choices(), required=True) teams = ListField(required=False, allow_null=False)
class Organization(Model): """ An organization represents a group of individuals which maintain ownership of projects. """ __core__ = True name = models.CharField(max_length=64) slug = models.SlugField(unique=True) status = BoundedPositiveIntegerField( choices=OrganizationStatus.as_choices(), default=OrganizationStatus.ACTIVE) date_added = models.DateTimeField(default=timezone.now) members = models.ManyToManyField(settings.AUTH_USER_MODEL, through='sentry.OrganizationMember', related_name='org_memberships') default_role = models.CharField( choices=roles.get_choices(), max_length=32, default=roles.get_default().id, ) flags = BitField(flags=( ('allow_joinleave', 'Allow members to join and leave teams without requiring approval.'), ('enhanced_privacy', 'Enable enhanced privacy controls to limit personally identifiable information (PII) as well as source code in things like notifications.' ), ('disable_shared_issues', 'Disable sharing of limited details on issues to anonymous users.'), ('early_adopter', 'Enable early adopter status, gaining access to features prior to public release.' ), ('require_2fa', 'Require and enforce two-factor authentication for all members.'), ), default=1) objects = OrganizationManager(cache_fields=( 'pk', 'slug', )) class Meta: app_label = 'sentry' db_table = 'sentry_organization' __repr__ = sane_repr('owner_id', 'name', 'slug') @classmethod def get_default(cls): """ Return the organization used in single organization mode. """ return cls.objects.filter(status=OrganizationStatus.ACTIVE, )[0] def __unicode__(self): return u'%s (%s)' % (self.name, self.slug) def save(self, *args, **kwargs): if not self.slug: lock = locks.get('slug:organization', duration=5) with TimedRetryPolicy(10)(lock.acquire): slugify_instance(self, self.name, reserved=RESERVED_ORGANIZATION_SLUGS) super(Organization, self).save(*args, **kwargs) else: super(Organization, self).save(*args, **kwargs) def delete(self): if self.is_default: raise Exception('You cannot delete the the default organization.') return super(Organization, self).delete() @cached_property def is_default(self): if not settings.SENTRY_SINGLE_ORGANIZATION: return False return self == type(self).get_default() def has_access(self, user, access=None): queryset = self.member_set.filter(user=user) if access is not None: queryset = queryset.filter(type__lte=access) return queryset.exists() def get_audit_log_data(self): return { 'id': self.id, 'slug': self.slug, 'name': self.name, 'status': self.status, 'flags': self.flags, 'default_role': self.default_role, } def get_owners(self): from sentry.models import User return User.objects.filter( sentry_orgmember_set__role=roles.get_top_dog().id, sentry_orgmember_set__organization=self, is_active=True, ) def get_default_owner(self): if not hasattr(self, '_default_owner'): self._default_owner = self.get_owners()[0] return self._default_owner def has_single_owner(self): from sentry.models import OrganizationMember count = OrganizationMember.objects.filter( organization=self, role=roles.get_top_dog().id, user__isnull=False, user__is_active=True, )[:2].count() return count == 1 def merge_to(from_org, to_org): from sentry.models import ( ApiKey, AuditLogEntry, Commit, OrganizationMember, OrganizationMemberTeam, Project, Release, ReleaseCommit, ReleaseEnvironment, ReleaseFile, ReleaseHeadCommit, Repository, Team, Environment, ) for from_member in OrganizationMember.objects.filter( organization=from_org, user__isnull=False): try: to_member = OrganizationMember.objects.get( organization=to_org, user=from_member.user, ) except OrganizationMember.DoesNotExist: from_member.update(organization=to_org) to_member = from_member else: qs = OrganizationMemberTeam.objects.filter( organizationmember=from_member, is_active=True, ).select_related() for omt in qs: OrganizationMemberTeam.objects.create_or_update( organizationmember=to_member, team=omt.team, defaults={ 'is_active': True, }, ) for team in Team.objects.filter(organization=from_org): try: with transaction.atomic(): team.update(organization=to_org) except IntegrityError: slugify_instance(team, team.name, organization=to_org) team.update( organization=to_org, slug=team.slug, ) for project in Project.objects.filter(organization=from_org): try: with transaction.atomic(): project.update(organization=to_org) except IntegrityError: slugify_instance(project, project.name, organization=to_org) project.update( organization=to_org, slug=project.slug, ) # TODO(jess): update this when adding unique constraint # on version, organization for releases for release in Release.objects.filter(organization=from_org): try: to_release = Release.objects.get(version=release.version, organization=to_org) except Release.DoesNotExist: Release.objects.filter(id=release.id).update( organization=to_org) else: Release.merge(to_release, [release]) for model in (ApiKey, AuditLogEntry, ReleaseFile): model.objects.filter( organization=from_org, ).update(organization=to_org) for model in (Commit, ReleaseCommit, ReleaseEnvironment, ReleaseHeadCommit, Repository, Environment): try: with transaction.atomic(): model.objects.filter(organization_id=from_org.id, ).update( organization_id=to_org.id) except IntegrityError: pass # TODO: Make these a mixin def update_option(self, *args, **kwargs): from sentry.models import OrganizationOption return OrganizationOption.objects.set_value(self, *args, **kwargs) def get_option(self, *args, **kwargs): from sentry.models import OrganizationOption return OrganizationOption.objects.get_value(self, *args, **kwargs) def delete_option(self, *args, **kwargs): from sentry.models import OrganizationOption return OrganizationOption.objects.unset_value(self, *args, **kwargs) def send_delete_confirmation(self, audit_log_entry, countdown): from sentry import options from sentry.utils.email import MessageBuilder owners = self.get_owners() context = { 'organization': self, 'audit_log_entry': audit_log_entry, 'eta': timezone.now() + timedelta(seconds=countdown), 'url': absolute_uri( reverse( 'sentry-restore-organization', args=[self.slug], )), } MessageBuilder( subject='%sOrganization Queued for Deletion' % (options.get('mail.subject-prefix'), ), template='sentry/emails/org_delete_confirm.txt', html_template='sentry/emails/org_delete_confirm.html', type='org.confirm_delete', context=context, ).send_async([o.email for o in owners]) def flag_has_changed(self, flag_name): "Returns ``True`` if ``flag`` has changed since initialization." return getattr(self.old_value('flags'), flag_name, None) != getattr( self.flags, flag_name) def send_setup_2fa_emails(self): from sentry import options from sentry.utils.email import MessageBuilder from sentry.models import User for user in User.objects.filter( is_active=True, sentry_orgmember_set__organization=self, ): if not Authenticator.objects.user_has_2fa(user): context = { 'user': user, 'url': absolute_uri(reverse('sentry-account-settings-2fa')), 'organization': self } message = MessageBuilder( subject='%s %s Mandatory: Enable Two-Factor Authentication' % (options.get('mail.subject-prefix'), self.name), template='sentry/emails/setup_2fa.txt', html_template='sentry/emails/setup_2fa.html', type='user.setup_2fa', context=context, ) message.send_async([user.email])
class OrganizationSettingsForm(forms.ModelForm): name = forms.CharField( help_text=_('The name of your organization. i.e. My Company')) slug = forms.SlugField( label=_('Short name'), help_text=_('A unique ID used to identify this organization.'), ) allow_joinleave = forms.BooleanField( label=_('Open Membership'), help_text=_( 'Allow organization members to freely join or leave any team.'), required=False, ) default_role = forms.ChoiceField( label=_('Default Role'), choices=roles.get_choices(), help_text=_('The default role new members will receive.'), ) enhanced_privacy = forms.BooleanField( label=_('Enhanced Privacy'), help_text= _('Enable enhanced privacy controls to limit personally identifiable information (PII) as well as source code in things like notifications.' ), required=False, ) allow_shared_issues = forms.BooleanField( label=_('Allow Shared Issues'), help_text=_( 'Enable sharing of limited details on issues to anonymous users.'), required=False, ) require_scrub_data = forms.BooleanField( label=_('Require Data Scrubber'), help_text=_( 'Require server-side data scrubbing be enabled for all projects.'), required=False) require_scrub_defaults = forms.BooleanField( label=_('Require Using Default Scrubbers'), help_text= _('Require the default scrubbers be applied to prevent things like passwords and credit cards from being stored for all projects.' ), required=False) sensitive_fields = forms.CharField( label=_('Global additional sensitive fields'), help_text= _('Additional field names to match against when scrubbing data for all projects. ' 'Separate multiple entries with a newline.<br /><strong>Note: These fields will be used in addition to project specific fields.</strong>' ), widget=forms.Textarea( attrs={ 'placeholder': mark_safe(_('e.g. email')), 'class': 'span8', 'rows': '3', }), required=False, ) require_scrub_ip_address = forms.BooleanField( label=_('Require not storing IP Addresses'), help_text= _('Require preventing IP addresses from being stored for new events on all projects.' ), required=False) class Meta: fields = ('name', 'slug', 'default_role') model = Organization def clean_sensitive_fields(self): value = self.cleaned_data.get('sensitive_fields') if not value: return return filter(bool, (v.lower().strip() for v in value.split('\n')))
class Organization(Model): """ An organization represents a group of individuals which maintain ownership of projects. """ name = models.CharField(max_length=64) slug = models.SlugField(unique=True) status = BoundedPositiveIntegerField(choices=( (OrganizationStatus.VISIBLE, _('Visible')), (OrganizationStatus.PENDING_DELETION, _('Pending Deletion')), (OrganizationStatus.DELETION_IN_PROGRESS, _('Deletion in Progress')), ), default=OrganizationStatus.VISIBLE) date_added = models.DateTimeField(default=timezone.now) members = models.ManyToManyField(settings.AUTH_USER_MODEL, through='sentry.OrganizationMember', related_name='org_memberships') default_role = models.CharField( choices=roles.get_choices(), max_length=32, default=roles.get_default().id, ) flags = BitField(flags=(( 'allow_joinleave', 'Allow members to join and leave teams without requiring approval.'), ), default=1) objects = OrganizationManager(cache_fields=( 'pk', 'slug', )) class Meta: app_label = 'sentry' db_table = 'sentry_organization' __repr__ = sane_repr('owner_id', 'name', 'slug') @classmethod def get_default(cls): """ Return the organization used in single organization mode. """ return cls.objects.filter(status=OrganizationStatus.VISIBLE, )[0] def __unicode__(self): return u'%s (%s)' % (self.name, self.slug) def save(self, *args, **kwargs): if not self.slug: lock_key = 'slug:organization' with Lock(lock_key): slugify_instance(self, self.name, reserved=RESERVED_ORGANIZATION_SLUGS) super(Organization, self).save(*args, **kwargs) else: super(Organization, self).save(*args, **kwargs) def delete(self): if self.is_default: raise Exception('You cannot delete the the default organization.') return super(Organization, self).delete() @cached_property def is_default(self): if not settings.SENTRY_SINGLE_ORGANIZATION: return False return self == type(self).get_default() def has_access(self, user, access=None): queryset = self.member_set.filter(user=user) if access is not None: queryset = queryset.filter(type__lte=access) return queryset.exists() def get_audit_log_data(self): return { 'id': self.id, 'slug': self.slug, 'name': self.name, 'status': self.status, 'flags': self.flags, 'default_role': self.default_role, } def get_default_owner(self): if not hasattr(self, '_default_owner'): from sentry.models import User self._default_owner = User.objects.filter( sentry_orgmember_set__role=roles.get_top_dog().id, sentry_orgmember_set__organization=self, )[0] return self._default_owner def has_single_owner(self): from sentry.models import OrganizationMember count = OrganizationMember.objects.filter( organization=self, role='owner', user__isnull=False, ).count() return count == 1 def merge_to(from_org, to_org): from sentry.models import (ApiKey, AuditLogEntry, OrganizationMember, OrganizationMemberTeam, Project, Team) for from_member in OrganizationMember.objects.filter( organization=from_org): try: to_member = OrganizationMember.objects.get( organization=to_org, user=from_member.user, ) except OrganizationMember.DoesNotExist: from_member.update(organization=to_org) to_member = from_member else: qs = OrganizationMemberTeam.objects.filter( organizationmember=from_member, is_active=True, ).select_related() for omt in qs: OrganizationMemberTeam.objects.create_or_update( organizationmember=to_member, team=omt.team, defaults={ 'is_active': True, }, ) for model in (Team, Project, ApiKey, AuditLogEntry): model.objects.filter( organization=from_org, ).update(organization=to_org)
class OrganizationMemberSerializer(serializers.Serializer): email = AllowedEmailField(max_length=75, required=True) role = serializers.ChoiceField(choices=roles.get_choices(), required=True) teams = ListField(required=False, allow_null=False)
class Organization(Model): """ An organization represents a group of individuals which maintain ownership of projects. """ name = models.CharField(max_length=64) slug = models.SlugField(unique=True) status = BoundedPositiveIntegerField(choices=( (OrganizationStatus.VISIBLE, _('Visible')), (OrganizationStatus.PENDING_DELETION, _('Pending Deletion')), (OrganizationStatus.DELETION_IN_PROGRESS, _('Deletion in Progress')), ), default=OrganizationStatus.VISIBLE) date_added = models.DateTimeField(default=timezone.now) members = models.ManyToManyField(settings.AUTH_USER_MODEL, through='sentry.OrganizationMember', related_name='org_memberships') default_role = models.CharField( choices=roles.get_choices(), max_length=32, default=roles.get_default().id, ) flags = BitField(flags=( ('allow_joinleave', 'Allow members to join and leave teams without requiring approval.'), ('enhanced_privacy', 'Enable enhanced privacy controls to limit personally identifiable information (PII) as well as source code in things like notifications.' ), ('disable_shared_issues', 'Disable sharing of limited details on issues to anonymous users.'), ('early_adopter', 'Enable early adopter status, gaining access to features prior to public release.' ), ), default=1) objects = OrganizationManager(cache_fields=( 'pk', 'slug', )) class Meta: app_label = 'sentry' db_table = 'sentry_organization' __repr__ = sane_repr('owner_id', 'name', 'slug') @classmethod def get_default(cls): """ Return the organization used in single organization mode. """ return cls.objects.filter(status=OrganizationStatus.VISIBLE, )[0] def __unicode__(self): return u'%s (%s)' % (self.name, self.slug) def save(self, *args, **kwargs): if not self.slug: lock = locks.get('slug:organization', duration=5) with TimedRetryPolicy(10)(lock.acquire): slugify_instance(self, self.name, reserved=RESERVED_ORGANIZATION_SLUGS) super(Organization, self).save(*args, **kwargs) else: super(Organization, self).save(*args, **kwargs) def delete(self): if self.is_default: raise Exception('You cannot delete the the default organization.') return super(Organization, self).delete() @cached_property def is_default(self): if not settings.SENTRY_SINGLE_ORGANIZATION: return False return self == type(self).get_default() def has_access(self, user, access=None): queryset = self.member_set.filter(user=user) if access is not None: queryset = queryset.filter(type__lte=access) return queryset.exists() def get_audit_log_data(self): return { 'id': self.id, 'slug': self.slug, 'name': self.name, 'status': self.status, 'flags': self.flags, 'default_role': self.default_role, } def get_default_owner(self): if not hasattr(self, '_default_owner'): from sentry.models import User self._default_owner = User.objects.filter( sentry_orgmember_set__role=roles.get_top_dog().id, sentry_orgmember_set__organization=self, )[0] return self._default_owner def has_single_owner(self): from sentry.models import OrganizationMember count = OrganizationMember.objects.filter( organization=self, role='owner', user__isnull=False, ).count() return count == 1 def merge_to(from_org, to_org): from sentry.models import (ApiKey, AuditLogEntry, OrganizationMember, OrganizationMemberTeam, Project, Team) for from_member in OrganizationMember.objects.filter( organization=from_org): try: to_member = OrganizationMember.objects.get( organization=to_org, user=from_member.user, ) except OrganizationMember.DoesNotExist: from_member.update(organization=to_org) to_member = from_member else: qs = OrganizationMemberTeam.objects.filter( organizationmember=from_member, is_active=True, ).select_related() for omt in qs: OrganizationMemberTeam.objects.create_or_update( organizationmember=to_member, team=omt.team, defaults={ 'is_active': True, }, ) for team in Team.objects.filter(organization=from_org): try: with transaction.atomic(): team.update(organization=to_org) except IntegrityError: slugify_instance(team, team.name, organization=to_org) team.update( organization=to_org, slug=team.slug, ) for project in Project.objects.filter(organization=from_org): try: with transaction.atomic(): project.update(organization=to_org) except IntegrityError: slugify_instance(project, project.name, organization=to_org) project.update( organization=to_org, slug=project.slug, ) for model in (ApiKey, AuditLogEntry): model.objects.filter( organization=from_org, ).update(organization=to_org) # TODO: Make these a mixin def update_option(self, *args, **kwargs): from sentry.models import OrganizationOption return OrganizationOption.objects.set_value(self, *args, **kwargs) def get_option(self, *args, **kwargs): from sentry.models import OrganizationOption return OrganizationOption.objects.get_value(self, *args, **kwargs) def delete_option(self, *args, **kwargs): from sentry.models import OrganizationOption return OrganizationOption.objects.unset_value(self, *args, **kwargs)