{% trans "Setup auth org ID" %}
++ This action will configure SSO to GetSmarter using edX credentials via Auth0, and populate this enterprise + customer's auth_org_id field. This overwrites whatever value is currently in that field. +
+ +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..ac6297c0c0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Change Log Unreleased ---------- +[4.8.19] +-------- + +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..c883a1e5ad 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.8.18" +__version__ = "4.8.19" diff --git a/enterprise/admin/__init__.py b/enterprise/admin/__init__.py index 665745c1d0..aa89955b50 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..2d88db1804 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, HttpResponseRedirect, HttpResponseForbidden, 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 ( @@ -52,6 +53,7 @@ get_ecommerce_worker_user, validate_course_exists_for_enterprise, validate_email_to_link, + get_sso_orchestrator_configure_edx_oauth_path, ) # Only create manual enrollments if running in edx-platform @@ -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,69 @@ 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 auth org ID. + # 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: + 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..f2dbd743c2 100644 --- a/enterprise/api_client/sso_orchestrator.py +++ b/enterprise/api_client/sso_orchestrator.py @@ -17,6 +17,7 @@ get_sso_orchestrator_basic_auth_password, get_sso_orchestrator_basic_auth_username, get_sso_orchestrator_configure_path, + get_sso_orchestrator_configure_edx_oauth_path, ) USER_AGENT = user_agent() @@ -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-oautht 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..f279c06f15 --- /dev/null +++ b/enterprise/templates/enterprise/admin/setup_auth_org_id.html @@ -0,0 +1,52 @@ +{% extends "admin/base_site.html" %} +{% load i18n static admin_urls %} + +{% block breadcrumbs %} +
+{% endblock %} + +{% block content %} ++ This action will configure SSO to GetSmarter using edX credentials via Auth0, and populate this enterprise + customer's auth_org_id field. This overwrites whatever value is currently in that field. +
+ +