Skip to content

Commit fe6b64b

Browse files
feat: implement toggle for redacting PII in ManualVerification during user retirement (#38890)
1 parent e0c7c5c commit fe6b64b

4 files changed

Lines changed: 77 additions & 3 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""
2+
Configuration toggles for ManualVerification.
3+
"""
4+
5+
from edx_toggles.toggles import SettingToggle
6+
7+
# .. toggle_name: REDACT_MANUAL_VERIFICATION_HISTORICAL_PII
8+
# .. toggle_implementation: SettingToggle
9+
# .. toggle_default: False
10+
# .. toggle_description: Clears the `name` field for `ManualVerification` records
11+
# before deleting those rows during user retirement.
12+
# .. toggle_use_cases: open_edx
13+
# .. toggle_creation_date: 2026-07-15
14+
REDACT_MANUAL_VERIFICATION_HISTORICAL_PII = SettingToggle(
15+
'REDACT_MANUAL_VERIFICATION_HISTORICAL_PII', default=False, module_name=__name__
16+
)

lms/djangoapps/verify_student/models.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
)
4444
from lms.djangoapps.verify_student.statuses import VerificationAttemptStatus
4545
from openedx.core.djangoapps.signals.signals import LEARNER_SSO_VERIFIED, PHOTO_VERIFICATION_APPROVED
46+
from openedx.core.djangolib.model_mixins import DeletableByUserValue
4647

4748
from .utils import auto_verify_for_testing_enabled, earliest_allowed_verification_date, submit_request_to_ss
4849

@@ -158,11 +159,14 @@ def active_at_datetime(self, deadline):
158159
)
159160

160161

161-
class ManualVerification(IDVerificationAttempt):
162+
class ManualVerification(IDVerificationAttempt, DeletableByUserValue):
162163
"""
163164
Each ManualVerification represents a user's verification that bypasses the need for
164165
any other verification.
165166
167+
The PII is retained by default, but can be redacted during retirement
168+
by enabling ``REDACT_MANUAL_VERIFICATION_HISTORICAL_PII``.
169+
166170
.. pii: The User's name is stored in the parent model
167171
.. pii_types: name
168172
.. pii_retirement: retained
@@ -191,6 +195,13 @@ def should_display_status_to_user(self):
191195
"""
192196
return False
193197

198+
@classmethod
199+
def redact_before_delete_fields(cls):
200+
"""
201+
Clear PII fields before delete in downstream soft-delete systems.
202+
"""
203+
return {'name': ''}
204+
194205

195206
class SSOVerification(IDVerificationAttempt):
196207
"""

lms/djangoapps/verify_student/signals/handlers.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99

1010
from common.djangoapps.student.models_api import get_name, get_pending_name_change
1111
from lms.djangoapps.verify_student.apps import VerifyStudentConfig # pylint: disable=unused-import # noqa: F401
12+
from lms.djangoapps.verify_student.config import REDACT_MANUAL_VERIFICATION_HISTORICAL_PII
1213
from lms.djangoapps.verify_student.models import (
14+
ManualVerification,
1315
SoftwareSecurePhotoVerification,
1416
VerificationAttempt,
1517
VerificationDeadline,
@@ -39,8 +41,13 @@ def _listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable
3941

4042
@receiver(USER_RETIRE_LMS_CRITICAL)
4143
def _listen_for_lms_retire(sender, **kwargs): # pylint: disable=unused-argument
44+
"""
45+
Retire verify_student records handled in the LMS retirement.
46+
"""
4247
user = kwargs.get('user')
4348
SoftwareSecurePhotoVerification.retire_user(user.id)
49+
if REDACT_MANUAL_VERIFICATION_HISTORICAL_PII.is_enabled():
50+
ManualVerification.delete_by_user_value(value=user.id, field='user_id')
4451

4552

4653
@receiver(post_save, sender=SoftwareSecurePhotoVerification)

lms/djangoapps/verify_student/tests/test_handlers.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@
22
Unit tests for the VerificationDeadline signals
33
"""
44

5-
65
from datetime import timedelta
76
from unittest.mock import patch # pylint: disable=wrong-import-order
87

8+
from django.db import connection
9+
from django.test import override_settings
10+
from django.test.utils import CaptureQueriesContext
911
from django.utils.timezone import now
1012

1113
from common.djangoapps.student.models_api import do_name_change_request
1214
from common.djangoapps.student.tests.factories import UserFactory
1315
from lms.djangoapps.verify_student.models import (
16+
ManualVerification,
1417
SoftwareSecurePhotoVerification,
1518
VerificationAttempt,
1619
VerificationDeadline,
@@ -25,6 +28,7 @@
2528
VerificationAttemptFactory,
2629
)
2730
from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import fake_completed_retirement
31+
from openedx.core.djangolib.testing.utils import assert_redact_before_delete
2832
from xmodule.modulestore.tests.django_utils import (
2933
ModuleStoreTestCase, # pylint: disable=wrong-import-order
3034
)
@@ -70,7 +74,7 @@ def test_deadline_explicit(self):
7074

7175
class RetirementHandlerTest(ModuleStoreTestCase):
7276
"""
73-
Tests for the VerificationDeadline handler
77+
Tests for verify_student handlers in the LMS retirement flow.
7478
"""
7579

7680
def _create_entry(self):
@@ -118,6 +122,42 @@ def test_idempotent(self):
118122
for field in ('name', 'face_image_url', 'photo_id_image_url', 'photo_id_key'):
119123
assert '' == getattr(ver_obj, field)
120124

125+
def test_manual_verification_retirement_behavior(self):
126+
user = UserFactory()
127+
other_user = UserFactory()
128+
user_name = 'Manual Verification Name'
129+
ManualVerification.objects.create(
130+
user=user,
131+
name=user_name,
132+
status='approved',
133+
)
134+
ManualVerification.objects.create(
135+
user=other_user,
136+
name=user_name,
137+
status='approved',
138+
)
139+
140+
with override_settings(REDACT_MANUAL_VERIFICATION_HISTORICAL_PII=False):
141+
_listen_for_lms_retire(sender=self.__class__, user=user)
142+
143+
manual_verification = ManualVerification.objects.get(user=user)
144+
assert manual_verification.name == user_name
145+
assert ManualVerification.objects.filter(user=user).exists()
146+
assert ManualVerification.objects.filter(user=other_user, name=user_name).exists()
147+
148+
with override_settings(REDACT_MANUAL_VERIFICATION_HISTORICAL_PII=True):
149+
with CaptureQueriesContext(connection) as context:
150+
_listen_for_lms_retire(sender=self.__class__, user=user)
151+
152+
assert_redact_before_delete(
153+
[query['sql'] for query in context.captured_queries],
154+
table=ManualVerification._meta.db_table,
155+
expected_redacted_value_list=[''],
156+
)
157+
assert not ManualVerification.objects.filter(user=user).exists()
158+
159+
assert ManualVerification.objects.filter(user=other_user, name=user_name).exists()
160+
121161

122162
class PostSavePhotoVerificationTest(ModuleStoreTestCase):
123163
"""

0 commit comments

Comments
 (0)