From 7fc07789ed2dc1a0d276d812e0b49f96f094f8fb Mon Sep 17 00:00:00 2001 From: Matt Tuchfarber Date: Thu, 21 Feb 2019 22:37:07 -0500 Subject: [PATCH] Adds username replacement API New API replaces all copies of username in LMS a desired username. Requires user be in username_replacement_admin group. Should only be ran as a larger job to update usernames across all services, otherwise the system will be left in a broken state for those users. --- .../user_api/accounts/permissions.py | 9 + .../djangoapps/user_api/accounts/views.py | 161 +++++++++++++++++- openedx/core/djangoapps/user_api/urls.py | 8 +- 3 files changed, 176 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/permissions.py b/openedx/core/djangoapps/user_api/accounts/permissions.py index fd7b197f9c..78eb36f271 100644 --- a/openedx/core/djangoapps/user_api/accounts/permissions.py +++ b/openedx/core/djangoapps/user_api/accounts/permissions.py @@ -5,6 +5,7 @@ from __future__ import unicode_literals from rest_framework import permissions +USERNAME_REPLACEMENT_GROUP = "username_replacement_admin" class CanDeactivateUser(permissions.BasePermission): """ @@ -23,3 +24,11 @@ class CanRetireUser(permissions.BasePermission): """ def has_permission(self, request, view): return request.user.has_perm('accounts.can_retire_user') + +class CanReplaceUsername(permissions.BasePermission): + """ + Grants access to the Username Replacement API for anyone in the group, + including the service user. + """ + def has_permission(self, request, view): + return request.user.groups.filter(name=USERNAME_REPLACEMENT_GROUP).exists() diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index d3a6a8457a..37866701da 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -7,9 +7,11 @@ https://openedx.atlassian.net/wiki/display/TNL/User+API import datetime import logging from functools import wraps +import uuid import pytz from consent.models import DataSharingConsent +from django.apps import apps from django.conf import settings from django.contrib.auth import authenticate, get_user_model, logout from django.contrib.sites.models import Site @@ -27,6 +29,7 @@ from rest_framework import permissions, status from rest_framework.authentication import SessionAuthentication from rest_framework.parsers import JSONParser 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 six import iteritems, text_type @@ -71,7 +74,7 @@ from ..models import ( UserRetirementStatus ) from .api import get_account_settings, update_account_settings -from .permissions import CanDeactivateUser, CanRetireUser +from .permissions import CanDeactivateUser, CanReplaceUsername, CanRetireUser from .serializers import UserRetirementPartnerReportSerializer, UserRetirementStatusSerializer from .signals import ( USER_RETIRE_LMS_CRITICAL, @@ -1002,3 +1005,159 @@ class AccountRetirementView(ViewSet): """ for entitlement in CourseEntitlement.objects.filter(user_id=user.id): entitlement.courseentitlementsupportdetail_set.all().update(comments='') + +class UsernameReplacementView(APIView): + """ + WARNING: This API is only meant to be used as part of a larger job that + updates usernames across all services. DO NOT run this alone or users will + not match across the system and things will be broken. + + API will recieve a list of current usernames and their requested new + username. If their new username is taken, it will randomly assign a new username. + """ + authentication_classes = (JwtAuthentication, ) + permission_classes = (permissions.IsAuthenticated, CanReplaceUsername) + + def post(self, request): + """ + POST /api/user/v1/accounts/replace_usernames/ + { + "username_mappings": [ + {"current_username_1": "desired_username_1"}, + {"current_username_2": "desired_username_2"} + ] + } + + **POST Parameters** + + A POST request must include the following parameter. + + * username_mappings: Required. A list of objects that map the current username (key) + to the desired username (value) + + **POST Response Values** + + As long as data validation passes, the request will return a 200 with a new mapping + of old usernames (key) to new username (value) + + { + "successful_replacements": [ + {"old_username_1": "new_username_1"} + ], + "failed_replacements": [ + {"old_username_2": "new_username_2"} + ] + } + + TODO: Determine if we need an audit trail outside of logging and API response. + """ + + # (model_name, column_name) + MODELS_WITH_USERNAME = ( + ('auth.user', 'username'), + ('consent.DataSharingConsent', 'username'), + ('consent.HistoricalDataSharingConsent', 'username'), + ('credit.CreditEligibility', 'username'), + ('credit.CreditRequest', 'username'), + ('credit.CreditRequirementStatus', 'username'), + ('user_api.UserRetirementPartnerReportingStatus', 'original_username'), + ('user_api.UserRetirementStatus', 'original_username') + ) + UNIQUE_SUFFIX_LENGTH = getattr(settings, 'SOCIAL_AUTH_UUID_LENGTH', 4) + + username_mappings = request.data.get("username_mappings") + replacement_locations = self._load_models(MODELS_WITH_USERNAME) + + if not self._has_valid_schema(username_mappings): + raise ValidationError("Request data does not match schema") + + successful_replacements, failed_replacements = [], [] + + for username_pair in username_mappings: + current_username = list(username_pair.keys())[0] + desired_username = list(username_pair.values())[0] + new_username = self._generate_unique_username(desired_username, suffix_length=UNIQUE_SUFFIX_LENGTH) + successfully_replaced = self._replace_username_for_all_models( + current_username, + new_username, + replacement_locations + ) + if successfully_replaced: + successful_replacements.append({current_username: new_username}) + else: + failed_replacements.append({current_username: new_username}) + return Response( + status=status.HTTP_200_OK, + data={ + "successful_replacements": successful_replacements, + "failed_replacements": failed_replacements + } + ) + + def _load_models(self, models_with_fields): + """ Takes tuples that contain a model path and returns the list with a loaded version of the model """ + try: + replacement_locations = [(apps.get_model(model), column) for (model, column) in models_with_fields] + except LookupError: + log.exception("Unable to load models for username replacement") + raise + return replacement_locations + + def _has_valid_schema(self, post_data): + """ Verifies the data is a list of objects with a single key:value pair """ + if not isinstance(post_data, list): + return False + for obj in post_data: + if not (isinstance(obj, dict) and len(obj) == 1): + return False + return True + + def _generate_unique_username(self, desired_username, suffix_length=4): + """ Accepts a username and returns a unique username if the requested is taken """ + User = apps.get_model('auth.user') + new_username = desired_username + # Keep checking usernames in case desired_username + random suffix is already taken + while True: + if User.objects.filter(username=new_username).exists(): + unique_suffix = uuid.uuid4().hex[:suffix_length] + new_username = desired_username + unique_suffix + else: + break + return new_username + + def _replace_username_for_all_models(self, current_username, new_username, replacement_locations): + """ + Replaces current_username with new_username for all (model, column) pairs in replacement locations. + Returns if it was successful or not. Will return successful even if no matching + + TODO: Determine if logs of username are a PII issue. + """ + try: + with transaction.atomic(): + num_rows_changed = 0 + for (model, column) in replacement_locations: + num_rows_changed += model.objects.filter( + **{column: current_username} + ).update( + **{column: new_username} + ) + except Exception as exc: + log.exception("Unable to change username from {current} to {new}. Reason: {error}".format( + current=current_username, + new=new_username, + error=exc + )) + return False + if num_rows_changed == 0: + log.warning("Unable to change username from {current} to {new} because {current} doesn't exist.".format( + current=current_username, + new=new_username, + )) + return False + + log.info("Successfully changed username from {current} to {new}.".format( + current=current_username, + new=new_username, + )) + return True + diff --git a/openedx/core/djangoapps/user_api/urls.py b/openedx/core/djangoapps/user_api/urls.py index bdea5668ca..334ffc7e8c 100644 --- a/openedx/core/djangoapps/user_api/urls.py +++ b/openedx/core/djangoapps/user_api/urls.py @@ -13,7 +13,8 @@ from .accounts.views import ( AccountRetirementView, AccountViewSet, DeactivateLogoutView, - LMSAccountRetirementView + LMSAccountRetirementView, + UsernameReplacementView ) from .preferences.views import PreferencesDetailView, PreferencesView from .verification_api.views import IDVerificationStatusView @@ -150,6 +151,11 @@ urlpatterns = [ RETIREMENT_UPDATE, name='accounts_retirement_update' ), + url( + r'^v1/accounts/replace_usernames/$', + UsernameReplacementView.as_view(), + name='username_replacement' + ), url( r'^v1/validation/registration$', RegistrationValidationView.as_view(),