Skip to content

Commit f9aa35a

Browse files
fix: retirement PII leaks by redacting pending secondary email/name data
1 parent 4326d69 commit f9aa35a

5 files changed

Lines changed: 64 additions & 24 deletions

File tree

common/djangoapps/student/models/user.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -954,7 +954,9 @@ def redact_and_delete_pending_secondary_email(cls, user_id):
954954
pending_secondary_email = cls.objects.get(user_id=user_id)
955955
except cls.DoesNotExist:
956956
return True
957-
pending_secondary_email.new_secondary_email = f"redacted+{user_id}@redacted.com"
957+
pending_secondary_email.new_secondary_email = get_retired_email_by_email(
958+
pending_secondary_email.new_secondary_email
959+
)
958960
pending_secondary_email.save(update_fields=['new_secondary_email'])
959961
pending_secondary_email.delete()
960962
return True
@@ -1743,8 +1745,8 @@ def retire_recovery_email(cls, user_id):
17431745
If an AccountRecovery record is found for this user it will be redacted and
17441746
deleted. If it is not found it is assumed this table has no PII for the given user.
17451747
1746-
Note: "Retire" here refers to retiring this user's data, not the recovery email
1747-
feature itself, which remains available for new accounts.
1748+
Note: In retire_recovery_email, "retire" means this is part of user retirement.
1749+
The recovery email itself remains available for use with future accounts.
17481750
17491751
:param user_id: int
17501752
:return: bool - True on success
@@ -1753,9 +1755,8 @@ def retire_recovery_email(cls, user_id):
17531755
account_recovery = cls.objects.get(user_id=user_id)
17541756
except cls.DoesNotExist:
17551757
return True
1756-
account_recovery.secondary_email = f"redacted+{user_id}@redacted.com"
1757-
account_recovery.is_active = False
1758-
account_recovery.save(update_fields=['secondary_email', 'is_active'])
1758+
account_recovery.secondary_email = get_retired_email_by_email(account_recovery.secondary_email)
1759+
account_recovery.save(update_fields=['secondary_email'])
17591760
account_recovery.delete()
17601761

17611762
return True

common/djangoapps/student/tests/test_models.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -734,34 +734,61 @@ class TestAccountRecovery(TestCase):
734734

735735
def test_retire_recovery_email(self):
736736
"""
737-
Assert that Account Record for a given user is deleted when `retire_recovery_email` is called
737+
Assert that AccountRecovery secondary_email is redacted before the record is deleted.
738738
"""
739-
# Create user and associated recovery email record
740739
user = UserFactory()
741-
AccountRecoveryFactory(user=user)
740+
AccountRecoveryFactory(user=user, secondary_email='recovery@example.com')
742741
assert len(AccountRecovery.objects.filter(user_id=user.id)) == 1
743742

744-
# Retire recovery email
745-
AccountRecovery.retire_recovery_email(user_id=user.id)
743+
saved_emails = []
746744

747-
# Assert that there is no longer an AccountRecovery record for this user
745+
original_save = AccountRecovery.save
746+
747+
def capture_save(instance, *args, **kwargs):
748+
saved_emails.append(instance.secondary_email)
749+
original_save(instance, *args, **kwargs)
750+
751+
with mock.patch.object(AccountRecovery, 'save', side_effect=capture_save):
752+
AccountRecovery.retire_recovery_email(user_id=user.id)
753+
754+
# Record must be gone
748755
assert len(AccountRecovery.objects.filter(user_id=user.id)) == 0
756+
# Redacted value was saved (not the original) before deletion
757+
assert len(saved_emails) == 1
758+
assert saved_emails[0] != 'recovery@example.com'
759+
assert 'example.com' not in saved_emails[0]
749760

750761

751762
class TestPendingSecondaryEmailChange(TestCase):
752763
"""Tests for retiring PendingSecondaryEmailChange records."""
753764

754765
def test_redact_and_delete_pending_secondary_email(self):
755-
"""Assert that pending secondary email records are deleted for retired users."""
766+
"""Assert that the pending secondary email is redacted before the record is deleted."""
756767
user = UserFactory()
757768
PendingSecondaryEmailChange.objects.create(
758769
user=user,
759770
new_secondary_email='new-secondary@example.com',
760771
activation_key='a' * 32,
761772
)
762773
assert len(PendingSecondaryEmailChange.objects.filter(user_id=user.id)) == 1
763-
PendingSecondaryEmailChange.redact_and_delete_pending_secondary_email(user_id=user.id)
774+
775+
saved_emails = []
776+
777+
original_save = PendingSecondaryEmailChange.save
778+
779+
def capture_save(instance, *args, **kwargs):
780+
saved_emails.append(instance.new_secondary_email)
781+
original_save(instance, *args, **kwargs)
782+
783+
with mock.patch.object(PendingSecondaryEmailChange, 'save', side_effect=capture_save):
784+
PendingSecondaryEmailChange.redact_and_delete_pending_secondary_email(user_id=user.id)
785+
786+
# Record must be gone
764787
assert len(PendingSecondaryEmailChange.objects.filter(user_id=user.id)) == 0
788+
# Redacted value was saved (not the original) before deletion
789+
assert len(saved_emails) == 1
790+
assert saved_emails[0] != 'new-secondary@example.com'
791+
assert 'example.com' not in saved_emails[0]
765792

766793
def test_redact_and_delete_pending_secondary_email_when_no_record(self):
767794
"""Assert retirement cleanup returns True when no pending secondary row exists."""

common/djangoapps/student/views/management.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@
8484
UserStanding,
8585
create_comments_service_user, # noqa: F401
8686
email_exists_or_retired, # noqa: F401
87-
get_retired_email_by_email,
8887
)
8988
from common.djangoapps.student.signals import REFUND_ORDER, USER_EMAIL_CHANGED
9089
from common.djangoapps.student.toggles import should_redirect_to_courseware_after_enrollment
@@ -891,9 +890,12 @@ def activate_secondary_email(request, key):
891890
'secondary_email': pending_secondary_email_change.new_secondary_email
892891
})
893892

894-
pending_secondary_email_change.new_secondary_email = get_retired_email_by_email(
895-
pending_secondary_email_change.new_secondary_email
896-
)
893+
# Overwrite the pending email with a neutral placeholder before deletion so
894+
# that any downstream soft-delete mirror does not retain the real address.
895+
# We do NOT use get_retired_email_by_email() here because this is a normal
896+
# user action, not a retirement flow; a hashed retirement-style value would
897+
# produce false-positive retirement signals in downstream systems.
898+
pending_secondary_email_change.new_secondary_email = "completed@delete-pending.com"
897899
pending_secondary_email_change.save(update_fields=['new_secondary_email'])
898900
pending_secondary_email_change.delete()
899901

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

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1154,11 +1154,7 @@ def post(self, request):
11541154
self.retire_entitlement_support_detail(user)
11551155

11561156
# Retire misc. models that may contain PII of this user
1157-
# Redact pending email change before deletion to prevent plaintext sync to Snowflake
1158-
pending_email = PendingEmailChange.objects.filter(user=user).first()
1159-
if pending_email:
1160-
pending_email.new_email = get_retired_email_by_email(pending_email.new_email)
1161-
pending_email.save(update_fields=['new_email'])
1157+
PendingEmailChange.redact_pending_email_by_user_value(user, field="user")
11621158
PendingEmailChange.delete_by_user_value(user, field="user")
11631159
UserOrgTag.delete_by_user_value(user, field="user")
11641160
PendingSecondaryEmailChange.redact_and_delete_pending_secondary_email(user.id)

openedx/core/djangoapps/user_api/management/tests/test_retire_user.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import csv
77
import os
8+
from unittest import mock
89

910
import pytest
1011
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
@@ -120,6 +121,19 @@ def test_retire_user_cleans_pending_secondary_email(setup_retirement_states): #
120121
)
121122
assert PendingSecondaryEmailChange.objects.filter(user=user).exists()
122123

123-
call_command('retire_user', username=user.username, user_email=user.email)
124+
saved_emails = []
125+
original_save = PendingSecondaryEmailChange.save
124126

127+
def capture_save(instance, *args, **kwargs):
128+
saved_emails.append(instance.new_secondary_email)
129+
original_save(instance, *args, **kwargs)
130+
131+
with mock.patch.object(PendingSecondaryEmailChange, 'save', side_effect=capture_save):
132+
call_command('retire_user', username=user.username, user_email=user.email)
133+
134+
# Record must be deleted
125135
assert not PendingSecondaryEmailChange.objects.filter(user=user).exists()
136+
# Redacted value was saved before deletion — not the original plaintext email
137+
assert len(saved_emails) == 1
138+
assert saved_emails[0] != 'pending-secondary@example.com'
139+
assert 'example.com' not in saved_emails[0]

0 commit comments

Comments
 (0)