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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion common/djangoapps/student/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -903,14 +903,34 @@ class PendingEmailChange(DeletableByUserValue, models.Model):
"""
This model keeps track of pending requested changes to a user's email address.

.. pii: Contains new_email, retired in AccountRetirementView
.. pii: Contains new_email, redacted then deleted in AccountRetirementView
.. pii_types: email_address
.. pii_retirement: local_api
"""
user = models.OneToOneField(User, unique=True, db_index=True, on_delete=models.CASCADE)
new_email = models.CharField(blank=True, max_length=255, db_index=True)
activation_key = models.CharField(('activation key'), max_length=32, unique=True, db_index=True)

@classmethod
def redact_pending_email_by_user_value(cls, value, field):
"""
Redact pending email change fields for records matching ``field=value``.

This method is intended for retirement flows where downstream replication
may keep soft-deleted snapshots of these rows.
"""
filter_kwargs = {field: value}
records_matching_user_value = cls.objects.filter(**filter_kwargs)

if not records_matching_user_value.exists():
return False

for record in records_matching_user_value:
record.new_email = get_retired_email_by_email(record.new_email)
record.save(update_fields=['new_email'])

return True
Comment on lines +914 to +932
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

redact_pending_email_by_user_value only redacts new_email, but leaves activation_key unchanged. The PR description/incident explains Snowflake soft-deleted snapshots can retain both pending email and activation key values, so keeping activation_key intact still leaks a sensitive token. Suggest redacting activation_key as well (e.g., replace with a new random/unique value to satisfy the unique=True constraint) and update the method/docstring accordingly.

Copilot uses AI. Check for mistakes.

def request_change(self, email):
"""Request a change to a user's email.

Expand Down
26 changes: 25 additions & 1 deletion common/djangoapps/student/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
PendingNameChange,
UserAttribute,
UserCelebration,
UserProfile
UserProfile,
get_retired_email_by_email,
)
from common.djangoapps.student.models_api import confirm_name_change, do_name_change_request, get_name
from common.djangoapps.student.tests.factories import AccountRecoveryFactory, CourseEnrollmentFactory, UserFactory
Expand Down Expand Up @@ -593,11 +594,34 @@ def test_delete_by_user_removes_pending_email_change(self):
assert record_was_deleted
assert 0 == len(PendingEmailChange.objects.all())

def test_redact_by_user_redacts_pending_email_change_fields(self):
original_new_email = self.email_change.new_email
original_activation_key = self.email_change.activation_key
expected_retired_email = get_retired_email_by_email(original_new_email)

record_was_redacted = PendingEmailChange.redact_pending_email_by_user_value(self.user, field='user')

assert record_was_redacted
self.email_change.refresh_from_db()
assert self.email_change.new_email == expected_retired_email
assert self.email_change.activation_key == original_activation_key
Comment thread
ktyagiapphelix2u marked this conversation as resolved.

def test_delete_by_user_no_effect_for_user_with_no_email_change(self):
record_was_deleted = PendingEmailChange.delete_by_user_value(self.user2, field='user')
assert not record_was_deleted
assert 1 == len(PendingEmailChange.objects.all())

def test_redact_by_user_no_effect_for_user_with_no_email_change(self):
original_new_email = self.email_change.new_email
original_activation_key = self.email_change.activation_key

record_was_redacted = PendingEmailChange.redact_pending_email_by_user_value(self.user2, field='user')

assert not record_was_redacted
self.email_change.refresh_from_db()
assert self.email_change.new_email == original_new_email
assert self.email_change.activation_key == original_activation_key


class TestCourseEnrollmentAllowed(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1466,7 +1466,13 @@ def test_retire_user_where_username_not_provided(self):

@mock.patch('openedx.core.djangoapps.user_api.accounts.views.get_profile_image_names')
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.remove_profile_images')
def test_retire_user(self, mock_remove_profile_images, mock_get_profile_image_names):
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.PendingEmailChange.redact_pending_email_by_user_value')
def test_retire_user(
self,
mock_redact_pending_email,
mock_remove_profile_images,
mock_get_profile_image_names,
):
data = {'username': self.original_username}
self.post_and_assert_status(data)

Expand Down Expand Up @@ -1497,6 +1503,7 @@ def test_retire_user(self, mock_remove_profile_images, mock_get_profile_image_na

self._entitlement_support_detail_assertions()

mock_redact_pending_email.assert_called_once_with(self.test_user, field="user")
assert not PendingEmailChange.objects.filter(user=self.test_user).exists()
assert not UserOrgTag.objects.filter(user=self.test_user).exists()

Expand All @@ -1509,6 +1516,24 @@ def test_retire_user_twice_idempotent(self):
fake_completed_retirement(self.test_user)
self.post_and_assert_status(data)

@mock.patch('openedx.core.djangoapps.user_api.accounts.views.PendingEmailChange.delete_by_user_value')
def test_retire_user_redacts_pending_email_before_delete(self, mock_delete_pending_email):
pending_email_before_retirement = PendingEmailChange.objects.get(user=self.test_user).new_email
expected_retired_pending_email = get_retired_email_by_email(pending_email_before_retirement)

def _assert_redacted_then_delete(value, field):
pending_record = PendingEmailChange.objects.get(user=self.test_user)
assert pending_record.new_email == expected_retired_pending_email
Comment on lines +1521 to +1526
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

The ordering test only verifies new_email is redacted before delete_by_user_value runs. Since the motivating issue includes activation_key retention in soft-deleted replicas, extend the assertion here to verify activation_key is also redacted/changed before deletion once the model helper is updated.

Suggested change
pending_email_before_retirement = PendingEmailChange.objects.get(user=self.test_user).new_email
expected_retired_pending_email = get_retired_email_by_email(pending_email_before_retirement)
def _assert_redacted_then_delete(value, field):
pending_record = PendingEmailChange.objects.get(user=self.test_user)
assert pending_record.new_email == expected_retired_pending_email
pending_email_record = PendingEmailChange.objects.get(user=self.test_user)
pending_email_before_retirement = pending_email_record.new_email
activation_key_before_retirement = pending_email_record.activation_key
expected_retired_pending_email = get_retired_email_by_email(pending_email_before_retirement)
def _assert_redacted_then_delete(value, field):
pending_record = PendingEmailChange.objects.get(user=self.test_user)
assert pending_record.new_email == expected_retired_pending_email
assert pending_record.activation_key != activation_key_before_retirement

Copilot uses AI. Check for mistakes.
pending_record.delete()
return True

mock_delete_pending_email.side_effect = _assert_redacted_then_delete

data = {'username': self.original_username}
self.post_and_assert_status(data)

assert not PendingEmailChange.objects.filter(user=self.test_user).exists()

@mock.patch('openedx.core.djangoapps.user_api.accounts.views.USER_RETIRE_LMS_CRITICAL')
def test_retirement_sends_critical_signal_with_retirement_data(self, mock_signal):
"""
Expand Down
5 changes: 4 additions & 1 deletion openedx/core/djangoapps/user_api/accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1193,7 +1193,10 @@ def post(self, request):

self.retire_entitlement_support_detail(user)

# Retire misc. models that may contain PII of this user
# Retire misc. models that may contain PII of this user.
# Redact pending primary email fields before delete because
# downstream replication can preserve soft-deleted snapshots.
PendingEmailChange.redact_pending_email_by_user_value(user, field="user")
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.

@ktyagiapphelix2u , Although this may not matter much but reaction value is user here, can you please confirm?

PendingEmailChange.delete_by_user_value(user, field="user")
UserOrgTag.delete_by_user_value(user, field="user")

Expand Down
Loading