diff --git a/cms/urls.py b/cms/urls.py index f0514f7aee..28becfbffe 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -152,7 +152,8 @@ urlpatterns = [ name='group_configurations_detail_handler'), url(r'^api/val/v0/', include('edxval.urls')), url(r'^api/tasks/v0/', include('user_tasks.urls')), - url(r'^accessibility$', contentstore.views.accessibility, name='accessibility') + url(r'^accessibility$', contentstore.views.accessibility, name='accessibility'), + url(r'^zendesk_proxy/', include('openedx.core.djangoapps.zendesk_proxy.urls')), ] JS_INFO_DICT = { diff --git a/lms/urls.py b/lms/urls.py index ac0624ee1b..041d819d4a 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -141,6 +141,9 @@ urlpatterns = [ url(r'^dashboard/', include('learner_dashboard.urls')), url(r'^api/experiments/', include('experiments.urls', namespace='api_experiments')), + + # Zendesk API proxy endpoint + url(r'^zendesk_proxy/', include('openedx.core.djangoapps.zendesk_proxy.urls')), ] # TODO: This needs to move to a separate urls.py once the student_account and diff --git a/openedx/core/djangoapps/zendesk_proxy/README.md b/openedx/core/djangoapps/zendesk_proxy/README.md new file mode 100644 index 0000000000..e693db2384 --- /dev/null +++ b/openedx/core/djangoapps/zendesk_proxy/README.md @@ -0,0 +1,7 @@ +# Zendesk API proxy endpoint + +Introduced via [EDUCATOR-1889](https://openedx.atlassian.net/browse/EDUCATOR-1889) + +### Purpose + +This djangoapp contains no models, just a single view. The intended purpose is to provide a way for unauthenticated POST requests to create ZenDesk tickets. The reason we use this proxy instead of a direct POST is that it allows us to keep ZenDesk credentials private, and rotate them if needed. This proxy endpoint should be rate-limited to avoid abuse. diff --git a/openedx/core/djangoapps/zendesk_proxy/__init__.py b/openedx/core/djangoapps/zendesk_proxy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/zendesk_proxy/tests/__init__.py b/openedx/core/djangoapps/zendesk_proxy/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/zendesk_proxy/tests/test_views.py b/openedx/core/djangoapps/zendesk_proxy/tests/test_views.py new file mode 100644 index 0000000000..9e85121d36 --- /dev/null +++ b/openedx/core/djangoapps/zendesk_proxy/tests/test_views.py @@ -0,0 +1,120 @@ +"""Tests for zendesk_proxy views.""" +from copy import deepcopy +import ddt +import json +from mock import MagicMock, patch + +from django.core.urlresolvers import reverse +from django.test.utils import override_settings + +from openedx.core.lib.api.test_utils import ApiTestCase +from openedx.core.djangoapps.zendesk_proxy.v0.views import ZENDESK_REQUESTS_PER_HOUR + + +@ddt.ddt +@override_settings( + ZENDESK_URL="https://www.superrealurlsthataredefinitelynotfake.com", + ZENDESK_OAUTH_ACCESS_TOKEN="abcdefghijklmnopqrstuvwxyz1234567890" +) +class ZendeskProxyTestCase(ApiTestCase): + """Tests for zendesk_proxy views.""" + + def setUp(self): + self.url = reverse('zendesk_proxy_v0') + self.request_data = { + 'name': 'John Q. Student', + 'tags': ['python_unit_test'], + 'email': { + 'from': 'JohnQStudent@example.com', + 'subject': 'Python Unit Test Help Request', + 'message': "Help! I'm trapped in a unit test factory and I can't get out!", + } + } + return super(ZendeskProxyTestCase, self).setUp() + + def test_post(self): + with patch('requests.post', return_value=MagicMock(status_code=201)) as mock_post: + response = self.request_without_auth( + 'post', + self.url, + data=json.dumps(self.request_data), + content_type='application/json' + ) + self.assertHttpCreated(response) + (mock_args, mock_kwargs) = mock_post.call_args + self.assertEqual(mock_args, ('https://www.superrealurlsthataredefinitelynotfake.com/api/v2/tickets.json',)) + self.assertEqual( + mock_kwargs, + { + 'headers': { + 'content-type': 'application/json', + 'Authorization': 'Bearer abcdefghijklmnopqrstuvwxyz1234567890' + }, + 'data': '{"ticket": {"comment": {"body": "Help! I\'m trapped in a unit test factory and I can\'t get out!"}, "subject": "Python Unit Test Help Request", "tags": ["python_unit_test"], "requester": {"name": "John Q. Student", "email": "JohnQStudent@example.com"}}}' # pylint: disable=line-too-long + } + ) + + @ddt.data('name', 'tags', 'email') + def test_bad_request(self, key_to_delete): + test_data = deepcopy(self.request_data) + _ = test_data.pop(key_to_delete) + + response = self.request_without_auth( + 'post', + self.url, + data=json.dumps(test_data), + content_type='application/json' + ) + self.assertHttpBadRequest(response) + + @override_settings( + ZENDESK_URL=None, + ZENDESK_OAUTH_ACCESS_TOKEN=None + ) + def test_missing_settings(self): + response = self.request_without_auth( + 'post', + self.url, + data=json.dumps(self.request_data), + content_type='application/json' + ) + self.assertEqual(response.status_code, 503) + + @ddt.data(201, 400, 401, 403, 404, 500) + def test_zendesk_status_codes(self, mock_code): + with patch('requests.post', return_value=MagicMock(status_code=mock_code)): + response = self.request_without_auth( + 'post', + self.url, + data=json.dumps(self.request_data), + content_type='application/json' + ) + self.assertEqual(response.status_code, mock_code) + + def test_unexpected_error_pinging_zendesk(self): + with patch('requests.post', side_effect=Exception("WHAMMY")): + response = self.request_without_auth( + 'post', + self.url, + data=json.dumps(self.request_data), + content_type='application/json' + ) + self.assertEqual(response.status_code, 500) + + @override_settings( + CACHES={ + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'zendesk_proxy', + } + } + ) + def test_rate_limiting(self): + """ + Confirm rate limits work as expected. Note that drf's rate limiting makes use of the default cache to enforce + limits; that's why this test needs a "real" default cache (as opposed to the usual-for-tests DummyCache) + """ + for _ in range(ZENDESK_REQUESTS_PER_HOUR): + self.request_without_auth('post', self.url) + response = self.request_without_auth('post', self.url) + self.assertEqual(response.status_code, 429) diff --git a/openedx/core/djangoapps/zendesk_proxy/urls.py b/openedx/core/djangoapps/zendesk_proxy/urls.py new file mode 100644 index 0000000000..668f5f6d9c --- /dev/null +++ b/openedx/core/djangoapps/zendesk_proxy/urls.py @@ -0,0 +1,11 @@ +""" +Map urls to the relevant view handlers +""" + +from django.conf.urls import url + +from openedx.core.djangoapps.zendesk_proxy.v0.views import ZendeskPassthroughView as v0_view + +urlpatterns = [ + url(r'^v0$', v0_view.as_view(), name='zendesk_proxy_v0'), +] diff --git a/openedx/core/djangoapps/zendesk_proxy/utils.py b/openedx/core/djangoapps/zendesk_proxy/utils.py new file mode 100644 index 0000000000..9a0c3cab4f --- /dev/null +++ b/openedx/core/djangoapps/zendesk_proxy/utils.py @@ -0,0 +1,72 @@ +""" +Utility functions for zendesk interaction. +""" +import json +import logging +from urlparse import urljoin + +from django.conf import settings +import requests +from rest_framework import status + +log = logging.getLogger(__name__) + + +def create_zendesk_ticket(requester_name, requester_email, subject, body, tags=None): + """ + Create a Zendesk ticket via API. + + Note that we do this differently in other locations (lms/djangoapps/commerce/signals.py and + common/djangoapps/util/views.py). Both of those callers use basic auth, and should be switched over to this oauth + implementation once the immediate pressures of zendesk_proxy are resolved. + """ + if not (settings.ZENDESK_URL and settings.ZENDESK_OAUTH_ACCESS_TOKEN): + log.debug('Zendesk is not configured. Cannot create a ticket.') + return status.HTTP_503_SERVICE_UNAVAILABLE + + # Remove duplicates from tags list + tags = list(set(tags)) + + data = { + 'ticket': { + 'requester': { + 'name': requester_name, + 'email': requester_email + }, + 'subject': subject, + 'comment': {'body': body}, + 'tags': tags + } + } + + # Encode the data to create a JSON payload + payload = json.dumps(data) + + # Set the request parameters + url = urljoin(settings.ZENDESK_URL, '/api/v2/tickets.json') + headers = { + 'content-type': 'application/json', + 'Authorization': "Bearer {}".format(settings.ZENDESK_OAUTH_ACCESS_TOKEN), + } + + def _std_error_message(details, payload): + """Internal helper to standardize error message. This allows for simpler splunk alerts.""" + return 'zendesk_proxy action required\n{}\nNo ticket created for payload {}'.format(details, payload) + + try: + response = requests.post(url, data=payload, headers=headers) + + # Check for HTTP codes other than 201 (Created) + if response.status_code == status.HTTP_201_CREATED: + log.debug('Successfully created ticket for {}'.format(requester_email)) + else: + log.error( + _std_error_message( + 'Unexpected response: {} - {}'.format(response.status_code, response.content), + payload + ) + ) + return response.status_code + except Exception: # pylint: disable=broad-except + log.exception(_std_error_message('Internal server error', payload)) + return status.HTTP_500_INTERNAL_SERVER_ERROR diff --git a/openedx/core/djangoapps/zendesk_proxy/v0/__init__.py b/openedx/core/djangoapps/zendesk_proxy/v0/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/zendesk_proxy/v0/views.py b/openedx/core/djangoapps/zendesk_proxy/v0/views.py new file mode 100644 index 0000000000..2a281b1dee --- /dev/null +++ b/openedx/core/djangoapps/zendesk_proxy/v0/views.py @@ -0,0 +1,64 @@ +""" +Define request handlers used by the zendesk_proxy djangoapp +""" +from rest_framework import status +from rest_framework.parsers import JSONParser +from rest_framework.response import Response +from rest_framework.throttling import SimpleRateThrottle +from rest_framework.views import APIView + +from openedx.core.djangoapps.zendesk_proxy.utils import create_zendesk_ticket + +ZENDESK_REQUESTS_PER_HOUR = 15 + + +class ZendeskProxyThrottle(SimpleRateThrottle): + """ + Custom throttle rates for this particular endpoint's use case. + """ + def __init__(self): + self.rate = '{}/hour'.format(ZENDESK_REQUESTS_PER_HOUR) + super(ZendeskProxyThrottle, self).__init__() + + def get_cache_key(self, request, view): # pylint: disable=unused-argument + """ + By providing a static string here, we are limiting *all* users to the same combined limit. + """ + return "ZendeskProxy_rate_limit_cache_key" + + +class ZendeskPassthroughView(APIView): + """ + An APIView that will take in inputs from an unauthenticated endpoint, and use them to securely create a zendesk + ticket. + """ + throttle_classes = ZendeskProxyThrottle, + parser_classes = JSONParser, + + def post(self, request): + """ + request body is expected to look like this: + { + "name": "John Q. Student", + "email": { + "from": "JohnQStudent@realemailhost.com", + "message": "I, John Q. Student, am having problems for the following reasons: ...", + "subject": "Help Request" + }, + "tags": ["zendesk_help_request"] + } + """ + try: + proxy_status = create_zendesk_ticket( + requester_name=request.data['name'], + requester_email=request.data['email']['from'], + subject=request.data['email']['subject'], + body=request.data['email']['message'], + tags=request.data['tags'] + ) + except KeyError: + return Response(status=status.HTTP_400_BAD_REQUEST) + + return Response( + status=proxy_status + ) diff --git a/openedx/core/lib/api/test_utils.py b/openedx/core/lib/api/test_utils.py index eb8c2a2aa3..77d8b48675 100644 --- a/openedx/core/lib/api/test_utils.py +++ b/openedx/core/lib/api/test_utils.py @@ -24,9 +24,16 @@ class ApiTestCase(TestCase): return {'HTTP_AUTHORIZATION': 'Basic ' + base64.b64encode('%s:%s' % (username, password))} def request_with_auth(self, method, *args, **kwargs): - """Issue a get request to the given URI with the API key header""" + """Issue a request to the given URI with the API key header""" return getattr(self.client, method)(*args, HTTP_X_EDX_API_KEY=TEST_API_KEY, **kwargs) + def request_without_auth(self, method, *args, **kwargs): + """ + Issue a request to the given URI without the API key header. This may be useful if you'll be calling + an endpoint from javascript code and want to avoid exposing our API key. + """ + return getattr(self.client, method)(*args, **kwargs) + def get_json(self, *args, **kwargs): """Make a request with the given args and return the parsed JSON response""" resp = self.request_with_auth("get", *args, **kwargs) @@ -52,6 +59,10 @@ class ApiTestCase(TestCase): """Assert that the given response has the status code 200""" self.assertEqual(response.status_code, 200) + def assertHttpCreated(self, response): + """Assert that the given response has the status code 201""" + self.assertEqual(response.status_code, 201) + def assertHttpForbidden(self, response): """Assert that the given response has the status code 403""" self.assertEqual(response.status_code, 403)