Skip to content

Commit 01d69f1

Browse files
authored
feat(admin): Impove user account merging (#72692)
We now ensure that user account merges are much more complete. All exportable (and even some non-exportable!) data associated with the user-being-merged is now properly pointed at the newly combined `User` and `OrganizationMember` models, and awkward collisions (what if the users had invited one another to a certain team?) are handled gracefully.
1 parent 473b4dd commit 01d69f1

File tree

13 files changed

+875
-466
lines changed

13 files changed

+875
-466
lines changed

src/sentry/backup/dependencies.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from typing import NamedTuple
88

99
from django.db import models
10-
from django.db.models import UniqueConstraint
10+
from django.db.models import Q, UniqueConstraint
1111
from django.db.models.fields.related import ForeignKey, OneToOneField
1212

1313
from sentry.backup.helpers import EXCLUDED_APPS
@@ -701,3 +701,28 @@ def get_exportable_sentry_models() -> set[type[models.base.Model]]:
701701
get_final_derivations_of(BaseModel),
702702
)
703703
)
704+
705+
706+
def merge_users_for_model_in_org(
707+
model: type[models.base.Model], *, organization_id: int, from_user_id: int, to_user_id: int
708+
) -> None:
709+
"""
710+
All instances of this model in a certain organization that reference both the organization and
711+
user in question will be pointed at the new user instead.
712+
"""
713+
714+
from sentry.models.organization import Organization
715+
from sentry.models.user import User
716+
717+
model_relations = dependencies()[get_model_name(model)]
718+
user_refs = {k for k, v in model_relations.foreign_keys.items() if v.model == User}
719+
org_refs = {
720+
k if k.endswith("_id") else f"{k}_id"
721+
for k, v in model_relations.foreign_keys.items()
722+
if v.model == Organization
723+
}
724+
for_this_org = Q(**{field_name: organization_id for field_name in org_refs})
725+
for user_ref in user_refs:
726+
q = for_this_org & Q(**{user_ref: from_user_id})
727+
obj = model.objects.filter(q)
728+
obj.update(**{user_ref: to_user_id})

src/sentry/models/user.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
NormalizedModelName,
2727
PrimaryKeyMap,
2828
get_model_name,
29+
merge_users_for_model_in_org,
2930
)
3031
from sentry.backup.helpers import ImportFlags
3132
from sentry.backup.sanitize import SanitizableField, Sanitizer
@@ -38,13 +39,15 @@
3839
sane_repr,
3940
)
4041
from sentry.db.models.utils import unique_db_instance
42+
from sentry.db.postgres.transactions import enforce_constraints
4143
from sentry.integrations.types import EXTERNAL_PROVIDERS, ExternalProviders
4244
from sentry.locks import locks
4345
from sentry.models.authenticator import Authenticator
4446
from sentry.models.avatars import UserAvatar
4547
from sentry.models.lostpasswordhash import LostPasswordHash
4648
from sentry.models.organizationmapping import OrganizationMapping
4749
from sentry.models.organizationmembermapping import OrganizationMemberMapping
50+
from sentry.models.orgauthtoken import OrgAuthToken
4851
from sentry.models.outbox import ControlOutboxBase, OutboxCategory, outbox_context
4952
from sentry.services.hybrid_cloud.organization import RpcRegionUser, organization_service
5053
from sentry.services.hybrid_cloud.user import RpcUser
@@ -332,7 +335,7 @@ def outboxes_for_user_update(
332335
shard_identifier=identifier,
333336
)
334337

335-
def merge_to(from_user, to_user):
338+
def merge_to(from_user: User, to_user: User) -> None:
336339
# TODO: we could discover relations automatically and make this useful
337340
from sentry.models.auditlogentry import AuditLogEntry
338341
from sentry.models.authenticator import Authenticator
@@ -343,33 +346,56 @@ def merge_to(from_user, to_user):
343346
from sentry.models.organizationmembermapping import OrganizationMemberMapping
344347
from sentry.models.useremail import UserEmail
345348

349+
from_user_id = from_user.id
350+
to_user_id = to_user.id
351+
346352
audit_logger.info(
347-
"user.merge", extra={"from_user_id": from_user.id, "to_user_id": to_user.id}
353+
"user.merge", extra={"from_user_id": from_user_id, "to_user_id": to_user_id}
348354
)
349355

350356
organization_ids: list[int]
351357
organization_ids = OrganizationMemberMapping.objects.filter(
352-
user_id=from_user.id
358+
user_id=from_user_id
353359
).values_list("organization_id", flat=True)
354360

355361
for organization_id in organization_ids:
356362
organization_service.merge_users(
357-
organization_id=organization_id, from_user_id=from_user.id, to_user_id=to_user.id
363+
organization_id=organization_id, from_user_id=from_user_id, to_user_id=to_user_id
358364
)
359365

360-
model_list: tuple[type[BaseModel], ...] = (
366+
# Update all organization control models to only use the new user id.
367+
#
368+
# TODO: in the future, proactively update `OrganizationMemberTeamReplica` as well.
369+
with enforce_constraints(
370+
transaction.atomic(using=router.db_for_write(OrganizationMemberMapping))
371+
):
372+
control_side_org_models: tuple[type[BaseModel], ...] = (
373+
OrgAuthToken,
374+
OrganizationMemberMapping,
375+
)
376+
for model in control_side_org_models:
377+
merge_users_for_model_in_org(
378+
model,
379+
organization_id=organization_id,
380+
from_user_id=from_user_id,
381+
to_user_id=to_user_id,
382+
)
383+
384+
# While it would be nice to make the following changes in a transaction, there are too many
385+
# unique constraints to make this feasible. Instead, we just do it sequentially and ignore
386+
# the `IntegrityError`s.
387+
user_related_models: tuple[type[BaseModel], ...] = (
361388
Authenticator,
362389
Identity,
363390
UserAvatar,
364391
UserEmail,
365392
UserOption,
366393
)
367-
368-
for model in model_list:
369-
for obj in model.objects.filter(user_id=from_user.id):
394+
for model in user_related_models:
395+
for obj in model.objects.filter(user_id=from_user_id):
370396
try:
371397
with transaction.atomic(using=router.db_for_write(User)):
372-
obj.update(user_id=to_user.id)
398+
obj.update(user_id=to_user_id)
373399
except IntegrityError:
374400
pass
375401

@@ -385,7 +411,7 @@ def merge_to(from_user, to_user):
385411
for ai in AuthIdentity.objects.filter(
386412
user=from_user,
387413
auth_provider__organization_id__in=AuthIdentity.objects.filter(
388-
user_id=to_user.id
414+
user_id=to_user_id
389415
).values("auth_provider__organization_id"),
390416
):
391417
ai.delete()

src/sentry/services/hybrid_cloud/organization/impl.py

Lines changed: 81 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,39 @@
33
from collections.abc import Mapping
44
from typing import Any
55

6-
from django.db import IntegrityError, models, router, transaction
6+
from django.db import models, router, transaction
77
from django.db.models.expressions import CombinedExpression, F
88
from django.dispatch import Signal
99

1010
from sentry import roles
1111
from sentry.api.serializers import serialize
12+
from sentry.backup.dependencies import merge_users_for_model_in_org
1213
from sentry.db.postgres.transactions import enforce_constraints
14+
from sentry.incidents.models.alert_rule import AlertRule, AlertRuleActivity
15+
from sentry.incidents.models.incident import IncidentActivity, IncidentSubscription
1316
from sentry.models.activity import Activity
17+
from sentry.models.dashboard import Dashboard
18+
from sentry.models.dynamicsampling import CustomDynamicSamplingRule
1419
from sentry.models.groupassignee import GroupAssignee
1520
from sentry.models.groupbookmark import GroupBookmark
21+
from sentry.models.groupsearchview import GroupSearchView
1622
from sentry.models.groupseen import GroupSeen
1723
from sentry.models.groupshare import GroupShare
1824
from sentry.models.groupsubscription import GroupSubscription
1925
from sentry.models.organization import Organization, OrganizationStatus
26+
from sentry.models.organizationaccessrequest import OrganizationAccessRequest
2027
from sentry.models.organizationmapping import OrganizationMapping
2128
from sentry.models.organizationmember import InviteStatus, OrganizationMember
2229
from sentry.models.organizationmemberteam import OrganizationMemberTeam
2330
from sentry.models.outbox import ControlOutbox, OutboxCategory, OutboxScope, outbox_context
31+
from sentry.models.projectbookmark import ProjectBookmark
32+
from sentry.models.recentsearch import RecentSearch
33+
from sentry.models.rule import Rule, RuleActivity
34+
from sentry.models.rulesnooze import RuleSnooze
35+
from sentry.models.savedsearch import SavedSearch
2436
from sentry.models.scheduledeletion import RegionScheduledDeletion
2537
from sentry.models.team import Team, TeamStatus
38+
from sentry.monitors.models import Monitor
2639
from sentry.services.hybrid_cloud import OptionValue, logger
2740
from sentry.services.hybrid_cloud.app import app_service
2841
from sentry.services.hybrid_cloud.organization import (
@@ -515,33 +528,75 @@ def merge_users(self, *, organization_id: int, from_user_id: int, to_user_id: in
515528

516529
assert to_member
517530

518-
for team in from_member.teams.all():
519-
try:
520-
with enforce_constraints(
521-
transaction.atomic(router.db_for_write(OrganizationMemberTeam))
522-
):
531+
with enforce_constraints(transaction.atomic(using=router.db_for_write(OrganizationMember))):
532+
# Delete all org access requests between the two now-merged users.
533+
OrganizationAccessRequest.objects.filter(
534+
member=from_member, requester_id=to_user_id
535+
).delete()
536+
OrganizationAccessRequest.objects.filter(
537+
member=to_member, requester_id=from_user_id
538+
).delete()
539+
540+
# All other org access requests should be pointed from the old member to the new
541+
# one.
542+
reqs = OrganizationAccessRequest.objects.filter(member=from_member)
543+
for req in reqs:
544+
req.member = to_member
545+
req.save()
546+
547+
# Move all old team memberships to the newly merged `OrganizationMember`.
548+
for team in from_member.teams.all():
549+
OrganizationMemberTeam.objects.filter(
550+
organizationmember=from_member, team=team
551+
).delete()
552+
to_member_team = OrganizationMemberTeam.objects.filter(
553+
organizationmember=to_member, team=team
554+
).first()
555+
if to_member_team is None:
523556
OrganizationMemberTeam.objects.create(organizationmember=to_member, team=team)
524-
except IntegrityError:
525-
pass
526-
527-
model_list = (
528-
GroupAssignee,
529-
GroupBookmark,
530-
GroupSeen,
531-
GroupShare,
532-
GroupSubscription,
533-
Activity,
534-
)
535557

536-
for model in model_list:
537-
for obj in model.objects.filter(
538-
user_id=from_user_id, project__organization_id=organization_id
539-
):
540-
try:
541-
with enforce_constraints(transaction.atomic(router.db_for_write(model))):
542-
obj.update(user_id=to_user_id)
543-
except IntegrityError:
544-
pass
558+
# Update all organization region models to only use the new user id.
559+
model_list = [
560+
Activity,
561+
AlertRule,
562+
AlertRuleActivity,
563+
CustomDynamicSamplingRule,
564+
Dashboard,
565+
GroupAssignee,
566+
GroupBookmark,
567+
GroupSeen,
568+
GroupShare,
569+
GroupSearchView,
570+
GroupSubscription,
571+
IncidentActivity,
572+
IncidentSubscription,
573+
OrganizationAccessRequest,
574+
ProjectBookmark,
575+
RecentSearch,
576+
Rule,
577+
RuleActivity,
578+
RuleSnooze,
579+
SavedSearch,
580+
]
581+
for model in model_list:
582+
merge_users_for_model_in_org(
583+
model,
584+
organization_id=organization_id,
585+
from_user_id=from_user_id,
586+
to_user_id=to_user_id,
587+
)
588+
589+
# Finally, delete the old member.
590+
from_member.delete()
591+
592+
# TODO: for some reason, `Monitor` insists on being updated outside of the transaction, even
593+
# though it's also not region siloed?
594+
merge_users_for_model_in_org(
595+
Monitor,
596+
organization_id=organization_id,
597+
from_user_id=from_user_id,
598+
to_user_id=to_user_id,
599+
)
545600

546601
def reset_idp_flags(self, *, organization_id: int) -> None:
547602
with unguarded_write(using=router.db_for_write(OrganizationMember)):

0 commit comments

Comments
 (0)