Skip to content

Commit

Permalink
feat: add API endpoint to retrieve secured Algolia API key for an Ent…
Browse files Browse the repository at this point in the history
…erpriseCustomer (#1039)
  • Loading branch information
adamstankiewicz authored Mar 3, 2025
1 parent 7e70e79 commit 579c177
Show file tree
Hide file tree
Showing 9 changed files with 427 additions and 34 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/mysql-migrations-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
- name: Cache pip dependencies
id: cache-dependencies
uses: actions/cache@v2
uses: actions/cache@v4
with:
path: ${{ steps.pip-cache-dir.outputs.dir }}
key: ${{ runner.os }}-pip-${{ hashFiles('requirements/pip_tools.txt') }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,24 @@ 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={
'enterprise_uuid': enterprise_uuid,
'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,
},
)
44 changes: 44 additions & 0 deletions enterprise_catalog/apps/api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,25 @@ def find_and_modify_catalog_query(
return content_filter_from_hash


class BaseSerializer(serializers.Serializer):
"""
Base serializer.
"""
def create(self, *args, **kwargs):
return None # pragma: no cover

def update(self, *args, **kwargs):
return None # pragma: no cover


class BaseErrorSerializer(BaseSerializer):
"""
Base error serializer.
"""
user_message = serializers.CharField()
developer_message = serializers.CharField()


class CatalogQuerySerializer(serializers.ModelSerializer):
"""
Serializer for the `CatalogQuery` model
Expand Down Expand Up @@ -525,3 +544,28 @@ 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(BaseErrorSerializer):
"""
Serializer for the error response of the secured Algolia API key endpoint.
"""
4 changes: 2 additions & 2 deletions enterprise_catalog/apps/api/v1/tests/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand All @@ -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)])

Expand Down
105 changes: 105 additions & 0 deletions enterprise_catalog/apps/api/v1/tests/test_enterprise_customer_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from unittest import mock

import pytz
from algoliasearch.exceptions import AlgoliaException
from django.test import override_settings
from rest_framework import status

from enterprise_catalog.apps.api.base.tests.enterprise_customer_views import (
Expand Down Expand Up @@ -470,3 +472,106 @@ 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={
'APPLICATION_ID': 'fake-app-id',
'API_KEY': 'fake-api-key',
'SEARCH_API_KEY': 'fake-search-api-key',
'INDEX_NAME': 'fake-index',
'REPLICA_INDEX_NAME': 'fake-replica-index',
},
)
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)
except ValueError: # pragma: no cover
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,
)

@override_settings(ALGOLIA={})
def test_secured_algolia_api_key_missing_search_api_key(self):
"""
Test that the secured Algolia API key is not generated if the search API key is missing.
"""
url = self._get_secured_algolia_api_key_base_url(self.enterprise_uuid)
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
expected_error_response = {
'user_message': 'Error generating secured Algolia API key.',
'developer_message': (
'Cannot generate secured Algolia API key without the ALGOLIA.SEARCH_API_KEY in settings.'
),
}
self.assertEqual(response.data, expected_error_response)

@mock.patch(
"algoliasearch.search_client.SearchClient.generate_secured_api_key",
side_effect=AlgoliaException("Mocked exception"),
)
@override_settings(ALGOLIA={'SEARCH_API_KEY': 'fake-search-api-search'})
def test_secured_algolia_api_key_algolia_exception(self, mock_generate_key): # pylint: disable=unused-argument
"""
Test that the secured Algolia API key is not generated if an AlgoliaException occurs.
"""
url = self._get_secured_algolia_api_key_base_url(self.enterprise_uuid)
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
expected_error_response = {
'user_message': 'Error generating secured Algolia API key.',
'developer_message': 'Mocked exception',
}
self.assertEqual(response.data, expected_error_response)

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_404_NOT_FOUND)
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_422_UNPROCESSABLE_ENTITY)
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)
134 changes: 131 additions & 3 deletions enterprise_catalog/apps/api/v1/views/enterprise_customer.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
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 import status
from rest_framework.decorators import action
from rest_framework.exceptions import NotFound, PermissionDenied
from rest_framework.response import Response
from rest_framework.status import HTTP_400_BAD_REQUEST

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


Expand Down Expand Up @@ -111,7 +119,7 @@ def contains_content_items(self, request, enterprise_uuid, course_run_ids, progr
)
return Response(
f'Error: invalid enterprice customer uuid: "{enterprise_uuid}" provided.',
status=HTTP_400_BAD_REQUEST
status=status.HTTP_400_BAD_REQUEST
)
customer_catalogs = EnterpriseCatalog.objects.filter(enterprise_uuid=enterprise_uuid)

Expand Down Expand Up @@ -209,3 +217,123 @@ 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=status.HTTP_404_NOT_FOUND,
)

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=status.HTTP_422_UNPROCESSABLE_ENTITY,
)

# 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'Could not attempt generation of secured Algolia API key for '
f'enterprise UUID {enterprise_uuid} due to improper configuration.'
)
error_response = {
'user_message': algolia_api_key_error_message,
'developer_message': exc,
}
return Response(
SecuredAlgoliaAPIKeyErrorResponseSerializer(error_response).data,
status=status.HTTP_422_UNPROCESSABLE_ENTITY,
)
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=status.HTTP_422_UNPROCESSABLE_ENTITY,
)

# 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)
Loading

0 comments on commit 579c177

Please sign in to comment.