Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
97 changes: 97 additions & 0 deletions openedx/core/djangoapps/user_api/accounts/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
""" Unit tests for custom UserProfile properties. """
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Akanshu-2u Looks like a valid comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added valid docstring.

Comment thread
Akanshu-2u marked this conversation as resolved.
Outdated

import unittest.mock
from contextlib import contextmanager

import ddt
from completion import models
from completion.test_utils import CompletionWaffleTestMixin
from django.apps import apps
from django.db import connection
from django.db.models.signals import pre_delete
from django.test import TestCase
from django.test.utils import CaptureQueriesContext, override_settings
from django.utils import timezone
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 redact_social_auth_pii_before_deletion
from openedx.core.djangoapps.user_api.accounts.utils import (
REDACTED_SOCIAL_AUTH_UID_PREFIX,
REDACTED_SOCIAL_AUTH_UID_SUFFIX,
redact_and_delete_historical_social_auth,
redact_and_delete_social_auth,
retrieve_last_sitewide_block_completed,
)
Expand Down Expand Up @@ -242,3 +248,94 @@ def test_redact_and_delete_redacts_multiple_sso_records(self):

assert_update_before_delete([query['sql'] for query in ctx])
assert not UserSocialAuth.objects.filter(id__in=social_auth_ids).exists()


@skip_unless_lms
class RedactAndDeleteHistoricalSocialAuthTest(TestCase):
"""
Tests for the redact_and_delete_historical_social_auth utility function.
"""

def setUp(self):
super().setUp()
self.user = UserFactory.create(username='testuser', email='testuser@example.com')
try:
self.historical_social_auth_model = apps.get_model('support', 'HistoricalUserSocialAuth')
except LookupError:
self.skipTest('support.HistoricalUserSocialAuth is not available in this test environment')
Comment thread
Akanshu-2u marked this conversation as resolved.
Outdated

def _create_historical_record(self, provider='google-oauth2', uid='user@example.com', extra_data=None, source_id=1):
"""Create a HistoricalUserSocialAuth record directly for test setup."""
if extra_data is None:
extra_data = {'email': uid, 'name': 'Test User'}
return self.historical_social_auth_model.objects.create(
user=self.user,
id=source_id,
provider=provider,
uid=uid,
extra_data=extra_data,
created=timezone.now(),
modified=timezone.now(),
history_date=timezone.now(),
history_type='+',
)

def test_redacted_uid_format(self):
"""
uid must follow the redacted-before-delete-{history_id}@safe.com format and extra_data
must be cleared. A pre_delete signal re-fetches the row at deletion time to confirm the
UPDATE was written before the DELETE fired.
"""
Comment thread
Akanshu-2u marked this conversation as resolved.
record = self._create_historical_record(uid='private@example.com')
history_id = record.history_id
expected_uid = f'{REDACTED_SOCIAL_AUTH_UID_PREFIX}{history_id}{REDACTED_SOCIAL_AUTH_UID_SUFFIX}'

signal_capture = {}

def capture_uid_at_delete(sender, instance, **kwargs): # pylint: disable=unused-argument
redacted_row = self.historical_social_auth_model.objects.get(history_id=instance.history_id)
signal_capture['uid'] = redacted_row.uid
signal_capture['extra_data'] = redacted_row.extra_data

pre_delete.connect(capture_uid_at_delete, sender=self.historical_social_auth_model)
try:
redact_and_delete_historical_social_auth(self.user.id)
finally:
pre_delete.disconnect(capture_uid_at_delete, sender=self.historical_social_auth_model)

assert signal_capture['uid'] == expected_uid, (
f"uid at delete time was {signal_capture['uid']!r}, expected {expected_uid!r}"
)
assert signal_capture['extra_data'] == {}
assert not self.historical_social_auth_model.objects.filter(history_id=history_id).exists()

def test_deletes_all_records_for_user(self):
"""All rows for the retired user are deleted; other users' rows are untouched."""
self._create_historical_record(provider='google-oauth2', uid='google@example.com', source_id=1)
self._create_historical_record(provider='tpa-saml', uid='saml@example.com', source_id=2)

other_user = UserFactory.create(username='otheruser', email='other@example.com')
other_record = self.historical_social_auth_model.objects.create(
user=other_user,
id=2,
provider='google-oauth2',
uid='other@example.com',
extra_data={},
created=timezone.now(),
modified=timezone.now(),
history_date=timezone.now(),
history_type='+',
)

redact_and_delete_historical_social_auth(self.user.id)

assert not self.historical_social_auth_model.objects.filter(user=self.user).exists()
assert self.historical_social_auth_model.objects.filter(history_id=other_record.history_id).exists()

def test_skips_gracefully_when_support_app_not_installed(self):
"""Returns without error when the support app is not installed."""
with unittest.mock.patch(
'openedx.core.djangoapps.user_api.accounts.utils.apps.get_model',
side_effect=LookupError('support'),
):
redact_and_delete_historical_social_auth(self.user.id)
33 changes: 33 additions & 0 deletions openedx/core/djangoapps/user_api/accounts/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import waffle # pylint: disable=invalid-django-waffle-import
from completion.models import BlockCompletion
from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH
from django.apps import apps
from django.conf import settings
from django.db.models import CharField, Value
from django.db.models.functions import Cast, Concat
Expand Down Expand Up @@ -226,6 +227,38 @@ def redact_and_delete_social_auth(user_id, skip_delete=False):
social_auth_queryset.delete()


def redact_and_delete_historical_social_auth(user_id):
"""
Redact PII from all HistoricalUserSocialAuth records for the given user, then delete them.

HistoricalUserSocialAuth rows are django-simple-history snapshots of every UserSocialAuth
change. They are not touched by the standard UserSocialAuth retirement step, leaving raw
email addresses stored in the ``uid`` field indefinitely.

Redacting before deleting ensures deletion-time handlers or other observers in the same
process/transaction see sanitised values before the rows are removed. It does not imply
that other transactions will observe the intermediate redacted state before deletion.
"""
try:
HistoricalUserSocialAuth = apps.get_model('support', 'HistoricalUserSocialAuth')
except LookupError:
Comment thread
Akanshu-2u marked this conversation as resolved.
Outdated
LOGGER.debug(
'redact_and_delete_historical_social_auth: support app not installed, skipping for user_id=%s',
Comment thread
Akanshu-2u marked this conversation as resolved.
Outdated
user_id,
)
return
Comment thread
Akanshu-2u marked this conversation as resolved.
Outdated
historical_queryset = HistoricalUserSocialAuth.objects.filter(user_id=user_id)
historical_queryset.update(
uid=Concat(
Value(REDACTED_SOCIAL_AUTH_UID_PREFIX),
Cast('history_id', output_field=CharField()),
Value(REDACTED_SOCIAL_AUTH_UID_SUFFIX),
),
Comment thread
Akanshu-2u marked this conversation as resolved.
extra_data={},
)
historical_queryset.delete()
Comment thread
Akanshu-2u marked this conversation as resolved.
Outdated


def create_retirement_request_and_deactivate_account(user):
"""
Adds user to retirement queue, unlinks social auth accounts, changes user passwords
Expand Down
6 changes: 5 additions & 1 deletion openedx/core/djangoapps/user_api/accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.user_api import accounts
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image
from openedx.core.djangoapps.user_api.accounts.utils import handle_retirement_cancellation
from openedx.core.djangoapps.user_api.accounts.utils import (
handle_retirement_cancellation,
redact_and_delete_historical_social_auth,
)
from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError
from openedx.core.lib.api.authentication import BearerAuthentication, BearerAuthenticationAllowInactiveUser
from openedx.core.lib.api.parsers import MergePatchParser
Expand Down Expand Up @@ -1104,6 +1107,7 @@ def post(self, request):
CreditRequest.retire_user(retirement)
ApiAccessRequest.retire_user(retirement.user)
CreditRequirementStatus.retire_user(retirement)
redact_and_delete_historical_social_auth(retirement.user.id)
Comment thread
Akanshu-2u marked this conversation as resolved.

# This signal allows code in higher points of LMS to retire the user as necessary
USER_RETIRE_LMS_MISC.send(sender=self.__class__, user=retirement.user)
Expand Down
Loading