Skip to content

Commit

Permalink
feat: per-user secured Algolia API keys
Browse files Browse the repository at this point in the history
  • Loading branch information
0x29a committed Dec 7, 2023
1 parent 6a59a2c commit 04df3a2
Show file tree
Hide file tree
Showing 8 changed files with 118 additions and 1 deletion.
43 changes: 42 additions & 1 deletion enterprise/api/v1/views/enterprise_customer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -17,10 +18,12 @@
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

Expand Down Expand Up @@ -436,3 +439,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)
2 changes: 2 additions & 0 deletions enterprise/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,8 @@ def root(*args):
'status': 'published'
}

ENTERPRISE_ALGOLIA_SEARCH_API_KEY = 'test'

SNOWFLAKE_SERVICE_USER = '[email protected]'
SNOWFLAKE_SERVICE_USER_PASSWORD = 'secret'

Expand Down
1 change: 1 addition & 0 deletions requirements/base.in
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions requirements/doc.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions requirements/test-master.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions requirements/test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions tests/test_enterprise/api/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Tests for the `edx-enterprise` api module.
"""

import base64
import copy
import json
import logging
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -1851,6 +1853,60 @@ 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
Expand Down

0 comments on commit 04df3a2

Please sign in to comment.