Tweak Sahara to make version discovery easier

Or at least, tweak Sahara to be more accomodating to keystoneauth's way
of doing version discovery.

* Don't require auth to do version discovery
* Make project ID in URL optional for APIv1 (and also for APIv2, but
  don't go around advertising that... *wink*)

Change-Id: Idb6f734aee26cab5bd629963a66ba01c92760864
Closes-Bug: #1744350
This commit is contained in:
Jeremy Freudberg 2018-01-20 03:25:21 +00:00
parent 58816b4c44
commit b1503b36c4
7 changed files with 131 additions and 117 deletions

View File

@ -1,5 +1,5 @@
[pipeline:sahara]
pipeline = cors http_proxy_to_wsgi request_id acl auth_validator_v2 sahara_api
pipeline = cors http_proxy_to_wsgi request_id versions acl auth_validator sahara_api
[composite:sahara_api]
use = egg:Paste#urlmap
@ -22,15 +22,18 @@ paste.filter_factory = oslo_middleware.request_id:RequestId.factory
[filter:acl]
paste.filter_factory = keystonemiddleware.auth_token:filter_factory
# this filter is given as a reference for v1-only deployments
# [filter:auth_validator]
# paste.filter_factory = sahara.api.middleware.auth_valid:AuthValidator.factory
[filter:auth_validator_v2]
paste.filter_factory = sahara.api.middleware.auth_valid:AuthValidatorV2.factory
[filter:auth_validator]
paste.filter_factory = sahara.api.middleware.auth_valid:AuthValidator.factory
[filter:debug]
paste.filter_factory = oslo_middleware.debug:Debug.factory
[filter:http_proxy_to_wsgi]
paste.filter_factory = oslo_middleware:HTTPProxyToWSGI.factory
[filter:versions]
paste.filter_factory = sahara.api.middleware.version_discovery:VersionResponseMiddlewareV2.factory
# this filter is given as a reference for v1-only deployments
#[filter:versions]
#paste.filter_factory = sahara.api.middleware.version_discovery:VersionResponseMiddlewareV1.factory

View File

@ -0,0 +1,3 @@
---
other:
- The presence of project ID in Sahara APIv1 paths is now optional.

View File

@ -16,6 +16,7 @@
from oslo_log import log as logging
from oslo_middleware import base
from oslo_utils import strutils
from oslo_utils import uuidutils
import webob
import webob.exc as ex
@ -48,55 +49,16 @@ class AuthValidator(base.Middleware):
path = req.environ['PATH_INFO']
if path != '/':
try:
version, url_tenant, rest = strutils.split_path(path, 3, 3,
True)
version, possibly_url_tenant, rest = (
strutils.split_path(path, 2, 3, True)
)
except ValueError:
LOG.warning("Incorrect path: {path}".format(path=path))
raise ex.HTTPNotFound(_("Incorrect path"))
if token_tenant != url_tenant:
LOG.debug("Unauthorized: token tenant != requested tenant")
raise ex.HTTPUnauthorized(
_('Token tenant != requested tenant'))
return self.application
class AuthValidatorV2(base.Middleware):
"""Handles token auth results and tenants."""
@webob.dec.wsgify
def __call__(self, req):
"""Ensures valid path and tenant
Handle incoming requests by checking tenant info from the
headers and url ({tenant_id} url attribute), if using v1 or v1.1
APIs. If using the v2 API, this function just makes sure that
keystonemiddleware has populated the WSGI environment.
Pass request downstream on success.
Reject request if tenant_id from headers is not equal to the
tenant_id from url in the case of v1.
"""
path = req.environ['PATH_INFO']
if path != '/':
token_tenant = req.environ.get("HTTP_X_TENANT_ID")
if not token_tenant:
LOG.warning("Can't get tenant_id from env")
raise ex.HTTPServiceUnavailable()
try:
if path.startswith('/v2'):
version, rest = strutils.split_path(path, 2, 2, True)
else:
version, requested_tenant, rest = strutils.split_path(
path, 3, 3, True)
except ValueError:
LOG.warning("Incorrect path: {path}".format(path=path))
raise ex.HTTPNotFound(_("Incorrect path"))
if path.startswith('/v1'):
if token_tenant != requested_tenant:
if uuidutils.is_uuid_like(possibly_url_tenant):
url_tenant = possibly_url_tenant
if token_tenant != url_tenant:
LOG.debug("Unauthorized: token tenant != requested tenant")
raise ex.HTTPUnauthorized(
_('Token tenant != requested tenant'))

View File

@ -28,26 +28,13 @@ from sahara.utils import api as api_utils
CONF = cfg.CONF
def build_app(version_response=None):
def build_app():
"""App builder (wsgi).
Entry point for Sahara REST API server
"""
app = flask.Flask('sahara.api')
version_response = (version_response or
{
"versions": [
{"id": "v1.0", "status": "SUPPORTED"},
{"id": "v1.1", "status": "CURRENT"}
]
})
@app.route('/', methods=['GET'])
def version_list():
context.set_ctx(None)
return api_utils.render(version_response)
@app.teardown_request
def teardown_request(_ex=None):
context.set_ctx(None)
@ -78,14 +65,7 @@ def build_v2_app():
Entry point for Experimental V2 Sahara REST API server
"""
version_response = {
"versions": [
{"id": "v1.0", "status": "SUPPORTED"},
{"id": "v1.1", "status": "CURRENT"},
{"id": "v2", "status": "EXPERIMENTAL"}
]
}
app = build_app(version_response)
app = build_app()
api_v2.register_blueprints(app, url_prefix='/v2')

View File

@ -0,0 +1,73 @@
# Copyright (c) 2018 Massachusetts Open Cloud
#
# 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 re
from oslo_middleware import base
from oslo_serialization import jsonutils
import webob
import webob.dec
class VersionResponseMiddlewareV1(base.Middleware):
@webob.dec.wsgify
def __call__(self, req):
"""Respond to a request for all Sahara API versions."""
path = req.environ['PATH_INFO']
if re.match(r"^/*$", path):
response = webob.Response(request=req, status=300,
content_type="application/json")
response.body = jsonutils.dumps(self._get_versions(req))
return response
else:
return self.application
def _get_versions(self, req):
"""Populate the version response with APIv1 stuff."""
version_response = {
"versions": [
{"id": "v1.0",
"status": "SUPPORTED",
"links": self._get_links("1.0", req)
},
{"id": "v1.1",
"status": "CURRENT",
"links": self._get_links("1.1", req)
}
]
}
return version_response
@staticmethod
def _get_links(version, req):
href = "%s/v%s/" % (req.host_url, version)
return [{"rel": "self", "href": href}]
class VersionResponseMiddlewareV2(VersionResponseMiddlewareV1):
def _get_versions(self, req):
"""Populate the version response with APIv1 and APIv2 stuff."""
version_response = (
super(VersionResponseMiddlewareV2, self)._get_versions(req)
)
version_response["versions"].append(
{"id": "v2",
"status": "EXPERIMENTAL",
"links": self._get_links("2", req)
}
)
return version_response

View File

@ -21,6 +21,9 @@ from sahara.tests.unit import base as test_base
class AuthValidatorTest(test_base.SaharaTestCase):
tid1 = '8f9f0c8c4c634d6280e785b44a10b8ab'
tid2 = '431c8ab1f14f4607bdfc17e05b3924d1'
@staticmethod
@webob.dec.wsgify
def application(req):
@ -30,71 +33,55 @@ class AuthValidatorTest(test_base.SaharaTestCase):
super(AuthValidatorTest, self).setUp()
self.app = auth_valid.AuthValidator(self.application)
def test_auth_ok(self):
req = webob.Request.blank("/v1.1/tid/clusters", accept="text/plain",
def test_auth_ok_project_id_in_url(self):
req = webob.Request.blank("/v1.1/%s/clusters" % self.tid1,
accept="text/plain",
method="GET",
environ={"HTTP_X_TENANT_ID": "tid"})
environ={"HTTP_X_TENANT_ID": self.tid1})
res = req.get_response(self.app)
self.assertEqual(200, res.status_code)
def test_auth_ok_no_project_id_in_url(self):
req = webob.Request.blank("/v1.1/clusters",
accept="text/plain",
method="GET",
environ={"HTTP_X_TENANT_ID": self.tid1})
res = req.get_response(self.app)
self.assertEqual(200, res.status_code)
def test_auth_ok_without_path(self):
req = webob.Request.blank("/", accept="text/plain", method="GET",
environ={"HTTP_X_TENANT_ID": "tid"})
environ={"HTTP_X_TENANT_ID": self.tid1})
res = req.get_response(self.app)
self.assertEqual(200, res.status_code)
def test_auth_without_header(self):
req = webob.Request.blank("/v1.1/tid/clusters", accept="text/plain",
def test_auth_without_environ(self):
req = webob.Request.blank("/v1.1/%s/clusters" % self.tid1,
accept="text/plain",
method="GET")
res = req.get_response(self.app)
self.assertEqual(503, res.status_code)
def test_auth_with_wrong_url(self):
req = webob.Request.blank("/v1.1", accept="text/plain", method="GET",
environ={"HTTP_X_TENANT_ID": "tid"})
environ={"HTTP_X_TENANT_ID": self.tid1})
res = req.get_response(self.app)
self.assertEqual(404, res.status_code)
def test_auth_different_tenant(self):
req = webob.Request.blank("/v1.1/tid1/clusters", accept="text/plain",
req = webob.Request.blank("/v1.1/%s/clusters" % self.tid1,
accept="text/plain",
method="GET",
environ={"HTTP_X_TENANT_ID": "tid2"})
environ={"HTTP_X_TENANT_ID": self.tid2})
res = req.get_response(self.app)
self.assertEqual(401, res.status_code)
class AuthValidatorV2Test(test_base.SaharaTestCase):
@staticmethod
@webob.dec.wsgify
def application(req):
return "Banana"
def setUp(self):
super(AuthValidatorV2Test, self).setUp()
self.app = auth_valid.AuthValidatorV2(self.application)
def test_auth_ok(self):
req = webob.Request.blank("/v2/tid/clusters", accept="text/plain",
def test_auth_tenant_id_in_url_v2(self):
# NOTE(jfreud): we expect AuthValidator to let this case pass through
# although Flask will reject it with a 404 further down the pipeline
req = webob.Request.blank("/v2/%s/clusters" % self.tid1,
accept="text/plain",
method="GET",
environ={"HTTP_X_TENANT_ID": "tid"})
environ={"HTTP_X_TENANT_ID": self.tid1})
res = req.get_response(self.app)
self.assertEqual(200, res.status_code)
def test_auth_ok_without_path(self):
req = webob.Request.blank("/", accept="text/plain", method="GET",
environ={"HTTP_X_TENANT_ID": "tid"})
res = req.get_response(self.app)
self.assertEqual(200, res.status_code)
def test_auth_without_environ(self):
req = webob.Request.blank("/v2/tid/clusters", accept="text/plain",
method="GET")
res = req.get_response(self.app)
self.assertEqual(503, res.status_code)
def test_auth_with_wrong_url(self):
req = webob.Request.blank("/v2", accept="text/plain", method="GET",
environ={"HTTP_X_TENANT_ID": "tid"})
res = req.get_response(self.app)
self.assertEqual(404, res.status_code)

View File

@ -76,7 +76,7 @@ class Rest(flask.Blueprint):
if status:
flask.request.status_code = status
kwargs.pop("tenant_id")
kwargs.pop("tenant_id", None)
req_id = flask.request.environ.get(oslo_req_id.ENV_REQUEST_ID)
auth_plugin = flask.request.environ.get('keystone.token_auth')
ctx = context.Context(
@ -103,6 +103,8 @@ class Rest(flask.Blueprint):
return internal_error(500, 'Internal Server Error', e)
f_rule = "/<tenant_id>" + rule
self.add_url_rule(rule, endpoint, handler, **options)
self.add_url_rule(rule + '.json', endpoint, handler, **options)
self.add_url_rule(f_rule, endpoint, handler, **options)
self.add_url_rule(f_rule + '.json', endpoint, handler, **options)
@ -131,6 +133,7 @@ class RestV2(Rest):
if status:
flask.request.status_code = status
kwargs.pop("tenant_id", None)
req_id = flask.request.environ.get(oslo_req_id.ENV_REQUEST_ID)
auth_plugin = flask.request.environ.get('keystone.token_auth')
ctx = context.Context(
@ -156,8 +159,11 @@ class RestV2(Rest):
except Exception as e:
return internal_error(500, 'Internal Server Error', e)
f_rule = "/<tenant_id>" + rule
self.add_url_rule(rule, endpoint, handler, **options)
self.add_url_rule(rule + '.json', endpoint, handler, **options)
self.add_url_rule(f_rule, endpoint, handler, **options)
self.add_url_rule(f_rule + '.json', endpoint, handler, **options)
return func