From 9a178e35f7ac1f41b1d16efd59b7c5cce9dd9a70 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Thu, 23 Apr 2026 09:42:58 +0000 Subject: [PATCH 01/25] fix: Redact SSO PII before deletion --- .../djangoapps/user_api/accounts/__init__.py | 3 + .../djangoapps/user_api/accounts/signals.py | 34 ++++++++- .../user_api/accounts/tests/test_utils.py | 73 ++++++++++++++++++- .../djangoapps/user_api/accounts/utils.py | 34 ++++++++- .../management/commands/retire_user.py | 7 +- .../management/tests/test_retire_user.py | 49 +++++++++++++ 6 files changed, 194 insertions(+), 6 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/__init__.py b/openedx/core/djangoapps/user_api/accounts/__init__.py index faed01a6bb8d..e80aabe4169d 100644 --- a/openedx/core/djangoapps/user_api/accounts/__init__.py +++ b/openedx/core/djangoapps/user_api/accounts/__init__.py @@ -6,6 +6,9 @@ from django.utils.text import format_lazy from django.utils.translation import gettext_lazy as _ +# Import signals to ensure they are registered +from . import signals # noqa: F401, pylint: disable=unused-import + # The maximum length for the bio ("about me") account field BIO_MAX_LENGTH = 300 diff --git a/openedx/core/djangoapps/user_api/accounts/signals.py b/openedx/core/djangoapps/user_api/accounts/signals.py index be7bf75d99ee..d66fba35aace 100644 --- a/openedx/core/djangoapps/user_api/accounts/signals.py +++ b/openedx/core/djangoapps/user_api/accounts/signals.py @@ -2,8 +2,15 @@ Django Signal related functionality for user_api accounts """ +import logging -from django.dispatch import Signal +from django.db.models.signals import pre_delete +from django.dispatch import Signal, receiver +from social_django.models import UserSocialAuth + +from .utils import redact_user_social_auth_pii + +logger = logging.getLogger(__name__) # Signal to retire a user from LMS-initiated mailings (course mailings, etc) # providing_args=["user"] @@ -16,3 +23,28 @@ # Signal to retire LMS misc information # providing_args=["user"] USER_RETIRE_LMS_MISC = Signal() + + +@receiver(pre_delete, sender=UserSocialAuth) +def redact_social_auth_pii_before_deletion(sender, instance, **kwargs): # pylint: disable=unused-argument + """ + Signal handler to redact PII from UserSocialAuth records before deletion. + + This ensures that when SSO records are deleted (either via user retirement, manual unlinking, + or any other method), PII is redacted first. This prevents soft-deleted records in Snowflake + from retaining sensitive user information. + + Note: We call redact_user_social_auth_pii which saves the redacted data before the actual + deletion happens. This is intentional - when Snowflake syncs, it will capture the redacted + state before marking the record as deleted. + """ + try: + redact_user_social_auth_pii(instance) + except Exception as e: # pylint: disable=broad-except + # Log the error but don't prevent the deletion + logger.exception( + "Failed to redact PII for UserSocialAuth before deletion: user_id=%s, provider=%s, error=%s", + instance.user_id, + instance.provider, + str(e) + ) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py index 77ac4ab1ba80..7c5f781b308f 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py @@ -6,10 +6,15 @@ from completion.test_utils import CompletionWaffleTestMixin from django.test import TestCase from django.test.utils import override_settings +from social_django.models import UserSocialAuth from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import UserFactory -from openedx.core.djangoapps.user_api.accounts.utils import retrieve_last_sitewide_block_completed +from openedx.core.djangoapps.user_api.accounts.utils import ( + REDACTED_SOCIAL_AUTH_UID, + redact_user_social_auth_pii, + retrieve_last_sitewide_block_completed, +) from openedx.core.djangolib.testing.utils import skip_unless_lms from xmodule.modulestore.tests.django_utils import ( SharedModuleStoreTestCase, # lint-amnesty, pylint: disable=wrong-import-order @@ -133,3 +138,69 @@ def test_retrieve_last_sitewide_block_completed(self): ) assert empty_block_url is None + + +@skip_unless_lms +class RedactUserSocialAuthPIITest(TestCase): + """ + Tests for SSO PII redaction before deletion. + """ + + def setUp(self): + super().setUp() + self.user = UserFactory.create(username='testuser', email='testuser@example.com') + + def create_social_auth(self, provider='google-oauth2', uid='user@example.com', extra_data=None): + if extra_data is None: + extra_data = { + 'email': 'user@example.com', + 'name': 'Test User', + 'id': '123456789', + } + return UserSocialAuth.objects.create( + user=self.user, + provider=provider, + uid=uid, + extra_data=extra_data, + ) + + def test_redact_user_social_auth_pii(self): + social_auth = self.create_social_auth() + + redact_user_social_auth_pii(social_auth) + social_auth.refresh_from_db() + + assert social_auth.uid == REDACTED_SOCIAL_AUTH_UID + assert social_auth.extra_data == {} + + def test_redact_user_social_auth_pii_idempotent(self): + social_auth = self.create_social_auth() + + redact_user_social_auth_pii(social_auth) + redact_user_social_auth_pii(social_auth) + social_auth.refresh_from_db() + + assert social_auth.uid == REDACTED_SOCIAL_AUTH_UID + assert social_auth.extra_data == {} + + def test_redact_multiple_sso_providers(self): + google_auth = self.create_social_auth( + provider='google-oauth2', + uid='google@example.com', + extra_data={'email': 'google@example.com', 'name': 'Google User'} + ) + saml_auth = self.create_social_auth( + provider='tpa-saml', + uid='saml@example.com', + extra_data={'email': 'saml@example.com', 'name': 'SAML User', 'uid': 'saml-uid'} + ) + + redact_user_social_auth_pii(google_auth) + redact_user_social_auth_pii(saml_auth) + google_auth.refresh_from_db() + saml_auth.refresh_from_db() + + assert google_auth.uid == REDACTED_SOCIAL_AUTH_UID + assert google_auth.extra_data == {} + assert saml_auth.uid == REDACTED_SOCIAL_AUTH_UID + assert saml_auth.extra_data == {} diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index ab70b8f15c8d..b6225502fbd9 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -25,6 +25,7 @@ ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH = 'enable_secondary_email_feature' LOGGER = logging.getLogger(__name__) +REDACTED_SOCIAL_AUTH_UID = 'redacted@redacted.com' def validate_social_link(platform_name, new_social_link): @@ -196,6 +197,33 @@ def is_secondary_email_feature_enabled(): return waffle.switch_is_active(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH) +def redact_user_social_auth_pii(user_social_auth): + """ + Redact PII from a UserSocialAuth record before deletion. + + Snowflake can retain deleted source rows as soft-deleted records, so sensitive + fields should be overwritten before deletion. + """ + if not user_social_auth or not user_social_auth.pk: + return + + update_fields = {} + + if user_social_auth.uid != REDACTED_SOCIAL_AUTH_UID: + update_fields['uid'] = REDACTED_SOCIAL_AUTH_UID + if user_social_auth.extra_data: + update_fields['extra_data'] = {} + + if not update_fields: + return + + UserSocialAuth.objects.filter(pk=user_social_auth.pk).update(**update_fields) + + # Keep instance in sync in case the caller reuses it. + for field_name, value in update_fields.items(): + setattr(user_social_auth, field_name, value) + + def create_retirement_request_and_deactivate_account(user): """ Adds user to retirement queue, unlinks social auth accounts, changes user passwords @@ -204,8 +232,10 @@ def create_retirement_request_and_deactivate_account(user): # Add user to retirement queue. UserRetirementStatus.create_retirement(user) - # Unlink LMS social auth accounts - UserSocialAuth.objects.filter(user_id=user.id).delete() + # Redact and unlink LMS social auth accounts + for social_auth in UserSocialAuth.objects.filter(user_id=user.id): + redact_user_social_auth_pii(social_auth) + social_auth.delete() # Change LMS password & email user.email = get_retired_email_by_email(user.email) diff --git a/openedx/core/djangoapps/user_api/management/commands/retire_user.py b/openedx/core/djangoapps/user_api/management/commands/retire_user.py index 1b3b497ea5c7..ca13d479d86b 100644 --- a/openedx/core/djangoapps/user_api/management/commands/retire_user.py +++ b/openedx/core/djangoapps/user_api/management/commands/retire_user.py @@ -11,6 +11,7 @@ from openedx.core.djangolib.oauth2_retirement_utils import retire_dot_oauth2_models from ...models import BulkUserRetirementConfig, UserRetirementStatus +from ...accounts.utils import redact_user_social_auth_pii logger = logging.getLogger(__name__) @@ -144,8 +145,10 @@ def handle(self, *args, **options): for user in users: # Add user to retirement queue. UserRetirementStatus.create_retirement(user) - # Unlink LMS social auth accounts - UserSocialAuth.objects.filter(user_id=user.id).delete() + # Redact and unlink LMS social auth accounts + for social_auth in UserSocialAuth.objects.filter(user_id=user.id): + redact_user_social_auth_pii(social_auth) + social_auth.delete() # Change LMS password & email user.email = get_retired_email_by_email(user.email) user.set_unusable_password() diff --git a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py index 6de2287fc4ba..696c903e4b88 100644 --- a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py +++ b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py @@ -5,10 +5,12 @@ import csv import os +from unittest import mock import pytest from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.management import CommandError, call_command +from social_django.models import UserSocialAuth from common.djangoapps.student.tests.factories import UserFactory # lint-amnesty, pylint: disable=wrong-import-order from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # lint-amnesty, pylint: disable=unused-import, wrong-import-order @@ -107,3 +109,50 @@ def test_retire_with_username_email_userfile(setup_retirement_states): # lint-a with pytest.raises(CommandError, match=r'You cannot use userfile option with username and user_email'): call_command('retire_user', user_file=user_file, username=username, user_email=user_email) remove_user_file() + + +@skip_unless_lms +def test_retire_user_redacts_sso_pii_before_deletion(setup_retirement_states): # lint-amnesty, pylint: disable=redefined-outer-name, unused-argument + user = UserFactory.create(username='sso-user', email='sso-user@example.com') + social_auth = UserSocialAuth.objects.create( + user=user, + provider='google-oauth2', + uid='sso-user@example.com', + extra_data={ + 'email': 'sso-user@example.com', + 'name': 'SSO Test User', + 'id': '123456789', + } + ) + social_auth_id = social_auth.id + + call_command('retire_user', username=user.username, user_email=user.email) + + assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() + retired_user_status = UserRetirementStatus.objects.filter(original_username=user.username).first() + assert retired_user_status is not None + assert retired_user_status.original_email == 'sso-user@example.com' + + +@skip_unless_lms +def test_retire_user_calls_redaction_for_each_social_auth(setup_retirement_states): # lint-amnesty, pylint: disable=redefined-outer-name, unused-argument + user = UserFactory.create(username='multi-sso-user', email='multi-sso@example.com') + UserSocialAuth.objects.create( + user=user, + provider='google-oauth2', + uid='google-multi@example.com', + extra_data={'email': 'google-multi@example.com', 'name': 'Google User'} + ) + UserSocialAuth.objects.create( + user=user, + provider='tpa-saml', + uid='saml-multi@example.com', + extra_data={'email': 'saml-multi@example.com', 'name': 'SAML User', 'uid': 'saml-123'} + ) + + with mock.patch( + 'openedx.core.djangoapps.user_api.management.commands.retire_user.redact_user_social_auth_pii' + ) as mock_redact: + call_command('retire_user', username=user.username, user_email=user.email) + + assert mock_redact.call_count == 2 From 8d57698164ff1c8883bfa8660786e212077253a9 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Thu, 23 Apr 2026 09:48:18 +0000 Subject: [PATCH 02/25] fix: Redact SSO PII before deletion --- openedx/core/djangoapps/user_api/accounts/signals.py | 4 ++-- .../djangoapps/user_api/management/commands/retire_user.py | 2 +- .../djangoapps/user_api/management/tests/test_retire_user.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/signals.py b/openedx/core/djangoapps/user_api/accounts/signals.py index d66fba35aace..fa71459bcd80 100644 --- a/openedx/core/djangoapps/user_api/accounts/signals.py +++ b/openedx/core/djangoapps/user_api/accounts/signals.py @@ -29,11 +29,11 @@ def redact_social_auth_pii_before_deletion(sender, instance, **kwargs): # pylint: disable=unused-argument """ Signal handler to redact PII from UserSocialAuth records before deletion. - + This ensures that when SSO records are deleted (either via user retirement, manual unlinking, or any other method), PII is redacted first. This prevents soft-deleted records in Snowflake from retaining sensitive user information. - + Note: We call redact_user_social_auth_pii which saves the redacted data before the actual deletion happens. This is intentional - when Snowflake syncs, it will capture the redacted state before marking the record as deleted. diff --git a/openedx/core/djangoapps/user_api/management/commands/retire_user.py b/openedx/core/djangoapps/user_api/management/commands/retire_user.py index ca13d479d86b..c3b103a08061 100644 --- a/openedx/core/djangoapps/user_api/management/commands/retire_user.py +++ b/openedx/core/djangoapps/user_api/management/commands/retire_user.py @@ -10,8 +10,8 @@ from common.djangoapps.student.models import AccountRecovery, Registration, get_retired_email_by_email from openedx.core.djangolib.oauth2_retirement_utils import retire_dot_oauth2_models -from ...models import BulkUserRetirementConfig, UserRetirementStatus from ...accounts.utils import redact_user_social_auth_pii +from ...models import BulkUserRetirementConfig, UserRetirementStatus logger = logging.getLogger(__name__) diff --git a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py index 696c903e4b88..3a81cca9c199 100644 --- a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py +++ b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py @@ -112,7 +112,7 @@ def test_retire_with_username_email_userfile(setup_retirement_states): # lint-a @skip_unless_lms -def test_retire_user_redacts_sso_pii_before_deletion(setup_retirement_states): # lint-amnesty, pylint: disable=redefined-outer-name, unused-argument +def test_retire_user_redacts_sso_pii_before_deletion(setup_retirement_states): # lint-amnesty, pylint: disable=redefined-outer-name, unused-argument # noqa: F811 user = UserFactory.create(username='sso-user', email='sso-user@example.com') social_auth = UserSocialAuth.objects.create( user=user, @@ -135,7 +135,7 @@ def test_retire_user_redacts_sso_pii_before_deletion(setup_retirement_states): @skip_unless_lms -def test_retire_user_calls_redaction_for_each_social_auth(setup_retirement_states): # lint-amnesty, pylint: disable=redefined-outer-name, unused-argument +def test_retire_user_calls_redaction_for_each_social_auth(setup_retirement_states): # lint-amnesty, pylint: disable=redefined-outer-name, unused-argument # noqa: F811 user = UserFactory.create(username='multi-sso-user', email='multi-sso@example.com') UserSocialAuth.objects.create( user=user, From 2688ac8596388a4d4ae3368c922f2d126d144c48 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Thu, 23 Apr 2026 09:59:36 +0000 Subject: [PATCH 03/25] fix: Redact SSO PII before deletion --- .../djangoapps/user_api/accounts/tests/test_utils.py | 12 ++++++++++++ .../user_api/management/tests/test_retire_user.py | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py index 7c5f781b308f..879f3aece738 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py @@ -151,6 +151,9 @@ def setUp(self): self.user = UserFactory.create(username='testuser', email='testuser@example.com') def create_social_auth(self, provider='google-oauth2', uid='user@example.com', extra_data=None): + """ + Helper method to create UserSocialAuth instances for testing. + """ if extra_data is None: extra_data = { 'email': 'user@example.com', @@ -165,6 +168,9 @@ def create_social_auth(self, provider='google-oauth2', uid='user@example.com', e ) def test_redact_user_social_auth_pii(self): + """ + Test that redact_user_social_auth_pii correctly redacts uid and extra_data fields. + """ social_auth = self.create_social_auth() redact_user_social_auth_pii(social_auth) @@ -174,6 +180,9 @@ def test_redact_user_social_auth_pii(self): assert social_auth.extra_data == {} def test_redact_user_social_auth_pii_idempotent(self): + """ + Test that calling redact_user_social_auth_pii multiple times is idempotent. + """ social_auth = self.create_social_auth() redact_user_social_auth_pii(social_auth) @@ -184,6 +193,9 @@ def test_redact_user_social_auth_pii_idempotent(self): assert social_auth.extra_data == {} def test_redact_multiple_sso_providers(self): + """ + Test that redaction works correctly for multiple SSO providers (Google OAuth and SAML). + """ google_auth = self.create_social_auth( provider='google-oauth2', uid='google@example.com', diff --git a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py index 3a81cca9c199..a3da6abcf1cb 100644 --- a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py +++ b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py @@ -113,6 +113,9 @@ def test_retire_with_username_email_userfile(setup_retirement_states): # lint-a @skip_unless_lms def test_retire_user_redacts_sso_pii_before_deletion(setup_retirement_states): # lint-amnesty, pylint: disable=redefined-outer-name, unused-argument # noqa: F811 + """ + Test that SSO PII is redacted before UserSocialAuth records are deleted during retirement. + """ user = UserFactory.create(username='sso-user', email='sso-user@example.com') social_auth = UserSocialAuth.objects.create( user=user, @@ -136,6 +139,9 @@ def test_retire_user_redacts_sso_pii_before_deletion(setup_retirement_states): @skip_unless_lms def test_retire_user_calls_redaction_for_each_social_auth(setup_retirement_states): # lint-amnesty, pylint: disable=redefined-outer-name, unused-argument # noqa: F811 + """ + Test that redact_user_social_auth_pii is called for each UserSocialAuth record during retirement. + """ user = UserFactory.create(username='multi-sso-user', email='multi-sso@example.com') UserSocialAuth.objects.create( user=user, From ff4b57e28cb279b70b69dd8eb0e7d451002574bd Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Thu, 23 Apr 2026 10:26:01 +0000 Subject: [PATCH 04/25] fix: Redact SSO PII before deletion --- openedx/core/djangoapps/user_api/accounts/signals.py | 6 +++--- openedx/core/djangoapps/user_api/accounts/utils.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/signals.py b/openedx/core/djangoapps/user_api/accounts/signals.py index fa71459bcd80..cf8b01a128c4 100644 --- a/openedx/core/djangoapps/user_api/accounts/signals.py +++ b/openedx/core/djangoapps/user_api/accounts/signals.py @@ -31,11 +31,11 @@ def redact_social_auth_pii_before_deletion(sender, instance, **kwargs): # pylin Signal handler to redact PII from UserSocialAuth records before deletion. This ensures that when SSO records are deleted (either via user retirement, manual unlinking, - or any other method), PII is redacted first. This prevents soft-deleted records in Snowflake - from retaining sensitive user information. + or any other method), PII is redacted first. This prevents downstream systems that maintain + soft-deleted records from retaining sensitive user information. Note: We call redact_user_social_auth_pii which saves the redacted data before the actual - deletion happens. This is intentional - when Snowflake syncs, it will capture the redacted + deletion happens. This is intentional - downstream systems will capture the redacted state before marking the record as deleted. """ try: diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index b6225502fbd9..4179aac93988 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -201,7 +201,7 @@ def redact_user_social_auth_pii(user_social_auth): """ Redact PII from a UserSocialAuth record before deletion. - Snowflake can retain deleted source rows as soft-deleted records, so sensitive + Downstream systems can retain deleted source rows as soft-deleted records, so sensitive fields should be overwritten before deletion. """ if not user_social_auth or not user_social_auth.pk: From 417aa3d9d2b2c28ab0dde28bcd028b0229577caf Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Tue, 28 Apr 2026 05:45:30 +0000 Subject: [PATCH 05/25] fix: Redact SSO PII before deletion --- .../djangoapps/user_api/accounts/signals.py | 6 ++- .../user_api/accounts/tests/test_utils.py | 47 +++++++++---------- .../djangoapps/user_api/accounts/utils.py | 9 ++-- .../management/tests/test_retire_user.py | 27 ++++++++++- 4 files changed, 59 insertions(+), 30 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/signals.py b/openedx/core/djangoapps/user_api/accounts/signals.py index cf8b01a128c4..5aaca96fe62d 100644 --- a/openedx/core/djangoapps/user_api/accounts/signals.py +++ b/openedx/core/djangoapps/user_api/accounts/signals.py @@ -37,14 +37,18 @@ def redact_social_auth_pii_before_deletion(sender, instance, **kwargs): # pylin Note: We call redact_user_social_auth_pii which saves the redacted data before the actual deletion happens. This is intentional - downstream systems will capture the redacted state before marking the record as deleted. + + If redaction fails, the exception is re-raised to prevent deletion from proceeding, + ensuring GDPR compliance and preventing PII leaks to downstream systems. """ try: redact_user_social_auth_pii(instance) except Exception as e: # pylint: disable=broad-except - # Log the error but don't prevent the deletion logger.exception( "Failed to redact PII for UserSocialAuth before deletion: user_id=%s, provider=%s, error=%s", instance.user_id, instance.provider, str(e) ) + # Re-raise to prevent deletion from proceeding without redaction + raise diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py index 879f3aece738..bc1871a637d9 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py @@ -11,7 +11,6 @@ from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.user_api.accounts.utils import ( - REDACTED_SOCIAL_AUTH_UID, redact_user_social_auth_pii, retrieve_last_sitewide_block_completed, ) @@ -176,7 +175,7 @@ def test_redact_user_social_auth_pii(self): redact_user_social_auth_pii(social_auth) social_auth.refresh_from_db() - assert social_auth.uid == REDACTED_SOCIAL_AUTH_UID + assert social_auth.uid == f'redacted_{social_auth.pk}@retired.invalid' assert social_auth.extra_data == {} def test_redact_user_social_auth_pii_idempotent(self): @@ -186,33 +185,31 @@ def test_redact_user_social_auth_pii_idempotent(self): social_auth = self.create_social_auth() redact_user_social_auth_pii(social_auth) + # Duplicate call to redact user method to validate idempotency redact_user_social_auth_pii(social_auth) social_auth.refresh_from_db() - assert social_auth.uid == REDACTED_SOCIAL_AUTH_UID + assert social_auth.uid == f'redacted_{social_auth.pk}@retired.invalid' assert social_auth.extra_data == {} def test_redact_multiple_sso_providers(self): """ - Test that redaction works correctly for multiple SSO providers (Google OAuth and SAML). - """ - google_auth = self.create_social_auth( - provider='google-oauth2', - uid='google@example.com', - extra_data={'email': 'google@example.com', 'name': 'Google User'} - ) - saml_auth = self.create_social_auth( - provider='tpa-saml', - uid='saml@example.com', - extra_data={'email': 'saml@example.com', 'name': 'SAML User', 'uid': 'saml-uid'} - ) - - redact_user_social_auth_pii(google_auth) - redact_user_social_auth_pii(saml_auth) - google_auth.refresh_from_db() - saml_auth.refresh_from_db() - - assert google_auth.uid == REDACTED_SOCIAL_AUTH_UID - assert google_auth.extra_data == {} - assert saml_auth.uid == REDACTED_SOCIAL_AUTH_UID - assert saml_auth.extra_data == {} + Test that redaction works correctly for multiple SSO providers. + """ + auths = [ + self.create_social_auth( + provider='google-oauth2', + uid='google@example.com', + extra_data={'email': 'google@example.com', 'name': 'Google User'} + ), + self.create_social_auth( + provider='tpa-saml', + uid='saml@example.com', + extra_data={'email': 'saml@example.com', 'name': 'SAML User', 'uid': 'saml-uid'} + ), + ] + for auth in auths: + redact_user_social_auth_pii(auth) + auth.refresh_from_db() + assert auth.uid == f'redacted_{auth.pk}@retired.invalid' + assert auth.extra_data == {} diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index 4179aac93988..bb7330b5da3a 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -25,7 +25,6 @@ ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH = 'enable_secondary_email_feature' LOGGER = logging.getLogger(__name__) -REDACTED_SOCIAL_AUTH_UID = 'redacted@redacted.com' def validate_social_link(platform_name, new_social_link): @@ -203,14 +202,18 @@ def redact_user_social_auth_pii(user_social_auth): Downstream systems can retain deleted source rows as soft-deleted records, so sensitive fields should be overwritten before deletion. + + Uses a unique redacted UID per record (redacted_{pk}@retired.invalid) to avoid + IntegrityError from the unique_together constraint on (provider, uid). """ if not user_social_auth or not user_social_auth.pk: return update_fields = {} - if user_social_auth.uid != REDACTED_SOCIAL_AUTH_UID: - update_fields['uid'] = REDACTED_SOCIAL_AUTH_UID + redacted_uid = f'redacted_{user_social_auth.pk}@retired.invalid' + if user_social_auth.uid != redacted_uid: + update_fields['uid'] = redacted_uid if user_social_auth.extra_data: update_fields['extra_data'] = {} diff --git a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py index a3da6abcf1cb..8afe56effa5f 100644 --- a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py +++ b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py @@ -115,6 +115,9 @@ def test_retire_with_username_email_userfile(setup_retirement_states): # lint-a def test_retire_user_redacts_sso_pii_before_deletion(setup_retirement_states): # lint-amnesty, pylint: disable=redefined-outer-name, unused-argument # noqa: F811 """ Test that SSO PII is redacted before UserSocialAuth records are deleted during retirement. + + This test verifies the order of operations by capturing the record's state + at the moment of deletion to ensure it was already redacted. """ user = UserFactory.create(username='sso-user', email='sso-user@example.com') social_auth = UserSocialAuth.objects.create( @@ -129,9 +132,31 @@ def test_retire_user_redacts_sso_pii_before_deletion(setup_retirement_states): ) social_auth_id = social_auth.id - call_command('retire_user', username=user.username, user_email=user.email) + # Capture the state at the moment of deletion to verify redaction happened first + captured_state = {} + original_delete = UserSocialAuth.delete + def capture_state_and_delete(self): + """Wrapper to capture state before deletion.""" + # Refresh from database to get the actual current state + self.refresh_from_db() + captured_state['uid'] = self.uid + captured_state['extra_data'] = dict(self.extra_data) if self.extra_data else {} + # Call original delete + return original_delete(self) + + with mock.patch.object(UserSocialAuth, 'delete', capture_state_and_delete): + call_command('retire_user', username=user.username, user_email=user.email) + + # Verify that at the moment of deletion, the record was already redacted + assert captured_state['uid'] == f'redacted_{social_auth_id}@retired.invalid', \ + "UID should be redacted before deletion" + assert captured_state['extra_data'] == {}, \ + "extra_data should be empty before deletion" + + # Verify deletion completed assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() + retired_user_status = UserRetirementStatus.objects.filter(original_username=user.username).first() assert retired_user_status is not None assert retired_user_status.original_email == 'sso-user@example.com' From 542b5be4aa680aadd3a85133d84a83ccf3168def Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Tue, 28 Apr 2026 05:52:18 +0000 Subject: [PATCH 06/25] fix: Redact SSO PII before deletion --- .../djangoapps/user_api/management/tests/test_retire_user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py index 8afe56effa5f..fd6806c5ca11 100644 --- a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py +++ b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py @@ -115,7 +115,7 @@ def test_retire_with_username_email_userfile(setup_retirement_states): # lint-a def test_retire_user_redacts_sso_pii_before_deletion(setup_retirement_states): # lint-amnesty, pylint: disable=redefined-outer-name, unused-argument # noqa: F811 """ Test that SSO PII is redacted before UserSocialAuth records are deleted during retirement. - + This test verifies the order of operations by capturing the record's state at the moment of deletion to ensure it was already redacted. """ @@ -156,7 +156,7 @@ def capture_state_and_delete(self): # Verify deletion completed assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() - + retired_user_status = UserRetirementStatus.objects.filter(original_username=user.username).first() assert retired_user_status is not None assert retired_user_status.original_email == 'sso-user@example.com' From 1b46be6a36069741473c210cc74d8717829ace34 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Mon, 4 May 2026 06:14:55 +0000 Subject: [PATCH 07/25] fix: Redact SSO PII before deletion --- .../djangoapps/user_api/accounts/utils.py | 15 ++++- .../management/commands/retire_user.py | 16 +++-- .../management/tests/test_retire_user.py | 64 +++++++++++-------- 3 files changed, 63 insertions(+), 32 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index bb7330b5da3a..5253c49b767b 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -11,6 +11,8 @@ from completion.models import BlockCompletion from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH from django.conf import settings +from django.db.models import CharField, Value +from django.db.models.functions import Cast, Concat from django.utils.translation import gettext as _ from edx_django_utils.user import generate_password from social_django.models import UserSocialAuth @@ -236,9 +238,16 @@ def create_retirement_request_and_deactivate_account(user): UserRetirementStatus.create_retirement(user) # Redact and unlink LMS social auth accounts - for social_auth in UserSocialAuth.objects.filter(user_id=user.id): - redact_user_social_auth_pii(social_auth) - social_auth.delete() + social_auth_queryset = UserSocialAuth.objects.filter(user_id=user.id) + social_auth_queryset.update( + uid=Concat( + Value('redacted_'), + Cast('id', output_field=CharField()), + Value('@retired.invalid'), + ), + extra_data={}, + ) + social_auth_queryset.delete() # Change LMS password & email user.email = get_retired_email_by_email(user.email) diff --git a/openedx/core/djangoapps/user_api/management/commands/retire_user.py b/openedx/core/djangoapps/user_api/management/commands/retire_user.py index c3b103a08061..9066f7a09453 100644 --- a/openedx/core/djangoapps/user_api/management/commands/retire_user.py +++ b/openedx/core/djangoapps/user_api/management/commands/retire_user.py @@ -5,12 +5,13 @@ from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.management.base import BaseCommand, CommandError from django.db import transaction +from django.db.models import CharField, Value +from django.db.models.functions import Cast, Concat from social_django.models import UserSocialAuth from common.djangoapps.student.models import AccountRecovery, Registration, get_retired_email_by_email from openedx.core.djangolib.oauth2_retirement_utils import retire_dot_oauth2_models -from ...accounts.utils import redact_user_social_auth_pii from ...models import BulkUserRetirementConfig, UserRetirementStatus logger = logging.getLogger(__name__) @@ -146,9 +147,16 @@ def handle(self, *args, **options): # Add user to retirement queue. UserRetirementStatus.create_retirement(user) # Redact and unlink LMS social auth accounts - for social_auth in UserSocialAuth.objects.filter(user_id=user.id): - redact_user_social_auth_pii(social_auth) - social_auth.delete() + social_auth_queryset = UserSocialAuth.objects.filter(user_id=user.id) + social_auth_queryset.update( + uid=Concat( + Value('redacted_'), + Cast('id', output_field=CharField()), + Value('@retired.invalid'), + ), + extra_data={}, + ) + social_auth_queryset.delete() # Change LMS password & email user.email = get_retired_email_by_email(user.email) user.set_unusable_password() diff --git a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py index fd6806c5ca11..a7bda497bd39 100644 --- a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py +++ b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py @@ -5,11 +5,11 @@ import csv import os -from unittest import mock import pytest from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.management import CommandError, call_command +from django.db.models.signals import pre_delete from social_django.models import UserSocialAuth from common.djangoapps.student.tests.factories import UserFactory # lint-amnesty, pylint: disable=wrong-import-order @@ -132,27 +132,30 @@ def test_retire_user_redacts_sso_pii_before_deletion(setup_retirement_states): ) social_auth_id = social_auth.id - # Capture the state at the moment of deletion to verify redaction happened first - captured_state = {} - original_delete = UserSocialAuth.delete + captured_states = [] - def capture_state_and_delete(self): - """Wrapper to capture state before deletion.""" - # Refresh from database to get the actual current state - self.refresh_from_db() - captured_state['uid'] = self.uid - captured_state['extra_data'] = dict(self.extra_data) if self.extra_data else {} - # Call original delete - return original_delete(self) + def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable=unused-argument + """Capture the database state seen by the pre_delete signal.""" + instance.refresh_from_db() + captured_states.append({ + 'id': instance.id, + 'uid': instance.uid, + 'extra_data': dict(instance.extra_data) if instance.extra_data else {}, + }) - with mock.patch.object(UserSocialAuth, 'delete', capture_state_and_delete): + pre_delete.connect(capture_state_before_delete, sender=UserSocialAuth) + try: call_command('retire_user', username=user.username, user_email=user.email) + finally: + pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth) # Verify that at the moment of deletion, the record was already redacted - assert captured_state['uid'] == f'redacted_{social_auth_id}@retired.invalid', \ - "UID should be redacted before deletion" - assert captured_state['extra_data'] == {}, \ - "extra_data should be empty before deletion" + assert captured_states == [{ + 'id': social_auth_id, + 'uid': f'redacted_{social_auth_id}@retired.invalid', + 'extra_data': {}, + }], \ + "SSO records should be redacted before deletion" # Verify deletion completed assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() @@ -163,27 +166,38 @@ def capture_state_and_delete(self): @skip_unless_lms -def test_retire_user_calls_redaction_for_each_social_auth(setup_retirement_states): # lint-amnesty, pylint: disable=redefined-outer-name, unused-argument # noqa: F811 +def test_retire_user_redacts_each_social_auth_before_bulk_deletion(setup_retirement_states): # lint-amnesty, pylint: disable=redefined-outer-name, unused-argument # noqa: F811 """ - Test that redact_user_social_auth_pii is called for each UserSocialAuth record during retirement. + Test that each UserSocialAuth record is redacted before bulk deletion during retirement. """ user = UserFactory.create(username='multi-sso-user', email='multi-sso@example.com') - UserSocialAuth.objects.create( + google_auth = UserSocialAuth.objects.create( user=user, provider='google-oauth2', uid='google-multi@example.com', extra_data={'email': 'google-multi@example.com', 'name': 'Google User'} ) - UserSocialAuth.objects.create( + saml_auth = UserSocialAuth.objects.create( user=user, provider='tpa-saml', uid='saml-multi@example.com', extra_data={'email': 'saml-multi@example.com', 'name': 'SAML User', 'uid': 'saml-123'} ) - with mock.patch( - 'openedx.core.djangoapps.user_api.management.commands.retire_user.redact_user_social_auth_pii' - ) as mock_redact: + captured_states = [] + + def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable=unused-argument + """Capture the database state seen by the pre_delete signal.""" + instance.refresh_from_db() + captured_states.append((instance.provider, instance.uid, dict(instance.extra_data) if instance.extra_data else {})) + + pre_delete.connect(capture_state_before_delete, sender=UserSocialAuth) + try: call_command('retire_user', username=user.username, user_email=user.email) + finally: + pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth) - assert mock_redact.call_count == 2 + assert sorted(captured_states) == sorted([ + ('google-oauth2', f'redacted_{google_auth.id}@retired.invalid', {}), + ('tpa-saml', f'redacted_{saml_auth.id}@retired.invalid', {}), + ]) From 74d655b0794b83f2335fd931e2bb614792155757 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Mon, 4 May 2026 07:36:12 +0000 Subject: [PATCH 08/25] fix: Redact SSO PII before deletion --- .../djangoapps/user_api/management/tests/test_retire_user.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py index a7bda497bd39..5653231335a9 100644 --- a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py +++ b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py @@ -189,7 +189,8 @@ def test_retire_user_redacts_each_social_auth_before_bulk_deletion(setup_retirem def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable=unused-argument """Capture the database state seen by the pre_delete signal.""" instance.refresh_from_db() - captured_states.append((instance.provider, instance.uid, dict(instance.extra_data) if instance.extra_data else {})) + extra = dict(instance.extra_data) if instance.extra_data else {} + captured_states.append((instance.provider, instance.uid, extra)) pre_delete.connect(capture_state_before_delete, sender=UserSocialAuth) try: From 08b491f8fec925439fe5e8d24b570298957dcaba Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Wed, 6 May 2026 06:18:29 +0000 Subject: [PATCH 09/25] fix: Redact SSO PII before deletion --- .../djangoapps/user_api/accounts/signals.py | 28 ++++-- .../user_api/accounts/tests/test_utils.py | 92 ++++++++++++++----- .../djangoapps/user_api/accounts/utils.py | 31 ------- 3 files changed, 89 insertions(+), 62 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/signals.py b/openedx/core/djangoapps/user_api/accounts/signals.py index 5aaca96fe62d..c83cdc7eaf98 100644 --- a/openedx/core/djangoapps/user_api/accounts/signals.py +++ b/openedx/core/djangoapps/user_api/accounts/signals.py @@ -8,8 +8,6 @@ from django.dispatch import Signal, receiver from social_django.models import UserSocialAuth -from .utils import redact_user_social_auth_pii - logger = logging.getLogger(__name__) # Signal to retire a user from LMS-initiated mailings (course mailings, etc) @@ -34,21 +32,33 @@ def redact_social_auth_pii_before_deletion(sender, instance, **kwargs): # pylin or any other method), PII is redacted first. This prevents downstream systems that maintain soft-deleted records from retaining sensitive user information. - Note: We call redact_user_social_auth_pii which saves the redacted data before the actual - deletion happens. This is intentional - downstream systems will capture the redacted - state before marking the record as deleted. + The redacted state is saved before the actual deletion happens. This is intentional - + downstream systems will capture the redacted state before marking the record as deleted. If redaction fails, the exception is re-raised to prevent deletion from proceeding, ensuring GDPR compliance and preventing PII leaks to downstream systems. """ + if not instance or not instance.pk: + return + try: - redact_user_social_auth_pii(instance) - except Exception as e: # pylint: disable=broad-except + update_fields = {} + redacted_uid = f'redacted_{instance.pk}@retired.invalid' + + if instance.uid != redacted_uid: + update_fields['uid'] = redacted_uid + if instance.extra_data: + update_fields['extra_data'] = {} + + if not update_fields: + return + + UserSocialAuth.objects.filter(pk=instance.pk).update(**update_fields) + except Exception: # pylint: disable=broad-except logger.exception( - "Failed to redact PII for UserSocialAuth before deletion: user_id=%s, provider=%s, error=%s", + "Failed to redact PII for UserSocialAuth before deletion: user_id=%s, provider=%s", instance.user_id, instance.provider, - str(e) ) # Re-raise to prevent deletion from proceeding without redaction raise diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py index bc1871a637d9..80aa3be54f50 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py @@ -11,7 +11,6 @@ from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.user_api.accounts.utils import ( - redact_user_social_auth_pii, retrieve_last_sitewide_block_completed, ) from openedx.core.djangolib.testing.utils import skip_unless_lms @@ -166,35 +165,70 @@ def create_social_auth(self, provider='google-oauth2', uid='user@example.com', e extra_data=extra_data, ) - def test_redact_user_social_auth_pii(self): + def test_delete_redacts_user_social_auth_pii(self): """ - Test that redact_user_social_auth_pii correctly redacts uid and extra_data fields. + Test that deleting social auth redacts uid and extra_data before removal. """ social_auth = self.create_social_auth() + social_auth_id = social_auth.id - redact_user_social_auth_pii(social_auth) - social_auth.refresh_from_db() + captured_states = [] - assert social_auth.uid == f'redacted_{social_auth.pk}@retired.invalid' - assert social_auth.extra_data == {} + def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable=unused-argument + instance.refresh_from_db() + captured_states.append({ + 'id': instance.id, + 'uid': instance.uid, + 'extra_data': dict(instance.extra_data) if instance.extra_data else {}, + }) - def test_redact_user_social_auth_pii_idempotent(self): + from django.db.models.signals import pre_delete + + pre_delete.connect(capture_state_before_delete, sender=UserSocialAuth) + try: + social_auth.delete() + finally: + pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth) + + assert captured_states == [{ + 'id': social_auth_id, + 'uid': f'redacted_{social_auth_id}@retired.invalid', + 'extra_data': {}, + }] + assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() + + def test_delete_already_redacted_user_social_auth_is_idempotent(self): """ - Test that calling redact_user_social_auth_pii multiple times is idempotent. + Test that deleting an already redacted social auth keeps the redacted state. """ social_auth = self.create_social_auth() + social_auth.uid = f'redacted_{social_auth.pk}@retired.invalid' + social_auth.extra_data = {} + social_auth.save(update_fields=['uid', 'extra_data']) + social_auth_id = social_auth.id + + captured_states = [] + + def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable=unused-argument + instance.refresh_from_db() + captured_states.append((instance.uid, instance.extra_data)) + + from django.db.models.signals import pre_delete - redact_user_social_auth_pii(social_auth) - # Duplicate call to redact user method to validate idempotency - redact_user_social_auth_pii(social_auth) - social_auth.refresh_from_db() + pre_delete.connect(capture_state_before_delete, sender=UserSocialAuth) + try: + social_auth.delete() + finally: + pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth) - assert social_auth.uid == f'redacted_{social_auth.pk}@retired.invalid' - assert social_auth.extra_data == {} + assert captured_states == [ + (f'redacted_{social_auth_id}@retired.invalid', {}), + ] + assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() - def test_redact_multiple_sso_providers(self): + def test_delete_redacts_multiple_sso_providers(self): """ - Test that redaction works correctly for multiple SSO providers. + Test that deletion redacts multiple SSO providers before removal. """ auths = [ self.create_social_auth( @@ -208,8 +242,22 @@ def test_redact_multiple_sso_providers(self): extra_data={'email': 'saml@example.com', 'name': 'SAML User', 'uid': 'saml-uid'} ), ] - for auth in auths: - redact_user_social_auth_pii(auth) - auth.refresh_from_db() - assert auth.uid == f'redacted_{auth.pk}@retired.invalid' - assert auth.extra_data == {} + captured_states = [] + + def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable=unused-argument + instance.refresh_from_db() + captured_states.append((instance.provider, instance.uid, instance.extra_data)) + + from django.db.models.signals import pre_delete + + pre_delete.connect(capture_state_before_delete, sender=UserSocialAuth) + try: + for auth in auths: + auth.delete() + finally: + pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth) + + assert sorted(captured_states) == sorted([ + ('google-oauth2', f'redacted_{auths[0].pk}@retired.invalid', {}), + ('tpa-saml', f'redacted_{auths[1].pk}@retired.invalid', {}), + ]) diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index 5253c49b767b..bc60f655f42f 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -198,37 +198,6 @@ def is_secondary_email_feature_enabled(): return waffle.switch_is_active(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH) -def redact_user_social_auth_pii(user_social_auth): - """ - Redact PII from a UserSocialAuth record before deletion. - - Downstream systems can retain deleted source rows as soft-deleted records, so sensitive - fields should be overwritten before deletion. - - Uses a unique redacted UID per record (redacted_{pk}@retired.invalid) to avoid - IntegrityError from the unique_together constraint on (provider, uid). - """ - if not user_social_auth or not user_social_auth.pk: - return - - update_fields = {} - - redacted_uid = f'redacted_{user_social_auth.pk}@retired.invalid' - if user_social_auth.uid != redacted_uid: - update_fields['uid'] = redacted_uid - if user_social_auth.extra_data: - update_fields['extra_data'] = {} - - if not update_fields: - return - - UserSocialAuth.objects.filter(pk=user_social_auth.pk).update(**update_fields) - - # Keep instance in sync in case the caller reuses it. - for field_name, value in update_fields.items(): - setattr(user_social_auth, field_name, value) - - def create_retirement_request_and_deactivate_account(user): """ Adds user to retirement queue, unlinks social auth accounts, changes user passwords From bbb5643eda293f564a1069f6859c2b6a1c3625a4 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Wed, 6 May 2026 06:56:23 +0000 Subject: [PATCH 10/25] fix: Redact SSO PII before deletion --- .../core/djangoapps/user_api/accounts/signals.py | 13 +++---------- .../user_api/accounts/tests/test_utils.py | 6 ++++-- .../user_api/management/tests/test_retire_user.py | 7 +++++-- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/signals.py b/openedx/core/djangoapps/user_api/accounts/signals.py index c83cdc7eaf98..423b216081a9 100644 --- a/openedx/core/djangoapps/user_api/accounts/signals.py +++ b/openedx/core/djangoapps/user_api/accounts/signals.py @@ -26,17 +26,10 @@ @receiver(pre_delete, sender=UserSocialAuth) def redact_social_auth_pii_before_deletion(sender, instance, **kwargs): # pylint: disable=unused-argument """ - Signal handler to redact PII from UserSocialAuth records before deletion. + Redacts PII fields (uid, extra_data) before UserSocialAuth deletion. - This ensures that when SSO records are deleted (either via user retirement, manual unlinking, - or any other method), PII is redacted first. This prevents downstream systems that maintain - soft-deleted records from retaining sensitive user information. - - The redacted state is saved before the actual deletion happens. This is intentional - - downstream systems will capture the redacted state before marking the record as deleted. - - If redaction fails, the exception is re-raised to prevent deletion from proceeding, - ensuring GDPR compliance and preventing PII leaks to downstream systems. + Replaces uid with 'redacted_{pk}@retired.invalid' and clears extra_data. + Blocks deletion if redaction fails to prevent PII leaks to downstream systems. """ if not instance or not instance.pk: return diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py index 80aa3be54f50..834a59d25ade 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py @@ -242,6 +242,8 @@ def test_delete_redacts_multiple_sso_providers(self): extra_data={'email': 'saml@example.com', 'name': 'SAML User', 'uid': 'saml-uid'} ), ] + # Save IDs before deletion (they become None after delete) + auth_ids = [auth.pk for auth in auths] captured_states = [] def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable=unused-argument @@ -258,6 +260,6 @@ def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable= pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth) assert sorted(captured_states) == sorted([ - ('google-oauth2', f'redacted_{auths[0].pk}@retired.invalid', {}), - ('tpa-saml', f'redacted_{auths[1].pk}@retired.invalid', {}), + ('google-oauth2', f'redacted_{auth_ids[0]}@retired.invalid', {}), + ('tpa-saml', f'redacted_{auth_ids[1]}@retired.invalid', {}), ]) diff --git a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py index 5653231335a9..7ab65c457361 100644 --- a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py +++ b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py @@ -183,6 +183,9 @@ def test_retire_user_redacts_each_social_auth_before_bulk_deletion(setup_retirem uid='saml-multi@example.com', extra_data={'email': 'saml-multi@example.com', 'name': 'SAML User', 'uid': 'saml-123'} ) + # Save IDs before deletion (they become None after delete) + google_auth_id = google_auth.id + saml_auth_id = saml_auth.id captured_states = [] @@ -199,6 +202,6 @@ def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable= pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth) assert sorted(captured_states) == sorted([ - ('google-oauth2', f'redacted_{google_auth.id}@retired.invalid', {}), - ('tpa-saml', f'redacted_{saml_auth.id}@retired.invalid', {}), + ('google-oauth2', f'redacted_{google_auth_id}@retired.invalid', {}), + ('tpa-saml', f'redacted_{saml_auth_id}@retired.invalid', {}), ]) From 07b82ffcba159fa9313e08421109d772e225c205 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Thu, 7 May 2026 05:45:11 +0000 Subject: [PATCH 11/25] fix: Redact SSO PII before deletion --- .../djangoapps/user_api/accounts/signals.py | 12 ++++++++++-- .../user_api/accounts/tests/test_utils.py | 17 ++++++++++------- .../core/djangoapps/user_api/accounts/utils.py | 5 +++-- .../user_api/management/commands/retire_user.py | 5 +++-- .../management/tests/test_retire_user.py | 7 ++++--- 5 files changed, 30 insertions(+), 16 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/signals.py b/openedx/core/djangoapps/user_api/accounts/signals.py index 423b216081a9..989163274420 100644 --- a/openedx/core/djangoapps/user_api/accounts/signals.py +++ b/openedx/core/djangoapps/user_api/accounts/signals.py @@ -10,6 +10,11 @@ logger = logging.getLogger(__name__) +# Prefix and suffix used to build a per-record redacted uid for UserSocialAuth. +# Both the signal handler and bulk retirement code use these to stay in sync. +REDACTED_SOCIAL_AUTH_UID_PREFIX = 'redacted-before-delete-' +REDACTED_SOCIAL_AUTH_UID_SUFFIX = '@safe.com' + # Signal to retire a user from LMS-initiated mailings (course mailings, etc) # providing_args=["user"] USER_RETIRE_MAILINGS = Signal() @@ -28,7 +33,8 @@ def redact_social_auth_pii_before_deletion(sender, instance, **kwargs): # pylin """ Redacts PII fields (uid, extra_data) before UserSocialAuth deletion. - Replaces uid with 'redacted_{pk}@retired.invalid' and clears extra_data. + Replaces uid with REDACTED_SOCIAL_AUTH_UID_PREFIX + pk + REDACTED_SOCIAL_AUTH_UID_SUFFIX + and clears extra_data. Blocks deletion if redaction fails to prevent PII leaks to downstream systems. """ if not instance or not instance.pk: @@ -36,8 +42,10 @@ def redact_social_auth_pii_before_deletion(sender, instance, **kwargs): # pylin try: update_fields = {} - redacted_uid = f'redacted_{instance.pk}@retired.invalid' + redacted_uid = f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{instance.pk}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}' + # These fields may have already been redacted as part of a bulk retirement, + # so we skip the update if it is already done to reduce query count. if instance.uid != redacted_uid: update_fields['uid'] = redacted_uid if instance.extra_data: diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py index 834a59d25ade..b6e89bb96828 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py @@ -10,6 +10,10 @@ from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.user_api.accounts.signals import ( + REDACTED_SOCIAL_AUTH_UID_PREFIX, + REDACTED_SOCIAL_AUTH_UID_SUFFIX, +) from openedx.core.djangoapps.user_api.accounts.utils import ( retrieve_last_sitewide_block_completed, ) @@ -192,7 +196,7 @@ def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable= assert captured_states == [{ 'id': social_auth_id, - 'uid': f'redacted_{social_auth_id}@retired.invalid', + 'uid': f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{social_auth_id}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}', 'extra_data': {}, }] assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() @@ -202,7 +206,7 @@ def test_delete_already_redacted_user_social_auth_is_idempotent(self): Test that deleting an already redacted social auth keeps the redacted state. """ social_auth = self.create_social_auth() - social_auth.uid = f'redacted_{social_auth.pk}@retired.invalid' + social_auth.uid = f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{social_auth.pk}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}' social_auth.extra_data = {} social_auth.save(update_fields=['uid', 'extra_data']) social_auth_id = social_auth.id @@ -222,7 +226,7 @@ def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable= pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth) assert captured_states == [ - (f'redacted_{social_auth_id}@retired.invalid', {}), + (f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{social_auth_id}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}', {}), ] assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() @@ -242,8 +246,7 @@ def test_delete_redacts_multiple_sso_providers(self): extra_data={'email': 'saml@example.com', 'name': 'SAML User', 'uid': 'saml-uid'} ), ] - # Save IDs before deletion (they become None after delete) - auth_ids = [auth.pk for auth in auths] + captured_states = [] def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable=unused-argument @@ -260,6 +263,6 @@ def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable= pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth) assert sorted(captured_states) == sorted([ - ('google-oauth2', f'redacted_{auth_ids[0]}@retired.invalid', {}), - ('tpa-saml', f'redacted_{auth_ids[1]}@retired.invalid', {}), + ('google-oauth2', f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{auth_ids[0]}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}', {}), + ('tpa-saml', f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{auth_ids[1]}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}', {}), ]) diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index bc60f655f42f..95691fb8ed7b 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -24,6 +24,7 @@ from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from ..models import UserRetirementStatus +from .signals import REDACTED_SOCIAL_AUTH_UID_PREFIX, REDACTED_SOCIAL_AUTH_UID_SUFFIX ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH = 'enable_secondary_email_feature' LOGGER = logging.getLogger(__name__) @@ -210,9 +211,9 @@ def create_retirement_request_and_deactivate_account(user): social_auth_queryset = UserSocialAuth.objects.filter(user_id=user.id) social_auth_queryset.update( uid=Concat( - Value('redacted_'), + Value(REDACTED_SOCIAL_AUTH_UID_PREFIX), Cast('id', output_field=CharField()), - Value('@retired.invalid'), + Value(REDACTED_SOCIAL_AUTH_UID_SUFFIX), ), extra_data={}, ) diff --git a/openedx/core/djangoapps/user_api/management/commands/retire_user.py b/openedx/core/djangoapps/user_api/management/commands/retire_user.py index 9066f7a09453..4cc3792bbdd7 100644 --- a/openedx/core/djangoapps/user_api/management/commands/retire_user.py +++ b/openedx/core/djangoapps/user_api/management/commands/retire_user.py @@ -12,6 +12,7 @@ from common.djangoapps.student.models import AccountRecovery, Registration, get_retired_email_by_email from openedx.core.djangolib.oauth2_retirement_utils import retire_dot_oauth2_models +from ...accounts.signals import REDACTED_SOCIAL_AUTH_UID_PREFIX, REDACTED_SOCIAL_AUTH_UID_SUFFIX from ...models import BulkUserRetirementConfig, UserRetirementStatus logger = logging.getLogger(__name__) @@ -150,9 +151,9 @@ def handle(self, *args, **options): social_auth_queryset = UserSocialAuth.objects.filter(user_id=user.id) social_auth_queryset.update( uid=Concat( - Value('redacted_'), + Value(REDACTED_SOCIAL_AUTH_UID_PREFIX), Cast('id', output_field=CharField()), - Value('@retired.invalid'), + Value(REDACTED_SOCIAL_AUTH_UID_SUFFIX), ), extra_data={}, ) diff --git a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py index 7ab65c457361..1acf50913509 100644 --- a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py +++ b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py @@ -18,6 +18,7 @@ ) from openedx.core.djangolib.testing.utils import skip_unless_lms # lint-amnesty, pylint: disable=wrong-import-order +from ...accounts.signals import REDACTED_SOCIAL_AUTH_UID_PREFIX, REDACTED_SOCIAL_AUTH_UID_SUFFIX from ...models import UserRetirementStatus pytestmark = pytest.mark.django_db @@ -152,7 +153,7 @@ def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable= # Verify that at the moment of deletion, the record was already redacted assert captured_states == [{ 'id': social_auth_id, - 'uid': f'redacted_{social_auth_id}@retired.invalid', + 'uid': f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{social_auth_id}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}', 'extra_data': {}, }], \ "SSO records should be redacted before deletion" @@ -202,6 +203,6 @@ def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable= pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth) assert sorted(captured_states) == sorted([ - ('google-oauth2', f'redacted_{google_auth_id}@retired.invalid', {}), - ('tpa-saml', f'redacted_{saml_auth_id}@retired.invalid', {}), + ('google-oauth2', f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{google_auth_id}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}', {}), + ('tpa-saml', f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{saml_auth_id}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}', {}), ]) From 15bcdc08793a88eb8d2fdc4f9fa98926240e5e68 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Thu, 7 May 2026 06:37:19 +0000 Subject: [PATCH 12/25] fix: Redact SSO PII before deletion --- openedx/core/djangoapps/user_api/accounts/tests/test_utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py index b6e89bb96828..e23000492045 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py @@ -246,6 +246,8 @@ def test_delete_redacts_multiple_sso_providers(self): extra_data={'email': 'saml@example.com', 'name': 'SAML User', 'uid': 'saml-uid'} ), ] + # Save IDs before deletion (they become None after delete) + auth_ids = [auth.pk for auth in auths] captured_states = [] From 2a9fba87a496cf5da274c7d346b0c2e02cbd6ca0 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Thu, 7 May 2026 14:02:35 +0000 Subject: [PATCH 13/25] fix: Redact SSO PII before deletion --- .../djangoapps/user_api/accounts/signals.py | 12 ++++++--- .../user_api/accounts/tests/test_utils.py | 26 ++++++++++++------- .../djangoapps/user_api/accounts/utils.py | 22 ++++++---------- .../management/commands/retire_user.py | 22 ++++++---------- .../management/tests/test_retire_user.py | 8 +++--- 5 files changed, 46 insertions(+), 44 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/signals.py b/openedx/core/djangoapps/user_api/accounts/signals.py index 989163274420..c9489fdfbd81 100644 --- a/openedx/core/djangoapps/user_api/accounts/signals.py +++ b/openedx/core/djangoapps/user_api/accounts/signals.py @@ -11,10 +11,16 @@ logger = logging.getLogger(__name__) # Prefix and suffix used to build a per-record redacted uid for UserSocialAuth. -# Both the signal handler and bulk retirement code use these to stay in sync. REDACTED_SOCIAL_AUTH_UID_PREFIX = 'redacted-before-delete-' REDACTED_SOCIAL_AUTH_UID_SUFFIX = '@safe.com' + +def get_redacted_social_auth_uid(pk): + """ + Return the redacted uid for a UserSocialAuth record. Single source of truth for this format. + """ + return f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{pk}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}' + # Signal to retire a user from LMS-initiated mailings (course mailings, etc) # providing_args=["user"] USER_RETIRE_MAILINGS = Signal() @@ -33,7 +39,7 @@ def redact_social_auth_pii_before_deletion(sender, instance, **kwargs): # pylin """ Redacts PII fields (uid, extra_data) before UserSocialAuth deletion. - Replaces uid with REDACTED_SOCIAL_AUTH_UID_PREFIX + pk + REDACTED_SOCIAL_AUTH_UID_SUFFIX + Replaces uid with get_redacted_social_auth_uid(pk) and clears extra_data. and clears extra_data. Blocks deletion if redaction fails to prevent PII leaks to downstream systems. """ @@ -42,7 +48,7 @@ def redact_social_auth_pii_before_deletion(sender, instance, **kwargs): # pylin try: update_fields = {} - redacted_uid = f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{instance.pk}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}' + redacted_uid = get_redacted_social_auth_uid(instance.pk) # These fields may have already been redacted as part of a bulk retirement, # so we skip the update if it is already done to reduce query count. diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py index e23000492045..d20a86d2ba43 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py @@ -10,10 +10,7 @@ from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import UserFactory -from openedx.core.djangoapps.user_api.accounts.signals import ( - REDACTED_SOCIAL_AUTH_UID_PREFIX, - REDACTED_SOCIAL_AUTH_UID_SUFFIX, -) +from openedx.core.djangoapps.user_api.accounts.signals import get_redacted_social_auth_uid from openedx.core.djangoapps.user_api.accounts.utils import ( retrieve_last_sitewide_block_completed, ) @@ -169,6 +166,17 @@ def create_social_auth(self, provider='google-oauth2', uid='user@example.com', e extra_data=extra_data, ) + def test_get_redacted_social_auth_uid_format(self): + """ + Test that get_redacted_social_auth_uid returns the expected string format. + + This is the single source of truth for the redacted uid format. + If this test breaks, the bulk retirement Concat/Cast in utils.py and + retire_user.py must also be updated to match. + """ + assert get_redacted_social_auth_uid(42) == 'redacted-before-delete-42@safe.com' + assert get_redacted_social_auth_uid(1) == 'redacted-before-delete-1@safe.com' + def test_delete_redacts_user_social_auth_pii(self): """ Test that deleting social auth redacts uid and extra_data before removal. @@ -196,7 +204,7 @@ def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable= assert captured_states == [{ 'id': social_auth_id, - 'uid': f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{social_auth_id}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}', + 'uid': get_redacted_social_auth_uid(social_auth_id), 'extra_data': {}, }] assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() @@ -206,7 +214,7 @@ def test_delete_already_redacted_user_social_auth_is_idempotent(self): Test that deleting an already redacted social auth keeps the redacted state. """ social_auth = self.create_social_auth() - social_auth.uid = f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{social_auth.pk}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}' + social_auth.uid = get_redacted_social_auth_uid(social_auth.pk) social_auth.extra_data = {} social_auth.save(update_fields=['uid', 'extra_data']) social_auth_id = social_auth.id @@ -226,7 +234,7 @@ def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable= pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth) assert captured_states == [ - (f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{social_auth_id}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}', {}), + (get_redacted_social_auth_uid(social_auth_id), {}), ] assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() @@ -265,6 +273,6 @@ def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable= pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth) assert sorted(captured_states) == sorted([ - ('google-oauth2', f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{auth_ids[0]}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}', {}), - ('tpa-saml', f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{auth_ids[1]}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}', {}), + ('google-oauth2', get_redacted_social_auth_uid(auth_ids[0]), {}), + ('tpa-saml', get_redacted_social_auth_uid(auth_ids[1]), {}), ]) diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index 95691fb8ed7b..ff32f1fac6fd 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -11,8 +11,6 @@ from completion.models import BlockCompletion from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH from django.conf import settings -from django.db.models import CharField, Value -from django.db.models.functions import Cast, Concat from django.utils.translation import gettext as _ from edx_django_utils.user import generate_password from social_django.models import UserSocialAuth @@ -24,7 +22,7 @@ from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from ..models import UserRetirementStatus -from .signals import REDACTED_SOCIAL_AUTH_UID_PREFIX, REDACTED_SOCIAL_AUTH_UID_SUFFIX +from .signals import get_redacted_social_auth_uid ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH = 'enable_secondary_email_feature' LOGGER = logging.getLogger(__name__) @@ -207,17 +205,13 @@ def create_retirement_request_and_deactivate_account(user): # Add user to retirement queue. UserRetirementStatus.create_retirement(user) - # Redact and unlink LMS social auth accounts - social_auth_queryset = UserSocialAuth.objects.filter(user_id=user.id) - social_auth_queryset.update( - uid=Concat( - Value(REDACTED_SOCIAL_AUTH_UID_PREFIX), - Cast('id', output_field=CharField()), - Value(REDACTED_SOCIAL_AUTH_UID_SUFFIX), - ), - extra_data={}, - ) - social_auth_queryset.delete() + # Redact and unlink LMS social auth accounts. + social_auth_records = list(UserSocialAuth.objects.filter(user_id=user.id)) + for auth in social_auth_records: + auth.uid = get_redacted_social_auth_uid(auth.pk) + auth.extra_data = {} + UserSocialAuth.objects.bulk_update(social_auth_records, ['uid', 'extra_data']) + UserSocialAuth.objects.filter(user_id=user.id).delete() # Change LMS password & email user.email = get_retired_email_by_email(user.email) diff --git a/openedx/core/djangoapps/user_api/management/commands/retire_user.py b/openedx/core/djangoapps/user_api/management/commands/retire_user.py index 4cc3792bbdd7..b8db230efe23 100644 --- a/openedx/core/djangoapps/user_api/management/commands/retire_user.py +++ b/openedx/core/djangoapps/user_api/management/commands/retire_user.py @@ -5,14 +5,12 @@ from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.management.base import BaseCommand, CommandError from django.db import transaction -from django.db.models import CharField, Value -from django.db.models.functions import Cast, Concat from social_django.models import UserSocialAuth from common.djangoapps.student.models import AccountRecovery, Registration, get_retired_email_by_email from openedx.core.djangolib.oauth2_retirement_utils import retire_dot_oauth2_models -from ...accounts.signals import REDACTED_SOCIAL_AUTH_UID_PREFIX, REDACTED_SOCIAL_AUTH_UID_SUFFIX +from ...accounts.signals import get_redacted_social_auth_uid from ...models import BulkUserRetirementConfig, UserRetirementStatus logger = logging.getLogger(__name__) @@ -147,17 +145,13 @@ def handle(self, *args, **options): for user in users: # Add user to retirement queue. UserRetirementStatus.create_retirement(user) - # Redact and unlink LMS social auth accounts - social_auth_queryset = UserSocialAuth.objects.filter(user_id=user.id) - social_auth_queryset.update( - uid=Concat( - Value(REDACTED_SOCIAL_AUTH_UID_PREFIX), - Cast('id', output_field=CharField()), - Value(REDACTED_SOCIAL_AUTH_UID_SUFFIX), - ), - extra_data={}, - ) - social_auth_queryset.delete() + # Redact and unlink LMS social auth accounts. + social_auth_records = list(UserSocialAuth.objects.filter(user_id=user.id)) + for auth in social_auth_records: + auth.uid = get_redacted_social_auth_uid(auth.pk) + auth.extra_data = {} + UserSocialAuth.objects.bulk_update(social_auth_records, ['uid', 'extra_data']) + UserSocialAuth.objects.filter(user_id=user.id).delete() # Change LMS password & email user.email = get_retired_email_by_email(user.email) user.set_unusable_password() diff --git a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py index 1acf50913509..b791b8a4cd54 100644 --- a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py +++ b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py @@ -18,7 +18,7 @@ ) from openedx.core.djangolib.testing.utils import skip_unless_lms # lint-amnesty, pylint: disable=wrong-import-order -from ...accounts.signals import REDACTED_SOCIAL_AUTH_UID_PREFIX, REDACTED_SOCIAL_AUTH_UID_SUFFIX +from ...accounts.signals import get_redacted_social_auth_uid from ...models import UserRetirementStatus pytestmark = pytest.mark.django_db @@ -153,7 +153,7 @@ def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable= # Verify that at the moment of deletion, the record was already redacted assert captured_states == [{ 'id': social_auth_id, - 'uid': f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{social_auth_id}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}', + 'uid': get_redacted_social_auth_uid(social_auth_id), 'extra_data': {}, }], \ "SSO records should be redacted before deletion" @@ -203,6 +203,6 @@ def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable= pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth) assert sorted(captured_states) == sorted([ - ('google-oauth2', f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{google_auth_id}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}', {}), - ('tpa-saml', f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{saml_auth_id}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}', {}), + ('google-oauth2', get_redacted_social_auth_uid(google_auth_id), {}), + ('tpa-saml', get_redacted_social_auth_uid(saml_auth_id), {}), ]) From dd7ac9c8cc12a49b1f80518b251f6b221522ac23 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Thu, 7 May 2026 16:10:45 +0000 Subject: [PATCH 14/25] fix: Redact SSO PII before deletion --- .../djangoapps/user_api/accounts/utils.py | 20 ++++++++++++------- .../management/commands/retire_user.py | 20 ++++++++++++------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index ff32f1fac6fd..ffc7b3887bd2 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -11,6 +11,8 @@ from completion.models import BlockCompletion from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH from django.conf import settings +from django.db.models import CharField, Value +from django.db.models.functions import Cast, Concat from django.utils.translation import gettext as _ from edx_django_utils.user import generate_password from social_django.models import UserSocialAuth @@ -22,7 +24,7 @@ from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from ..models import UserRetirementStatus -from .signals import get_redacted_social_auth_uid +from .signals import REDACTED_SOCIAL_AUTH_UID_PREFIX, REDACTED_SOCIAL_AUTH_UID_SUFFIX ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH = 'enable_secondary_email_feature' LOGGER = logging.getLogger(__name__) @@ -206,12 +208,16 @@ def create_retirement_request_and_deactivate_account(user): UserRetirementStatus.create_retirement(user) # Redact and unlink LMS social auth accounts. - social_auth_records = list(UserSocialAuth.objects.filter(user_id=user.id)) - for auth in social_auth_records: - auth.uid = get_redacted_social_auth_uid(auth.pk) - auth.extra_data = {} - UserSocialAuth.objects.bulk_update(social_auth_records, ['uid', 'extra_data']) - UserSocialAuth.objects.filter(user_id=user.id).delete() + social_auth_queryset = UserSocialAuth.objects.filter(user_id=user.id) + social_auth_queryset.update( + uid=Concat( + Value(REDACTED_SOCIAL_AUTH_UID_PREFIX), + Cast('id', output_field=CharField()), + Value(REDACTED_SOCIAL_AUTH_UID_SUFFIX), + ), + extra_data={}, + ) + social_auth_queryset.delete() # Change LMS password & email user.email = get_retired_email_by_email(user.email) diff --git a/openedx/core/djangoapps/user_api/management/commands/retire_user.py b/openedx/core/djangoapps/user_api/management/commands/retire_user.py index b8db230efe23..29a4c0860ad9 100644 --- a/openedx/core/djangoapps/user_api/management/commands/retire_user.py +++ b/openedx/core/djangoapps/user_api/management/commands/retire_user.py @@ -5,12 +5,14 @@ from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.management.base import BaseCommand, CommandError from django.db import transaction +from django.db.models import CharField, Value +from django.db.models.functions import Cast, Concat from social_django.models import UserSocialAuth from common.djangoapps.student.models import AccountRecovery, Registration, get_retired_email_by_email from openedx.core.djangolib.oauth2_retirement_utils import retire_dot_oauth2_models -from ...accounts.signals import get_redacted_social_auth_uid +from ...accounts.signals import REDACTED_SOCIAL_AUTH_UID_PREFIX, REDACTED_SOCIAL_AUTH_UID_SUFFIX from ...models import BulkUserRetirementConfig, UserRetirementStatus logger = logging.getLogger(__name__) @@ -146,12 +148,16 @@ def handle(self, *args, **options): # Add user to retirement queue. UserRetirementStatus.create_retirement(user) # Redact and unlink LMS social auth accounts. - social_auth_records = list(UserSocialAuth.objects.filter(user_id=user.id)) - for auth in social_auth_records: - auth.uid = get_redacted_social_auth_uid(auth.pk) - auth.extra_data = {} - UserSocialAuth.objects.bulk_update(social_auth_records, ['uid', 'extra_data']) - UserSocialAuth.objects.filter(user_id=user.id).delete() + social_auth_queryset = UserSocialAuth.objects.filter(user_id=user.id) + social_auth_queryset.update( + uid=Concat( + Value(REDACTED_SOCIAL_AUTH_UID_PREFIX), + Cast('id', output_field=CharField()), + Value(REDACTED_SOCIAL_AUTH_UID_SUFFIX), + ), + extra_data={}, + ) + social_auth_queryset.delete() # Change LMS password & email user.email = get_retired_email_by_email(user.email) user.set_unusable_password() From 5ca020fe93850f385f78819493ad7d5e7d7d5928 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Fri, 8 May 2026 06:23:24 +0000 Subject: [PATCH 15/25] fix: Redact SSO PII before deletion --- .../djangoapps/user_api/accounts/signals.py | 45 +++++++------------ .../djangoapps/user_api/accounts/utils.py | 37 ++++++++++----- .../management/commands/retire_user.py | 16 +------ 3 files changed, 44 insertions(+), 54 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/signals.py b/openedx/core/djangoapps/user_api/accounts/signals.py index c9489fdfbd81..86c7c33b3f1a 100644 --- a/openedx/core/djangoapps/user_api/accounts/signals.py +++ b/openedx/core/djangoapps/user_api/accounts/signals.py @@ -8,16 +8,16 @@ from django.dispatch import Signal, receiver from social_django.models import UserSocialAuth -logger = logging.getLogger(__name__) +from .utils import REDACTED_SOCIAL_AUTH_UID_PREFIX, REDACTED_SOCIAL_AUTH_UID_SUFFIX -# Prefix and suffix used to build a per-record redacted uid for UserSocialAuth. -REDACTED_SOCIAL_AUTH_UID_PREFIX = 'redacted-before-delete-' -REDACTED_SOCIAL_AUTH_UID_SUFFIX = '@safe.com' +logger = logging.getLogger(__name__) def get_redacted_social_auth_uid(pk): """ - Return the redacted uid for a UserSocialAuth record. Single source of truth for this format. + Return the redacted uid for a UserSocialAuth record. + + This must match the format used in redact_and_delete_social_auth. """ return f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{pk}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}' @@ -37,35 +37,20 @@ def get_redacted_social_auth_uid(pk): @receiver(pre_delete, sender=UserSocialAuth) def redact_social_auth_pii_before_deletion(sender, instance, **kwargs): # pylint: disable=unused-argument """ - Redacts PII fields (uid, extra_data) before UserSocialAuth deletion. + Safety-net signal handler that redacts PII on any UserSocialAuth before deletion. - Replaces uid with get_redacted_social_auth_uid(pk) and clears extra_data. - and clears extra_data. - Blocks deletion if redaction fails to prevent PII leaks to downstream systems. + Records deleted via ``redact_and_delete_social_auth`` will already be redacted; + this handler is a fallback for any other deletion path. """ - if not instance or not instance.pk: - return - - try: - update_fields = {} - redacted_uid = get_redacted_social_auth_uid(instance.pk) - - # These fields may have already been redacted as part of a bulk retirement, - # so we skip the update if it is already done to reduce query count. - if instance.uid != redacted_uid: - update_fields['uid'] = redacted_uid - if instance.extra_data: - update_fields['extra_data'] = {} + from .utils import redact_and_delete_social_auth # local import to avoid circular dependency - if not update_fields: - return + redacted_uid = get_redacted_social_auth_uid(instance.pk) - UserSocialAuth.objects.filter(pk=instance.pk).update(**update_fields) - except Exception: # pylint: disable=broad-except - logger.exception( - "Failed to redact PII for UserSocialAuth before deletion: user_id=%s, provider=%s", + # Safety-net in case the record wasn't redacted before delete. + if instance.extra_data or instance.uid != redacted_uid: + logger.warning( + 'Social auth link for user_id=%s, provider=%s was deleted without first being redacted.', instance.user_id, instance.provider, ) - # Re-raise to prevent deletion from proceeding without redaction - raise + redact_and_delete_social_auth(instance.user_id, skip_delete=True) diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index ffc7b3887bd2..30abb8ed4c5d 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -24,7 +24,10 @@ from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from ..models import UserRetirementStatus -from .signals import REDACTED_SOCIAL_AUTH_UID_PREFIX, REDACTED_SOCIAL_AUTH_UID_SUFFIX + +# Prefix and suffix used to build a per-record redacted uid for UserSocialAuth. +REDACTED_SOCIAL_AUTH_UID_PREFIX = 'redacted-before-delete-' +REDACTED_SOCIAL_AUTH_UID_SUFFIX = '@safe.com' ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH = 'enable_secondary_email_feature' LOGGER = logging.getLogger(__name__) @@ -199,16 +202,17 @@ def is_secondary_email_feature_enabled(): return waffle.switch_is_active(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH) -def create_retirement_request_and_deactivate_account(user): - """ - Adds user to retirement queue, unlinks social auth accounts, changes user passwords - and delete tokens and activation keys +def redact_and_delete_social_auth(user_id, skip_delete=False): """ - # Add user to retirement queue. - UserRetirementStatus.create_retirement(user) + Redact PII from all UserSocialAuth records for the given user, then delete them. - # Redact and unlink LMS social auth accounts. - social_auth_queryset = UserSocialAuth.objects.filter(user_id=user.id) + Redaction happens before deletion so that any observers see only sanitised data. + The uid format matches ``get_redacted_social_auth_uid()``. + + ``skip_delete`` should only be set to True when called from the pre_delete signal + handler, where deletion is already in progress. + """ + social_auth_queryset = UserSocialAuth.objects.filter(user_id=user_id) social_auth_queryset.update( uid=Concat( Value(REDACTED_SOCIAL_AUTH_UID_PREFIX), @@ -217,7 +221,20 @@ def create_retirement_request_and_deactivate_account(user): ), extra_data={}, ) - social_auth_queryset.delete() + if not skip_delete: + social_auth_queryset.delete() + + +def create_retirement_request_and_deactivate_account(user): + """ + Adds user to retirement queue, unlinks social auth accounts, changes user passwords + and delete tokens and activation keys + """ + # Add user to retirement queue. + UserRetirementStatus.create_retirement(user) + + # Redact and unlink LMS social auth accounts. + redact_and_delete_social_auth(user.id) # Change LMS password & email user.email = get_retired_email_by_email(user.email) diff --git a/openedx/core/djangoapps/user_api/management/commands/retire_user.py b/openedx/core/djangoapps/user_api/management/commands/retire_user.py index 29a4c0860ad9..4959bb31eb0f 100644 --- a/openedx/core/djangoapps/user_api/management/commands/retire_user.py +++ b/openedx/core/djangoapps/user_api/management/commands/retire_user.py @@ -5,14 +5,11 @@ from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.management.base import BaseCommand, CommandError from django.db import transaction -from django.db.models import CharField, Value -from django.db.models.functions import Cast, Concat -from social_django.models import UserSocialAuth from common.djangoapps.student.models import AccountRecovery, Registration, get_retired_email_by_email from openedx.core.djangolib.oauth2_retirement_utils import retire_dot_oauth2_models -from ...accounts.signals import REDACTED_SOCIAL_AUTH_UID_PREFIX, REDACTED_SOCIAL_AUTH_UID_SUFFIX +from ...accounts.utils import redact_and_delete_social_auth from ...models import BulkUserRetirementConfig, UserRetirementStatus logger = logging.getLogger(__name__) @@ -148,16 +145,7 @@ def handle(self, *args, **options): # Add user to retirement queue. UserRetirementStatus.create_retirement(user) # Redact and unlink LMS social auth accounts. - social_auth_queryset = UserSocialAuth.objects.filter(user_id=user.id) - social_auth_queryset.update( - uid=Concat( - Value(REDACTED_SOCIAL_AUTH_UID_PREFIX), - Cast('id', output_field=CharField()), - Value(REDACTED_SOCIAL_AUTH_UID_SUFFIX), - ), - extra_data={}, - ) - social_auth_queryset.delete() + redact_and_delete_social_auth(user.id) # Change LMS password & email user.email = get_retired_email_by_email(user.email) user.set_unusable_password() From cdb49a2edd967e5dbaf6bb84bc45b3f512d0a5a5 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Fri, 8 May 2026 07:31:18 +0000 Subject: [PATCH 16/25] fix: Redact SSO PII before deletion --- openedx/core/djangoapps/user_api/accounts/signals.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/signals.py b/openedx/core/djangoapps/user_api/accounts/signals.py index 86c7c33b3f1a..deb62b6e97eb 100644 --- a/openedx/core/djangoapps/user_api/accounts/signals.py +++ b/openedx/core/djangoapps/user_api/accounts/signals.py @@ -8,7 +8,7 @@ from django.dispatch import Signal, receiver from social_django.models import UserSocialAuth -from .utils import REDACTED_SOCIAL_AUTH_UID_PREFIX, REDACTED_SOCIAL_AUTH_UID_SUFFIX +from .utils import REDACTED_SOCIAL_AUTH_UID_PREFIX, REDACTED_SOCIAL_AUTH_UID_SUFFIX, redact_and_delete_social_auth logger = logging.getLogger(__name__) @@ -42,8 +42,6 @@ def redact_social_auth_pii_before_deletion(sender, instance, **kwargs): # pylin Records deleted via ``redact_and_delete_social_auth`` will already be redacted; this handler is a fallback for any other deletion path. """ - from .utils import redact_and_delete_social_auth # local import to avoid circular dependency - redacted_uid = get_redacted_social_auth_uid(instance.pk) # Safety-net in case the record wasn't redacted before delete. From bd3c10877c1214593f35a64d7cd61cedcdc6f1f0 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Fri, 8 May 2026 12:55:41 +0000 Subject: [PATCH 17/25] fix: Redact SSO PII before deletion --- openedx/core/djangoapps/user_api/accounts/signals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/user_api/accounts/signals.py b/openedx/core/djangoapps/user_api/accounts/signals.py index deb62b6e97eb..6f150f362336 100644 --- a/openedx/core/djangoapps/user_api/accounts/signals.py +++ b/openedx/core/djangoapps/user_api/accounts/signals.py @@ -47,7 +47,7 @@ def redact_social_auth_pii_before_deletion(sender, instance, **kwargs): # pylin # Safety-net in case the record wasn't redacted before delete. if instance.extra_data or instance.uid != redacted_uid: logger.warning( - 'Social auth link for user_id=%s, provider=%s was deleted without first being redacted.', + 'Social auth link for user_id=%s, provider=%s was deleted without first being redacted. Redacting in pre_delete.', instance.user_id, instance.provider, ) From 7528c082e2c1f45e697002145e2a76406955af23 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Fri, 8 May 2026 13:03:34 +0000 Subject: [PATCH 18/25] fix: Redact SSO PII before deletion --- openedx/core/djangoapps/user_api/accounts/signals.py | 3 ++- openedx/core/djangoapps/user_api/accounts/utils.py | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/user_api/accounts/signals.py b/openedx/core/djangoapps/user_api/accounts/signals.py index 6f150f362336..7c4026cd4d85 100644 --- a/openedx/core/djangoapps/user_api/accounts/signals.py +++ b/openedx/core/djangoapps/user_api/accounts/signals.py @@ -47,7 +47,8 @@ def redact_social_auth_pii_before_deletion(sender, instance, **kwargs): # pylin # Safety-net in case the record wasn't redacted before delete. if instance.extra_data or instance.uid != redacted_uid: logger.warning( - 'Social auth link for user_id=%s, provider=%s was deleted without first being redacted. Redacting in pre_delete.', + 'Social auth link for user_id=%s, provider=%s was deleted without first being redacted.' + ' Redacting in pre_delete.', instance.user_id, instance.provider, ) diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index 30abb8ed4c5d..12a92318fb2b 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -207,6 +207,8 @@ def redact_and_delete_social_auth(user_id, skip_delete=False): Redact PII from all UserSocialAuth records for the given user, then delete them. Redaction happens before deletion so that any observers see only sanitised data. + Downstream copies of data may use soft-deletes, and redacting before deleting + ensures PII for retired users (or future retirements) is not retained. The uid format matches ``get_redacted_social_auth_uid()``. ``skip_delete`` should only be set to True when called from the pre_delete signal From 2af3cb437aac950978cfc7003f91d4dcf8fb41e1 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Mon, 11 May 2026 11:24:12 +0000 Subject: [PATCH 19/25] fix: Redact SSO PII before deletion --- .../djangoapps/user_api/accounts/signals.py | 2 +- .../user_api/accounts/tests/test_signals.py | 79 ++++++++++ .../user_api/accounts/tests/test_utils.py | 144 +++++++----------- .../djangoapps/user_api/accounts/utils.py | 3 +- .../management/tests/test_retire_user.py | 89 +++++------ 5 files changed, 180 insertions(+), 137 deletions(-) create mode 100644 openedx/core/djangoapps/user_api/accounts/tests/test_signals.py diff --git a/openedx/core/djangoapps/user_api/accounts/signals.py b/openedx/core/djangoapps/user_api/accounts/signals.py index 7c4026cd4d85..d0f2df3a03fc 100644 --- a/openedx/core/djangoapps/user_api/accounts/signals.py +++ b/openedx/core/djangoapps/user_api/accounts/signals.py @@ -40,7 +40,7 @@ def redact_social_auth_pii_before_deletion(sender, instance, **kwargs): # pylin Safety-net signal handler that redacts PII on any UserSocialAuth before deletion. Records deleted via ``redact_and_delete_social_auth`` will already be redacted; - this handler is a fallback for any other deletion path. + this handler is a fallback for any missed deletion path. """ redacted_uid = get_redacted_social_auth_uid(instance.pk) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_signals.py b/openedx/core/djangoapps/user_api/accounts/tests/test_signals.py new file mode 100644 index 000000000000..feae94757104 --- /dev/null +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_signals.py @@ -0,0 +1,79 @@ +""" +Tests for user_api accounts signals. +""" + +import logging +from unittest.mock import patch + +from django.test import TestCase +from social_django.models import UserSocialAuth + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.user_api.accounts.signals import get_redacted_social_auth_uid +from openedx.core.djangolib.testing.utils import skip_unless_lms + + +@skip_unless_lms +class RedactSocialAuthPIIOnDeleteSignalTest(TestCase): + """ + Tests for the redact_social_auth_pii_before_deletion pre_delete signal handler. + """ + + def setUp(self): + super().setUp() + self.user = UserFactory.create(username='testuser', email='testuser@example.com') + + def _create_social_auth(self, uid='user@example.com', extra_data=None): + if extra_data is None: + extra_data = {'email': 'user@example.com', 'name': 'Test User'} + return UserSocialAuth.objects.create( + user=self.user, + provider='google-oauth2', + uid=uid, + extra_data=extra_data, + ) + + def test_get_redacted_social_auth_uid_format(self): + """ + Test that get_redacted_social_auth_uid returns the expected string format. + + This is the single source of truth for the redacted uid format. + """ + assert get_redacted_social_auth_uid(42) == 'redacted-before-delete-42@safe.com' + assert get_redacted_social_auth_uid(1) == 'redacted-before-delete-1@safe.com' + + def test_signal_warns_and_redacts_when_not_already_redacted(self): + """ + When a UserSocialAuth is deleted without prior redaction, the signal handler + should log a warning and call redact_and_delete_social_auth with skip_delete=True. + """ + social_auth = self._create_social_auth() + + with patch( + 'openedx.core.djangoapps.user_api.accounts.signals.redact_and_delete_social_auth' + ) as mock_redact, self.assertLogs( + 'openedx.core.djangoapps.user_api.accounts.signals', level=logging.WARNING + ) as log_ctx: + social_auth.delete() + + mock_redact.assert_called_once_with(self.user.id, skip_delete=True) + assert any('was deleted without first being redacted' in msg for msg in log_ctx.output) + + def test_signal_skips_warning_and_redaction_when_already_redacted(self): + """ + When a UserSocialAuth is already redacted before deletion, the signal handler + should not log a warning and should not call redact_and_delete_social_auth. + """ + social_auth = self._create_social_auth() + social_auth.uid = get_redacted_social_auth_uid(social_auth.pk) + social_auth.extra_data = {} + social_auth.save(update_fields=['uid', 'extra_data']) + social_auth_id = social_auth.id + + with patch( + 'openedx.core.djangoapps.user_api.accounts.signals.redact_and_delete_social_auth' + ) as mock_redact: + social_auth.delete() + + mock_redact.assert_not_called() + assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py index d20a86d2ba43..a0d627012244 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py @@ -1,17 +1,20 @@ """ Unit tests for custom UserProfile properties. """ - import ddt from completion import models from completion.test_utils import CompletionWaffleTestMixin +from unittest import mock +from django.db import connection +from django.db.models.signals import pre_delete from django.test import TestCase -from django.test.utils import override_settings +from django.test.utils import CaptureQueriesContext, override_settings from social_django.models import UserSocialAuth from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import UserFactory -from openedx.core.djangoapps.user_api.accounts.signals import get_redacted_social_auth_uid +from openedx.core.djangoapps.user_api.accounts.signals import redact_social_auth_pii_before_deletion from openedx.core.djangoapps.user_api.accounts.utils import ( + redact_and_delete_social_auth, retrieve_last_sitewide_block_completed, ) from openedx.core.djangolib.testing.utils import skip_unless_lms @@ -140,15 +143,48 @@ def test_retrieve_last_sitewide_block_completed(self): @skip_unless_lms -class RedactUserSocialAuthPIITest(TestCase): +class RedactAndDeleteSocialAuthTest(TestCase): """ - Tests for SSO PII redaction before deletion. + Tests for the redact_and_delete_social_auth utility function. + + The safety-net pre_delete signal handler is disconnected for all tests in this class + to verify that redact_and_delete_social_auth itself redacts before deleting, + not the fallback signal. """ + @classmethod + def setUpClass(cls): + super().setUpClass() + # Disconnect the safety-net signal so tests verify redact_and_delete_social_auth + # itself issues the UPDATE before DELETE, not the signal. + pre_delete.disconnect(redact_social_auth_pii_before_deletion, sender=UserSocialAuth) + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + # Reconnect the signal after the test class completes. + pre_delete.connect(redact_social_auth_pii_before_deletion, sender=UserSocialAuth) + def setUp(self): super().setUp() self.user = UserFactory.create(username='testuser', email='testuser@example.com') + def _assert_update_before_delete(self, sql_list, table='social_auth_usersocialauth'): + """ + Assert that an UPDATE on ``table`` runs before the DELETE on ``table``. + + This verifies that redaction happens in the helper itself, not via the + fallback pre_delete signal. + """ + table_key = table.upper() + update_indices = [i for i, sql in enumerate(sql_list) if 'UPDATE' in sql.upper() and table_key in sql.upper()] + delete_indices = [i for i, sql in enumerate(sql_list) if 'DELETE' in sql.upper() and table_key in sql.upper()] + assert update_indices, f'Expected at least one UPDATE on {table}' + assert delete_indices, f'Expected at least one DELETE on {table}' + assert any(update_idx < delete_idx for update_idx in update_indices for delete_idx in delete_indices), ( + 'Expected at least one UPDATE to precede at least one DELETE' + ) + def create_social_auth(self, provider='google-oauth2', uid='user@example.com', extra_data=None): """ Helper method to create UserSocialAuth instances for testing. @@ -166,81 +202,23 @@ def create_social_auth(self, provider='google-oauth2', uid='user@example.com', e extra_data=extra_data, ) - def test_get_redacted_social_auth_uid_format(self): - """ - Test that get_redacted_social_auth_uid returns the expected string format. - - This is the single source of truth for the redacted uid format. - If this test breaks, the bulk retirement Concat/Cast in utils.py and - retire_user.py must also be updated to match. - """ - assert get_redacted_social_auth_uid(42) == 'redacted-before-delete-42@safe.com' - assert get_redacted_social_auth_uid(1) == 'redacted-before-delete-1@safe.com' - - def test_delete_redacts_user_social_auth_pii(self): - """ - Test that deleting social auth redacts uid and extra_data before removal. - """ - social_auth = self.create_social_auth() - social_auth_id = social_auth.id - - captured_states = [] - - def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable=unused-argument - instance.refresh_from_db() - captured_states.append({ - 'id': instance.id, - 'uid': instance.uid, - 'extra_data': dict(instance.extra_data) if instance.extra_data else {}, - }) - - from django.db.models.signals import pre_delete - - pre_delete.connect(capture_state_before_delete, sender=UserSocialAuth) - try: - social_auth.delete() - finally: - pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth) - - assert captured_states == [{ - 'id': social_auth_id, - 'uid': get_redacted_social_auth_uid(social_auth_id), - 'extra_data': {}, - }] - assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() - - def test_delete_already_redacted_user_social_auth_is_idempotent(self): + def test_redact_and_delete_issues_update_before_delete(self): """ - Test that deleting an already redacted social auth keeps the redacted state. + Test that redact_and_delete_social_auth redacts before deleting a social auth record. """ social_auth = self.create_social_auth() - social_auth.uid = get_redacted_social_auth_uid(social_auth.pk) - social_auth.extra_data = {} - social_auth.save(update_fields=['uid', 'extra_data']) social_auth_id = social_auth.id - captured_states = [] - - def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable=unused-argument - instance.refresh_from_db() - captured_states.append((instance.uid, instance.extra_data)) + with CaptureQueriesContext(connection) as ctx: + redact_and_delete_social_auth(self.user.id) - from django.db.models.signals import pre_delete - - pre_delete.connect(capture_state_before_delete, sender=UserSocialAuth) - try: - social_auth.delete() - finally: - pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth) - - assert captured_states == [ - (get_redacted_social_auth_uid(social_auth_id), {}), - ] + self._assert_update_before_delete([query['sql'] for query in ctx]) assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() - def test_delete_redacts_multiple_sso_providers(self): + def test_redact_and_delete_redacts_multiple_sso_providers(self): """ - Test that deletion redacts multiple SSO providers before removal. + Test that redact_and_delete_social_auth redacts and deletes records for + multiple SSO providers in a single call. """ auths = [ self.create_social_auth( @@ -254,25 +232,11 @@ def test_delete_redacts_multiple_sso_providers(self): extra_data={'email': 'saml@example.com', 'name': 'SAML User', 'uid': 'saml-uid'} ), ] - # Save IDs before deletion (they become None after delete) auth_ids = [auth.pk for auth in auths] - captured_states = [] - - def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable=unused-argument - instance.refresh_from_db() - captured_states.append((instance.provider, instance.uid, instance.extra_data)) - - from django.db.models.signals import pre_delete - - pre_delete.connect(capture_state_before_delete, sender=UserSocialAuth) - try: - for auth in auths: - auth.delete() - finally: - pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth) + with CaptureQueriesContext(connection) as ctx: + redact_and_delete_social_auth(self.user.id) - assert sorted(captured_states) == sorted([ - ('google-oauth2', get_redacted_social_auth_uid(auth_ids[0]), {}), - ('tpa-saml', get_redacted_social_auth_uid(auth_ids[1]), {}), - ]) + self._assert_update_before_delete([query['sql'] for query in ctx]) + for auth_id in auth_ids: + assert not UserSocialAuth.objects.filter(id=auth_id).exists() diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index 12a92318fb2b..85868f67f078 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -206,15 +206,14 @@ def redact_and_delete_social_auth(user_id, skip_delete=False): """ Redact PII from all UserSocialAuth records for the given user, then delete them. - Redaction happens before deletion so that any observers see only sanitised data. Downstream copies of data may use soft-deletes, and redacting before deleting ensures PII for retired users (or future retirements) is not retained. - The uid format matches ``get_redacted_social_auth_uid()``. ``skip_delete`` should only be set to True when called from the pre_delete signal handler, where deletion is already in progress. """ social_auth_queryset = UserSocialAuth.objects.filter(user_id=user_id) + # Important: this redacted uid must match the format used by ``get_redacted_social_auth_uid()``. social_auth_queryset.update( uid=Concat( Value(REDACTED_SOCIAL_AUTH_UID_PREFIX), diff --git a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py index b791b8a4cd54..ed4d7e863adb 100644 --- a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py +++ b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py @@ -5,26 +5,57 @@ import csv import os +from contextlib import contextmanager import pytest from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.management import CommandError, call_command +from django.db import connection from django.db.models.signals import pre_delete +from django.test.utils import CaptureQueriesContext from social_django.models import UserSocialAuth from common.djangoapps.student.tests.factories import UserFactory # lint-amnesty, pylint: disable=wrong-import-order +from openedx.core.djangoapps.user_api.accounts.signals import ( # lint-amnesty, pylint: disable=wrong-import-order + redact_social_auth_pii_before_deletion, +) from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # lint-amnesty, pylint: disable=unused-import, wrong-import-order setup_retirement_states, # noqa: F401 ) from openedx.core.djangolib.testing.utils import skip_unless_lms # lint-amnesty, pylint: disable=wrong-import-order -from ...accounts.signals import get_redacted_social_auth_uid from ...models import UserRetirementStatus pytestmark = pytest.mark.django_db user_file = 'userfile.csv' +@contextmanager +def disconnected_social_auth_redaction_signal(): + """ + Temporarily disconnect the fallback signal so these tests exercise the command path. + """ + pre_delete.disconnect(redact_social_auth_pii_before_deletion, sender=UserSocialAuth) + try: + yield + finally: + pre_delete.connect(redact_social_auth_pii_before_deletion, sender=UserSocialAuth) + + +def assert_update_before_delete(sql_list, table='social_auth_usersocialauth'): + """ + Assert that at least one social auth UPDATE occurs before a DELETE. + """ + table_key = table.upper() + update_indices = [i for i, sql in enumerate(sql_list) if 'UPDATE' in sql.upper() and table_key in sql.upper()] + delete_indices = [i for i, sql in enumerate(sql_list) if 'DELETE' in sql.upper() and table_key in sql.upper()] + assert update_indices, f'Expected at least one UPDATE on {table}' + assert delete_indices, f'Expected at least one DELETE on {table}' + assert any(update_idx < delete_idx for update_idx in update_indices for delete_idx in delete_indices), ( + 'Expected at least one UPDATE to precede at least one DELETE' + ) + + def generate_dummy_users(): """ Function to generate dummy users that needs to be retired @@ -117,8 +148,8 @@ def test_retire_user_redacts_sso_pii_before_deletion(setup_retirement_states): """ Test that SSO PII is redacted before UserSocialAuth records are deleted during retirement. - This test verifies the order of operations by capturing the record's state - at the moment of deletion to ensure it was already redacted. + The safety-net pre_delete signal handler is disconnected for this test so that + we verify the redaction comes from retire_user itself, not the fallback signal. """ user = UserFactory.create(username='sso-user', email='sso-user@example.com') social_auth = UserSocialAuth.objects.create( @@ -133,32 +164,11 @@ def test_retire_user_redacts_sso_pii_before_deletion(setup_retirement_states): ) social_auth_id = social_auth.id - captured_states = [] - - def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable=unused-argument - """Capture the database state seen by the pre_delete signal.""" - instance.refresh_from_db() - captured_states.append({ - 'id': instance.id, - 'uid': instance.uid, - 'extra_data': dict(instance.extra_data) if instance.extra_data else {}, - }) - - pre_delete.connect(capture_state_before_delete, sender=UserSocialAuth) - try: + with disconnected_social_auth_redaction_signal(), CaptureQueriesContext(connection) as ctx: call_command('retire_user', username=user.username, user_email=user.email) - finally: - pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth) - # Verify that at the moment of deletion, the record was already redacted - assert captured_states == [{ - 'id': social_auth_id, - 'uid': get_redacted_social_auth_uid(social_auth_id), - 'extra_data': {}, - }], \ - "SSO records should be redacted before deletion" + assert_update_before_delete([query['sql'] for query in ctx]) - # Verify deletion completed assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() retired_user_status = UserRetirementStatus.objects.filter(original_username=user.username).first() @@ -169,7 +179,10 @@ def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable= @skip_unless_lms def test_retire_user_redacts_each_social_auth_before_bulk_deletion(setup_retirement_states): # lint-amnesty, pylint: disable=redefined-outer-name, unused-argument # noqa: F811 """ - Test that each UserSocialAuth record is redacted before bulk deletion during retirement. + Test that all UserSocialAuth records are redacted before bulk deletion during retirement. + + The safety-net pre_delete signal handler is disconnected for this test so that + we verify the redaction comes from retire_user itself, not the fallback signal. """ user = UserFactory.create(username='multi-sso-user', email='multi-sso@example.com') google_auth = UserSocialAuth.objects.create( @@ -184,25 +197,13 @@ def test_retire_user_redacts_each_social_auth_before_bulk_deletion(setup_retirem uid='saml-multi@example.com', extra_data={'email': 'saml-multi@example.com', 'name': 'SAML User', 'uid': 'saml-123'} ) - # Save IDs before deletion (they become None after delete) google_auth_id = google_auth.id saml_auth_id = saml_auth.id - captured_states = [] - - def capture_state_before_delete(sender, instance, **kwargs): # pylint: disable=unused-argument - """Capture the database state seen by the pre_delete signal.""" - instance.refresh_from_db() - extra = dict(instance.extra_data) if instance.extra_data else {} - captured_states.append((instance.provider, instance.uid, extra)) - - pre_delete.connect(capture_state_before_delete, sender=UserSocialAuth) - try: + with disconnected_social_auth_redaction_signal(), CaptureQueriesContext(connection) as ctx: call_command('retire_user', username=user.username, user_email=user.email) - finally: - pre_delete.disconnect(capture_state_before_delete, sender=UserSocialAuth) - assert sorted(captured_states) == sorted([ - ('google-oauth2', get_redacted_social_auth_uid(google_auth_id), {}), - ('tpa-saml', get_redacted_social_auth_uid(saml_auth_id), {}), - ]) + assert_update_before_delete([query['sql'] for query in ctx]) + + assert not UserSocialAuth.objects.filter(id=google_auth_id).exists() + assert not UserSocialAuth.objects.filter(id=saml_auth_id).exists() From 9a8ba84ff435d9433e76fd2cc07b1cae0d3a476b Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Mon, 11 May 2026 11:32:27 +0000 Subject: [PATCH 20/25] fix: Redact SSO PII before deletion --- openedx/core/djangoapps/user_api/accounts/tests/test_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py index a0d627012244..561d16b42962 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py @@ -3,7 +3,6 @@ import ddt from completion import models from completion.test_utils import CompletionWaffleTestMixin -from unittest import mock from django.db import connection from django.db.models.signals import pre_delete from django.test import TestCase From 0cbee498f3c8cbd4fb8c24b7d27d79c1fb0a429d Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Tue, 12 May 2026 07:10:25 +0000 Subject: [PATCH 21/25] fix: Redact SSO PII before deletion --- .../user_api/accounts/tests/test_signals.py | 13 ++++ .../user_api/accounts/tests/test_utils.py | 57 ++++++++------- .../djangoapps/user_api/accounts/utils.py | 27 ++++--- .../management/tests/test_retire_user.py | 73 ++++++------------- 4 files changed, 82 insertions(+), 88 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_signals.py b/openedx/core/djangoapps/user_api/accounts/tests/test_signals.py index feae94757104..e865ccd62cd0 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_signals.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_signals.py @@ -77,3 +77,16 @@ def test_signal_skips_warning_and_redaction_when_already_redacted(self): mock_redact.assert_not_called() assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() + + def test_signal_respects_atomicity(self): + """ + Test that the redact_social_auth_pii_before_deletion signal respects atomicity. + """ + social_auth = self._create_social_auth() + + with patch( + 'openedx.core.djangoapps.user_api.accounts.signals.redact_and_delete_social_auth' + ) as mock_redact: + social_auth.delete() + + mock_redact.assert_called_once_with(self.user.id, skip_delete=True) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py index 3994dc2e46fc..f7ffbeb2658f 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py @@ -188,12 +188,11 @@ def create_social_auth(self, provider='google-oauth2', uid='user@example.com', e """ Helper method to create UserSocialAuth instances for testing. """ - if extra_data is None: - extra_data = { - 'email': 'user@example.com', - 'name': 'Test User', - 'id': '123456789', - } + extra_data = extra_data or { + 'email': f'{provider}@example.com', + 'name': f'{provider.capitalize()} User', + 'id': '123456789', + } return UserSocialAuth.objects.create( user=self.user, provider=provider, @@ -201,12 +200,26 @@ def create_social_auth(self, provider='google-oauth2', uid='user@example.com', e extra_data=extra_data, ) - def test_redact_and_delete_issues_update_before_delete(self): + @data( + { + 'provider': 'google-oauth2', + 'uid': 'google@example.com', + 'extra_data': {'email': 'google@example.com', 'name': 'Google User'} + }, + { + 'provider': 'tpa-saml', + 'uid': 'saml@example.com', + 'extra_data': {'email': 'saml@example.com', 'name': 'SAML User', 'uid': 'saml-uid'} + } + ) + @unpack + def test_redact_and_delete_redacts_multiple_sso_providers(self, provider, uid, extra_data): """ - Test that redact_and_delete_social_auth redacts before deleting a social auth record. + Test that redact_and_delete_social_auth redacts and deletes records for + multiple SSO providers in a single call. """ - social_auth = self.create_social_auth() - social_auth_id = social_auth.id + social_auth = self.create_social_auth(provider=provider, uid=uid, extra_data=extra_data) + social_auth_id = social_auth.pk with CaptureQueriesContext(connection) as ctx: redact_and_delete_social_auth(self.user.id) @@ -214,28 +227,16 @@ def test_redact_and_delete_issues_update_before_delete(self): self._assert_update_before_delete([query['sql'] for query in ctx]) assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() - def test_redact_and_delete_redacts_multiple_sso_providers(self): + def test_atomicity_of_redact_and_delete(self): """ - Test that redact_and_delete_social_auth redacts and deletes records for - multiple SSO providers in a single call. + Test that redact_and_delete_social_auth operates within an atomic block. """ - auths = [ - self.create_social_auth( - provider='google-oauth2', - uid='google@example.com', - extra_data={'email': 'google@example.com', 'name': 'Google User'} - ), - self.create_social_auth( - provider='tpa-saml', - uid='saml@example.com', - extra_data={'email': 'saml@example.com', 'name': 'SAML User', 'uid': 'saml-uid'} - ), - ] - auth_ids = [auth.pk for auth in auths] + social_auth = self.create_social_auth() + social_auth_id = social_auth.pk with CaptureQueriesContext(connection) as ctx: redact_and_delete_social_auth(self.user.id) + # Ensure atomicity by verifying no partial updates or deletions occurred. self._assert_update_before_delete([query['sql'] for query in ctx]) - for auth_id in auth_ids: - assert not UserSocialAuth.objects.filter(id=auth_id).exists() + assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index 083f45ecc9e0..09bf25654822 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -11,6 +11,7 @@ from completion.models import BlockCompletion from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH from django.conf import settings +from django.db import transaction from django.db.models import CharField, Value from django.db.models.functions import Cast, Concat from django.utils.translation import gettext as _ @@ -213,17 +214,21 @@ def redact_and_delete_social_auth(user_id, skip_delete=False): handler, where deletion is already in progress. """ social_auth_queryset = UserSocialAuth.objects.filter(user_id=user_id) - # Important: this redacted uid must match the format used by ``get_redacted_social_auth_uid()``. - social_auth_queryset.update( - uid=Concat( - Value(REDACTED_SOCIAL_AUTH_UID_PREFIX), - Cast('id', output_field=CharField()), - Value(REDACTED_SOCIAL_AUTH_UID_SUFFIX), - ), - extra_data={}, - ) - if not skip_delete: - social_auth_queryset.delete() + # Wrap update + delete in an atomic block so that a failure on DELETE rolls + # back the UPDATE too, leaving no partially-redacted records and allowing + # the caller to retry the full operation. + with transaction.atomic(): + # Important: this redacted uid must match the format used by ``get_redacted_social_auth_uid()``. + social_auth_queryset.update( + uid=Concat( + Value(REDACTED_SOCIAL_AUTH_UID_PREFIX), + Cast('id', output_field=CharField()), + Value(REDACTED_SOCIAL_AUTH_UID_SUFFIX), + ), + extra_data={}, + ) + if not skip_delete: + social_auth_queryset.delete() def create_retirement_request_and_deactivate_account(user): diff --git a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py index 733cc1a4e96a..e6d4a8fa904a 100644 --- a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py +++ b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py @@ -144,66 +144,41 @@ def test_retire_with_username_email_userfile(setup_retirement_states): # pylint @skip_unless_lms -def test_retire_user_redacts_sso_pii_before_deletion(setup_retirement_states): # lint-amnesty, pylint: disable=redefined-outer-name, unused-argument # noqa: F811 +@pytest.mark.parametrize('social_auth_configs', [ + # Single SSO provider + [ + {'provider': 'google-oauth2', 'uid': 'sso@example.com', + 'extra_data': {'email': 'sso@example.com', 'name': 'SSO Test User', 'id': '123456789'}}, + ], + # Multiple SSO providers + [ + {'provider': 'google-oauth2', 'uid': 'google@example.com', + 'extra_data': {'email': 'google@example.com', 'name': 'Google User'}}, + {'provider': 'tpa-saml', 'uid': 'saml@example.com', + 'extra_data': {'email': 'saml@example.com', 'name': 'SAML User', 'uid': 'saml-123'}}, + ], +]) +def test_retire_user_redacts_sso_pii_before_deletion(setup_retirement_states, social_auth_configs): # lint-amnesty, pylint: disable=redefined-outer-name, unused-argument # noqa: F811 """ Test that SSO PII is redacted before UserSocialAuth records are deleted during retirement. + Covers both single and multiple SSO provider scenarios. - The safety-net pre_delete signal handler is disconnected for this test so that - we verify the redaction comes from retire_user itself, not the fallback signal. + The safety-net pre_delete signal handler is disconnected so we verify the redaction + comes from retire_user itself, not the fallback signal. """ user = UserFactory.create(username='sso-user', email='sso-user@example.com') - social_auth = UserSocialAuth.objects.create( - user=user, - provider='google-oauth2', - uid='sso-user@example.com', - extra_data={ - 'email': 'sso-user@example.com', - 'name': 'SSO Test User', - 'id': '123456789', - } - ) - social_auth_id = social_auth.id + auth_ids = [ + UserSocialAuth.objects.create(user=user, **cfg).id + for cfg in social_auth_configs + ] with disconnected_social_auth_redaction_signal(), CaptureQueriesContext(connection) as ctx: call_command('retire_user', username=user.username, user_email=user.email) assert_update_before_delete([query['sql'] for query in ctx]) - - assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() + for auth_id in auth_ids: + assert not UserSocialAuth.objects.filter(id=auth_id).exists() retired_user_status = UserRetirementStatus.objects.filter(original_username=user.username).first() assert retired_user_status is not None assert retired_user_status.original_email == 'sso-user@example.com' - - -@skip_unless_lms -def test_retire_user_redacts_each_social_auth_before_bulk_deletion(setup_retirement_states): # lint-amnesty, pylint: disable=redefined-outer-name, unused-argument # noqa: F811 - """ - Test that all UserSocialAuth records are redacted before bulk deletion during retirement. - - The safety-net pre_delete signal handler is disconnected for this test so that - we verify the redaction comes from retire_user itself, not the fallback signal. - """ - user = UserFactory.create(username='multi-sso-user', email='multi-sso@example.com') - google_auth = UserSocialAuth.objects.create( - user=user, - provider='google-oauth2', - uid='google-multi@example.com', - extra_data={'email': 'google-multi@example.com', 'name': 'Google User'} - ) - saml_auth = UserSocialAuth.objects.create( - user=user, - provider='tpa-saml', - uid='saml-multi@example.com', - extra_data={'email': 'saml-multi@example.com', 'name': 'SAML User', 'uid': 'saml-123'} - ) - google_auth_id = google_auth.id - saml_auth_id = saml_auth.id - - with disconnected_social_auth_redaction_signal(), CaptureQueriesContext(connection) as ctx: - call_command('retire_user', username=user.username, user_email=user.email) - - assert_update_before_delete([query['sql'] for query in ctx]) - - assert not UserSocialAuth.objects.filter(id=google_auth_id).exists() - assert not UserSocialAuth.objects.filter(id=saml_auth_id).exists() From c902e561f2279996964d6ae55b72a6f9316cc44f Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Tue, 12 May 2026 07:16:01 +0000 Subject: [PATCH 22/25] fix: Redact SSO PII before deletion --- openedx/core/djangoapps/user_api/accounts/tests/test_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py index f7ffbeb2658f..297814591f3a 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py @@ -200,7 +200,7 @@ def create_social_auth(self, provider='google-oauth2', uid='user@example.com', e extra_data=extra_data, ) - @data( + @ddt.data( { 'provider': 'google-oauth2', 'uid': 'google@example.com', @@ -212,7 +212,7 @@ def create_social_auth(self, provider='google-oauth2', uid='user@example.com', e 'extra_data': {'email': 'saml@example.com', 'name': 'SAML User', 'uid': 'saml-uid'} } ) - @unpack + @ddt.unpack def test_redact_and_delete_redacts_multiple_sso_providers(self, provider, uid, extra_data): """ Test that redact_and_delete_social_auth redacts and deletes records for From 5b3312ebac94540c1f4a386ac6225b16577bf3b1 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Tue, 12 May 2026 07:51:50 +0000 Subject: [PATCH 23/25] fix: Redact SSO PII before deletion --- openedx/core/djangoapps/user_api/accounts/tests/test_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py index 297814591f3a..a27fb4d065be 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py @@ -141,6 +141,7 @@ def test_retrieve_last_sitewide_block_completed(self): assert empty_block_url is None +@ddt.ddt @skip_unless_lms class RedactAndDeleteSocialAuthTest(TestCase): """ From 9aa4192ab02a67275e15102138f2a91317fc64b5 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Tue, 12 May 2026 10:31:55 +0000 Subject: [PATCH 24/25] fix: Redact SSO PII before deletion --- .../user_api/accounts/tests/test_signals.py | 13 --------- .../user_api/accounts/tests/test_utils.py | 14 ---------- .../djangoapps/user_api/accounts/utils.py | 27 ++++++++----------- 3 files changed, 11 insertions(+), 43 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_signals.py b/openedx/core/djangoapps/user_api/accounts/tests/test_signals.py index e865ccd62cd0..feae94757104 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_signals.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_signals.py @@ -77,16 +77,3 @@ def test_signal_skips_warning_and_redaction_when_already_redacted(self): mock_redact.assert_not_called() assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() - - def test_signal_respects_atomicity(self): - """ - Test that the redact_social_auth_pii_before_deletion signal respects atomicity. - """ - social_auth = self._create_social_auth() - - with patch( - 'openedx.core.djangoapps.user_api.accounts.signals.redact_and_delete_social_auth' - ) as mock_redact: - social_auth.delete() - - mock_redact.assert_called_once_with(self.user.id, skip_delete=True) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py index a27fb4d065be..3335ca1d7388 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py @@ -227,17 +227,3 @@ def test_redact_and_delete_redacts_multiple_sso_providers(self, provider, uid, e self._assert_update_before_delete([query['sql'] for query in ctx]) assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() - - def test_atomicity_of_redact_and_delete(self): - """ - Test that redact_and_delete_social_auth operates within an atomic block. - """ - social_auth = self.create_social_auth() - social_auth_id = social_auth.pk - - with CaptureQueriesContext(connection) as ctx: - redact_and_delete_social_auth(self.user.id) - - # Ensure atomicity by verifying no partial updates or deletions occurred. - self._assert_update_before_delete([query['sql'] for query in ctx]) - assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index 09bf25654822..083f45ecc9e0 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -11,7 +11,6 @@ from completion.models import BlockCompletion from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH from django.conf import settings -from django.db import transaction from django.db.models import CharField, Value from django.db.models.functions import Cast, Concat from django.utils.translation import gettext as _ @@ -214,21 +213,17 @@ def redact_and_delete_social_auth(user_id, skip_delete=False): handler, where deletion is already in progress. """ social_auth_queryset = UserSocialAuth.objects.filter(user_id=user_id) - # Wrap update + delete in an atomic block so that a failure on DELETE rolls - # back the UPDATE too, leaving no partially-redacted records and allowing - # the caller to retry the full operation. - with transaction.atomic(): - # Important: this redacted uid must match the format used by ``get_redacted_social_auth_uid()``. - social_auth_queryset.update( - uid=Concat( - Value(REDACTED_SOCIAL_AUTH_UID_PREFIX), - Cast('id', output_field=CharField()), - Value(REDACTED_SOCIAL_AUTH_UID_SUFFIX), - ), - extra_data={}, - ) - if not skip_delete: - social_auth_queryset.delete() + # Important: this redacted uid must match the format used by ``get_redacted_social_auth_uid()``. + social_auth_queryset.update( + uid=Concat( + Value(REDACTED_SOCIAL_AUTH_UID_PREFIX), + Cast('id', output_field=CharField()), + Value(REDACTED_SOCIAL_AUTH_UID_SUFFIX), + ), + extra_data={}, + ) + if not skip_delete: + social_auth_queryset.delete() def create_retirement_request_and_deactivate_account(user): From 36192df5c34157b64a1c086c498462405416b976 Mon Sep 17 00:00:00 2001 From: ktyagiapphelix2u Date: Wed, 13 May 2026 12:34:46 +0000 Subject: [PATCH 25/25] fix: Redact SSO PII before deletion --- .../user_api/accounts/tests/test_signals.py | 15 +-- .../user_api/accounts/tests/test_utils.py | 119 ++++++++++-------- .../management/tests/test_retire_user.py | 23 +--- 3 files changed, 79 insertions(+), 78 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_signals.py b/openedx/core/djangoapps/user_api/accounts/tests/test_signals.py index feae94757104..7a6d6bd87fd5 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_signals.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_signals.py @@ -42,16 +42,15 @@ def test_get_redacted_social_auth_uid_format(self): assert get_redacted_social_auth_uid(42) == 'redacted-before-delete-42@safe.com' assert get_redacted_social_auth_uid(1) == 'redacted-before-delete-1@safe.com' - def test_signal_warns_and_redacts_when_not_already_redacted(self): + @patch('openedx.core.djangoapps.user_api.accounts.signals.redact_and_delete_social_auth') + def test_signal_warns_and_redacts_when_not_already_redacted(self, mock_redact): """ When a UserSocialAuth is deleted without prior redaction, the signal handler should log a warning and call redact_and_delete_social_auth with skip_delete=True. """ social_auth = self._create_social_auth() - with patch( - 'openedx.core.djangoapps.user_api.accounts.signals.redact_and_delete_social_auth' - ) as mock_redact, self.assertLogs( + with self.assertLogs( 'openedx.core.djangoapps.user_api.accounts.signals', level=logging.WARNING ) as log_ctx: social_auth.delete() @@ -59,7 +58,8 @@ def test_signal_warns_and_redacts_when_not_already_redacted(self): mock_redact.assert_called_once_with(self.user.id, skip_delete=True) assert any('was deleted without first being redacted' in msg for msg in log_ctx.output) - def test_signal_skips_warning_and_redaction_when_already_redacted(self): + @patch('openedx.core.djangoapps.user_api.accounts.signals.redact_and_delete_social_auth') + def test_signal_skips_warning_and_redaction_when_already_redacted(self, mock_redact): """ When a UserSocialAuth is already redacted before deletion, the signal handler should not log a warning and should not call redact_and_delete_social_auth. @@ -70,10 +70,7 @@ def test_signal_skips_warning_and_redaction_when_already_redacted(self): social_auth.save(update_fields=['uid', 'extra_data']) social_auth_id = social_auth.id - with patch( - 'openedx.core.djangoapps.user_api.accounts.signals.redact_and_delete_social_auth' - ) as mock_redact: - social_auth.delete() + social_auth.delete() mock_redact.assert_not_called() assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py index 3335ca1d7388..c05f30d6870d 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_utils.py @@ -1,5 +1,7 @@ """ Unit tests for custom UserProfile properties. """ +from contextlib import contextmanager + import ddt from completion import models from completion.test_utils import CompletionWaffleTestMixin @@ -28,6 +30,39 @@ from ..utils import format_social_link, validate_social_link +def assert_update_before_delete(sql_list, num_redact_delete_pairs=1, table='social_auth_usersocialauth'): + """ + Assert that UPDATE and DELETE queries for ``table`` occur in consecutive pairs. + """ + table_key = table.upper() + expected_sql_list = [ + sql for sql in sql_list + if table_key in sql.upper() and ('UPDATE' in sql.upper() or 'DELETE' in sql.upper()) + ] + assert len(expected_sql_list) == num_redact_delete_pairs * 2, ( + f'Expected {num_redact_delete_pairs * 2} UPDATE/DELETE queries on {table}, ' + f'got {len(expected_sql_list)}' + ) + + for index in range(0, len(expected_sql_list), 2): + update_sql = expected_sql_list[index] + delete_sql = expected_sql_list[index + 1] + assert 'UPDATE' in update_sql.upper(), f'Expected UPDATE at position {index} for {table}' + assert 'DELETE' in delete_sql.upper(), f'Expected DELETE at position {index + 1} for {table}' + +# Use a context manager to guarantee signal reconnection between tests. +@contextmanager +def disconnected_social_auth_redaction_signal(): + """ + Temporarily disconnect the fallback signal so tests exercise the helper path. + """ + pre_delete.disconnect(redact_social_auth_pii_before_deletion, sender=UserSocialAuth) + try: + yield + finally: + pre_delete.connect(redact_social_auth_pii_before_deletion, sender=UserSocialAuth) + + @ddt.ddt class UserAccountSettingsTest(TestCase): """Unit tests for setting Social Media Links.""" @@ -146,45 +181,12 @@ def test_retrieve_last_sitewide_block_completed(self): class RedactAndDeleteSocialAuthTest(TestCase): """ Tests for the redact_and_delete_social_auth utility function. - - The safety-net pre_delete signal handler is disconnected for all tests in this class - to verify that redact_and_delete_social_auth itself redacts before deleting, - not the fallback signal. """ - @classmethod - def setUpClass(cls): - super().setUpClass() - # Disconnect the safety-net signal so tests verify redact_and_delete_social_auth - # itself issues the UPDATE before DELETE, not the signal. - pre_delete.disconnect(redact_social_auth_pii_before_deletion, sender=UserSocialAuth) - - @classmethod - def tearDownClass(cls): - super().tearDownClass() - # Reconnect the signal after the test class completes. - pre_delete.connect(redact_social_auth_pii_before_deletion, sender=UserSocialAuth) - def setUp(self): super().setUp() self.user = UserFactory.create(username='testuser', email='testuser@example.com') - def _assert_update_before_delete(self, sql_list, table='social_auth_usersocialauth'): - """ - Assert that an UPDATE on ``table`` runs before the DELETE on ``table``. - - This verifies that redaction happens in the helper itself, not via the - fallback pre_delete signal. - """ - table_key = table.upper() - update_indices = [i for i, sql in enumerate(sql_list) if 'UPDATE' in sql.upper() and table_key in sql.upper()] - delete_indices = [i for i, sql in enumerate(sql_list) if 'DELETE' in sql.upper() and table_key in sql.upper()] - assert update_indices, f'Expected at least one UPDATE on {table}' - assert delete_indices, f'Expected at least one DELETE on {table}' - assert any(update_idx < delete_idx for update_idx in update_indices for delete_idx in delete_indices), ( - 'Expected at least one UPDATE to precede at least one DELETE' - ) - def create_social_auth(self, provider='google-oauth2', uid='user@example.com', extra_data=None): """ Helper method to create UserSocialAuth instances for testing. @@ -201,29 +203,42 @@ def create_social_auth(self, provider='google-oauth2', uid='user@example.com', e extra_data=extra_data, ) - @ddt.data( - { - 'provider': 'google-oauth2', - 'uid': 'google@example.com', - 'extra_data': {'email': 'google@example.com', 'name': 'Google User'} - }, - { - 'provider': 'tpa-saml', - 'uid': 'saml@example.com', - 'extra_data': {'email': 'saml@example.com', 'name': 'SAML User', 'uid': 'saml-uid'} - } - ) - @ddt.unpack - def test_redact_and_delete_redacts_multiple_sso_providers(self, provider, uid, extra_data): + def test_redact_and_delete_redacts_single_sso_record(self): """ - Test that redact_and_delete_social_auth redacts and deletes records for - multiple SSO providers in a single call. + Test that redact_and_delete_social_auth redacts and deletes a single SSO record. """ - social_auth = self.create_social_auth(provider=provider, uid=uid, extra_data=extra_data) + social_auth = self.create_social_auth( + provider='google-oauth2', + uid='google@example.com', + extra_data={'email': 'google@example.com', 'name': 'Google User'}, + ) social_auth_id = social_auth.pk - with CaptureQueriesContext(connection) as ctx: + with disconnected_social_auth_redaction_signal(), CaptureQueriesContext(connection) as ctx: redact_and_delete_social_auth(self.user.id) - self._assert_update_before_delete([query['sql'] for query in ctx]) + assert_update_before_delete([query['sql'] for query in ctx]) assert not UserSocialAuth.objects.filter(id=social_auth_id).exists() + + def test_redact_and_delete_redacts_multiple_sso_records(self): + """ + Test that redact_and_delete_social_auth redacts and deletes all SSO records for a user. + """ + social_auth_ids = [ + self.create_social_auth( + provider='google-oauth2', + uid='google@example.com', + extra_data={'email': 'google@example.com', 'name': 'Google User'}, + ).pk, + self.create_social_auth( + provider='tpa-saml', + uid='saml@example.com', + extra_data={'email': 'saml@example.com', 'name': 'SAML User', 'uid': 'saml-uid'}, + ).pk, + ] + + with disconnected_social_auth_redaction_signal(), CaptureQueriesContext(connection) as ctx: + redact_and_delete_social_auth(self.user.id) + + assert_update_before_delete([query['sql'] for query in ctx]) + assert not UserSocialAuth.objects.filter(id__in=social_auth_ids).exists() diff --git a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py index e6d4a8fa904a..e57c1c50c938 100644 --- a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py +++ b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py @@ -16,12 +16,15 @@ from social_django.models import UserSocialAuth from common.djangoapps.student.tests.factories import UserFactory # lint-amnesty, pylint: disable=wrong-import-order -from openedx.core.djangoapps.user_api.accounts.signals import ( # lint-amnesty, pylint: disable=wrong-import-order +from openedx.core.djangoapps.user_api.accounts.signals import ( redact_social_auth_pii_before_deletion, ) -from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # lint-amnesty, pylint: disable=unused-import, wrong-import-order +from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( setup_retirement_states, # noqa: F401 ) +from openedx.core.djangoapps.user_api.accounts.tests.test_utils import ( + assert_update_before_delete, +) from openedx.core.djangolib.testing.utils import skip_unless_lms # pylint: disable=wrong-import-order from ...models import UserRetirementStatus @@ -29,7 +32,7 @@ pytestmark = pytest.mark.django_db user_file = 'userfile.csv' - +# Use a context manager to guarantee signal reconnection between tests. @contextmanager def disconnected_social_auth_redaction_signal(): """ @@ -42,20 +45,6 @@ def disconnected_social_auth_redaction_signal(): pre_delete.connect(redact_social_auth_pii_before_deletion, sender=UserSocialAuth) -def assert_update_before_delete(sql_list, table='social_auth_usersocialauth'): - """ - Assert that at least one social auth UPDATE occurs before a DELETE. - """ - table_key = table.upper() - update_indices = [i for i, sql in enumerate(sql_list) if 'UPDATE' in sql.upper() and table_key in sql.upper()] - delete_indices = [i for i, sql in enumerate(sql_list) if 'DELETE' in sql.upper() and table_key in sql.upper()] - assert update_indices, f'Expected at least one UPDATE on {table}' - assert delete_indices, f'Expected at least one DELETE on {table}' - assert any(update_idx < delete_idx for update_idx in update_indices for delete_idx in delete_indices), ( - 'Expected at least one UPDATE to precede at least one DELETE' - ) - - def generate_dummy_users(): """ Function to generate dummy users that needs to be retired