Add pre-signed url generation endpoint

This patchs adds the first interesting part of this blueprint. The patch
adds an endpoint that can be called to generate a pre-signed URL. To be
precise, as stated in the spec, the endpoint returns a json containing a
dictionary with the data required to validate the pre-signed URL.

The returned data follows this format:

    {
    'signature': '518b51ea133c4facadae42c328d6b77b',
    'expires': 2015-05-31T19:00:17Z,
    'project': '7d2f63fd4dcc47528e9b1d08f989cc00',
    'url': '/v2/queues/shared_queue/messages',
    'methods': ['GET', 'POST']
    }

Change-Id: I1dcc9b378478a6a74cf6b2e851e8a203d3bc18fd
Partially-Implements: pre-signed-url
This commit is contained in:
Flavio Percoco
2015-07-03 09:19:14 +02:00
parent 3f3e99f587
commit ff71aad4fc
6 changed files with 314 additions and 1 deletions

View File

@@ -46,6 +46,15 @@ _DRIVER_OPTIONS = (
_DRIVER_GROUP = 'drivers'
_SIGNED_URL_OPTIONS = (
cfg.StrOpt('secret_key', default=None,
help=('Secret key used to encrypt pre-signed URLs.')),
)
_SIGNED_URL_GROUP = 'signed_url'
def _config_options():
return [(None, _GENERAL_OPTIONS),
(_DRIVER_GROUP, _DRIVER_OPTIONS)]
(_DRIVER_GROUP, _DRIVER_OPTIONS),
(_SIGNED_URL_GROUP, _SIGNED_URL_OPTIONS)]

84
zaqar/common/urls.py Normal file
View File

@@ -0,0 +1,84 @@
# Copyright (c) 2015 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import datetime
import hashlib
import hmac
from oslo_utils import timeutils
import six
from zaqar import i18n
_LE = i18n._LE
_DATE_FORMAT = '%Y-%m-%dT%H:%M:%S%Z'
def create_signed_url(key, path, project=None, expires=None, methods=None):
"""Creates a signed url for the specified path
This function will create a pre-signed URL for `path` using the
specified `options` or the default ones. The signature will be the
hex value of the hmac created using `key`
:param key: A string to use as a `key` for the hmac generation.
:param path: A string representing an URL path.
:param project: (Default None) The ID of the project this URL belongs to.
:param methods: (Default ['GET']) A list of methods that will be
supported by the generated URL.
:params expires: (Default time() + 86400) The expiration date for
the generated URL.
"""
methods = methods or ['GET']
if key is None:
raise ValueError(_LE('The `key` can\'t be None'))
if path is None:
raise ValueError(_LE('The `path` can\'t be None'))
if not isinstance(methods, list):
raise ValueError(_LE('`methods` should be a list'))
# NOTE(flaper87): The default expiration time is 1day
# Evaluate whether this should be configurable. We may
# also want to have a "maximum" expiration time. Food
# for thoughts.
if expires is not None:
# NOTE(flaper87): Verify if the format is correct
# and normalize the value to UTC.
parsed = timeutils.parse_isotime(expires)
expires = timeutils.normalize_time(parsed)
else:
delta = datetime.timedelta(days=1)
expires = timeutils.utcnow(with_timezone=True) + delta
methods.sort()
expires_str = expires.strftime(_DATE_FORMAT)
hmac_body = six.b('%(path)s\\n%(methods)s\\n%(project)s\\n%(expires)s' %
{'path': path, 'methods': ','.join(methods),
'project': project, 'expires': expires_str})
if not isinstance(key, six.binary_type):
key = six.binary_type(key.encode('utf-8'))
return {'path': path,
'methods': methods,
'project': project,
'expires': expires_str,
'signature': hmac.new(key, hmac_body, hashlib.sha256).hexdigest()}

View File

@@ -0,0 +1,79 @@
# Copyright (c) 2015 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import datetime
import hashlib
import hmac
from oslo_utils import timeutils
import six
from zaqar.common import urls
from zaqar.tests import base
class TestURLs(base.TestBase):
def test_create_signed_url(self):
timeutils.set_time_override()
self.addCleanup(timeutils.clear_time_override)
key = six.b('test')
methods = ['POST']
project = 'my-project'
path = '/v2/queues/shared/messages'
expires = timeutils.utcnow(True) + datetime.timedelta(days=1)
expires_str = expires.strftime(urls._DATE_FORMAT)
hmac_body = six.b('%(path)s\\n%(methods)s\\n'
'%(project)s\\n%(expires)s' %
{'path': path, 'methods': ','.join(methods),
'project': project, 'expires': expires_str})
expected = hmac.new(key, hmac_body, hashlib.sha256).hexdigest()
actual = urls.create_signed_url(key, path, methods=['POST'],
project=project)
self.assertEqual(expected, actual['signature'])
def test_create_signed_url_utc(self):
"""Test that the method converts the TZ to UTC."""
date_str = '2015-05-31T19:00:17+02'
date_str_utc = '2015-05-31T17:00:17Z'
key = six.b('test')
project = None
methods = ['GET']
path = '/v2/queues/shared/messages'
parsed = timeutils.parse_isotime(date_str_utc)
expires = timeutils.normalize_time(parsed)
expires_str = expires.strftime(urls._DATE_FORMAT)
hmac_body = six.b('%(path)s\\n%(methods)s\\n'
'%(project)s\\n%(expires)s' %
{'path': path, 'methods': ','.join(methods),
'project': project, 'expires': expires_str})
expected = hmac.new(key, hmac_body, hashlib.sha256).hexdigest()
actual = urls.create_signed_url(key, path, expires=date_str)
self.assertEqual(expected, actual['signature'])
def test_create_signed_urls_validation(self):
self.assertRaises(ValueError, urls.create_signed_url, None, '/test')
self.assertRaises(ValueError, urls.create_signed_url, 'test', None)
self.assertRaises(ValueError, urls.create_signed_url, 'test', '/test',
methods='not list')
self.assertRaises(ValueError, urls.create_signed_url, 'test', '/test',
expires='wrong date format')

View File

@@ -0,0 +1,70 @@
# Copyright 2015 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import datetime
import falcon
from oslo_serialization import jsonutils
from oslo_utils import timeutils
from zaqar.common import urls
from zaqar.tests.unit.transport.wsgi import base
class TestURL(base.V2Base):
config_file = 'wsgi_mongodb.conf'
def setUp(self):
super(TestURL, self).setUp()
self.signed_url_prefix = self.url_prefix + '/queues/shared_queue/share'
self.config(secret_key='test', group='signed_url')
def test_url_generation(self):
timeutils.set_time_override()
self.addCleanup(timeutils.clear_time_override)
data = {'methods': ['GET', 'POST']}
response = self.simulate_post(self.signed_url_prefix,
body=jsonutils.dumps(data))
self.assertEqual(self.srmock.status, falcon.HTTP_200)
content = jsonutils.loads(response[0])
expires = timeutils.utcnow(True) + datetime.timedelta(days=1)
expires_str = expires.strftime(urls._DATE_FORMAT)
for field in ['signature', 'project', 'methods', 'path', 'expires']:
self.assertIn(field, content)
self.assertEqual(expires_str, content['expires'])
self.assertEqual(data['methods'], content['methods'])
def test_url_bad_request(self):
self.simulate_post(self.signed_url_prefix, body='not json')
self.assertEqual(self.srmock.status, falcon.HTTP_400)
data = {'dummy': 'meh'}
self.simulate_post(self.signed_url_prefix, body=jsonutils.dumps(data))
self.assertEqual(self.srmock.status, falcon.HTTP_400)
data = {'expires': 'wrong date format'}
self.simulate_post(self.signed_url_prefix, body=jsonutils.dumps(data))
self.assertEqual(self.srmock.status, falcon.HTTP_400)
data = {'methods': 'methods not list'}
self.simulate_post(self.signed_url_prefix, body=jsonutils.dumps(data))
self.assertEqual(self.srmock.status, falcon.HTTP_400)

View File

@@ -22,6 +22,7 @@ from zaqar.transport.wsgi.v2_0 import pools
from zaqar.transport.wsgi.v2_0 import queues
from zaqar.transport.wsgi.v2_0 import stats
from zaqar.transport.wsgi.v2_0 import subscriptions
from zaqar.transport.wsgi.v2_0 import urls
VERSION = {
@@ -103,6 +104,9 @@ def public_endpoints(driver, conf):
('/queues/{queue_name}/subscriptions/{subscription_id}',
subscriptions.ItemResource(driver._validate,
subscription_controller)),
# Pre-Signed URL Endpoint
('/queues/{queue_name}/share', urls.Resource(driver)),
]

View File

@@ -0,0 +1,67 @@
# Copyright 2015 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy
# of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
import os
from oslo_log import log as logging
import six
from zaqar.common import urls
from zaqar import i18n
from zaqar.transport import utils
from zaqar.transport.wsgi import errors as wsgi_errors
from zaqar.transport.wsgi import utils as wsgi_utils
_ = i18n._
_LE = i18n._LE
LOG = logging.getLogger(__name__)
_KNOWN_KEYS = set(['methods', 'expires'])
class Resource(object):
__slots__ = ('_driver', '_conf')
def __init__(self, driver):
self._driver = driver
self._conf = driver._conf
def on_post(self, req, resp, project_id, queue_name):
LOG.debug(u'Pre-Signed URL Creation for queue: %(queue)s, '
u'project: %(project)s',
{'queue': queue_name, 'project': project_id})
try:
document = wsgi_utils.deserialize(req.stream, req.content_length)
except ValueError as ex:
LOG.debug(ex)
raise wsgi_errors.HTTPBadRequestAPI(six.text_type(ex))
diff = set(document.keys()) - _KNOWN_KEYS
if diff:
msg = six.text_type(_LE('Unknown keys: %s') % diff)
raise wsgi_errors.HTTPBadRequestAPI(msg)
key = self._conf.signed_url.secret_key
path = os.path.join(req.path[:-6], 'messages')
try:
data = urls.create_signed_url(key, path,
project=project_id,
**document)
except ValueError as err:
raise wsgi_errors.HTTPBadRequestAPI(str(err))
resp.body = utils.to_json(data)