diff --git a/common/djangoapps/third_party_auth/api/serializers.py b/common/djangoapps/third_party_auth/api/serializers.py index 3e8513de73..a510cbe07a 100644 --- a/common/djangoapps/third_party_auth/api/serializers.py +++ b/common/djangoapps/third_party_auth/api/serializers.py @@ -20,4 +20,7 @@ class UserMappingSerializer(serializers.Serializer): # pylint: disable=abstract def get_remote_id(self, social_user): """ Gets remote id from social user based on provider """ + remote_id_field_name = self.context.get('remote_id_field_name', None) + if remote_id_field_name: + return self.provider.get_remote_id_from_field_name(social_user, remote_id_field_name) return self.provider.get_remote_id_from_social_auth(social_user) diff --git a/common/djangoapps/third_party_auth/api/tests/test_views.py b/common/djangoapps/third_party_auth/api/tests/test_views.py index f7834001d6..61740268db 100644 --- a/common/djangoapps/third_party_auth/api/tests/test_views.py +++ b/common/djangoapps/third_party_auth/api/tests/test_views.py @@ -38,8 +38,10 @@ LINKED_USERS = (ALICE_USERNAME, STAFF_USERNAME, ADMIN_USERNAME) PASSWORD = "edx" -def get_mapping_data_by_usernames(usernames): +def get_mapping_data_by_usernames(usernames, remote_id_field_name=False): """ Generate mapping data used in response """ + if remote_id_field_name: + return [{'username': username, 'remote_id': 'external_' + username} for username in usernames] return [{'username': username, 'remote_id': 'remote_' + username} for username in usernames] @@ -76,11 +78,13 @@ class TpaAPITestCase(ThirdPartyAuthTestMixin, APITestCase): provider=google.backend_name, uid=f'{username}@gmail.com', ) - UserSocialAuth.objects.create( + usa = UserSocialAuth.objects.create( user=user, provider=testshib.backend_name, uid=f'{testshib.slug}:remote_{username}', ) + usa.set_extra_data({'external_user_id': f'external_{username}'}) + usa.refresh_from_db() # Create another user not linked to any providers: UserFactory.create(username=CARL_USERNAME, email=f'{CARL_USERNAME}@example.com', password=PASSWORD) @@ -304,12 +308,20 @@ class UserMappingViewAPITests(TpaAPITestCase): @ddt.data( ({'username': [ALICE_USERNAME, STAFF_USERNAME]}, 200, get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME])), + ({'username': [ALICE_USERNAME, STAFF_USERNAME], 'remote_id_field_name': 'external_user_id'}, 200, + get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME], remote_id_field_name=True)), ({'remote_id': ['remote_' + ALICE_USERNAME, 'remote_' + STAFF_USERNAME, 'remote_' + CARL_USERNAME]}, 200, get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME])), + ({'remote_id': ['remote_' + ALICE_USERNAME, 'remote_' + STAFF_USERNAME, 'remote_' + CARL_USERNAME], + 'remote_id_field_name': 'external_user_id'}, 200, + get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME], remote_id_field_name=True)), ({'username': [ALICE_USERNAME, CARL_USERNAME, STAFF_USERNAME]}, 200, get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME])), ({'username': [ALICE_USERNAME], 'remote_id': ['remote_' + STAFF_USERNAME]}, 200, get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME])), + ({'username': [ALICE_USERNAME], 'remote_id': ['remote_' + STAFF_USERNAME], + 'remote_id_field_name': 'external_user_id'}, 200, + get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME], remote_id_field_name=True)), ) @ddt.unpack def test_user_mappings_with_query_params_comma_separated(self, query_params, expect_code, expect_data): @@ -321,6 +333,8 @@ class UserMappingViewAPITests(TpaAPITestCase): for attr in ['username', 'remote_id']: if attr in query_params: params.append('{}={}'.format(attr, ','.join(query_params[attr]))) + if 'remote_id_field_name' in query_params: + params.append('remote_id_field_name={}'.format(query_params['remote_id_field_name'])) url = "{}?{}".format(base_url, '&'.join(params)) response = self.client.get(url, HTTP_X_EDX_API_KEY=VALID_API_KEY) self._verify_response(response, expect_code, expect_data) @@ -328,12 +342,20 @@ class UserMappingViewAPITests(TpaAPITestCase): @ddt.data( ({'username': [ALICE_USERNAME, STAFF_USERNAME]}, 200, get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME])), + ({'username': [ALICE_USERNAME, STAFF_USERNAME], 'remote_id_field_name': 'external_user_id'}, 200, + get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME], remote_id_field_name=True)), ({'remote_id': ['remote_' + ALICE_USERNAME, 'remote_' + STAFF_USERNAME, 'remote_' + CARL_USERNAME]}, 200, get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME])), + ({'remote_id': ['remote_' + ALICE_USERNAME, 'remote_' + STAFF_USERNAME, 'remote_' + CARL_USERNAME], + 'remote_id_field_name': 'external_user_id'}, 200, + get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME], remote_id_field_name=True)), ({'username': [ALICE_USERNAME, CARL_USERNAME, STAFF_USERNAME]}, 200, get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME])), ({'username': [ALICE_USERNAME], 'remote_id': ['remote_' + STAFF_USERNAME]}, 200, get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME])), + ({'username': [ALICE_USERNAME], 'remote_id': ['remote_' + STAFF_USERNAME], + 'remote_id_field_name': 'external_user_id'}, 200, + get_mapping_data_by_usernames([ALICE_USERNAME, STAFF_USERNAME], remote_id_field_name=True)), ) @ddt.unpack def test_user_mappings_with_query_params_multi_value_key(self, query_params, expect_code, expect_data): @@ -345,6 +367,8 @@ class UserMappingViewAPITests(TpaAPITestCase): for attr in ['username', 'remote_id']: if attr in query_params: params.setlist(attr, query_params[attr]) + if 'remote_id_field_name' in query_params: + params['remote_id_field_name'] = query_params['remote_id_field_name'] url = f"{base_url}?{params.urlencode()}" response = self.client.get(url, HTTP_X_EDX_API_KEY=VALID_API_KEY) self._verify_response(response, expect_code, expect_data) diff --git a/common/djangoapps/third_party_auth/api/views.py b/common/djangoapps/third_party_auth/api/views.py index c2b8b0dd6f..89d55e2eec 100644 --- a/common/djangoapps/third_party_auth/api/views.py +++ b/common/djangoapps/third_party_auth/api/views.py @@ -323,6 +323,9 @@ class UserMappingView(ListAPIView): GET /api/third_party_auth/v0/providers/{provider_id}/users?username={username1},{username2} + GET /api/third_party_auth/v0/providers/{provider_id}/users?username={username1}& + remote_id_field_name={external_id_field_name} + GET /api/third_party_auth/v0/providers/{provider_id}/users?username={username1}&usernames={username2} GET /api/third_party_auth/v0/providers/{provider_id}/users?remote_id={remote_id1},{remote_id2} @@ -346,6 +349,9 @@ class UserMappingView(ListAPIView): * usernames: Optional. List of comma separated edX usernames to filter the result set. e.g. ?usernames=bob123,jane456 + * remote_id_field_name: Optional. The field name to use for the remote id lookup. + Useful when learners are coming from external LMS. e.g. ?remote_id_field_name=ext_userid_sf + * page, page_size: Optional. Used for paging the result set, especially when getting an unfiltered list. @@ -415,6 +421,7 @@ class UserMappingView(ListAPIView): remove idp_slug from the remote_id if there is any """ context = super().get_serializer_context() + context['remote_id_field_name'] = self.request.query_params.get('remote_id_field_name', None) context['provider'] = self.provider return context diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py index 98877ddb75..ceab0fa8c7 100644 --- a/common/djangoapps/third_party_auth/models.py +++ b/common/djangoapps/third_party_auth/models.py @@ -810,6 +810,17 @@ class SAMLProviderConfig(ProviderConfig): prefix = self.slug + ":" return self.backend_name == social_auth.provider and social_auth.uid.startswith(prefix) + def get_remote_id_from_field_name(self, social_auth, field_name): + """ Given a UserSocialAuth object, return the user remote ID against the field name provided. """ + if not self.match_social_auth(social_auth): + raise ValueError( + f"UserSocialAuth record does not match given provider {self.provider_id}" + ) + field_value = social_auth.extra_data.get(field_name, None) + if field_value and isinstance(field_value, list): + return field_value[0] + return field_value + def get_remote_id_from_social_auth(self, social_auth): """ Given a UserSocialAuth object, return the remote ID used by this provider. """ assert self.match_social_auth(social_auth)