From d7a60fd21bd8e4fb3b678a2bc0acb556e11555e0 Mon Sep 17 00:00:00 2001 From: ansabgillani Date: Tue, 17 May 2022 13:30:23 +0500 Subject: [PATCH] feat: add SSO History to support --- .../decisions/0001-sso-history-in-support.rst | 48 +++++++++++++++++++ .../support/migrations/0001_initial.py | 42 ++++++++++++++++ lms/djangoapps/support/migrations/__init__.py | 0 lms/djangoapps/support/models.py | 9 ++++ lms/djangoapps/support/serializers.py | 38 +++++++++++---- lms/djangoapps/support/tests/test_views.py | 18 +++++++ lms/djangoapps/support/views/sso_records.py | 25 +++++++++- 7 files changed, 169 insertions(+), 11 deletions(-) create mode 100644 lms/djangoapps/support/docs/decisions/0001-sso-history-in-support.rst create mode 100644 lms/djangoapps/support/migrations/0001_initial.py create mode 100644 lms/djangoapps/support/migrations/__init__.py create mode 100644 lms/djangoapps/support/models.py diff --git a/lms/djangoapps/support/docs/decisions/0001-sso-history-in-support.rst b/lms/djangoapps/support/docs/decisions/0001-sso-history-in-support.rst new file mode 100644 index 0000000000..332f68af07 --- /dev/null +++ b/lms/djangoapps/support/docs/decisions/0001-sso-history-in-support.rst @@ -0,0 +1,48 @@ +1. Registering SSO History Model in Support +================================================ + +Status +------ + +Accepted + +Context +------- + +SSO History was one of the feature requested by support that provides the +historical data of any particular SSO record (based on UserSocialAuth) +in tools UI via support API. This SSO History can be utilized to track id changes, +Additional data or any other relevant data changes inside SSO model. + +Although the UserSocialAuth is applied within common apps but is not configured for +cms. This has caused major breakage and a temporary outage in the authentication flow +in cms stage. + +Decision +-------- + +The simple_django_history registration for UserSocialAuth model +is introduced in the Support app for LMS instead of the +third_party_auth in Common. + +Consequences +------------ + +The most optimum method to introduce the feature was to register the model +in support app and get the data via support API. + +Alternative/Rejected Approaches +------------ + +Addition for third_party_auth was attempted for the studio, +but failed in the stage. The primary reason was the failing migration +tests in CMS with the current configurations in the studio. +We tried to add the third_party_auth as an installed app on Studio +but later found out that Studio is not configured for third_party_auth +and configuring third_party_auth on studio would have caused auth issues. +Consequently, there was over 6 hours of pipeline outage on stage +and we had to revert the changes made in the system. + +Third party auth is primarily LMS-only app but since it has been in common, +we opted to go ahead with adding history in common, +only to later realize the impact of enabling third party auth in studio. diff --git a/lms/djangoapps/support/migrations/0001_initial.py b/lms/djangoapps/support/migrations/0001_initial.py new file mode 100644 index 0000000000..302155a6a3 --- /dev/null +++ b/lms/djangoapps/support/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 3.2.13 on 2022-05-17 08:26 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import simple_history.models +import social_django.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalUserSocialAuth', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('provider', models.CharField(max_length=32)), + ('uid', models.CharField(db_index=True, max_length=255)), + ('extra_data', social_django.fields.JSONField(default=dict)), + ('created', models.DateTimeField(blank=True, editable=False)), + ('modified', models.DateTimeField(blank=True, editable=False)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical user social auth', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/lms/djangoapps/support/migrations/__init__.py b/lms/djangoapps/support/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/support/models.py b/lms/djangoapps/support/models.py new file mode 100644 index 0000000000..3f2693f918 --- /dev/null +++ b/lms/djangoapps/support/models.py @@ -0,0 +1,9 @@ +""" +Models used to implement support related models in such as SSO History model +""" + +from simple_history import register +from social_django.models import UserSocialAuth + +# Registers UserSocialAuth with simple-django-history. +register(UserSocialAuth, app=__package__) diff --git a/lms/djangoapps/support/serializers.py b/lms/djangoapps/support/serializers.py index 0d4daa83e9..f1a28640ca 100644 --- a/lms/djangoapps/support/serializers.py +++ b/lms/djangoapps/support/serializers.py @@ -84,17 +84,35 @@ def serialize_user_info(user, user_social_auths=None): return user_info -def serialize_sso_records(user_social_auths): +def serialize_sso_records(user_social_auth, user_social_auths_history): """ Serialize user social auth model object """ - sso_records = [] - for user_social_auth in user_social_auths: - sso_records.append({ - 'provider': user_social_auth.provider, - 'uid': user_social_auth.uid, - 'created': user_social_auth.created, - 'modified': user_social_auth.modified, - 'extraData': json.dumps(user_social_auth.extra_data), - }) + sso_records = { + 'provider': user_social_auth.provider, + 'uid': user_social_auth.uid, + 'created': user_social_auth.created, + 'modified': user_social_auth.modified, + 'history': serialize_sso_history( + user_social_auths_history + ), + 'extraData': json.dumps(user_social_auth.extra_data), + } return sso_records + + +def serialize_sso_history(user_social_auths_history): + """ + Serialize history for user social auth model object + """ + history = [] + for sso_history in user_social_auths_history: + history.append({ + 'uid': sso_history.uid, + 'provider': sso_history.provider, + 'created': sso_history.created, + 'modified': sso_history.modified, + 'extraData': json.dumps(sso_history.extra_data), + 'history_date': sso_history.history_date + }) + return history diff --git a/lms/djangoapps/support/tests/test_views.py b/lms/djangoapps/support/tests/test_views.py index a6b8e43947..3aa56a0a76 100644 --- a/lms/djangoapps/support/tests/test_views.py +++ b/lms/djangoapps/support/tests/test_views.py @@ -1542,6 +1542,24 @@ class SsoRecordsTests(SupportViewTestCase): # lint-amnesty, pylint: disable=mis assert len(data) == 1 self.assertContains(response, '"uid": "test@example.com"') + def test_history_response(self): + '''Tests changes in SSO history for a user''' + user_social_auth = UserSocialAuth.objects.create( # lint-amnesty, pylint: disable=unused-variable + user=self.student, + uid=self.student.email, + provider='tpa-saml' + ) + sso = UserSocialAuth.objects.get(user=self.student) + sso.uid = self.student.email + ':' + sso.provider + sso.save() + response = self.client.get(self.url) + data = json.loads(response.content.decode('utf-8')) + assert response.status_code == 200 + assert len(data) == 1 + assert len(data[0].get('history')) == 2 + assert data[0].get('history')[0].get('uid') == "test@example.com:tpa-saml" + assert data[0].get('history')[1].get('uid') == "test@example.com" + class FeatureBasedEnrollmentSupportApiViewTests(SupportViewTestCase): """ diff --git a/lms/djangoapps/support/views/sso_records.py b/lms/djangoapps/support/views/sso_records.py index 372a4f4c53..b7d620a376 100644 --- a/lms/djangoapps/support/views/sso_records.py +++ b/lms/djangoapps/support/views/sso_records.py @@ -19,6 +19,7 @@ class SsoView(GenericAPIView): """ Returns a list of SSO records for a given user. Sample response: + Sample response: [ { "provider": "tpa-saml", @@ -26,6 +27,25 @@ class SsoView(GenericAPIView): "created": "2022-03-02T04:41:33.145Z", "modified": "2022-03-15T11:28:17.809Z", "extraData": "{}", + "history": + [ + { + "uid": "new-channel:testuser", + "provider": "tpa-saml", + "created": "2022-03-02T04:41:33.145Z", + "modified": "2022-03-15T11:28:17.809Z", + "extraData": "{}", + "history_date": "2022-03-15T11:28:17.832Z" + }, + { + "uid": "default-channel:testuser", + "provider": "tpa-saml", + "created": "2022-03-02T04:41:33.145Z", + "modified": "2022-03-10T12:28:32.720Z", + "extraData": "{}", + "history_date": "2022-03-15T11:12:02.420Z" + } + ] } ] """ @@ -36,5 +56,8 @@ class SsoView(GenericAPIView): except User.DoesNotExist: return JsonResponse([]) user_social_auths = UserSocialAuth.objects.filter(user=user) - sso_records = serialize_sso_records(user_social_auths) + sso_records = [] + for user_social_auth in user_social_auths: + user_social_auths_history = UserSocialAuth.history.filter(id=user_social_auth.id) + sso_records.append(serialize_sso_records(user_social_auth, user_social_auths_history)) return JsonResponse(sso_records)