Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions openedx/core/djangoapps/user_api/accounts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
49 changes: 48 additions & 1 deletion openedx/core/djangoapps/user_api/accounts/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,18 @@
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

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"]
Expand All @@ -16,3 +26,40 @@
# 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
"""
Redacts PII fields (uid, extra_data) before UserSocialAuth deletion.

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:
return

try:
update_fields = {}
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:
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",
instance.user_id,
instance.provider,
)
# Re-raise to prevent deletion from proceeding without redaction
raise
137 changes: 136 additions & 1 deletion openedx/core/djangoapps/user_api/accounts/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,17 @@
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.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,
)
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
Expand Down Expand Up @@ -133,3 +140,131 @@ 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):
"""
Helper method to create UserSocialAuth instances for testing.
"""
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_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': 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()

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.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

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 == [
(f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{social_auth_id}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}', {}),
]
assert not UserSocialAuth.objects.filter(id=social_auth_id).exists()

def test_delete_redacts_multiple_sso_providers(self):
"""
Test that deletion redacts multiple SSO providers before removal.
"""
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'}
),
]
# 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)

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}', {}),
])
16 changes: 14 additions & 2 deletions openedx/core/djangoapps/user_api/accounts/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,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__)
Expand Down Expand Up @@ -204,8 +207,17 @@ 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
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +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 REDACTED_SOCIAL_AUTH_UID_PREFIX, REDACTED_SOCIAL_AUTH_UID_SUFFIX
from ...models import BulkUserRetirementConfig, UserRetirementStatus

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -144,8 +147,17 @@ 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
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()
Expand Down
Loading
Loading