diff --git a/enterprise/api/v1/views/enterprise_customer.py b/enterprise/api/v1/views/enterprise_customer.py index 1685304d17..73e2d694da 100644 --- a/enterprise/api/v1/views/enterprise_customer.py +++ b/enterprise/api/v1/views/enterprise_customer.py @@ -4,10 +4,11 @@ from urllib.parse import quote_plus, unquote +from algoliasearch.search_client import SearchClient from edx_rbac.decorators import permission_required from rest_framework import permissions from rest_framework.decorators import action -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import ValidationError, NotFound from rest_framework.response import Response from rest_framework.status import ( HTTP_200_OK, @@ -17,12 +18,15 @@ HTTP_409_CONFLICT, ) +from django.conf import settings from django.contrib import auth from django.core import exceptions from django.db import transaction from django.db.models import Q +from django.http import Http404 from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator +from django.utils.translation import gettext_lazy as _ from enterprise import models from enterprise.api.filters import EnterpriseLinkedUserFilterBackend @@ -436,3 +440,41 @@ def unlink_users(self, request, pk=None): # pylint: disable=unused-argument raise UnlinkUserFromEnterpriseError(msg) from exc return Response(status=HTTP_200_OK) + + @action(detail=False) + def algolia_key(self, request, *args, **kwargs): + """ + Returns an Algolia API key that is secured to only allow searching for + objects associated with enterprise customers that the user is linked to. + + This endpoint is used with `frontend-app-learner-portal-enterprise` MFE + currently. + """ + + if not (api_key := getattr(settings, "ENTERPRISE_ALGOLIA_SEARCH_API_KEY", "")): + LOGGER.warning("Algolia search API key is not configured. To enable this view, " + "set `ENTERPRISE_ALGOLIA_SEARCH_API_KEY` in settings.") + raise Http404 + + queryset = self.queryset.filter( + **{ + self.USER_ID_FILTER: request.user.id, + "enterprise_customer_users__linked": True + } + ).values_list("uuid", flat=True) + + if len(queryset) == 0: + raise NotFound(_("User is not linked to any enterprise customers.")) + + secured_key = SearchClient.generate_secured_api_key( + api_key, + { + "filters": " OR ".join( + f"enterprise_customer_uuids:{enterprise_customer_uuid}" + for enterprise_customer_uuid + in queryset + ), + } + ) + + return Response({"key": secured_key}, status=HTTP_200_OK) diff --git a/enterprise/settings/test.py b/enterprise/settings/test.py index 20c1490ccd..2691b83660 100644 --- a/enterprise/settings/test.py +++ b/enterprise/settings/test.py @@ -224,6 +224,8 @@ def root(*args): 'status': 'published' } +ENTERPRISE_ALGOLIA_SEARCH_API_KEY = 'test' + SNOWFLAKE_SERVICE_USER = 'TEST@EDX.ORG' SNOWFLAKE_SERVICE_USER_PASSWORD = 'secret' diff --git a/requirements/base.in b/requirements/base.in index b54f17646e..650a0fdc86 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -2,6 +2,7 @@ # This file contains the dependencies explicitly needed for this library. # # Packages directly used by this library that we do not need pinned to a specific version. +algoliasearch bleach celery code-annotations diff --git a/requirements/dev.txt b/requirements/dev.txt index fbe87b0ec8..ff14144b77 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -24,6 +24,11 @@ alabaster==0.7.13 # via # -r requirements/doc.txt # sphinx +algoliasearch==2.6.3 + # via + # -r requirements/doc.txt + # -r requirements/test-master.txt + # -r requirements/test.txt amqp==5.2.0 # via # -r requirements/doc.txt @@ -761,6 +766,7 @@ requests==2.31.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt + # algoliasearch # coreapi # django-oauth-toolkit # edx-drf-extensions diff --git a/requirements/doc.txt b/requirements/doc.txt index 62eae90148..661aa61519 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -16,6 +16,8 @@ aiosignal==1.3.1 # aiohttp alabaster==0.7.13 # via sphinx +algoliasearch==2.6.3 + # via -r requirements/test-master.txt amqp==5.2.0 # via # -r requirements/test-master.txt @@ -433,6 +435,7 @@ readme-renderer==42.0 requests==2.31.0 # via # -r requirements/test-master.txt + # algoliasearch # coreapi # django-oauth-toolkit # edx-drf-extensions diff --git a/requirements/test-master.txt b/requirements/test-master.txt index 6f7c3fcff4..96a5cc7255 100644 --- a/requirements/test-master.txt +++ b/requirements/test-master.txt @@ -12,6 +12,10 @@ aiosignal==1.3.1 # via # -c requirements/edx-platform-constraints.txt # aiohttp +algoliasearch==2.6.3 + # via + # -c requirements/edx-platform-constraints.txt + # -r requirements/base.in amqp==5.2.0 # via kombu aniso8601==9.0.1 @@ -419,6 +423,7 @@ requests==2.31.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in + # algoliasearch # coreapi # django-oauth-toolkit # edx-drf-extensions diff --git a/requirements/test.txt b/requirements/test.txt index 6880b62747..3e1416f2ae 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -15,6 +15,8 @@ aiosignal==1.3.1 # via # -r requirements/test-master.txt # kombu +algoliasearch==2.6.3 + # via -r requirements/test-master.txt aniso8601==9.0.1 # via # -r requirements/test-master.txt @@ -412,6 +414,7 @@ pyyaml==6.0.1 requests==2.31.0 # via # -r requirements/test-master.txt + # algoliasearch # coreapi # django-oauth-toolkit # edx-drf-extensions diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index aa41038a9a..e302d6fb94 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -2,6 +2,7 @@ Tests for the `edx-enterprise` api module. """ +import base64 import copy import json import logging @@ -139,6 +140,7 @@ ENTERPRISE_LEARNER_LIST_ENDPOINT = reverse('enterprise-learner-list') ENTERPRISE_CUSTOMER_WITH_ACCESS_TO_ENDPOINT = reverse('enterprise-customer-with-access-to') ENTERPRISE_CUSTOMER_UNLINK_USERS_ENDPOINT = reverse('enterprise-customer-unlink-users', kwargs={'pk': FAKE_UUIDS[0]}) +ENTERPRISE_CUSTOMER_ALGOLIA_KEY_ENDPOINT = reverse('enterprise-customer-algolia-key') PENDING_ENTERPRISE_LEARNER_LIST_ENDPOINT = reverse('pending-enterprise-learner-list') LICENSED_ENTERPRISE_COURSE_ENROLLMENTS_REVOKE_ENDPOINT = reverse( 'licensed-enterprise-course-enrollment-license-revoke' @@ -1851,6 +1853,52 @@ def test_unlink_users(self, enterprise_role, enterprise_uuid_for_role, is_relink assert enterprise_customer_user_2.is_relinkable == is_relinkable assert enterprise_customer_user_2.is_relinkable == is_relinkable + def test_algolia_key(self): + """ + Tests that the endpoint algolia_key endpoint returns the correct secured key. + """ + + # Test that the endpoint returns 401 if the user is not logged in. + self.client.logout() + response = self.client.get(ENTERPRISE_CUSTOMER_ALGOLIA_KEY_ENDPOINT) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + username = 'test_learner_portal_user' + self.create_user(username=username, is_staff=False) + self.client.login(username=username, password=TEST_PASSWORD) + + # Test that the endpoint returns 404 if the Algolia Search API key is not set. + with override_settings(ENTERPRISE_ALGOLIA_SEARCH_API_KEY=None): + response = self.client.get(ENTERPRISE_CUSTOMER_ALGOLIA_KEY_ENDPOINT) + assert response.status_code == status.HTTP_404_NOT_FOUND + + # Test that the endpoint returns 404 if the user is not linked to any enterprise. + response = self.client.get(ENTERPRISE_CUSTOMER_ALGOLIA_KEY_ENDPOINT) + assert response.status_code == status.HTTP_404_NOT_FOUND + + # Test that the endpoint returns 200 if the user is linked to at least one enterprise. + enterprise_customer_1 = factories.EnterpriseCustomerFactory(uuid=FAKE_UUIDS[0]) + enterprise_customer_2 = factories.EnterpriseCustomerFactory(uuid=FAKE_UUIDS[1]) + factories.EnterpriseCustomerFactory(uuid=FAKE_UUIDS[2]) # extra unlinked enterprise + + factories.EnterpriseCustomerUserFactory( + user_id=self.user.id, + enterprise_customer=enterprise_customer_1 + ) + factories.EnterpriseCustomerUserFactory( + user_id=self.user.id, + enterprise_customer=enterprise_customer_2 + ) + + response = self.client.get(ENTERPRISE_CUSTOMER_ALGOLIA_KEY_ENDPOINT) + assert response.status_code == status.HTTP_200_OK + + # Test that the endpoint returns the key encoding correct filters. + decoded_key = base64.b64decode(response.json()["key"]).decode("utf-8") + assert decoded_key.endswith( + f"filters=enterprise_customer_uuids%3A{FAKE_UUIDS[0]}+OR+enterprise_customer_uuids%3A{FAKE_UUIDS[1]}" + ) + @ddt.ddt @mark.django_db