Skip to content

Commit a0531a1

Browse files
fix: redact pending primary email before retirement deletion
1 parent f1edb33 commit a0531a1

6 files changed

Lines changed: 108 additions & 49 deletions

File tree

common/djangoapps/student/models/user.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -913,22 +913,25 @@ class PendingEmailChange(DeletableByUserValue, models.Model): # noqa: DJ008
913913
activation_key = models.CharField(('activation key'), max_length=32, unique=True, db_index=True)
914914

915915
@classmethod
916-
def redact_pending_email_by_user_value(cls, value, field):
916+
def delete_by_user_value(cls, value, field):
917917
"""
918-
Redact pending email change fields for records matching ``field=value``.
918+
Deletes instances of this model where ``field`` equals ``value``.
919919
920-
This method is intended for retirement flows where downstream systems
921-
may keep soft-deleted snapshots of these rows.
920+
Automatically redacts new_email before deletion to ensure PII is cleared.
921+
Uses bulk ORM update for efficiency.
922922
923-
Returns True if redacted, and False if no matching records found.
923+
Returns True if any instances were deleted.
924+
Returns False otherwise.
924925
"""
925926
filter_kwargs = {field: value}
926-
records = list(cls.objects.filter(**filter_kwargs))
927-
if not records:
927+
records_matching_user_value = cls.objects.filter(**filter_kwargs)
928+
929+
if not records_matching_user_value.exists():
928930
return False
929-
for record in records:
930-
record.new_email = get_retired_email_by_email(record.new_email)
931-
record.save(update_fields=['new_email'])
931+
932+
# Redact new_email before deletion using bulk update
933+
records_matching_user_value.update(new_email='[email protected]')
934+
records_matching_user_value.delete()
932935
return True
933936

934937
def request_change(self, email):

common/djangoapps/student/tests/test_email.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
1111
from django.core import mail
1212
from django.db import transaction
13+
from django.db.models.signals import pre_delete
1314
from django.http import HttpResponse
1415
from django.test import TransactionTestCase, override_settings
1516
from django.test.client import RequestFactory
@@ -608,6 +609,33 @@ def test_successful_email_change(self, test_body_type, test_marketing_enabled, m
608609
assert self.pending_change_request.new_email == User.objects.get(username=self.user.username).email
609610
assert PendingEmailChange.objects.count() == 0
610611

612+
@skip_unless_lms
613+
@patch('common.djangoapps.student.views.management.ace')
614+
def test_successful_email_change_redacts_pending_email_before_delete(self, ace_mail):
615+
original_email = self.user.email
616+
expected_new_email = self.pending_change_request.new_email
617+
captured_state = {}
618+
619+
def capture_before_delete(sender, instance, **kwargs):
620+
captured_state['new_email'] = instance.new_email
621+
622+
ace_mail.send.side_effect = [None, None]
623+
pre_delete.connect(capture_before_delete, sender=PendingEmailChange)
624+
try:
625+
response = confirm_email_change(self.request, self.key)
626+
finally:
627+
pre_delete.disconnect(capture_before_delete, sender=PendingEmailChange)
628+
629+
assert response.status_code == 200
630+
assert mock_render_to_response('email_change_successful.html', {
631+
'old_email': original_email,
632+
'new_email': expected_new_email,
633+
}).content.decode('utf-8') == response.content.decode('utf-8')
634+
assert captured_state['new_email'] == '[email protected]'
635+
assert User.objects.get(username=self.user.username).email == expected_new_email
636+
assert PendingEmailChange.objects.count() == 0
637+
assert ace_mail.send.call_count == 2
638+
611639
@patch('common.djangoapps.student.views.PendingEmailChange.objects.get', Mock(side_effect=TestException))
612640
def test_always_rollback(self):
613641
connection = transaction.get_connection()

common/djangoapps/student/tests/test_models.py

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from django.contrib.auth.models import AnonymousUser, User # lint-amnesty, pylint: disable=imported-auth-user
1212
from django.core.cache import cache
1313
from django.db.models.functions import Lower
14+
from django.db.models.signals import pre_delete
1415
from django.test import TestCase, override_settings
1516
from edx_toggles.toggles.testutils import override_waffle_flag
1617
from freezegun import freeze_time
@@ -601,20 +602,33 @@ def test_delete_by_user_no_effect_for_user_with_no_email_change(self):
601602
assert not record_was_deleted
602603
assert 1 == len(PendingEmailChange.objects.all())
603604

604-
def test_redact_by_user_redacts_pending_email_change_fields(self):
605-
original_new_email = self.email_change.new_email
606-
original_activation_key = self.email_change.activation_key
607-
expected_retired_email = get_retired_email_by_email(original_new_email)
608-
record_was_redacted = PendingEmailChange.redact_pending_email_by_user_value(self.user, field='user')
609-
assert record_was_redacted
610-
self.email_change.refresh_from_db()
611-
assert self.email_change.new_email == expected_retired_email
612-
assert self.email_change.activation_key == original_activation_key
613-
614-
def test_redact_by_user_no_effect_for_user_with_no_email_change(self):
615-
"""Verify that redacting a user with no pending email change returns False."""
616-
record_was_redacted = PendingEmailChange.redact_pending_email_by_user_value(self.user2, field='user')
617-
assert not record_was_redacted
605+
def test_delete_by_user_value_redacts_pending_email_before_deletion(self):
606+
"""
607+
Verify that delete_by_user_value redacts new_email before deletion.
608+
"""
609+
expected_redacted_email = '[email protected]'
610+
captured_state = {}
611+
612+
def capture_before_delete(sender, instance, **kwargs):
613+
"""
614+
Capture email and activation_key before deletion.
615+
"""
616+
captured_state['new_email'] = instance.new_email
617+
captured_state['activation_key'] = instance.activation_key
618+
619+
pre_delete.connect(capture_before_delete, sender=PendingEmailChange)
620+
try:
621+
assert PendingEmailChange.objects.filter(user=self.user).exists()
622+
623+
record_was_deleted = PendingEmailChange.delete_by_user_value(self.user, field='user')
624+
assert record_was_deleted
625+
626+
assert captured_state['new_email'] == expected_redacted_email
627+
assert captured_state['activation_key'] == self.email_change.activation_key
628+
629+
assert not PendingEmailChange.objects.filter(user=self.user).exists()
630+
finally:
631+
pre_delete.disconnect(capture_before_delete, sender=PendingEmailChange)
618632

619633

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

common/djangoapps/student/views/management.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -961,16 +961,20 @@ def confirm_email_change(request, key):
961961
transaction.set_rollback(True)
962962
return response
963963

964-
user.email = pec.new_email
964+
new_email = pec.new_email
965+
user.email = new_email
965966
user.save()
966-
pec.delete()
967+
968+
# Use model-level deletion to redact pending email before delete.
969+
PendingEmailChange.delete_by_user_value(user, field="user")
970+
967971
# And send it to the new email...
968-
msg.recipient = Recipient(user.id, pec.new_email)
972+
msg.recipient = Recipient(user.id, new_email)
969973
try:
970974
ace.send(msg)
971975
except Exception: # pylint: disable=broad-except
972976
log.warning('Unable to send confirmation email to new address', exc_info=True)
973-
response = render_to_response("email_change_failed.html", {'email': pec.new_email})
977+
response = render_to_response("email_change_failed.html", {'email': new_email})
974978
transaction.set_rollback(True)
975979
return response
976980

openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from django.contrib.sites.models import Site
1313
from django.core import mail
1414
from django.core.cache import cache
15+
from django.db.models.signals import pre_delete
1516
from django.test import TestCase
1617
from django.urls import reverse
1718
from opaque_keys.edx.keys import CourseKey
@@ -1365,10 +1366,8 @@ def test_retire_user_where_username_not_provided(self):
13651366

13661367
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.get_profile_image_names')
13671368
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.remove_profile_images')
1368-
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.PendingEmailChange.redact_pending_email_by_user_value')
13691369
def test_retire_user(
13701370
self,
1371-
mock_redact_pending_email,
13721371
mock_remove_profile_images,
13731372
mock_get_profile_image_names,
13741373
):
@@ -1415,22 +1414,34 @@ def test_retire_user_twice_idempotent(self):
14151414
fake_completed_retirement(self.test_user)
14161415
self.post_and_assert_status(data)
14171416

1418-
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.PendingEmailChange.delete_by_user_value')
1419-
def test_retire_user_redacts_pending_email_before_delete(self, mock_delete_pending_email):
1420-
pending_email_record = PendingEmailChange.objects.get(user=self.test_user)
1421-
pending_email_before_retirement = pending_email_record.new_email
1422-
expected_retired_pending_email = get_retired_email_by_email(pending_email_before_retirement)
1423-
1424-
def _assert_redacted_then_delete(value, field):
1425-
pending_record = PendingEmailChange.objects.get(user=self.test_user)
1426-
assert pending_record.new_email == expected_retired_pending_email
1427-
pending_record.delete()
1428-
return True
1429-
1430-
mock_delete_pending_email.side_effect = _assert_redacted_then_delete
1431-
data = {'username': self.original_username}
1432-
self.post_and_assert_status(data)
1433-
assert not PendingEmailChange.objects.filter(user=self.test_user).exists()
1417+
def test_retire_user_redacts_pending_email_before_delete(self):
1418+
"""
1419+
Verify that delete_by_user_value redacts new_email using bulk update before deletion.
1420+
"""
1421+
expected_redacted_email = '[email protected]'
1422+
captured_state = {}
1423+
1424+
def capture_before_delete(sender, instance, **kwargs):
1425+
"""Capture email value before it's deleted."""
1426+
captured_state['new_email'] = instance.new_email
1427+
1428+
# Connect signal to capture pre-delete state
1429+
pre_delete.connect(capture_before_delete, sender=PendingEmailChange)
1430+
try:
1431+
# Verify the record exists with original email before retirement
1432+
assert PendingEmailChange.objects.filter(user=self.test_user).exists()
1433+
1434+
# Retire the user
1435+
data = {'username': self.original_username}
1436+
self.post_and_assert_status(data)
1437+
1438+
# Verify the redaction happened before deletion
1439+
assert captured_state.get('new_email') == expected_redacted_email
1440+
1441+
# Verify the record was deleted
1442+
assert not PendingEmailChange.objects.filter(user=self.test_user).exists()
1443+
finally:
1444+
pre_delete.disconnect(capture_before_delete, sender=PendingEmailChange)
14341445

14351446
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.USER_RETIRE_LMS_CRITICAL')
14361447
def test_retirement_sends_critical_signal_with_retirement_data(self, mock_signal):

openedx/core/djangoapps/user_api/accounts/views.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1152,9 +1152,8 @@ def post(self, request):
11521152
self.retire_entitlement_support_detail(user)
11531153

11541154
# Retire misc. models that may contain PII of this user.
1155-
# Redact pending email before delete because downstream systems
1156-
# may preserve soft-deleted snapshots.
1157-
PendingEmailChange.redact_pending_email_by_user_value(user, field="user")
1155+
# PendingEmailChange.delete_by_user_value() automatically redacts new_email
1156+
# before deletion because downstream systems may preserve soft-deleted snapshots.
11581157
PendingEmailChange.delete_by_user_value(user, field="user")
11591158
UserOrgTag.delete_by_user_value(user, field="user")
11601159

0 commit comments

Comments
 (0)