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(max_length=32, default=str(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", on_delete=models.SET_NULL, ) 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().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(str(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=f"Action Required for {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.models import LostPasswordHash from sentry.utils.email import MessageBuilder email = self.get_email() recover_uri = "{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=f"Action Required for {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): scopes = roles.get(self.role).scopes disabled_scopes = set() if self.role == "member": if not self.organization.get_option("sentry:events_member_admin", EVENTS_MEMBER_ADMIN_DEFAULT): disabled_scopes.add("event:admin") if not self.organization.get_option("sentry:alerts_member_write", ALERTS_MEMBER_WRITE_DEFAULT): disabled_scopes.add("alerts:write") scopes = frozenset(s for s in scopes if s not in disabled_scopes) return 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(), 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", through_fields=("organization", "user"), ) default_role = models.CharField(max_length=32, default=str(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", ), ( "require_email_verification", "Require and enforce email verification for all members.", ), ), default=1, ) objects = OrganizationManager(cache_fields=("pk", "slug")) class Meta: app_label = "sentry" db_table = "sentry_organization" # TODO: Once we're on a version of Django that supports functional indexes, # include index on `upper((slug::text))` here. __repr__ = sane_repr("owner_id", "name", "slug") @classmethod def get_default(cls): """ Return the organization used in single organization mode. """ if settings.SENTRY_ORGANIZATION is not None: return cls.objects.get(id=settings.SENTRY_ORGANIZATION) return cls.objects.filter(status=OrganizationStatus.ACTIVE)[0] def __str__(self): return f"{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().save(*args, **kwargs) else: super().save(*args, **kwargs) def delete(self, **kwargs): from sentry.models import NotificationSetting if self.is_default: raise Exception("You cannot delete the the default organization.") # There is no foreign key relationship so we have to manually cascade. NotificationSetting.objects.remove_for_organization(self) return super().delete(**kwargs) @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, Environment, OrganizationAvatar, OrganizationIntegration, OrganizationMember, OrganizationMemberTeam, Project, Release, ReleaseCommit, ReleaseEnvironment, ReleaseFile, ReleaseHeadCommit, Repository, Team, ) 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( f"{model_name}.migrate-skipped", extra={ "from_organization_id": from_org.id, "to_organization_id": to_org.id, }, ) else: logger.info( f"{model_name}.migrate", extra={ "instance_id": instance.id, "from_organization_id": from_org.id, "to_organization_id": to_org.id, }, ) else: logger.info( f"{model_name}.migrate", 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="{}Organization Queued for Deletion".format( 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 _handle_requirement_change(self, request, task): from sentry.models import ApiKey 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"] task.delay(self.id, actor_id=actor_id, actor_key_id=api_key_id, ip_address=ip_address) def handle_2fa_required(self, request): from sentry.tasks.auth import remove_2fa_non_compliant_members self._handle_requirement_change(request, remove_2fa_non_compliant_members) def handle_email_verification_required(self, request): from sentry.tasks.auth import remove_email_verification_non_compliant_members if features.has("organizations:required-email-verification", self): self._handle_requirement_change( request, remove_email_verification_non_compliant_members) def get_url_viewname(self): return "sentry-organization-issue-list" 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 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 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)
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. """ __include_in_export__ = True objects = OrganizationMemberManager() 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(max_length=32, default=str(roles.get_default().id)) flags = BitField( flags=( ("sso:linked", "sso:linked"), ("sso:invalid", "sso:invalid"), ("member-limit:restricted", "member-limit:restricted"), ), 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", on_delete=models.SET_NULL, ) 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().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(str(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=f"Action Required for {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.models import LostPasswordHash from sentry.utils.email import MessageBuilder email = self.get_email() recover_uri = "{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=f"Action Required for {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 OrganizationMemberTeam, 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 OrganizationMemberTeam, Team return Team.objects.filter( status=TeamStatus.VISIBLE, id__in=OrganizationMemberTeam.objects.filter( organizationmember=self, is_active=True).values("team"), ) def get_scopes(self): scopes = roles.get(self.role).scopes disabled_scopes = set() if self.role == "member": if not self.organization.get_option("sentry:events_member_admin", EVENTS_MEMBER_ADMIN_DEFAULT): disabled_scopes.add("event:admin") if not self.organization.get_option("sentry:alerts_member_write", ALERTS_MEMBER_WRITE_DEFAULT): disabled_scopes.add("alerts:write") 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 ]
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 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])