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.
This commit is contained in:
Matt Tuchfarber
2019-02-21 22:37:07 -05:00
parent 5b38c7e39e
commit 7fc07789ed
3 changed files with 176 additions and 2 deletions

View File

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

View File

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

View File

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