diff --git a/lms/djangoapps/bulk_user_retirement/__init__.py b/lms/djangoapps/bulk_user_retirement/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lms/djangoapps/bulk_user_retirement/tests/__init__.py b/lms/djangoapps/bulk_user_retirement/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lms/djangoapps/bulk_user_retirement/tests/test_views.py b/lms/djangoapps/bulk_user_retirement/tests/test_views.py new file mode 100644 index 0000000000000000000000000000000000000000..07a46c946b0aab6528239613c2ade49cc91b061b --- /dev/null +++ b/lms/djangoapps/bulk_user_retirement/tests/test_views.py @@ -0,0 +1,121 @@ +""" +Test cases for GDPR User Retirement Views +""" +from django.urls import reverse +from rest_framework.test import APIClient, APITestCase +from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus +from common.djangoapps.student.tests.factories import UserFactory + + +class BulkUserRetirementViewTests(APITestCase): + """ + Tests the bulk user retirement api + """ + def setUp(self): + super().setUp() + self.client = APIClient() + self.user1 = UserFactory.create( + username='testuser1', + email='test1@example.com', + password='test1_password', + profile__name="Test User1" + ) + self.client.login(username=self.user1.username, password='test1_password') + self.user2 = UserFactory.create( + username='testuser2', + email='test2@example.com', + password='test2_password', + profile__name="Test User2" + ) + self.client.login(username=self.user2.username, password='test2_password') + self.user3 = UserFactory.create( + username='testuser3', + email='test3@example.com', + password='test3_password', + profile__name="Test User3" + ) + self.user4 = UserFactory.create( + username='testuser4', + email='test4@example.com', + password='test4_password', + profile__name="Test User4" + ) + RetirementState.objects.create( + state_name='PENDING', + state_execution_order=1, + is_dead_end_state=False, + required=True + ) + self.pending_state = RetirementState.objects.get(state_name='PENDING') + self.client.force_authenticate(user=self.user1) + + def test_gdpr_user_retirement_api(self): + user_retirement_url = reverse('bulk_retirement_api') + expected_response = { + 'successful_user_retirements': [self.user2.username], + 'failed_user_retirements': [] + } + with self.settings(RETIREMENT_SERVICE_WORKER_USERNAME=self.user1.username): + response = self.client.post(user_retirement_url, {"usernames": self.user2.username}) + assert response.status_code == 200 + assert response.data == expected_response + + retirement_status = UserRetirementStatus.objects.get(user__username=self.user2.username) + assert retirement_status.current_state == self.pending_state + + def test_retirement_for_non_existing_users(self): + user_retirement_url = reverse('bulk_retirement_api') + expected_response = { + 'successful_user_retirements': [], + 'failed_user_retirements': ["non_existing_user"] + } + with self.settings(RETIREMENT_SERVICE_WORKER_USERNAME=self.user1.username): + response = self.client.post(user_retirement_url, {"usernames": "non_existing_user"}) + assert response.status_code == 200 + assert response.data == expected_response + + def test_retirement_for_multiple_users(self): + user_retirement_url = reverse('bulk_retirement_api') + expected_response = { + 'successful_user_retirements': [self.user3.username, self.user4.username], + 'failed_user_retirements': [] + } + with self.settings(RETIREMENT_SERVICE_WORKER_USERNAME=self.user1.username): + response = self.client.post(user_retirement_url, { + "usernames": '{user1},{user2}'.format(user1=self.user3.username, user2=self.user4.username) + }) + assert response.status_code == 200 + assert response.data == expected_response + + retirement_status_1 = UserRetirementStatus.objects.get(user__username=self.user3.username) + assert retirement_status_1.current_state == self.pending_state + + retirement_status_2 = UserRetirementStatus.objects.get(user__username=self.user4.username) + assert retirement_status_2.current_state == self.pending_state + + def test_retirement_for_multiple_users_with_some_nonexisting_users(self): + user_retirement_url = reverse('bulk_retirement_api') + expected_response = { + 'successful_user_retirements': [self.user3.username, self.user4.username], + 'failed_user_retirements': ['non_existing_user'] + } + with self.settings(RETIREMENT_SERVICE_WORKER_USERNAME=self.user1.username): + response = self.client.post(user_retirement_url, { + "usernames": '{user1},{user2}, non_existing_user'.format( + user1=self.user3.username, + user2=self.user4.username + ) + }) + assert response.status_code == 200 + assert response.data == expected_response + + retirement_status_1 = UserRetirementStatus.objects.get(user__username=self.user3.username) + assert retirement_status_1.current_state == self.pending_state + + retirement_status_2 = UserRetirementStatus.objects.get(user__username=self.user4.username) + assert retirement_status_2.current_state == self.pending_state + + def test_retirement_for_unauthorized_users(self): + user_retirement_url = reverse('bulk_retirement_api') + response = self.client.post(user_retirement_url, {"usernames": self.user2.username}) + assert response.status_code == 403 diff --git a/lms/djangoapps/bulk_user_retirement/urls.py b/lms/djangoapps/bulk_user_retirement/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..49098fb3b1a73fc7ca3e38a7a25760782074548a --- /dev/null +++ b/lms/djangoapps/bulk_user_retirement/urls.py @@ -0,0 +1,16 @@ +""" +Defines the URL route for this app. +""" + +from django.conf.urls import url + +from .views import BulkUsersRetirementView + + +urlpatterns = [ + url( + r'v1/accounts/bulk_retire_users$', + BulkUsersRetirementView.as_view(), + name='bulk_retirement_api' + ), +] diff --git a/lms/djangoapps/bulk_user_retirement/views.py b/lms/djangoapps/bulk_user_retirement/views.py new file mode 100644 index 0000000000000000000000000000000000000000..d90f01e5f171bc50639a5fc19341595a5c9bde7e --- /dev/null +++ b/lms/djangoapps/bulk_user_retirement/views.py @@ -0,0 +1,77 @@ +""" +An API for retiring user accounts. +""" +import logging + +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from django.contrib.auth import get_user_model +from django.db import transaction +from rest_framework import permissions, status +from rest_framework.response import Response +from rest_framework.views import APIView +from openedx.core.djangoapps.user_api.accounts.permissions import CanRetireUser +from openedx.core.djangoapps.user_api.accounts.utils import create_retirement_request_and_deactivate_account + +log = logging.getLogger(__name__) + + +class BulkUsersRetirementView(APIView): + """ + **Use Case** + + Implementation for Bulk User Retirement API. Creates a retirement request + for one or more users. + + **Example Request** + + POST /v1/accounts/bulk_retire_users { + "usernames": "test_user1, test_user2" + } + + **POST Parameters** + + A POST request can include the following parameter. + + * usernames: Comma separated strings of usernames that should be retired. + """ + authentication_classes = (JwtAuthentication, ) + permission_classes = (permissions.IsAuthenticated, CanRetireUser) + + def post(self, request, **kwargs): # pylint: disable=unused-argument + """ + Initiates the bulk retirement process for the given users. + """ + request_usernames = request.data.get('usernames') + + if request_usernames: + usernames_to_retire = [each_username.strip() for each_username in request_usernames.split(',')] + else: + usernames_to_retire = [] + + User = get_user_model() + + successful_user_retirements, failed_user_retirements = [], [] + + for username in usernames_to_retire: + try: + user_to_retire = User.objects.get(username=username) + with transaction.atomic(): + create_retirement_request_and_deactivate_account(user_to_retire) + + except User.DoesNotExist: + log.exception('The user "{}" does not exist.'.format(username)) + failed_user_retirements.append(username) + + except Exception as exc: # pylint: disable=broad-except + log.exception('500 error retiring account {}'.format(exc)) + failed_user_retirements.append(username) + + successful_user_retirements = list(set(usernames_to_retire).difference(failed_user_retirements)) + + return Response( + status=status.HTTP_200_OK, + data={ + "successful_user_retirements": successful_user_retirements, + "failed_user_retirements": failed_user_retirements + } + ) diff --git a/lms/envs/common.py b/lms/envs/common.py index 42ae81cbab79a8ddf7b700b10e7072c05ccf5f6a..faf00d0ac181fb5d642d90126c8c32e75c793391 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -905,6 +905,18 @@ FEATURES = { # .. toggle_creation_date: 2021-01-27 # .. toggle_tickets: https://openedx.atlassian.net/browse/ENT-4022 'ALLOW_ADMIN_ENTERPRISE_COURSE_ENROLLMENT_DELETION': False, + + # .. toggle_name: FEATURES['ENABLE_BULK_USER_RETIREMENT'] + # .. toggle_implementation: DjangoSetting + # .. toggle_default: False + # .. toggle_description: Set to True to enable bulk user retirement through REST API. This is disabled by + # default. + # .. toggle_use_cases: open_edx + # .. toggle_creation_date: 2021-03-11 + # .. toggle_target_removal_date: None + # .. toggle_warnings: None + # .. toggle_tickets: 'https://openedx.atlassian.net/browse/OSPR-5290' + 'ENABLE_BULK_USER_RETIREMENT': False, } # Specifies extra XBlock fields that should available when requested via the Course Blocks API @@ -3069,6 +3081,9 @@ INSTALLED_APPS = [ # Database-backed Organizations App (http://github.com/edx/edx-organizations) 'organizations', + # Bulk User Retirement + 'lms.djangoapps.bulk_user_retirement', + # management of user-triggered async tasks (course import/export, etc.) # This is only used by Studio, but is being added here because the # app-permissions script that assigns users to Django admin roles only runs diff --git a/lms/envs/test.py b/lms/envs/test.py index 23a00d4b071f3337d74911bccf87cbca297b7609..930dae6f3e1e47d75268b7c60871dd94bc0ea1ec 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -81,6 +81,8 @@ FEATURES['ENABLE_ENROLLMENT_TRACK_USER_PARTITION'] = True FEATURES['ENABLE_BULK_ENROLLMENT_VIEW'] = True +FEATURES['ENABLE_BULK_USER_RETIREMENT'] = True + DEFAULT_MOBILE_AVAILABLE = True # Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it. diff --git a/lms/urls.py b/lms/urls.py index d0bc9f53576d8847fcf9fe4d1f98563acb418a63..88c7581e2f623ac768de824d10513e87fdde7c39 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -998,3 +998,9 @@ urlpatterns += [ urlpatterns += [ url(r'^api/course_experience/', include('openedx.features.course_experience.api.v1.urls')), ] + +# Bulk User Retirement API urls +if settings.FEATURES.get('ENABLE_BULK_USER_RETIREMENT'): + urlpatterns += [ + url(r'', include('lms.djangoapps.bulk_user_retirement.urls')), + ] diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py index ac821988c579e5da203cead18b04f9cd611fde5c..5351b035bbe4ba53ad968b75f79ac91f689d6523 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py @@ -197,7 +197,7 @@ class TestDeactivateLogout(RetirementTestCase): def build_post(self, password): return {'password': password} - @mock.patch('openedx.core.djangoapps.user_api.accounts.views.retire_dot_oauth2_models') + @mock.patch('openedx.core.djangoapps.user_api.accounts.utils.retire_dot_oauth2_models') def test_user_can_deactivate_self(self, mock_retire_dot): """ Verify a user calling the deactivation endpoint logs out the user, deletes all their SSO tokens, diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index 717e4e4c2c86456049530054b2dec02cdf1999ff..7ffd3f473496c5cf184f37f588294555499478ab 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -11,14 +11,19 @@ from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH from completion.models import BlockCompletion from django.conf import settings from django.utils.translation import ugettext as _ +from social_django.models import UserSocialAuth from common.djangoapps.third_party_auth.config.waffle import ENABLE_MULTIPLE_SSO_ACCOUNTS_ASSOCIATION_TO_SAML_USER +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 openedx.core.djangoapps.site_configuration.models import SiteConfiguration from openedx.core.djangoapps.theming.helpers import get_config_value_from_site_or_settings, get_current_site from openedx.core.djangoapps.user_api.config.waffle import ENABLE_MULTIPLE_USER_ENTERPRISES_FEATURE from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError +from ..models import UserRetirementStatus + ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH = 'enable_secondary_email_feature' @@ -206,3 +211,28 @@ def is_multiple_sso_accounts_association_to_saml_user_enabled(): Boolean value representing switch status """ return ENABLE_MULTIPLE_SSO_ACCOUNTS_ASSOCIATION_TO_SAML_USER.is_enabled() + + +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) + + # Unlink LMS social auth accounts + 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() + user.save() + + # TODO: Unlink social accounts & change password on each IDA. + # Remove the activation keys sent by email to the user for account activation. + Registration.objects.filter(user=user).delete() + + # Delete OAuth tokens associated with the user. + retire_dot_oauth2_models(user) + AccountRecovery.retire_recovery_email(user.id) diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 45963bfc5d984647e20d588e8dfe57749563f3fa..371b82c34290bdd1482163b4ab2b6836fc09f796 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -34,7 +34,6 @@ from rest_framework.response import Response from rest_framework.serializers import ValidationError from rest_framework.views import APIView from rest_framework.viewsets import ViewSet -from social_django.models import UserSocialAuth from wiki.models import ArticleRevision from wiki.models.pluginbase import RevisionPluginRevision @@ -48,7 +47,6 @@ from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY from openedx.core.djangoapps.profile_images.images import remove_profile_images from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError -from openedx.core.djangolib.oauth2_retirement_utils import retire_dot_oauth2_models from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser from openedx.core.lib.api.parsers import MergePatchParser from common.djangoapps.student.models import ( # lint-amnesty, pylint: disable=unused-import @@ -81,6 +79,7 @@ from .api import get_account_settings, update_account_settings from .permissions import CanDeactivateUser, CanReplaceUsername, CanRetireUser from .serializers import UserRetirementPartnerReportSerializer, UserRetirementStatusSerializer from .signals import USER_RETIRE_LMS_CRITICAL, USER_RETIRE_LMS_MISC, USER_RETIRE_MAILINGS +from .utils import create_retirement_request_and_deactivate_account try: from coaching.api import has_ever_consented_to_coaching @@ -426,23 +425,8 @@ class DeactivateLogoutView(APIView): if verify_user_password_response.status_code != status.HTTP_204_NO_CONTENT: return verify_user_password_response with transaction.atomic(): - # Add user to retirement queue. - UserRetirementStatus.create_retirement(request.user) - # Unlink LMS social auth accounts - UserSocialAuth.objects.filter(user_id=request.user.id).delete() - # Change LMS password & email user_email = request.user.email - request.user.email = get_retired_email_by_email(request.user.email) - request.user.save() - _set_unusable_password(request.user) - - # TODO: Unlink social accounts & change password on each IDA. - # Remove the activation keys sent by email to the user for account activation. - Registration.objects.filter(user=request.user).delete() - - # Delete OAuth tokens associated with the user. - retire_dot_oauth2_models(request.user) - AccountRecovery.retire_recovery_email(request.user.id) + create_retirement_request_and_deactivate_account(request.user) try: # Send notification email to user