From 86bfcea19c1564f4f65dbbd606c698adb05b2c98 Mon Sep 17 00:00:00 2001 From: Pooja Kulkarni <13742492+pkulkark@users.noreply.github.com> Date: Thu, 8 Apr 2021 23:20:12 +0530 Subject: [PATCH] feat: Django app to allow user retirement via API (#25800) This adds a new django app to allow the GDPR user retirement via Open edX's REST API. Prior to this the only way to trigger the user retirement was either by the user themself clicking "Delete my account" in the account setting page or via creating a User Retirement request by admin. With these changes, the user retirement process can be triggered using REST API. --- .../bulk_user_retirement/__init__.py | 0 .../bulk_user_retirement/tests/__init__.py | 0 .../bulk_user_retirement/tests/test_views.py | 121 ++++++++++++++++++ lms/djangoapps/bulk_user_retirement/urls.py | 16 +++ lms/djangoapps/bulk_user_retirement/views.py | 77 +++++++++++ lms/envs/common.py | 15 +++ lms/envs/test.py | 2 + lms/urls.py | 6 + .../accounts/tests/test_retirement_views.py | 2 +- .../djangoapps/user_api/accounts/utils.py | 30 +++++ .../djangoapps/user_api/accounts/views.py | 20 +-- 11 files changed, 270 insertions(+), 19 deletions(-) create mode 100644 lms/djangoapps/bulk_user_retirement/__init__.py create mode 100644 lms/djangoapps/bulk_user_retirement/tests/__init__.py create mode 100644 lms/djangoapps/bulk_user_retirement/tests/test_views.py create mode 100644 lms/djangoapps/bulk_user_retirement/urls.py create mode 100644 lms/djangoapps/bulk_user_retirement/views.py diff --git a/lms/djangoapps/bulk_user_retirement/__init__.py b/lms/djangoapps/bulk_user_retirement/__init__.py new file mode 100644 index 0000000000..e69de29bb2 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 0000000000..e69de29bb2 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 0000000000..07a46c946b --- /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 0000000000..49098fb3b1 --- /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 0000000000..d90f01e5f1 --- /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 42ae81cbab..faf00d0ac1 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 23a00d4b07..930dae6f3e 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 d0bc9f5357..88c7581e2f 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 ac821988c5..5351b035bb 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 717e4e4c2c..7ffd3f4734 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 45963bfc5d..371b82c342 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