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.
This commit is contained in:
Pooja Kulkarni
2021-04-08 23:20:12 +05:30
committed by GitHub
parent 709b8217e1
commit 86bfcea19c
11 changed files with 270 additions and 19 deletions

View File

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

View File

@@ -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'
),
]

View File

@@ -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
}
)

View File

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

View File

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

View File

@@ -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')),
]

View File

@@ -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,

View File

@@ -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)

View File

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