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:
@@ -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
84
zaqar/common/urls.py
Normal 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()}
|
||||
79
zaqar/tests/unit/common/test_urls.py
Normal file
79
zaqar/tests/unit/common/test_urls.py
Normal 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')
|
||||
70
zaqar/tests/unit/transport/wsgi/v2_0/test_urls.py
Normal file
70
zaqar/tests/unit/transport/wsgi/v2_0/test_urls.py
Normal 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)
|
||||
@@ -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)),
|
||||
]
|
||||
|
||||
|
||||
|
||||
67
zaqar/transport/wsgi/v2_0/urls.py
Normal file
67
zaqar/transport/wsgi/v2_0/urls.py
Normal 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)
|
||||
Reference in New Issue
Block a user