Skip to content

Commit 6320d0b

Browse files
fix: retirement PII leaks by redacting pending secondary email/name data
1 parent d929ff9 commit 6320d0b

4 files changed

Lines changed: 41 additions & 43 deletions

File tree

common/djangoapps/student/tests/test_models.py

Lines changed: 24 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
from django.conf import settings
1111
from django.contrib.auth.models import AnonymousUser, User # lint-amnesty, pylint: disable=imported-auth-user
1212
from django.core.cache import cache
13+
from django.db import connection
1314
from django.db.models.functions import Lower
1415
from django.test import TestCase, override_settings
16+
from django.test.utils import CaptureQueriesContext
1517
from edx_toggles.toggles.testutils import override_waffle_flag
1618
from freezegun import freeze_time
1719
from opaque_keys.edx.keys import CourseKey
@@ -740,24 +742,21 @@ def test_retire_recovery_email(self):
740742
AccountRecoveryFactory(user=user, secondary_email='recovery@example.com')
741743
assert len(AccountRecovery.objects.filter(user_id=user.id)) == 1
742744

743-
from django.db.models.signals import pre_delete
744-
saved_emails = []
745-
746-
def capture_pre_delete(sender, instance, **kwargs):
747-
saved_emails.append(instance.secondary_email)
748-
749-
pre_delete.connect(capture_pre_delete, sender=AccountRecovery)
750-
try:
745+
with CaptureQueriesContext(connection) as ctx:
751746
AccountRecovery.retire_recovery_email(user_id=user.id)
752-
finally:
753-
pre_delete.disconnect(capture_pre_delete, sender=AccountRecovery)
747+
748+
sql_list = [query['sql'].upper() for query in ctx]
749+
table_name = 'AUTH_ACCOUNTRECOVERY'
750+
update_indices = [i for i, sql in enumerate(sql_list) if 'UPDATE' in sql and table_name in sql]
751+
delete_indices = [i for i, sql in enumerate(sql_list) if 'DELETE' in sql and table_name in sql]
752+
assert update_indices, f'Expected at least one UPDATE on {table_name}'
753+
assert delete_indices, f'Expected at least one DELETE on {table_name}'
754+
assert any(update_idx < delete_idx for update_idx in update_indices for delete_idx in delete_indices), (
755+
'Expected at least one UPDATE to precede at least one DELETE'
756+
)
754757

755758
# Record must be gone
756759
assert len(AccountRecovery.objects.filter(user_id=user.id)) == 0
757-
# Redacted value was saved (not the original) before deletion
758-
assert len(saved_emails) == 1
759-
assert saved_emails[0] != 'recovery@example.com'
760-
assert 'example.com' not in saved_emails[0]
761760

762761

763762
class TestPendingSecondaryEmailChange(TestCase):
@@ -773,24 +772,21 @@ def test_redact_and_delete_pending_secondary_email(self):
773772
)
774773
assert len(PendingSecondaryEmailChange.objects.filter(user_id=user.id)) == 1
775774

776-
from django.db.models.signals import pre_delete
777-
saved_emails = []
778-
779-
def capture_pre_delete(sender, instance, **kwargs):
780-
saved_emails.append(instance.new_secondary_email)
781-
782-
pre_delete.connect(capture_pre_delete, sender=PendingSecondaryEmailChange)
783-
try:
775+
with CaptureQueriesContext(connection) as ctx:
784776
PendingSecondaryEmailChange.redact_and_delete_pending_secondary_email(user_id=user.id)
785-
finally:
786-
pre_delete.disconnect(capture_pre_delete, sender=PendingSecondaryEmailChange)
777+
778+
sql_list = [query['sql'].upper() for query in ctx]
779+
table_name = 'STUDENT_PENDINGSECONDARYEMAILCHANGE'
780+
update_indices = [i for i, sql in enumerate(sql_list) if 'UPDATE' in sql and table_name in sql]
781+
delete_indices = [i for i, sql in enumerate(sql_list) if 'DELETE' in sql and table_name in sql]
782+
assert update_indices, f'Expected at least one UPDATE on {table_name}'
783+
assert delete_indices, f'Expected at least one DELETE on {table_name}'
784+
assert any(update_idx < delete_idx for update_idx in update_indices for delete_idx in delete_indices), (
785+
'Expected at least one UPDATE to precede at least one DELETE'
786+
)
787787

788788
# Record must be gone
789789
assert len(PendingSecondaryEmailChange.objects.filter(user_id=user.id)) == 0
790-
# Redacted value was saved (not the original) before deletion
791-
assert len(saved_emails) == 1
792-
assert saved_emails[0] != 'new-secondary@example.com'
793-
assert 'example.com' not in saved_emails[0]
794790

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

common/djangoapps/student/views/management.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@
118118

119119
log = logging.getLogger("edx.student")
120120

121+
PENDING_SECONDARY_EMAIL_REDACTED_VALUE = 'redacted@retired.invalid'
122+
121123
AUDIT_LOG = logging.getLogger("audit")
122124
ReverifyInfo = namedtuple(
123125
'ReverifyInfo',
@@ -894,7 +896,7 @@ def activate_secondary_email(request, key):
894896
# that any downstream soft-delete mirror does not retain the real address.
895897
# We do NOT use get_retired_email_by_email() here because this is a normal
896898
# user action, not a retirement flow.
897-
pending_secondary_email_change.new_secondary_email = "redacted@retired.invalid"
899+
pending_secondary_email_change.new_secondary_email = PENDING_SECONDARY_EMAIL_REDACTED_VALUE
898900
pending_secondary_email_change.save(update_fields=['new_secondary_email'])
899901
pending_secondary_email_change.delete()
900902

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1408,6 +1408,7 @@ def test_retire_user(self, mock_remove_profile_images, mock_get_profile_image_na
14081408
'last_name': '',
14091409
'is_active': False,
14101410
'username': self.retired_username,
1411+
'email': self.retired_email,
14111412
}
14121413
for field, expected_value in expected_user_values.items():
14131414
assert expected_value == getattr(self.test_user, field)

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

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
import pytest
1010
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
1111
from django.core.management import CommandError, call_command
12-
from django.db.models.signals import pre_delete
12+
from django.db import connection
13+
from django.test.utils import CaptureQueriesContext
1314

1415
from common.djangoapps.student.models import PendingSecondaryEmailChange
1516
from common.djangoapps.student.tests.factories import UserFactory # lint-amnesty, pylint: disable=wrong-import-order
@@ -121,20 +122,18 @@ def test_retire_user_cleans_pending_secondary_email(setup_retirement_states): #
121122
)
122123
assert PendingSecondaryEmailChange.objects.filter(user=user).exists()
123124

124-
saved_emails = []
125-
126-
def capture_pre_delete(sender, instance, **kwargs):
127-
saved_emails.append(instance.new_secondary_email)
128-
129-
pre_delete.connect(capture_pre_delete, sender=PendingSecondaryEmailChange)
130-
try:
125+
with CaptureQueriesContext(connection) as ctx:
131126
call_command('retire_user', username=user.username, user_email=user.email)
132-
finally:
133-
pre_delete.disconnect(capture_pre_delete, sender=PendingSecondaryEmailChange)
127+
128+
sql_list = [query['sql'].upper() for query in ctx]
129+
table_name = 'STUDENT_PENDINGSECONDARYEMAILCHANGE'
130+
update_indices = [i for i, sql in enumerate(sql_list) if 'UPDATE' in sql and table_name in sql]
131+
delete_indices = [i for i, sql in enumerate(sql_list) if 'DELETE' in sql and table_name in sql]
132+
assert update_indices, f'Expected at least one UPDATE on {table_name}'
133+
assert delete_indices, f'Expected at least one DELETE on {table_name}'
134+
assert any(update_idx < delete_idx for update_idx in update_indices for delete_idx in delete_indices), (
135+
'Expected at least one UPDATE to precede at least one DELETE'
136+
)
134137

135138
# Record must be deleted
136139
assert not PendingSecondaryEmailChange.objects.filter(user=user).exists()
137-
# Redacted value was saved before deletion — not the original plaintext email
138-
assert len(saved_emails) == 1
139-
assert saved_emails[0] != 'pending-secondary@example.com'
140-
assert 'example.com' not in saved_emails[0]

0 commit comments

Comments
 (0)