diff --git a/common/djangoapps/third_party_auth/migrations/0023_auto_20190418_2033.py b/common/djangoapps/third_party_auth/migrations/0023_auto_20190418_2033.py new file mode 100644 index 0000000000..b46da4eeac --- /dev/null +++ b/common/djangoapps/third_party_auth/migrations/0023_auto_20190418_2033.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.20 on 2019-04-18 20:33 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0006_auto_20171207_0259'), + ('third_party_auth', '0022_auto_20181012_0307'), + ] + + operations = [ + migrations.AddField( + model_name='ltiproviderconfig', + name='organization', + field=models.OneToOneField(blank=True, help_text="optional. If this provider is an Organization, this attribute can be used reference users in that Organization", null=True, on_delete=django.db.models.deletion.CASCADE, to='organizations.Organization'), + ), + migrations.AddField( + model_name='oauth2providerconfig', + name='organization', + field=models.OneToOneField(blank=True, help_text="optional. If this provider is an Organization, this attribute can be used reference users in that Organization", null=True, on_delete=django.db.models.deletion.CASCADE, to='organizations.Organization'), + ), + migrations.AddField( + model_name='samlproviderconfig', + name='organization', + field=models.OneToOneField(blank=True, help_text="optional. If this provider is an Organization, this attribute can be used reference users in that Organization", null=True, on_delete=django.db.models.deletion.CASCADE, to='organizations.Organization'), + ), + ] diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py index 132c6b8787..5c2ce28562 100644 --- a/common/djangoapps/third_party_auth/models.py +++ b/common/djangoapps/third_party_auth/models.py @@ -16,6 +16,7 @@ from django.core.exceptions import ValidationError from django.db import models from django.utils import timezone from django.utils.translation import ugettext_lazy as _ +from organizations.models import Organization from provider.oauth2.models import Client from provider.utils import long_token from social_core.backends.base import BaseAuth @@ -125,6 +126,16 @@ class ProviderConfig(ConfigurationModel): 'in a separate list of "Institution" login providers.' ), ) + organization = models.OneToOneField( + Organization, + blank=True, + null=True, + on_delete=models.CASCADE, + help_text=_( + 'optional. If this provider is an Organization, this attribute ' + 'can be used reference users in that Organization' + ) + ) site = models.ForeignKey( Site, default=settings.SITE_ID, diff --git a/common/djangoapps/third_party_auth/tests/test_admin.py b/common/djangoapps/third_party_auth/tests/test_admin.py index 0bc674328e..e80ec30428 100644 --- a/common/djangoapps/third_party_auth/tests/test_admin.py +++ b/common/djangoapps/third_party_auth/tests/test_admin.py @@ -64,9 +64,10 @@ class Oauth2ProviderConfigAdminTest(testutil.TestCase): # Remove the icon_image from the POST data, to simulate unchanged icon_image post_data = models.model_to_dict(provider1) del post_data['icon_image'] - # Remove max_session_length; it has a default null value which must be POSTed + # Remove max_session_length and organization. A default null value must be POSTed # back as an absent value, rather than as a "null-like" included value. del post_data['max_session_length'] + del post_data['organization'] # Change the name, to verify POST post_data['name'] = 'Another name' diff --git a/lms/djangoapps/program_enrollments/tests/test_utils.py b/lms/djangoapps/program_enrollments/tests/test_utils.py new file mode 100644 index 0000000000..706e2d14a5 --- /dev/null +++ b/lms/djangoapps/program_enrollments/tests/test_utils.py @@ -0,0 +1,123 @@ +""" +Unit tests for program_enrollments utils. +""" +from uuid import uuid4 +import pytest +from django.core.cache import cache + +from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL +from openedx.core.djangoapps.catalog.tests.factories import ( + OrganizationFactory as CatalogOrganizationFactory, ProgramFactory +) +from openedx.core.djangolib.testing.utils import CacheIsolationTestCase +from organizations.tests.factories import OrganizationFactory +from program_enrollments.utils import ( + get_user_by_program_id, ProgramDoesNotExistException, OrganizationDoesNotExistException, + ProviderDoesNotExistException +) +from social_django.models import UserSocialAuth +from student.tests.factories import UserFactory +from third_party_auth.tests.factories import SAMLProviderConfigFactory + + +class GetPlatformUserTests(CacheIsolationTestCase): + """ + Tests for the get_platform_user function + """ + ENABLED_CACHES = ['default'] + + def setUp(self): + super(GetPlatformUserTests, self).setUp() + self.program_uuid = uuid4() + self.organization_key = 'ufo' + self.external_user_id = '1234' + self.user = UserFactory.create() + self.setup_catalog_cache(self.program_uuid, self.organization_key) + + def setup_catalog_cache(self, program_uuid, organization_key): + """ + helper function to initialize a cached program with an single authoring_organization + """ + catalog_org = CatalogOrganizationFactory.create(key=organization_key) + program = ProgramFactory.create( + uuid=program_uuid, + authoring_organizations=[catalog_org] + ) + cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=program_uuid), program, None) + + def create_social_auth_entry(self, user, provider, external_id): + """ + helper functio to create a user social auth entry + """ + UserSocialAuth.objects.create( + user=user, + uid='{0}:{1}'.format(provider.slug, external_id) + ) + + def test_get_user_success(self): + """ + Test lms user is successfully found + """ + organization = OrganizationFactory.create(short_name=self.organization_key) + provider = SAMLProviderConfigFactory.create(organization=organization) + self.create_social_auth_entry(self.user, provider, self.external_user_id) + + user = get_user_by_program_id(self.external_user_id, self.program_uuid) + self.assertEquals(user, self.user) + + def test_social_auth_user_not_created(self): + """ + None should be returned if no lms user exists for an external id + """ + organization = OrganizationFactory.create(short_name=self.organization_key) + SAMLProviderConfigFactory.create(organization=organization) + + user = get_user_by_program_id(self.external_user_id, self.program_uuid) + self.assertIsNone(user) + + def test_catalog_program_does_not_exist(self): + """ + Test ProgramDoesNotExistException is thrown if the program cache does + not include the requested program uuid. + """ + with pytest.raises(ProgramDoesNotExistException): + get_user_by_program_id('school-id-1234', uuid4()) + + def test_catalog_program_missing_org(self): + """ + Test OrganizationDoesNotExistException is thrown if the cached program does not + have an authoring organization. + """ + program = ProgramFactory.create( + uuid=self.program_uuid, + authoring_organizations=[] + ) + cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=self.program_uuid), program, None) + + organization = OrganizationFactory.create(short_name=self.organization_key) + provider = SAMLProviderConfigFactory.create(organization=organization) + self.create_social_auth_entry(self.user, provider, self.external_user_id) + + with pytest.raises(OrganizationDoesNotExistException): + get_user_by_program_id(self.external_user_id, self.program_uuid) + + def test_lms_organization_not_found(self): + """ + Test an OrganizationDoesNotExistException is thrown if the LMS has no organization + matching the catalog program's authoring_organization + """ + organization = OrganizationFactory.create(short_name='some_other_org') + provider = SAMLProviderConfigFactory.create(organization=organization) + self.create_social_auth_entry(self.user, provider, self.external_user_id) + + with pytest.raises(OrganizationDoesNotExistException): + get_user_by_program_id(self.external_user_id, self.program_uuid) + + def test_saml_provider_not_found(self): + """ + Test an sdf is thrown if no SAML provider exists for this program's organization + """ + OrganizationFactory.create(short_name=self.organization_key) + + with pytest.raises(ProviderDoesNotExistException): + get_user_by_program_id(self.external_user_id, self.program_uuid) diff --git a/lms/djangoapps/program_enrollments/utils.py b/lms/djangoapps/program_enrollments/utils.py new file mode 100644 index 0000000000..cfd731cdd8 --- /dev/null +++ b/lms/djangoapps/program_enrollments/utils.py @@ -0,0 +1,89 @@ +""" +utility functions for program enrollments +""" +import logging +from openedx.core.djangoapps.catalog.utils import get_programs +from organizations.models import Organization +from social_django.models import UserSocialAuth + +log = logging.getLogger(__name__) + + +class UserLookupException(Exception): + pass + + +class ProgramDoesNotExistException(UserLookupException): + pass + + +class OrganizationDoesNotExistException(UserLookupException): + pass + + +class ProviderDoesNotExistException(UserLookupException): + pass + + +def get_user_by_program_id(external_user_id, program_uuid): + """ + Returns a User model for an external_user_id with a social auth entry. + + Args: + external_user_id: external user id used for social auth + program_uuid: a program this user is/will be enrolled in + + Returns: + A User object or None, if no user with the given external id for the given organization exists. + + Raises: + ProgramDoesNotExistException if no such program exists. + OrganizationDoesNotExistException if no organization exists. + ProviderDoesNotExistException if there is no SAML provider configured for the related organization. + """ + program = get_programs(uuid=program_uuid) + if program is None: + log.error(u'Unable to find catalog program matching uuid [%s]', program_uuid) + raise ProgramDoesNotExistException + + try: + org_key = program['authoring_organizations'][0]['key'] + organization = Organization.objects.get(short_name=org_key) + except (KeyError, IndexError): + log.error(u'Cannot determine authoring organization key for catalog program [%s]', program_uuid) + raise OrganizationDoesNotExistException + except Organization.DoesNotExist: + log.error(u'Unable to find organization for short_name [%s]', org_key) + raise OrganizationDoesNotExistException + + return get_user_by_organization(external_user_id, organization) + + +def get_user_by_organization(external_user_id, organization): + """ + Returns a User model for an external_user_id with a social auth entry. + + This function finds a matching SAML Provider for the given organization, and looks + for a social auth entry with the provided exernal id. + + Args: + external_user_id: external user id used for social auth + organization: organization providing saml authentication for this user + + Returns: + A User object or None, if no user with the given external id for the given organization exists. + + Raises: + ProviderDoesNotExistException if there is no SAML provider configured for the related organization. + """ + try: + provider_slug = organization.samlproviderconfig.provider_id.strip('saml-') + except Organization.samlproviderconfig.RelatedObjectDoesNotExist: + log.error(u'No SAML provider found for organization id [%s]', organization.id) + raise ProviderDoesNotExistException + + try: + social_auth_uid = '{0}:{1}'.format(provider_slug, external_user_id) + return UserSocialAuth.objects.get(uid=social_auth_uid).user + except UserSocialAuth.DoesNotExist: + return None