Zendesk Proxy
This change creates a new lms/cms endpoint which accepts unauthenticated requests to securely create zendesk tickets. This allows javascript code to create tickets without exposing ZENDESK_OAUTH_ACCESS_TOKEN EDUCATOR-1889
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
7
openedx/core/djangoapps/zendesk_proxy/README.md
Normal file
7
openedx/core/djangoapps/zendesk_proxy/README.md
Normal file
@@ -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.
|
||||
0
openedx/core/djangoapps/zendesk_proxy/__init__.py
Normal file
0
openedx/core/djangoapps/zendesk_proxy/__init__.py
Normal file
120
openedx/core/djangoapps/zendesk_proxy/tests/test_views.py
Normal file
120
openedx/core/djangoapps/zendesk_proxy/tests/test_views.py
Normal file
@@ -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)
|
||||
11
openedx/core/djangoapps/zendesk_proxy/urls.py
Normal file
11
openedx/core/djangoapps/zendesk_proxy/urls.py
Normal file
@@ -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'),
|
||||
]
|
||||
72
openedx/core/djangoapps/zendesk_proxy/utils.py
Normal file
72
openedx/core/djangoapps/zendesk_proxy/utils.py
Normal file
@@ -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
|
||||
64
openedx/core/djangoapps/zendesk_proxy/v0/views.py
Normal file
64
openedx/core/djangoapps/zendesk_proxy/v0/views.py
Normal file
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user