Commit 8743cda0 by Eric Fischer

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
parent 98b391a2
......@@ -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
......
# 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.
"""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)
"""
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'),
]
"""
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
"""
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)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment