diff options
| author | Barry Warsaw | 2014-08-12 16:42:12 -0400 |
|---|---|---|
| committer | Barry Warsaw | 2014-08-12 16:42:12 -0400 |
| commit | 826261effa9d74b8ecdf1247e9ebba75fa3b2baa (patch) | |
| tree | f6e609e56db4fe202a3b85361651832433cb514b | |
| parent | d4d71f71f08d6d440b17482eecc5472dcfe6cbae (diff) | |
| download | mailman-826261effa9d74b8ecdf1247e9ebba75fa3b2baa.tar.gz mailman-826261effa9d74b8ecdf1247e9ebba75fa3b2baa.tar.zst mailman-826261effa9d74b8ecdf1247e9ebba75fa3b2baa.zip | |
| -rw-r--r-- | src/mailman/rest/docs/basic.rst | 70 | ||||
| -rw-r--r-- | src/mailman/rest/preferences.py | 10 | ||||
| -rw-r--r-- | src/mailman/rest/root.py | 100 | ||||
| -rw-r--r-- | src/mailman/rest/tests/test_root.py | 55 | ||||
| -rw-r--r-- | src/mailman/rest/wsgiapp.py | 66 | ||||
| -rw-r--r-- | src/mailman/testing/helpers.py | 5 |
6 files changed, 210 insertions, 96 deletions
diff --git a/src/mailman/rest/docs/basic.rst b/src/mailman/rest/docs/basic.rst index 51b287c90..42e5379ad 100644 --- a/src/mailman/rest/docs/basic.rst +++ b/src/mailman/rest/docs/basic.rst @@ -7,70 +7,48 @@ Mailman exposes a REST HTTP server for administrative control. The server listens for connections on a configurable host name and port. It is always protected by HTTP basic authentication using a single global -username and password. The credentials are set in the webservice section -of the config using the admin_user and admin_pass properties. +user name and password. The credentials are set in the `[webservice]` section +of the configuration using the `admin_user` and `admin_pass` properties. Because the REST server has full administrative access, it should always be run only on localhost, unless you really know what you're doing. In addition -you should set the username and password to secure values and distribute them +you should set the user name and password to secure values and distribute them to any REST clients with reasonable precautions. The Mailman major and minor version numbers are in the URL. -System information can be retrieved from the server. By default JSON is -returned. - >>> dump_json('http://localhost:9001/3.0/system') - http_etag: "..." - mailman_version: GNU Mailman 3.0... (...) - python_version: ... - self_link: http://localhost:9001/3.0/system - - -Non-existent links -================== - -When you try to access a link that doesn't exist, you get the appropriate HTTP -404 Not Found error. - - >>> dump_json('http://localhost:9001/3.0/does-not-exist') - Traceback (most recent call last): - ... - HTTPError: HTTP Error 404: 404 Not Found - - -Invalid credentials -=================== +Credentials +=========== -When you try to access the REST server using invalid credentials you will get -an appropriate HTTP 401 Unauthorized error. -:: +When the `Authorization` header contains the proper creditials, the request +succeeds. >>> from base64 import b64encode - >>> auth = b64encode('baduser:badpass') - - >>> url = 'http://localhost:9001/3.0/system' + >>> from httplib2 import Http + >>> auth = b64encode('{0}:{1}'.format(config.webservice.admin_user, + ... config.webservice.admin_pass)) >>> headers = { ... 'Content-Type': 'application/x-www-form-urlencode', ... 'Authorization': 'Basic ' + auth, ... } - - >>> from httplib2 import Http - >>> response, content = Http().request(url, 'GET', None, headers) - >>> print(content) - 401 Unauthorized - <BLANKLINE> - User is not authorized for the REST API - <BLANKLINE> - -But with the right headers, the request succeeds. - - >>> auth = b64encode('{0}:{1}'.format(config.webservice.admin_user, - ... config.webservice.admin_pass)) - >>> headers['Authorization'] = 'Basic ' + auth + >>> url = 'http://localhost:9001/3.0/system' >>> response, content = Http().request(url, 'GET', None, headers) >>> print(response.status) 200 +Basic information +================= + +System information can be retrieved from the server, in the form of a JSON +encoded response. + + >>> dump_json('http://localhost:9001/3.0/system') + http_etag: "..." + mailman_version: GNU Mailman 3.0... (...) + python_version: ... + self_link: http://localhost:9001/3.0/system + + .. _REST: http://en.wikipedia.org/wiki/REST diff --git a/src/mailman/rest/preferences.py b/src/mailman/rest/preferences.py index bbc6b1769..d501ef865 100644 --- a/src/mailman/rest/preferences.py +++ b/src/mailman/rest/preferences.py @@ -26,6 +26,8 @@ __all__ = [ ] +import falcon + from lazr.config import as_boolean from restish import http, resource @@ -48,15 +50,14 @@ PREFERENCES = ( -class ReadOnlyPreferences(resource.Resource): +class ReadOnlyPreferences: """.../<object>/preferences""" def __init__(self, parent, base_url): self._parent = parent self._base_url = base_url - @resource.GET() - def preferences(self, segments): + def on_get(self, request, response): resource = dict() for attr in PREFERENCES: # Handle this one specially. @@ -72,7 +73,8 @@ class ReadOnlyPreferences(resource.Resource): # Add the self link. resource['self_link'] = path_to( '{0}/preferences'.format(self._base_url)) - return http.ok([], etag(resource)) + response.status = falcon.HTTP_200 + response.body = etag(resource) diff --git a/src/mailman/rest/root.py b/src/mailman/rest/root.py index ec0c9c93b..6b13d8246 100644 --- a/src/mailman/rest/root.py +++ b/src/mailman/rest/root.py @@ -25,8 +25,10 @@ __all__ = [ ] +import falcon + from base64 import b64decode -from restish import guard, http, resource +from restish import http from zope.component import getUtility from mailman.config import config @@ -44,21 +46,25 @@ from mailman.rest.templates import TemplateFinder from mailman.rest.users import AUser, AllUsers - -def webservice_auth_checker(request, obj): - auth = request.environ.get('HTTP_AUTHORIZATION', '') - if auth.startswith('Basic '): - credentials = b64decode(auth[6:]) - username, password = credentials.split(':', 1) - if (username != config.webservice.admin_user or - password != config.webservice.admin_pass): - # Not authorized. - raise guard.GuardError(b'User is not authorized for the REST API') - else: - raise guard.GuardError(b'The REST API requires authentication') +def child(matcher=None): + def decorator(func): + if matcher is None: + func.__matcher__ = func.__name__ + else: + func.__matcher__ = matcher + return func + return decorator + + +class BadRequest: + def on_get(self, request, response): + raise falcon.HTTPError(falcon.HTTP_400, None) + +STOP = [] -class Root(resource.Resource): + +class Root: """The RESTful root resource. At the root of the tree are the API version numbers. Everything else @@ -67,33 +73,59 @@ class Root(resource.Resource): always be the case though. """ - @resource.child(config.webservice.api_version) - @guard.guard(webservice_auth_checker) + @child(config.webservice.api_version) def api_version(self, request, segments): - return TopLevel() + # We have to do this here instead of in a @falcon.before() handler + # because those handlers are not compatible with our custom traversal + # logic. Specifically, falcon's before/after handlers will call the + # responder, but the method we're wrapping isn't a responder, it's a + # child traversal method. There's no way to cause the thing that + # calls the before hook to follow through with the child traversal in + # the case where no error is raised. + if request.auth is None: + raise falcon.HTTPUnauthorized( + b'401 Unauthorized', + b'The REST API requires authentication') + if request.auth.startswith('Basic '): + credentials = b64decode(request.auth[6:]) + username, password = credentials.split(':', 1) + if (username != config.webservice.admin_user or + password != config.webservice.admin_pass): + # Not authorized. + raise falcon.HTTPUnauthorized( + b'401 Unauthorized', + b'User is not authorized for the REST API') + return TopLevel(), segments + + +class System: + def on_get(self, request, response): + """/<api>/system""" + resource = dict( + mailman_version=system.mailman_version, + python_version=system.python_version, + self_link=path_to('system'), + ) + response.status = falcon.HTTP_200 + response.body = etag(resource) -class TopLevel(resource.Resource): +class TopLevel: """Top level collections and entries.""" - @resource.child() + @child() def system(self, request, segments): """/<api>/system""" if len(segments) == 0: - resource = dict( - mailman_version=system.mailman_version, - python_version=system.python_version, - self_link=path_to('system'), - ) + return System(), STOP elif len(segments) > 1: - return http.bad_request() + return BadRequest(), STOP elif segments[0] == 'preferences': - return ReadOnlyPreferences(system_preferences, 'system'), [] + return ReadOnlyPreferences(system_preferences, 'system'), STOP else: - return http.bad_request() - return http.ok([], etag(resource)) + return BadRequest(), STOP - @resource.child() + @child() def addresses(self, request, segments): """/<api>/addresses /<api>/addresses/<email> @@ -104,7 +136,7 @@ class TopLevel(resource.Resource): email = segments.pop(0) return AnAddress(email), segments - @resource.child() + @child() def domains(self, request, segments): """/<api>/domains /<api>/domains/<domain> @@ -115,7 +147,7 @@ class TopLevel(resource.Resource): domain = segments.pop(0) return ADomain(domain), segments - @resource.child() + @child() def lists(self, request, segments): """/<api>/lists /<api>/lists/<list> @@ -135,7 +167,7 @@ class TopLevel(resource.Resource): list_identifier = segments.pop(0) return AList(list_identifier), segments - @resource.child() + @child() def members(self, request, segments): """/<api>/members""" if len(segments) == 0: @@ -148,7 +180,7 @@ class TopLevel(resource.Resource): else: return AMember(segment), segments - @resource.child() + @child() def users(self, request, segments): """/<api>/users""" if len(segments) == 0: @@ -157,7 +189,7 @@ class TopLevel(resource.Resource): user_id = segments.pop(0) return AUser(user_id), segments - @resource.child() + @child() def templates(self, request, segments): """/<api>/templates/<fqdn_listname>/<template>/[<language>] diff --git a/src/mailman/rest/tests/test_root.py b/src/mailman/rest/tests/test_root.py index 42ea2f6f9..7d9124e02 100644 --- a/src/mailman/rest/tests/test_root.py +++ b/src/mailman/rest/tests/test_root.py @@ -21,24 +21,41 @@ from __future__ import absolute_import, print_function, unicode_literals __metaclass__ = type __all__ = [ - 'TestSystem', + 'TestRoot', ] -import unittest import os +import json +import unittest -from urllib2 import HTTPError - +from base64 import b64encode +from httplib2 import Http from mailman.config import config +from mailman.core.system import system from mailman.testing.helpers import call_api from mailman.testing.layers import RESTLayer +from urllib2 import HTTPError -class TestSystem(unittest.TestCase): +class TestRoot(unittest.TestCase): layer = RESTLayer + def test_root_system(self): + # You can get the system preferences via the root path. + url = 'http://localhost:9001/3.0/system' + json, response = call_api(url) + self.assertEqual(json['mailman_version'], system.mailman_version) + self.assertEqual(json['python_version'], system.python_version) + self.assertEqual(json['self_link'], url) + + def test_path_under_root_does_not_exist(self): + # Accessing a non-existent path under root returns a 404. + with self.assertRaises(HTTPError) as cm: + call_api('http://localhost:9001/3.0/does-not-exist') + self.assertEqual(cm.exception.code, 404) + def test_system_url_too_long(self): # /system/foo/bar is not allowed. with self.assertRaises(HTTPError) as cm: @@ -76,3 +93,31 @@ class TestSystem(unittest.TestCase): # directory in var/queue. queue_directory = os.path.join(config.QUEUE_DIR, 'rest') self.assertFalse(os.path.isdir(queue_directory)) + + def test_no_basic_auth(self): + # If Basic Auth credentials are missing, it is a 401 error. + url = 'http://localhost:9001/3.0/system' + headers = { + 'Content-Type': 'application/x-www-form-urlencode', + } + response, raw_content = Http().request(url, 'GET', None, headers) + self.assertEqual(response.status, 401) + content = json.loads(raw_content) + self.assertEqual(content['title'], '401 Unauthorized') + self.assertEqual(content['description'], + 'The REST API requires authentication') + + def test_unauthorized(self): + # Bad Basic Auth credentials results in a 401 error. + auth = b64encode('baduser:badpass') + url = 'http://localhost:9001/3.0/system' + headers = { + 'Content-Type': 'application/x-www-form-urlencode', + 'Authorization': 'Basic ' + auth, + } + response, raw_content = Http().request(url, 'GET', None, headers) + self.assertEqual(response.status, 401) + content = json.loads(raw_content) + self.assertEqual(content['title'], '401 Unauthorized') + self.assertEqual(content['description'], + 'User is not authorized for the REST API') diff --git a/src/mailman/rest/wsgiapp.py b/src/mailman/rest/wsgiapp.py index b7ad3d698..8c2ee1758 100644 --- a/src/mailman/rest/wsgiapp.py +++ b/src/mailman/rest/wsgiapp.py @@ -28,7 +28,10 @@ __all__ = [ import logging -from restish.app import RestishApp +from falcon import API +from falcon.api_helpers import create_http_method_map +from falcon.status_codes import HTTP_404 + from wsgiref.simple_server import WSGIRequestHandler from wsgiref.simple_server import make_server as wsgi_server @@ -40,6 +43,12 @@ from mailman.rest.root import Root log = logging.getLogger('mailman.http') +def path_not_found(request, response, **kws): + # Like falcon.responders.path_not_found() but sets the body. + response.status = HTTP_404 + response.body = b'404 Not Found' + + class AdminWebServiceWSGIRequestHandler(WSGIRequestHandler): """Handler class which just logs output to the right place.""" @@ -49,15 +58,62 @@ class AdminWebServiceWSGIRequestHandler(WSGIRequestHandler): log.info('%s - - %s', self.address_string(), format % args) -class AdminWebServiceApplication(RestishApp): - """Connect the restish WSGI application to Mailman's database.""" +class RootedAPI(API): + def __init__(self, root, *args, **kws): + self._root = root + super(RootedAPI, self).__init__(*args, **kws) @transactional def __call__(self, environ, start_response): """See `RestishApp`.""" - return super(AdminWebServiceApplication, self).__call__( + return super(RootedAPI, self).__call__( environ, start_response) + def _get_responder(self, req): + path = req.path + method = req.method + path_segments = path.split('/') + # Since the path is always rooted at /, skip the first segment, which + # will always be the empty string. + path_segments.pop(0) + if len(path_segments) == 0: + # We're at the end of the path, so the root must be the responder. + method_map = create_http_method_map(self._root, None, None, None) + responder = method_map[method] + return responder, {}, self._root + this_segment = path_segments.pop(0) + resource = self._root + while True: + # See if there's a child matching the current segment. + # See if any of the resource's child links match the next segment. + for name in dir(resource): + if name.startswith('__') and name.endswith('__'): + continue + attribute = getattr(resource, name) + assert attribute is not None, name + matcher = getattr(attribute, '__matcher__', None) + if matcher is None: + continue + if matcher == this_segment: + resource, path_segments = attribute(req, path_segments) + # The method could have truncated the remaining segments, + # meaning, it's consumed all the path segments, or this is + # the last path segment. In that case the resource we're + # left at is the responder. + if len(path_segments) == 0: + # We're at the end of the path, so the root must be the + # responder. + method_map = create_http_method_map( + resource, None, None, None) + responder = method_map[method] + return responder, {}, resource + this_segment = path_segments.pop(0) + break + else: + # None of the attributes matched this path component, so the + # response is a 404. + return path_not_found, {}, None + def make_application(): @@ -66,7 +122,7 @@ def make_application(): Use this if you want to integrate Mailman's REST server with your own WSGI server. """ - return AdminWebServiceApplication(Root()) + return RootedAPI(Root()) def make_server(): diff --git a/src/mailman/testing/helpers.py b/src/mailman/testing/helpers.py index 1ef9e964e..aab23ab75 100644 --- a/src/mailman/testing/helpers.py +++ b/src/mailman/testing/helpers.py @@ -313,8 +313,9 @@ def call_api(url, data=None, method=None, username=None, password=None): :param password: The HTTP Basic Auth password. None means use the value from the configuration. :type username: str - :return: The response object and the JSON decoded content, if there is - any. If not, the second tuple item will be None. + :return: A 2-tuple containing the JSON decoded content (if there is any, + else None) and the response object. + :rtype: 2-tuple of (dict, response) :raises HTTPError: when a non-2xx return code is received. """ headers = {} |
