From 90c9103a892977d8371121a873f4aa3981c24e83 Mon Sep 17 00:00:00 2001 From: Zachary Hancock Date: Tue, 6 Aug 2019 15:48:19 -0400 Subject: [PATCH] command to remove social auth users (#21280) remove social auth users command --- .../commands/remove_social_auth_users.py | 64 ++++++++++ .../tests/test_remove_social_auth_users.py | 119 ++++++++++++++++++ lms/envs/common.py | 3 + lms/envs/devstack.py | 3 + 4 files changed, 189 insertions(+) create mode 100644 common/djangoapps/third_party_auth/management/commands/remove_social_auth_users.py create mode 100644 common/djangoapps/third_party_auth/management/commands/tests/test_remove_social_auth_users.py diff --git a/common/djangoapps/third_party_auth/management/commands/remove_social_auth_users.py b/common/djangoapps/third_party_auth/management/commands/remove_social_auth_users.py new file mode 100644 index 0000000000..145a56c2ff --- /dev/null +++ b/common/djangoapps/third_party_auth/management/commands/remove_social_auth_users.py @@ -0,0 +1,64 @@ +""" +Management command to remove social auth users. Intended for use in masters +integration sandboxes to allow partners reset users and enrollment data. +""" +from __future__ import absolute_import + +import logging + +from django.conf import settings +from django.contrib.auth.models import User +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from six.moves import input + +from third_party_auth.models import SAMLProviderConfig + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Management command to remove all social auth entries AND the corresponding edX + users for a given IDP. + + Usage: + manage.py remove_social_auth_users gtx + """ + confirmation_prompt = "Type 'confirm' to continue with deletion\n" + + def add_arguments(self, parser): + parser.add_argument('IDP', help='slug for the idp to remove all users from') + parser.add_argument( + '--force', + action='store_true', + help='Skip manual confirmation step before deleting objects', + ) + + @transaction.atomic + def handle(self, *args, **options): + slug = options['IDP'] + + if not settings.FEATURES.get('ENABLE_ENROLLMENT_RESET'): + raise CommandError('ENABLE_ENROLLMENT_RESET feature not enabled on this enviroment') + + try: + SAMLProviderConfig.objects.current_set().get(slug=slug) + except SAMLProviderConfig.DoesNotExist: + raise CommandError(u'No SAML provider found for slug {}'.format(slug)) + + users = User.objects.filter(social_auth__provider=slug) + user_count = len(users) + count, models = users.delete() + log.info( + u'\n%s users and their related models will be deleted:\n%s\n', + user_count, + models, + ) + + if not options['force']: + confirmation = input(self.confirmation_prompt) + if confirmation != 'confirm': + raise CommandError('User confirmation required. No records have been modified') + + log.info(u'Deleting %s records...', count) diff --git a/common/djangoapps/third_party_auth/management/commands/tests/test_remove_social_auth_users.py b/common/djangoapps/third_party_auth/management/commands/tests/test_remove_social_auth_users.py new file mode 100644 index 0000000000..71ba00683e --- /dev/null +++ b/common/djangoapps/third_party_auth/management/commands/tests/test_remove_social_auth_users.py @@ -0,0 +1,119 @@ +""" +Tests for `remove_social_auth_users` management command +""" +from __future__ import absolute_import + +import sys +import unittest +from contextlib import contextmanager +from StringIO import StringIO +from uuid import uuid4 + +from django.conf import settings +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import TestCase, override_settings +from social_django.models import UserSocialAuth + +from student.models import User +from student.tests.factories import UserFactory +from third_party_auth.management.commands import remove_social_auth_users +from third_party_auth.tests.factories import SAMLProviderConfigFactory + +FEATURES_WITH_ENABLED = settings.FEATURES.copy() +FEATURES_WITH_ENABLED['ENABLE_ENROLLMENT_RESET'] = True + + +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class TestRemoveSocialAuthUsersCommand(TestCase): + """ + Test django management command + """ + @classmethod + def setUpClass(cls): + super(TestRemoveSocialAuthUsersCommand, cls).setUpClass() + cls.command = remove_social_auth_users.Command() + + def setUp(self): + super(TestRemoveSocialAuthUsersCommand, self).setUp() + self.provider_hogwarts = SAMLProviderConfigFactory.create(slug='hogwarts') + self.provider_durmstrang = SAMLProviderConfigFactory.create(slug='durmstrang') + + self.user_fleur = UserFactory(username='fleur') # no social auth + self.user_harry = UserFactory(username='harry') # social auth for Hogwarts + self.user_viktor = UserFactory(username='viktor') # social auth for Durmstrang + + self.create_social_auth_entry(self.user_harry, self.provider_hogwarts) + self.create_social_auth_entry(self.user_viktor, self.provider_durmstrang) + + @contextmanager + def _replace_stdin(self, text): + orig = sys.stdin + sys.stdin = StringIO(text) + yield + sys.stdin = orig + + def create_social_auth_entry(self, user, provider): + external_id = uuid4() + UserSocialAuth.objects.create( + user=user, + uid='{0}:{1}'.format(provider.slug, external_id), + provider=provider.slug, + ) + + def find_user_social_auth_entry(self, username): + UserSocialAuth.objects.get(user__username=username) + + @override_settings(FEATURES=FEATURES_WITH_ENABLED) + def test_remove_users(self): + call_command(self.command, self.provider_hogwarts.slug, force=True) + + # user with input idp is removed, along with social auth entries + with self.assertRaises(User.DoesNotExist): + User.objects.get(username='harry') + with self.assertRaises(UserSocialAuth.DoesNotExist): + self.find_user_social_auth_entry('harry') + + # other users intact + self.user_fleur.refresh_from_db() + self.user_viktor.refresh_from_db() + self.assertIsNotNone(self.user_fleur) + self.assertIsNotNone(self.user_viktor) + + # other social auth intact + self.find_user_social_auth_entry(self.user_viktor.username) + + @override_settings(FEATURES=FEATURES_WITH_ENABLED) + def test_invalid_idp(self): + invalid_slug = 'jedi-academy' + err_string = u'No SAML provider found for slug {}'.format(invalid_slug) + with self.assertRaisesRegexp(CommandError, err_string): + call_command(self.command, invalid_slug) + + @override_settings(FEATURES=FEATURES_WITH_ENABLED) + def test_confirmation_required(self): + """ By default this command will require user input to confirm """ + with self._replace_stdin('confirm'): + call_command(self.command, self.provider_hogwarts.slug) + + with self.assertRaises(User.DoesNotExist): + User.objects.get(username='harry') + with self.assertRaises(UserSocialAuth.DoesNotExist): + self.find_user_social_auth_entry('harry') + + @override_settings(FEATURES=FEATURES_WITH_ENABLED) + def test_confirmation_failure(self): + err_string = 'User confirmation required. No records have been modified' + with self.assertRaisesRegexp(CommandError, err_string): + with self._replace_stdin('no'): + call_command(self.command, self.provider_hogwarts.slug) + + # no users should be removed + self.assertEqual(len(User.objects.all()), 3) + self.assertEqual(len(UserSocialAuth.objects.all()), 2) + + def test_feature_default_disabled(self): + """ By default this command should not be enabled """ + err_string = 'ENABLE_ENROLLMENT_RESET feature not enabled on this enviroment' + with self.assertRaisesRegexp(CommandError, err_string): + call_command(self.command, self.provider_hogwarts.slug, force=True) diff --git a/lms/envs/common.py b/lms/envs/common.py index 629f0668e3..69a963adc1 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -420,6 +420,9 @@ FEATURES = { # Whether to display the account deletion section the account settings page 'ENABLE_ACCOUNT_DELETION': True, + + # Enable feature to remove enrollments and users. Used to reset state of master's integration environments + 'ENABLE_ENROLLMENT_RESET': False, } # Settings for the course reviews tool template and identification key, set either to None to disable course reviews diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 952127230b..da3357239b 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -222,6 +222,9 @@ FEATURES['STORE_BILLING_INFO'] = True FEATURES['ENABLE_PAID_COURSE_REGISTRATION'] = True FEATURES['ENABLE_COSMETIC_DISPLAY_PRICE'] = True +######################### Program Enrollments ##################### +FEATURES['ENABLE_ENROLLMENT_RESET'] = True + ########################## Third Party Auth ####################### if FEATURES.get('ENABLE_THIRD_PARTY_AUTH') and 'third_party_auth.dummy.DummyBackend' not in AUTHENTICATION_BACKENDS: