Skip to content

fix(auth): do not update email in UserDetailsEndpoint#119253

Merged
nora-shap merged 9 commits into
masterfrom
nora/uname-update
Jul 10, 2026
Merged

fix(auth): do not update email in UserDetailsEndpoint#119253
nora-shap merged 9 commits into
masterfrom
nora/uname-update

Conversation

@nora-shap

@nora-shap nora-shap commented Jul 8, 2026

Copy link
Copy Markdown
Member

Removes the verified email enforcement and the validate override from UserDetailsEndpoint.put() that incorrectly synced email when username changed. Changing username should never require a verified email nor implicitly update the user's primary email.

  1. You cannot change email through this endpoint, all email changing is through UserEmailsEndpoint
  2. You can only change your username if it is different from your email
  3. Changing username should never change email

Removing this check revealed a new issue: A username equal to another user's primary email shadows that account at login: find_users() resolves a username before falling back to email, so the original user logging in with their email matches the other account and fails. Username uniqueness was only enforced against other usernames, not emails, leaving this possible via the account-settings endpoint, the relocation claim form, and relocation import.

Add User.is_username_available (checks both username and primary email) and route all three surfaces through it:

  • UserDetailsEndpoint and the relocation claim form reject a conflicting username.
  • Relocation import suffixes the username to keep it unique rather than inserting a shadowing row.

@nora-shap
nora-shap requested a review from a team July 8, 2026 19:53
@github-actions github-actions Bot added the Scope: Backend Automatically applied to PRs that change backend components label Jul 8, 2026
@nora-shap
nora-shap marked this pull request as ready for review July 8, 2026 19:58
@nora-shap
nora-shap requested a review from a team as a code owner July 8, 2026 19:58
raise serializers.ValidationError("That username is already in use.")
return value

def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It looks like BaseUserSerializer is inherited by a bunch of other serializers, so removing this might affect how the other serializers work.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think this is actually a good consequence - the sync removal now applies uniformly to regular, superuser, and staff edits. I don't think changing username should ever change email, but let me take a look at how BaseUserSerializer is being used.

Looks like BaseUserSerializer is only being used for the 3 other UserSerializers in this file, which are only used here and in 1 other place: write_relocation_import on the User model. (note there is another unrelated UserSerializer in src/sentry/users/api/serializers/user.py)

The validate() function I removed was mutating validated_data so the changes it caused would only persist if serializer.save() was used.
This is not the case for the uses in src/sentry/users/models/user.py, which use self.save(), so the removed code was already not being used in write_relocation_import

@nora-shap
nora-shap requested a review from a team as a code owner July 9, 2026 01:25
)

serializer_cls = BaseUserSerializer
serializer_cls: type[BaseUserSerializer]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

driveby cleanup

Comment thread src/sentry/users/api/endpoints/user_details.py
attrs.setdefault("email", attrs["username"])
# Reject a value already taken as a username OR another user's email
# because login resolves username before falling back to email.
if others.filter(Q(username__iexact=value) | Q(email__iexact=value)).exists():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm... I think relocation currently allows for potentially duplicate emails. Would this change the logic for relocation?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good call - relocation uses a "resolve-and-insert" method m rather than failing and rejecting on username collision.

I extended this behavior by handling relocated user's username == existing user's email the same way username collisions were handled.

Relocated users are created in recovery state, when they successfully log in they are prompted to change their username so they can change their username if they don't like it. (added the username == email check to this form validation as well)

@nora-shap
nora-shap requested a review from a team as a code owner July 9, 2026 21:29

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3b06650. Configure here.

Comment thread src/sentry/users/models/user.py
@nora-shap
nora-shap force-pushed the nora/uname-update branch from f9fde73 to 57dc341 Compare July 9, 2026 21:59
Comment thread src/sentry/users/models/user.py Outdated
"""
username_or_primary_taken = (
cls.objects.exclude(id=exclude_user_id)
.filter(Q(username__iexact=username) | Q(email__iexact=username))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thoughts on defensively adding a email_unique check too? The user's email doesn't always match the email_unique 🙃

Comment thread src/sentry/users/models/user.py Outdated
Comment thread src/sentry/users/models/user.py
Comment on lines +556 to +560
verified_email_taken = (
UserEmail.objects.filter(email__iexact=username, is_verified=True)
.exclude(user_id=exclude_user_id)
.exists()
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If username_or_primary_taken is true, you could return early saving a query.

Comment thread src/sentry/users/models/user.py
@linear-code

linear-code Bot commented Jul 10, 2026

Copy link
Copy Markdown

ID-1696

@nora-shap
nora-shap enabled auto-merge (squash) July 10, 2026 21:24
@nora-shap
nora-shap merged commit 7b5cf72 into master Jul 10, 2026
86 of 87 checks passed
@nora-shap
nora-shap deleted the nora/uname-update branch July 10, 2026 21:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Backend Automatically applied to PRs that change backend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants