Skip to content
Merged
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
Empty file.
Empty file.
121 changes: 121 additions & 0 deletions lms/djangoapps/bulk_user_retirement/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions lms/djangoapps/bulk_user_retirement/urls.py
Original file line number Diff line number Diff line change
@@ -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'
),
]
77 changes: 77 additions & 0 deletions lms/djangoapps/bulk_user_retirement/views.py
Original file line number Diff line number Diff line change
@@ -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
}
)
15 changes: 15 additions & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -916,6 +916,18 @@
# .. 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
Expand Down Expand Up @@ -3083,6 +3095,9 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
# Database-backed Organizations App (https://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
Expand Down
2 changes: 2 additions & 0 deletions lms/envs/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@

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.
Expand Down
6 changes: 6 additions & 0 deletions lms/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -1004,3 +1004,9 @@
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')),
]
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ def setUp(self):
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,
Expand Down
30 changes: 30 additions & 0 deletions openedx/core/djangoapps/user_api/accounts/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,19 @@
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'


Expand Down Expand Up @@ -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)
20 changes: 2 additions & 18 deletions openedx/core/djangoapps/user_api/accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
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

Expand All @@ -48,7 +47,6 @@
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
Expand Down Expand Up @@ -81,6 +79,7 @@
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
Expand Down Expand Up @@ -426,23 +425,8 @@ def post(self, request):
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
Expand Down