From 5d9da6db3cf8300f5f1071c6d002887e02f5e048 Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Wed, 3 Jan 2024 22:27:18 -0800 Subject: [PATCH] feat: add "Setup Auth org id" action for Enterprise Customers ENT-8169 --- .gitignore | 3 + .python-version | 1 - CHANGELOG.rst | 5 ++ enterprise/__init__.py | 2 +- enterprise/admin/__init__.py | 52 +++++++++-- enterprise/admin/utils.py | 1 + enterprise/admin/views.py | 75 +++++++++++++++- enterprise/api_client/sso_orchestrator.py | 32 ++++++- enterprise/settings/test.py | 1 + .../enterprise/admin/setup_auth_org_id.html | 59 ++++++++++++ enterprise/utils.py | 7 ++ tests/test_admin/test_view.py | 90 +++++++++++++++++++ .../api_client/test_sso_orchestrator.py | 42 ++++++++- 13 files changed, 358 insertions(+), 12 deletions(-) delete mode 100644 .python-version create mode 100644 enterprise/templates/enterprise/admin/setup_auth_org_id.html diff --git a/.gitignore b/.gitignore index dd36223bc2..62ccda9979 100644 --- a/.gitignore +++ b/.gitignore @@ -90,5 +90,8 @@ enterprise/static/enterprise/bundles/*.js # Virtual environments venv/ +# pyenv +.python-version + # TODO: When we move to be a service, ignore this too. #enterprise/static/enterprise/bundles/ diff --git a/.python-version b/.python-version deleted file mode 100644 index 8bed2f106e..0000000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.8.12/envs/venv diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9f453e6582..60b2dd8d3e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Change Log Unreleased ---------- +[4.9.0] +-------- + +feat: add "Setup Auth org id" action for Enterprise Customers (ENT-8169) + [4.8.18] -------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index 6c2a61ee09..901407901d 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.8.18" +__version__ = "4.9.0" diff --git a/enterprise/admin/__init__.py b/enterprise/admin/__init__.py index 665745c1d0..3182c91968 100644 --- a/enterprise/admin/__init__.py +++ b/enterprise/admin/__init__.py @@ -36,6 +36,7 @@ CatalogQueryPreviewView, EnterpriseCustomerManageLearnerDataSharingConsentView, EnterpriseCustomerManageLearnersView, + EnterpriseCustomerSetupAuthOrgIDView, EnterpriseCustomerTransmitCoursesView, TemplatePreviewView, ) @@ -45,6 +46,7 @@ discovery_query_url, get_all_field_names, get_default_catalog_content_filter, + get_sso_orchestrator_configure_edx_oauth_path, localized_utcnow, ) @@ -233,7 +235,29 @@ class EnterpriseCustomerAdmin(DjangoObjectActions, SimpleHistoryAdmin): export_as_csv_action('CSV Export', fields=EXPORT_AS_CSV_FIELDS), ] - change_actions = ('manage_learners', 'manage_learners_data_sharing_consent', 'transmit_courses_metadata') + change_actions = ( + 'setup_auth_org_id', + 'manage_learners', + 'manage_learners_data_sharing_consent', + 'transmit_courses_metadata', + ) + + def get_change_actions(self, *args, **kwargs): + """ + Buttons that appear at the top of the "Change Enterprise Customer" page. + + Due to a known deficiency in the upstream django_object_actions library, we must STILL define change_actions + above with all possible values. + """ + change_actions = ( + 'manage_learners', + 'manage_learners_data_sharing_consent', + 'transmit_courses_metadata', + ) + # Add the "Setup Auth org id" button only if it is configured. + if get_sso_orchestrator_configure_edx_oauth_path(): + change_actions = ('setup_auth_org_id',) + change_actions + return change_actions form = EnterpriseCustomerAdminForm @@ -357,6 +381,19 @@ def transmit_courses_metadata(self, request, obj): transmit_courses_metadata.label = 'Transmit Courses Metadata' + @admin.action( + description='Setup auth_org_id for this Enterprise Customer' + ) + def setup_auth_org_id(self, request, obj): + """ + Object tool handler method - redirects to `Setup Auth org id` view. + """ + # url names coming from get_urls are prefixed with 'admin' namespace + setup_auth_org_id_url = reverse('admin:' + UrlNames.SETUP_AUTH_ORG_ID, args=(obj.uuid,)) + return HttpResponseRedirect(setup_auth_org_id_url) + + setup_auth_org_id.label = 'Setup Auth org id' + def get_urls(self): """ Returns the additional urls used by the custom object tools. @@ -365,18 +402,23 @@ def get_urls(self): re_path( r"^([^/]+)/manage_learners$", self.admin_site.admin_view(EnterpriseCustomerManageLearnersView.as_view()), - name=UrlNames.MANAGE_LEARNERS + name=UrlNames.MANAGE_LEARNERS, ), re_path( r"^([^/]+)/clear_learners_data_sharing_consent", self.admin_site.admin_view(EnterpriseCustomerManageLearnerDataSharingConsentView.as_view()), - name=UrlNames.MANAGE_LEARNERS_DSC + name=UrlNames.MANAGE_LEARNERS_DSC, ), re_path( r"^([^/]+)/transmit_courses_metadata", self.admin_site.admin_view(EnterpriseCustomerTransmitCoursesView.as_view()), - name=UrlNames.TRANSMIT_COURSES_METADATA - ) + name=UrlNames.TRANSMIT_COURSES_METADATA, + ), + re_path( + r"^([^/]+)/setup_auth_org_id", + self.admin_site.admin_view(EnterpriseCustomerSetupAuthOrgIDView.as_view()), + name=UrlNames.SETUP_AUTH_ORG_ID, + ), ] return customer_urls + super().get_urls() diff --git a/enterprise/admin/utils.py b/enterprise/admin/utils.py index e8ff946f18..f8d3011c3f 100644 --- a/enterprise/admin/utils.py +++ b/enterprise/admin/utils.py @@ -25,6 +25,7 @@ class UrlNames: MANAGE_LEARNERS = URL_PREFIX + "manage_learners" MANAGE_LEARNERS_DSC = URL_PREFIX + "manage_learners_data_sharing_consent" TRANSMIT_COURSES_METADATA = URL_PREFIX + "transmit_courses_metadata" + SETUP_AUTH_ORG_ID = URL_PREFIX + "setup_auth_org_id" PREVIEW_EMAIL_TEMPLATE = URL_PREFIX + "preview_email_template" PREVIEW_QUERY_RESULT = URL_PREFIX + "preview_query_result" diff --git a/enterprise/admin/views.py b/enterprise/admin/views.py index da997683b5..9addac150f 100644 --- a/enterprise/admin/views.py +++ b/enterprise/admin/views.py @@ -15,7 +15,7 @@ from django.core.management import call_command from django.db import transaction from django.db.models import Q -from django.http import HttpResponse, HttpResponseRedirect, JsonResponse +from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect, HttpResponseServerError, JsonResponse from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.utils.translation import gettext as _ @@ -36,6 +36,7 @@ ) from enterprise.api_client.discovery import get_course_catalog_api_service_client from enterprise.api_client.ecommerce import EcommerceApiClient +from enterprise.api_client.sso_orchestrator import EnterpriseSSOOrchestratorApiClient, SsoOrchestratorClientError from enterprise.constants import PAGE_SIZE from enterprise.errors import LinkUserToEnterpriseError from enterprise.models import ( @@ -50,6 +51,7 @@ delete_data_sharing_consent, enroll_users_in_course, get_ecommerce_worker_user, + get_sso_orchestrator_configure_edx_oauth_path, validate_course_exists_for_enterprise, validate_email_to_link, ) @@ -174,7 +176,8 @@ def get_form_view(self, request, customer_uuid, additional_context=None): render the form with appropriate context. """ context = self._build_context(request, customer_uuid) - context.update(additional_context) + if additional_context: + context.update(additional_context) return render(request, self.template, context) @@ -895,3 +898,71 @@ def delete(self, request, customer_uuid): return HttpResponse(message, content_type="application/json", status=404) return JsonResponse({}) + + +class EnterpriseCustomerSetupAuthOrgIDView(BaseEnterpriseCustomerView): + """ + Setup Auth org id View. + + This action will configure SSO to GetSmarter using edX credentials via Auth0. + """ + template = 'enterprise/admin/setup_auth_org_id.html' + + def get(self, request, customer_uuid): + """ + Handle GET request - render "Setup Auth org id" form. + + Arguments: + request (django.http.request.HttpRequest): Request instance + customer_uuid (str): Enterprise Customer UUID + + Returns: + django.http.response.HttpResponse: HttpResponse + """ + if not get_sso_orchestrator_configure_edx_oauth_path(): + return HttpResponseForbidden( + "The ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_PATH setting was not configured." + ) + return self.get_form_view(request, customer_uuid) + + def post(self, request, customer_uuid): + """ + Handle POST request - handle form submissions. + + Arguments: + request (django.http.request.HttpRequest): Request instance + customer_uuid (str): Enterprise Customer UUID + """ + if not get_sso_orchestrator_configure_edx_oauth_path(): + return HttpResponseForbidden( + "The ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_PATH setting was not configured." + ) + + enterprise_customer = EnterpriseCustomer.objects.get(uuid=customer_uuid) + + # Call the configure-edx-oauth endpoint on the enterprise-sso-orchestrator service to obtain an orgId. + # This will raise SsoOrchestratorClientError if the API request fails. + try: + auth_org_id = EnterpriseSSOOrchestratorApiClient().configure_edx_oauth(enterprise_customer) + except SsoOrchestratorClientError as exc: + error_msg = ( + f"Error configuring edx oauth for enterprise customer {enterprise_customer.name}" + f"<{enterprise_customer.uuid}>: {exc}" + ) + LOG.exception(error_msg) + return HttpResponseServerError(error_msg) + + if auth_org_id: + enterprise_customer.auth_org_id = auth_org_id + enterprise_customer.save() + messages.success(request, _('Successfully written the "Auth org id" field for this enterprise customer.')) + return HttpResponseRedirect(reverse("admin:" + UrlNames.SETUP_AUTH_ORG_ID, args=(customer_uuid,))) + else: + # Annoyingly, there's still the remote possibility that the request succeeded but we failed to retrieve the + # auth_org_id. This might be due to a regression in the API response schema. + error_msg = ( + f"Error configuring edx oauth for enterprise customer {enterprise_customer.name}" + f"<{enterprise_customer.uuid}>: Missing orgId." + ) + LOG.exception(error_msg) + return HttpResponseServerError(error_msg) diff --git a/enterprise/api_client/sso_orchestrator.py b/enterprise/api_client/sso_orchestrator.py index 693987aa2c..28fe70f640 100644 --- a/enterprise/api_client/sso_orchestrator.py +++ b/enterprise/api_client/sso_orchestrator.py @@ -16,6 +16,7 @@ get_sso_orchestrator_api_base_url, get_sso_orchestrator_basic_auth_password, get_sso_orchestrator_basic_auth_username, + get_sso_orchestrator_configure_edx_oauth_path, get_sso_orchestrator_configure_path, ) @@ -67,6 +68,14 @@ def _get_orchestrator_configure_url(self): # probably want config value validated for this return urljoin(self.base_url, get_sso_orchestrator_configure_path()) + def _get_orchestrator_configure_edx_oauth_url(self): + """ + get the configure-edx-oauth url for the SSO Orchestrator API + """ + if path := get_sso_orchestrator_configure_edx_oauth_path(): + return urljoin(self.base_url, path) + return None + def _create_auth_header(self): """ create the basic auth header for requests to the SSO Orchestrator API @@ -93,7 +102,7 @@ def _create_session(self): def _post(self, url, data=None): """ - make a GET request to the SSO Orchestrator API + make a POST request to the SSO Orchestrator API """ self._create_session() response = self.session.post(url, json=data, auth=self._create_auth_header()) @@ -133,3 +142,24 @@ def configure_sso_orchestration_record( response = self._post(self._get_orchestrator_configure_url(), data=request_data) return response.get('samlServiceProviderInformation', {}).get('spMetadataUrl', {}) + + def configure_edx_oauth(self, enterprise_customer): + """ + Configure SSO to GetSmarter using edX credentials via Auth0. + + Args: + enterprise_customer (EnterpriseCustomer): The enterprise customer for which to configure edX OAuth. + + Returns: + str: Auth0 Organization ID. + + Raises: + SsoOrchestratorClientError: If the request to the SSO Orchestrator API failed. + """ + request_data = { + 'enterpriseName': enterprise_customer.name, + 'enterpriseSlug': enterprise_customer.slug, + 'enterpriseUuid': str(enterprise_customer.uuid), + } + response = self._post(self._get_orchestrator_configure_edx_oauth_url(), data=request_data) + return response.get('orgId', None) diff --git a/enterprise/settings/test.py b/enterprise/settings/test.py index 20c1490ccd..e95dcfad49 100644 --- a/enterprise/settings/test.py +++ b/enterprise/settings/test.py @@ -363,3 +363,4 @@ def root(*args): ENTERPRISE_SSO_ORCHESTRATOR_WORKER_PASSWORD = 'password' ENTERPRISE_SSO_ORCHESTRATOR_BASE_URL = 'https://foobar.com' ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_PATH = 'configure' +ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_PATH = 'configure-edx-oauth' diff --git a/enterprise/templates/enterprise/admin/setup_auth_org_id.html b/enterprise/templates/enterprise/admin/setup_auth_org_id.html new file mode 100644 index 0000000000..3f9dbadc6a --- /dev/null +++ b/enterprise/templates/enterprise/admin/setup_auth_org_id.html @@ -0,0 +1,59 @@ +{% extends "admin/base_site.html" %} +{% load i18n static admin_urls %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+
+

{% trans "Setup Auth org id" %}

+

+ This action is required for customers who will have learners in executive education courses. Setting up the + Auth org id will enable the enterprise's learners to take Exec Ed or OCM courses using the same set of login + credentials. Clicking the button below will facilitate the necessary steps with our external identity vendor, + Auth0, and will overwrite any value that may already be in the "Auth org id" field. +

+
+ {% csrf_token %} +
+
+ + + +
+
+ +
+
+
+
+
+{% endblock %} + +{% block footer %} + {{ block.super }} +{% endblock %} diff --git a/enterprise/utils.py b/enterprise/utils.py index 291f7d139b..631eeefd48 100644 --- a/enterprise/utils.py +++ b/enterprise/utils.py @@ -1565,6 +1565,13 @@ def get_sso_orchestrator_configure_path(): return settings.ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_PATH +def get_sso_orchestrator_configure_edx_oauth_path(): + """ + Return the SSO orchestrator configure-edx-oauth endpoint path, or None if it is not defined. + """ + return getattr(settings, "ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_PATH", None) + + def get_enterprise_worker_user(): """ Return the user object of enterprise worker user. diff --git a/tests/test_admin/test_view.py b/tests/test_admin/test_view.py index 87288b60d3..ca2602881b 100644 --- a/tests/test_admin/test_view.py +++ b/tests/test_admin/test_view.py @@ -33,6 +33,7 @@ TransmitEnterpriseCoursesForm, ) from enterprise.admin.utils import ValidationMessages +from enterprise.api_client.sso_orchestrator import SsoOrchestratorClientError from enterprise.constants import PAGE_SIZE from enterprise.models import ( EnrollmentNotificationEmailTemplate, @@ -2078,3 +2079,92 @@ def test_post_validation_errors(self): ) ] } + + +class BaseTestEnterpriseCustomerSetupAuthOrgIDView(BaseEnterpriseCustomerView): + """ + Common functionality for EnterpriseCustomerTransmitCoursesView tests. + """ + + def setUp(self): + """ + Test set up + """ + super().setUp() + self.enterprise_customer.auth_org_id = None + self.enterprise_customer.save() + self.view_url = reverse( + 'admin:' + enterprise_admin.utils.UrlNames.SETUP_AUTH_ORG_ID, + args=(self.enterprise_customer.uuid,) + ) + + +@ddt.ddt +@mark.django_db +@override_settings(ROOT_URLCONF='test_utils.admin_urls') +class TestEnterpriseCustomerSetupAuthOrgIDViewGet(BaseTestEnterpriseCustomerSetupAuthOrgIDView): + """ + Tests for EnterpriseCustomerSetupAuthOrgIDView GET endpoint. + """ + + def _test_get_response(self, response): + """ + Test view GET response for common parts. + """ + assert response.status_code == 200 + self._test_common_context(response.context) + assert response.context['enterprise_customer'] == self.enterprise_customer + + def test_get_not_logged_in(self): + response = self.client.get(self.view_url) + assert response.status_code == 302 + + def test_get_links(self): + self._login() + + response = self.client.get(self.view_url) + self._test_get_response(response) + + +@ddt.ddt +@mark.django_db +@override_settings(ROOT_URLCONF='test_utils.admin_urls') +class TestEnterpriseCustomerSetupAuthOrgIDViewPost(BaseTestEnterpriseCustomerSetupAuthOrgIDView): + """ + Tests for EnterpriseCustomerSetupAuthOrgIDView POST endpoint. + """ + + def test_post_not_logged_in(self): + response = self.client.post(self.view_url, data={}) + assert response.status_code == 302 + + @mock.patch('enterprise.api_client.sso_orchestrator.EnterpriseSSOOrchestratorApiClient.configure_edx_oauth') + def test_post_happy_path(self, mock_configure_edx_oauth): + fake_org_id = 'foobar' + mock_configure_edx_oauth.return_value = fake_org_id + self._login() + response = self.client.post(self.view_url) + mock_configure_edx_oauth.assert_called_once_with(self.enterprise_customer) + self.enterprise_customer.refresh_from_db() + assert self.enterprise_customer.auth_org_id == fake_org_id + + # Now check that the redirect is correct and that the success message is set. + self.assertRedirects(response, self.view_url, fetch_redirect_response=False) + get_response = self.client.get(self.view_url) + actual_messages = { + (m.level, m.message) for m in get_response.context['messages'] + } + expected_messages = { + (messages.SUCCESS, 'Successfully written the "Auth org id" field for this enterprise customer.'), + } + assert actual_messages == expected_messages + + @mock.patch('enterprise.api_client.sso_orchestrator.EnterpriseSSOOrchestratorApiClient.configure_edx_oauth') + def test_post_api_raises_error(self, mock_configure_edx_oauth): + mock_configure_edx_oauth.side_effect = SsoOrchestratorClientError('foobar') + self._login() + response = self.client.post(self.view_url) + assert response.status_code == 500 + mock_configure_edx_oauth.assert_called_once_with(self.enterprise_customer) + self.enterprise_customer.refresh_from_db() + assert self.enterprise_customer.auth_org_id is None diff --git a/tests/test_enterprise/api_client/test_sso_orchestrator.py b/tests/test_enterprise/api_client/test_sso_orchestrator.py index 683fe2fec6..02dbb39be5 100644 --- a/tests/test_enterprise/api_client/test_sso_orchestrator.py +++ b/tests/test_enterprise/api_client/test_sso_orchestrator.py @@ -11,12 +11,24 @@ from django.conf import settings from enterprise.api_client import sso_orchestrator -from enterprise.utils import get_sso_orchestrator_api_base_url, get_sso_orchestrator_configure_path +from enterprise.utils import ( + get_sso_orchestrator_api_base_url, + get_sso_orchestrator_configure_edx_oauth_path, + get_sso_orchestrator_configure_path, +) +from test_utils.factories import EnterpriseCustomerFactory TEST_ENTERPRISE_ID = '1840e1dc-59cf-4a78-82c5-c5bbc0b5df0f' TEST_ENTERPRISE_SSO_CONFIG_UUID = uuid4() TEST_ENTERPRISE_NAME = 'Test Enterprise' -SSO_ORCHESTRATOR_CONFIGURE_URL = urljoin(get_sso_orchestrator_api_base_url(), get_sso_orchestrator_configure_path()) +SSO_ORCHESTRATOR_CONFIGURE_URL = urljoin( + get_sso_orchestrator_api_base_url(), + get_sso_orchestrator_configure_path() +) +SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_URL = urljoin( + get_sso_orchestrator_api_base_url(), + get_sso_orchestrator_configure_edx_oauth_path() +) @responses.activate @@ -51,3 +63,29 @@ def test_post_sso_configuration(): 'name': TEST_ENTERPRISE_NAME, 'slug': TEST_ENTERPRISE_NAME } + + +@responses.activate +def test_configure_edx_oauth(): + """ + Test the configure_edx_oauth method. + """ + fake_enterprise_customer = EnterpriseCustomerFactory.stub() + fake_org_id = 'foobar' + responses.add( + responses.POST, + SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_URL, + json={'orgId': fake_org_id, 'status': 200}, + ) + client = sso_orchestrator.EnterpriseSSOOrchestratorApiClient() + + # Call the method under test: + actual_response = client.configure_edx_oauth(enterprise_customer=fake_enterprise_customer) + + assert actual_response == fake_org_id + responses.assert_call_count(count=1, url=SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_URL) + + sent_body_params = json.loads(responses.calls[0].request.body) + assert sent_body_params['enterpriseName'] == fake_enterprise_customer.name + assert sent_body_params['enterpriseSlug'] == fake_enterprise_customer.slug + assert sent_body_params['enterpriseUuid'] == str(fake_enterprise_customer.uuid)