diff --git a/enterprise_catalog/apps/api/base/tests/enterprise_customer_views.py b/enterprise_catalog/apps/api/base/tests/enterprise_customer_views.py index c14a14c9..b08045b2 100644 --- a/enterprise_catalog/apps/api/base/tests/enterprise_customer_views.py +++ b/enterprise_catalog/apps/api/base/tests/enterprise_customer_views.py @@ -64,6 +64,9 @@ def _get_generate_diff_base_url(self, enterprise_catalog_uuid=None): ) def _get_content_metadata_base_url(self, enterprise_uuid, content_identifier): + """ + Helper to construct the base url for the customer content metadata endpoint + """ return reverse( f'api:{self.VERSION}:customer-content-metadata-retrieve', kwargs={ @@ -71,3 +74,14 @@ def _get_content_metadata_base_url(self, enterprise_uuid, content_identifier): 'content_identifier': content_identifier, }, ) + + def _get_secured_algolia_api_key_base_url(self, enterprise_uuid): + """ + Helper to construct the base url for the secured Algolia API key endpoint + """ + return reverse( + f'api:{self.VERSION}:enterprise-customer-secured-algolia-api-key', + kwargs={ + 'enterprise_uuid': enterprise_uuid, + }, + ) diff --git a/enterprise_catalog/apps/api/v1/serializers.py b/enterprise_catalog/apps/api/v1/serializers.py index 168593ac..7f802395 100644 --- a/enterprise_catalog/apps/api/v1/serializers.py +++ b/enterprise_catalog/apps/api/v1/serializers.py @@ -87,6 +87,17 @@ def find_and_modify_catalog_query( return content_filter_from_hash +class BaseSerializer(serializers.Serializer): + """ + Base serializer. + """ + def create(self, *args, **kwargs): + return None + + def update(self, *args, **kwargs): + return None + + class CatalogQuerySerializer(serializers.ModelSerializer): """ Serializer for the `CatalogQuery` model @@ -525,3 +536,30 @@ class Meta: "external_id", "title" ] + + +class SecuredAlgoliaAPIKeySerializer(BaseSerializer): + """ + Serializer for the secured Algolia API key and expiration. + """ + secured_api_key = serializers.CharField() + valid_until = serializers.DateTimeField() + + +class SecuredAlgoliaAPIKeyResponseSerializer(BaseSerializer): + """ + Serializer for the response of the secured Algolia API key endpoint. + """ + algolia = SecuredAlgoliaAPIKeySerializer(help_text='Secured Algolia API key and expiration.') + catalog_uuids_to_catalog_query_uuids = serializers.DictField( + child=serializers.UUIDField(), + help_text='Mapping of catalog UUIDs to catalog query UUIDs.', + ) + + +class SecuredAlgoliaAPIKeyErrorResponseSerializer(BaseSerializer): + """ + Serializer for the error response of the secured Algolia API key endpoint. + """ + user_message = serializers.CharField() + developer_message = serializers.CharField() diff --git a/enterprise_catalog/apps/api/v1/tests/mixins.py b/enterprise_catalog/apps/api/v1/tests/mixins.py index 1c876aa0..a187735e 100644 --- a/enterprise_catalog/apps/api/v1/tests/mixins.py +++ b/enterprise_catalog/apps/api/v1/tests/mixins.py @@ -96,7 +96,7 @@ def set_up_staff(self): self.assign_catalog_admin_feature_role() self.assign_catalog_admin_jwt_role() - def set_up_catalog_learner(self): + def set_up_catalog_learner(self, enterprise_uuid=None): """ Helper for setting up tests as a catalog learner """ @@ -106,7 +106,7 @@ def set_up_catalog_learner(self): self.role_assignment = EnterpriseCatalogRoleAssignmentFactory( role=self.role, user=self.user, - enterprise_id=self.enterprise_uuid + enterprise_id=(enterprise_uuid or self.enterprise_uuid) ) self.set_jwt_cookie([(ENTERPRISE_CATALOG_LEARNER_ROLE, self.enterprise_uuid)]) diff --git a/enterprise_catalog/apps/api/v1/tests/test_enterprise_customer_views.py b/enterprise_catalog/apps/api/v1/tests/test_enterprise_customer_views.py index 20d00eba..4b9cff16 100644 --- a/enterprise_catalog/apps/api/v1/tests/test_enterprise_customer_views.py +++ b/enterprise_catalog/apps/api/v1/tests/test_enterprise_customer_views.py @@ -3,6 +3,7 @@ from unittest import mock import pytz +from django.test import override_settings from rest_framework import status from enterprise_catalog.apps.api.base.tests.enterprise_customer_views import ( @@ -470,3 +471,64 @@ def test_filter_content_items_specified_catalogs(self): # response should only contain content keys found in the "second_catalog" self.assertEqual(response.get('filtered_content_keys'), [relevant_content_key]) + + @override_settings(ALGOLIA={'SEARCH_API_KEY': 'fake-search-api-search'}) + def test_secured_algolia_api_key_generation(self): + """ + Test that the secured Algolia API key is generated correctly. + """ + url = self._get_secured_algolia_api_key_base_url(self.enterprise_uuid) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_catalog_to_catalog_query_mappings = { + str(self.enterprise_catalog.uuid): str(self.enterprise_catalog.catalog_query.uuid), + } + self.assertIsInstance(response.data['algolia']['secured_api_key'], str) + self.assertTrue(bool(response.data['algolia']['secured_api_key'])) # Ensures the string isn't empty + + # Validate ISO format for valid_until + valid_until = response.data['algolia']['valid_until'] + try: + datetime.fromisoformat(valid_until) # Will raise ValueError if the format is incorrect + except ValueError: + self.fail(f"Invalid ISO format for valid_until: {valid_until}") + + # Validate catalog uuids to catalog query uuids mapping + self.assertEqual( + response.data['catalog_uuids_to_catalog_query_uuids'], + expected_catalog_to_catalog_query_mappings, + ) + + def test_secured_algolia_api_key_no_catalogs(self): + """ + Test that the secured Algolia API key is not generated if there are no catalogs. + """ + fake_enterprise_uuid = uuid.uuid4() + self.set_up_catalog_learner(enterprise_uuid=fake_enterprise_uuid) + url = self._get_secured_algolia_api_key_base_url(enterprise_uuid=fake_enterprise_uuid) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + expected_error_response = { + 'user_message': 'Error generating secured Algolia API key.', + 'developer_message': 'No enterprise catalogs found for the specified enterprise customer.', + } + self.assertEqual(response.data, expected_error_response) + + def test_secured_algolia_api_key_no_catalog_queries(self): + """ + Test that the secured Algolia API key is not generated if there are no catalog queries. + """ + fake_enterprise_uuid = uuid.uuid4() + EnterpriseCatalogFactory( + enterprise_uuid=fake_enterprise_uuid, + catalog_query=None, # Ensure no catalog query is associated with the catalog + ) + self.set_up_catalog_learner(enterprise_uuid=fake_enterprise_uuid) + url = self._get_secured_algolia_api_key_base_url(enterprise_uuid=fake_enterprise_uuid) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + expected_error_response = { + 'user_message': 'Error generating secured Algolia API key.', + 'developer_message': 'No catalog queries found for the specified enterprise customer.', + } + self.assertEqual(response.data, expected_error_response) diff --git a/enterprise_catalog/apps/api/v1/views/enterprise_customer.py b/enterprise_catalog/apps/api/v1/views/enterprise_customer.py index a36bc89c..f3277e76 100644 --- a/enterprise_catalog/apps/api/v1/views/enterprise_customer.py +++ b/enterprise_catalog/apps/api/v1/views/enterprise_customer.py @@ -1,7 +1,10 @@ import logging import uuid +from algoliasearch.exceptions import AlgoliaException +from django.core.exceptions import ImproperlyConfigured from django.utils.decorators import method_decorator +from drf_spectacular.utils import OpenApiExample, extend_schema from edx_rbac.utils import get_decoded_jwt from rest_framework.decorators import action from rest_framework.exceptions import NotFound, PermissionDenied @@ -11,9 +14,14 @@ from enterprise_catalog.apps.api.v1.decorators import ( require_at_least_one_query_parameter, ) -from enterprise_catalog.apps.api.v1.serializers import ContentMetadataSerializer +from enterprise_catalog.apps.api.v1.serializers import ( + ContentMetadataSerializer, + SecuredAlgoliaAPIKeyErrorResponseSerializer, + SecuredAlgoliaAPIKeyResponseSerializer, +) from enterprise_catalog.apps.api.v1.utils import unquote_course_keys from enterprise_catalog.apps.api.v1.views.base import BaseViewSet +from enterprise_catalog.apps.api_client.algolia import AlgoliaSearchClient from enterprise_catalog.apps.catalog.models import EnterpriseCatalog @@ -209,3 +217,122 @@ def content_metadata(self, customer_uuid, content_identifier, **kwargs): # pyli """ serializer = self.get_metadata_item_serializer() return Response(serializer.data) + + @extend_schema( + summary='Get secured Algolia API key for enterprise customer', + description='Returns a secured Algolia API key for the given enterprise UUID. ' + 'The key can be used within frontends to access Algolia search API, ' + 'restricted with the enterprise\'s available catalog query UUIDs.', + responses={ + 200: SecuredAlgoliaAPIKeyResponseSerializer, + 400: SecuredAlgoliaAPIKeyErrorResponseSerializer, + }, + tags=['Enterprise Customer'], + examples=[ + OpenApiExample( + 'Secured Algolia API Key Response', + value={ + 'algolia': { + 'secured_api_key': '...', + 'valid_until': '2025-02-28T12:00:00Z', + }, + 'catalog_uuids_to_catalog_query_uuids': { + 'catalog_uuid_1': 'catalog_query_uuid_1', + 'catalog_uuid_2': 'catalog_query_uuid_2', + }, + }, + ), + ] + ) + @action(detail=True, methods=['get'], url_path='secured-algolia-api-key') + def secured_algolia_api_key(self, request, enterprise_uuid, **kwargs): + """ + Returns a secured Algolia API key for the given enterprise UUID. + The key can be used within frontends to access Algolia search API, + restricted with the enterprise's available catalog query UUIDs. + """ + # Fetch all EnterpriseCatalogs associated with the given enterprise_uuid + enterprise_catalogs = EnterpriseCatalog.objects.filter( + enterprise_uuid=enterprise_uuid + ).select_related('catalog_query') + algolia_api_key_error_message = 'Error generating secured Algolia API key.' + + # Return an error response if no EnterpriseCatalogs are found + if not enterprise_catalogs: + logger.error( + f'No enterprise catalogs found for the given enterprise UUID: {enterprise_uuid}.' + ) + error_response = { + 'user_message': algolia_api_key_error_message, + 'developer_message': 'No enterprise catalogs found for the specified enterprise customer.', + } + return Response( + SecuredAlgoliaAPIKeyErrorResponseSerializer(error_response).data, + status=HTTP_400_BAD_REQUEST, + ) + + catalog_query_uuids = [ + enterprise_catalog.catalog_query.uuid for enterprise_catalog in enterprise_catalogs + if enterprise_catalog.catalog_query + ] + # Return an error response if no CatalogQueries are found + if not catalog_query_uuids: + logger.error( + f'No catalog queries found for the given enterprise UUID: {enterprise_uuid}.' + ) + error_response = { + 'user_message': algolia_api_key_error_message, + 'developer_message': 'No catalog queries found for the specified enterprise customer.' + } + return Response( + SecuredAlgoliaAPIKeyErrorResponseSerializer(error_response).data, + status=HTTP_400_BAD_REQUEST, + ) + + # Create a mapping of catalog UUIDs to their corresponding catalog query UUIDs + catalog_uuids_to_catalog_query_uuids = { + str(catalog.uuid): str(catalog.catalog_query.uuid) if catalog.catalog_query else None + for catalog in enterprise_catalogs + } + + # Generate a secured Algolia API key using the AlgoliaSearchClient + algolia_client = AlgoliaSearchClient() + try: + result = algolia_client.generate_secured_api_key( + user_id=request.user.id, + enterprise_catalog_query_uuids=catalog_query_uuids, + ) + except ImproperlyConfigured as exc: + logger.exception( + f'Secured Algolia API key generation failed for enterprise UUID {enterprise_uuid}' + ) + error_response = { + 'user_message': algolia_api_key_error_message, + 'developer_message': exc, + } + return Response( + SecuredAlgoliaAPIKeyErrorResponseSerializer(error_response).data, + status=HTTP_400_BAD_REQUEST, + ) + except AlgoliaException as exc: + logger.exception( + f'Secured Algolia API key generation failed for enterprise UUID {enterprise_uuid}' + ) + error_response = { + 'user_message': algolia_api_key_error_message, + 'developer_message': exc + } + return Response( + SecuredAlgoliaAPIKeyErrorResponseSerializer(error_response).data, + status=HTTP_400_BAD_REQUEST, + ) + + # Serialize the response data + response_data = { + 'algolia': { + 'secured_api_key': result.get('secured_api_key'), + 'valid_until': result.get('valid_until'), + }, + 'catalog_uuids_to_catalog_query_uuids': catalog_uuids_to_catalog_query_uuids, + } + return Response(SecuredAlgoliaAPIKeyResponseSerializer(response_data).data) diff --git a/enterprise_catalog/apps/api_client/algolia.py b/enterprise_catalog/apps/api_client/algolia.py index c50361b4..952e676b 100644 --- a/enterprise_catalog/apps/api_client/algolia.py +++ b/enterprise_catalog/apps/api_client/algolia.py @@ -3,10 +3,13 @@ """ import logging +import time +from datetime import datetime, timezone from algoliasearch.exceptions import AlgoliaException from algoliasearch.search_client import SearchClient from django.conf import settings +from django.core.exceptions import ImproperlyConfigured logger = logging.getLogger(__name__) @@ -19,6 +22,7 @@ class AlgoliaSearchClient: ALGOLIA_APPLICATION_ID = settings.ALGOLIA.get('APPLICATION_ID') ALGOLIA_API_KEY = settings.ALGOLIA.get('API_KEY') + ALGOLIA_SEARCH_API_KEY = settings.ALGOLIA.get('SEARCH_API_KEY') ALGOLIA_INDEX_NAME = settings.ALGOLIA.get('INDEX_NAME') ALGOLIA_REPLICA_INDEX_NAME = settings.ALGOLIA.get('REPLICA_INDEX_NAME') @@ -43,16 +47,28 @@ def init_index(self): ) return + # Create SearchClient self._client = SearchClient.create(self.ALGOLIA_APPLICATION_ID, self.ALGOLIA_API_KEY) - try: - self.algolia_index = self._client.init_index(self.ALGOLIA_INDEX_NAME) - self.replica_index = self._client.init_index(self.ALGOLIA_REPLICA_INDEX_NAME) - except AlgoliaException as exc: - logger.exception( - 'Could not initialize %s index in Algolia due to an exception.', - self.ALGOLIA_INDEX_NAME, - ) - raise exc + + # Initialize Algolia indices + if self.ALGOLIA_INDEX_NAME: + try: + self.algolia_index = self._client.init_index(self.ALGOLIA_INDEX_NAME) + except AlgoliaException as exc: + logger.exception( + 'Could not initialize %s index in Algolia due to an exception.', + self.ALGOLIA_INDEX_NAME, + ) + raise exc + if self.ALGOLIA_REPLICA_INDEX_NAME: + try: + self.replica_index = self._client.init_index(self.ALGOLIA_REPLICA_INDEX_NAME) + except AlgoliaException as exc: + logger.exception( + 'Could not initialize %s index in Algolia due to an exception.', + self.ALGOLIA_REPLICA_INDEX_NAME, + ) + raise exc def set_index_settings(self, index_settings, primary_index=True): """ @@ -173,3 +189,68 @@ def remove_objects(self, object_ids): self.ALGOLIA_INDEX_NAME, ) raise exc + + def generate_secured_api_key(self, user_id, enterprise_catalog_query_uuids): + """ + Generates a secured api key for the Algolia search API. + The secured api key will be used to restrict the search results to only those + that are associated with the given enterprise catalog query uuids. + The secured api key will also be restricted to the given user id. + Arguments: + user_id (str): The user id to restrict the api key to. + enterprise_catalog_query_uuids (list): The enterprise catalog query uuids to restrict the api key to. + Returns: + dict: A dictionary containing the secured api key and the expiration time. + The expiration time is in ISO format. + """ + if not self.ALGOLIA_SEARCH_API_KEY: + logger.error( + 'Could not generate secured Algolia API key due to missing Algolia settings: %s', + 'SEARCH_API_KEY', + ) + raise ImproperlyConfigured( + 'Cannot generate secured Algolia API key without the ALGOLIA_SEARCH_API_KEY in settings.' + ) + + expiration_time = getattr(settings, 'SECURED_ALGOLIA_API_KEY_EXPIRATION', 3600) # Default to 1 hour + valid_until = int(time.time()) + expiration_time + iso_format = "%Y-%m-%dT%H:%M:%SZ" + valid_until_iso = datetime.fromtimestamp(valid_until, tz=timezone.utc).strftime(iso_format) + catalog_query_filter = ' OR '.join( + [f'enterprise_catalog_query_uuids:{query_uuid}' for query_uuid in enterprise_catalog_query_uuids] + ) + + # Base secured API key restrictions + restrictions = { + 'filters': catalog_query_filter, + 'validUntil': valid_until, + 'userToken': user_id, + } + + # Determine indicies to restrict + indicies = [] + if self.ALGOLIA_INDEX_NAME: + indicies.append(self.ALGOLIA_INDEX_NAME) + if self.ALGOLIA_REPLICA_INDEX_NAME: + indicies.append(self.ALGOLIA_REPLICA_INDEX_NAME) + if indicies: + restrictions.update({ + 'restrictIndices': indicies, + }) + + # Generate secured api key + logger.info('[AlgoliaSearchClient.generate_secured_api_key] restrictions: %s', restrictions) + try: + secured_api_key = SearchClient.generate_secured_api_key( + self.ALGOLIA_SEARCH_API_KEY, + restrictions, + ) + except AlgoliaException as exc: + logger.exception('Could not generate secured Algolia API key due to an AlgoliaException.') + raise exc + + # Return secured api key and expiration time + return { + 'secured_api_key': secured_api_key, + 'valid_until': valid_until_iso, + }