changes/10/382810/1
Mike Fedosin 7 years ago
parent a15b8b1b9a
commit b4bcc61991

@ -0,0 +1,8 @@
[DEFAULT]
test_command=OS_STDOUT_CAPTURE=${OS_STDOUT_CAPTURE:-1} \
OS_STDERR_CAPTURE=${OS_STDERR_CAPTURE:-1} \
OS_TEST_TIMEOUT=${OS_TEST_TIMEOUT:-160} \
${PYTHON:-python} -m subunit.run discover -t ./ ./glare/tests $LISTOPT $IDOPTION
test_id_option=--load-list $IDFILE
test_list_option=--list

@ -0,0 +1 @@
[python: **.py]

@ -0,0 +1,245 @@
# optional: after how many files to update progress
#show_progress_every: 100
# optional: plugins directory name
#plugins_dir: 'plugins'
# optional: plugins discovery name pattern
plugin_name_pattern: '*.py'
# optional: terminal escape sequences to display colors
#output_colors:
# DEFAULT: '\033[0m'
# HEADER: '\033[95m'
# LOW: '\033[94m'
# MEDIUM: '\033[93m'
# HIGH: '\033[91m'
# optional: log format string
#log_format: "[%(module)s]\t%(levelname)s\t%(message)s"
# globs of files which should be analyzed
include:
- '*.py'
- '*.pyw'
# a list of strings, which if found in the path will cause files to be excluded
# for example /tests/ - to remove all all files in tests directory
exclude_dirs:
- '/tests/'
profiles:
gate:
include:
- any_other_function_with_shell_equals_true
- assert_used
- blacklist_calls
- blacklist_import_func
# One of the blacklisted imports is the subprocess module. Keystone
# has to import the subprocess module in a single module for
# eventlet support so in most cases bandit won't be able to detect
# that subprocess is even being imported. Also, Bandit's
# recommendation is just to check that the use is safe without any
# documentation on what safe or unsafe usage is. So this test is
# skipped.
# - blacklist_imports
- exec_used
- execute_with_run_as_root_equals_true
# - hardcoded_bind_all_interfaces # TODO: enable this test
# Not working because wordlist/default-passwords file not bundled,
# see https://bugs.launchpad.net/bandit/+bug/1451575 :
# - hardcoded_password
# Not used because it's prone to false positives:
# - hardcoded_sql_expressions
# - hardcoded_tmp_directory # TODO: enable this test
- jinja2_autoescape_false
- linux_commands_wildcard_injection
- paramiko_calls
- password_config_option_not_marked_secret
- request_with_no_cert_validation
- set_bad_file_permissions
- subprocess_popen_with_shell_equals_true
# - subprocess_without_shell_equals_true # TODO: enable this test
- start_process_with_a_shell
# - start_process_with_no_shell # TODO: enable this test
- start_process_with_partial_path
- ssl_with_bad_defaults
- ssl_with_bad_version
- ssl_with_no_version
# - try_except_pass # TODO: enable this test
- use_of_mako_templates
blacklist_calls:
bad_name_sets:
# - pickle:
# qualnames: [pickle.loads, pickle.load, pickle.Unpickler,
# cPickle.loads, cPickle.load, cPickle.Unpickler]
# message: "Pickle library appears to be in use, possible security issue."
# TODO: enable this test
- marshal:
qualnames: [marshal.load, marshal.loads]
message: "Deserialization with the marshal module is possibly dangerous."
# - md5:
# qualnames: [hashlib.md5, Crypto.Hash.MD2.new, Crypto.Hash.MD4.new, Crypto.Hash.MD5.new, cryptography.hazmat.primitives.hashes.MD5]
# message: "Use of insecure MD2, MD4, or MD5 hash function."
# TODO: enable this test
- mktemp_q:
qualnames: [tempfile.mktemp]
message: "Use of insecure and deprecated function (mktemp)."
- eval:
qualnames: [eval]
message: "Use of possibly insecure function - consider using safer ast.literal_eval."
- mark_safe:
names: [mark_safe]
message: "Use of mark_safe() may expose cross-site scripting vulnerabilities and should be reviewed."
- httpsconnection:
qualnames: [httplib.HTTPSConnection]
message: "Use of HTTPSConnection does not provide security, see https://wiki.openstack.org/wiki/OSSN/OSSN-0033"
- yaml_load:
qualnames: [yaml.load]
message: "Use of unsafe yaml load. Allows instantiation of arbitrary objects. Consider yaml.safe_load()."
- urllib_urlopen:
qualnames: [urllib.urlopen, urllib.urlretrieve, urllib.URLopener, urllib.FancyURLopener, urllib2.urlopen, urllib2.Request]
message: "Audit url open for permitted schemes. Allowing use of file:/ or custom schemes is often unexpected."
- random:
qualnames: [random.random, random.randrange, random.randint, random.choice, random.uniform, random.triangular]
message: "Standard pseudo-random generators are not suitable for security/cryptographic purposes."
level: "LOW"
# Most of this is based off of Christian Heimes' work on defusedxml:
# https://pypi.python.org/pypi/defusedxml/#defusedxml-sax
# TODO(jaegerandi): Enable once defusedxml is in global requirements.
#- xml_bad_cElementTree:
# qualnames: [xml.etree.cElementTree.parse,
# xml.etree.cElementTree.iterparse,
# xml.etree.cElementTree.fromstring,
# xml.etree.cElementTree.XMLParser]
# message: "Using {func} to parse untrusted XML data is known to be vulnerable to XML attacks. Replace {func} with it's defusedxml equivilent function."
#- xml_bad_ElementTree:
# qualnames: [xml.etree.ElementTree.parse,
# xml.etree.ElementTree.iterparse,
# xml.etree.ElementTree.fromstring,
# xml.etree.ElementTree.XMLParser]
# message: "Using {func} to parse untrusted XML data is known to be vulnerable to XML attacks. Replace {func} with it's defusedxml equivilent function."
- xml_bad_expatreader:
qualnames: [xml.sax.expatreader.create_parser]
message: "Using {func} to parse untrusted XML data is known to be vulnerable to XML attacks. Replace {func} with it's defusedxml equivilent function."
- xml_bad_expatbuilder:
qualnames: [xml.dom.expatbuilder.parse,
xml.dom.expatbuilder.parseString]
message: "Using {func} to parse untrusted XML data is known to be vulnerable to XML attacks. Replace {func} with it's defusedxml equivilent function."
- xml_bad_sax:
qualnames: [xml.sax.parse,
xml.sax.parseString,
xml.sax.make_parser]
message: "Using {func} to parse untrusted XML data is known to be vulnerable to XML attacks. Replace {func} with it's defusedxml equivilent function."
- xml_bad_minidom:
qualnames: [xml.dom.minidom.parse,
xml.dom.minidom.parseString]
message: "Using {func} to parse untrusted XML data is known to be vulnerable to XML attacks. Replace {func} with it's defusedxml equivilent function."
- xml_bad_pulldom:
qualnames: [xml.dom.pulldom.parse,
xml.dom.pulldom.parseString]
message: "Using {func} to parse untrusted XML data is known to be vulnerable to XML attacks. Replace {func} with it's defusedxml equivilent function."
- xml_bad_etree:
qualnames: [lxml.etree.parse,
lxml.etree.fromstring,
lxml.etree.RestrictedElement,
lxml.etree.GlobalParserTLS,
lxml.etree.getDefaultParser,
lxml.etree.check_docinfo]
message: "Using {func} to parse untrusted XML data is known to be vulnerable to XML attacks. Replace {func} with it's defusedxml equivilent function."
shell_injection:
# Start a process using the subprocess module, or one of its wrappers.
subprocess: [subprocess.Popen, subprocess.call, subprocess.check_call,
subprocess.check_output, utils.execute, utils.execute_with_timeout]
# Start a process with a function vulnerable to shell injection.
shell: [os.system, os.popen, os.popen2, os.popen3, os.popen4,
popen2.popen2, popen2.popen3, popen2.popen4, popen2.Popen3,
popen2.Popen4, commands.getoutput, commands.getstatusoutput]
# Start a process with a function that is not vulnerable to shell injection.
no_shell: [os.execl, os.execle, os.execlp, os.execlpe, os.execv,os.execve,
os.execvp, os.execvpe, os.spawnl, os.spawnle, os.spawnlp,
os.spawnlpe, os.spawnv, os.spawnve, os.spawnvp, os.spawnvpe,
os.startfile]
blacklist_imports:
bad_import_sets:
- telnet:
imports: [telnetlib]
level: HIGH
message: "Telnet is considered insecure. Use SSH or some other encrypted protocol."
- info_libs:
imports: [pickle, cPickle, subprocess, Crypto]
level: LOW
message: "Consider possible security implications associated with {module} module."
# Most of this is based off of Christian Heimes' work on defusedxml:
# https://pypi.python.org/pypi/defusedxml/#defusedxml-sax
- xml_libs:
imports: [xml.etree.cElementTree,
xml.etree.ElementTree,
xml.sax.expatreader,
xml.sax,
xml.dom.expatbuilder,
xml.dom.minidom,
xml.dom.pulldom,
lxml.etree,
lxml]
message: "Using {module} to parse untrusted XML data is known to be vulnerable to XML attacks. Replace {module} with the equivilent defusedxml package."
level: LOW
- xml_libs_high:
imports: [xmlrpclib]
message: "Using {module} to parse untrusted XML data is known to be vulnerable to XML attacks. Use defused.xmlrpc.monkey_patch() function to monkey-patch xmlrpclib and mitigate XML vulnerabilities."
level: HIGH
hardcoded_tmp_directory:
tmp_dirs: ['/tmp', '/var/tmp', '/dev/shm']
hardcoded_password:
# Support for full path, relative path and special "%(site_data_dir)s"
# substitution (/usr/{local}/share)
word_list: "%(site_data_dir)s/wordlist/default-passwords"
ssl_with_bad_version:
bad_protocol_versions:
- 'PROTOCOL_SSLv2'
- 'SSLv2_METHOD'
- 'SSLv23_METHOD'
- 'PROTOCOL_SSLv3' # strict option
- 'PROTOCOL_TLSv1' # strict option
- 'SSLv3_METHOD' # strict option
- 'TLSv1_METHOD' # strict option
password_config_option_not_marked_secret:
function_names:
- oslo.config.cfg.StrOpt
- oslo_config.cfg.StrOpt
execute_with_run_as_root_equals_true:
function_names:
- ceilometer.utils.execute
- cinder.utils.execute
- neutron.agent.linux.utils.execute
- nova.utils.execute
- nova.utils.trycmd
try_except_pass:
check_typed_exception: True

@ -0,0 +1,38 @@
# Use this pipeline for no auth - DEFAULT
[pipeline:glare-api]
pipeline = cors faultwrapper healthcheck versionnegotiation osprofiler unauthenticated-context glarev1api
# Use this pipeline for keystone auth
[pipeline:glare-api-keystone]
pipeline = cors faultwrapper healthcheck versionnegotiation osprofiler authtoken context glarev1api
[app:glarev1api]
paste.app_factory = glare.api.v1.router:API.factory
[filter:healthcheck]
paste.filter_factory = oslo_middleware:Healthcheck.factory
backends = disable_by_file
disable_by_file_path = /etc/glare/healthcheck_disable
[filter:versionnegotiation]
paste.filter_factory = glare.api.middleware.version_negotiation:GlareVersionNegotiationFilter.factory
[filter:faultwrapper]
paste.filter_factory = glare.api.middleware.fault:GlareFaultWrapperFilter.factory
[filter:context]
paste.filter_factory = glare.api.middleware.glare_context:ContextMiddleware.factory
[filter:unauthenticated-context]
paste.filter_factory = glare.api.middleware.glare_context:UnauthenticatedContextMiddleware.factory
[filter:authtoken]
paste.filter_factory = keystonemiddleware.auth_token:filter_factory
delay_auth_decision = true
[filter:osprofiler]
paste.filter_factory = osprofiler.web:WsgiMiddleware.factory
[filter:cors]
use = egg:oslo.middleware#cors
oslo_config_project = glare

@ -0,0 +1,25 @@
# glare-swift.conf.sample
#
# This file is an example config file when
# multiple swift accounts/backing stores are enabled.
#
# Specify the reference name in []
# For each section, specify the auth_address, user and key.
#
# WARNING:
# * If any of auth_address, user or key is not specified,
# the glare's swift store will fail to configure
[ref1]
user = tenant:user1
key = key1
auth_version = 2
auth_address = http://localhost:5000/v2.0
[ref2]
user = project_name:user_name2
key = key2
user_domain_id = default
project_domain_id = default
auth_version = 3
auth_address = http://localhost:5000/v3

@ -0,0 +1,9 @@
[DEFAULT]
output_file = etc/glare.conf.sample
namespace = glare
namespace = glance.store
namespace = oslo.db
namespace = oslo.db.concurrency
namespace = keystonemiddleware.auth_token
namespace = oslo.log
namespace = oslo.middleware.cors

@ -0,0 +1,130 @@
# Copyright 2016 OpenStack Foundation
# All Rights Reserved.
#
# 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.
"""A middleware that turns exceptions into parsable string.
Inspired by Cinder's and Heat't faultwrapper.
"""
import sys
import traceback
from oslo_config import cfg
from oslo_log import log as logging
from oslo_utils import reflection
import six
import webob
from glare.common import exception
from glare.common import wsgi
LOG = logging.getLogger(__name__)
class Fault(object):
def __init__(self, error):
self.error = error
@webob.dec.wsgify(RequestClass=wsgi.Request)
def __call__(self, req):
serializer = wsgi.JSONResponseSerializer()
resp = webob.Response(request=req)
default_webob_exc = webob.exc.HTTPInternalServerError()
resp.status_code = self.error.get('code', default_webob_exc.code)
serializer.default(resp, self.error)
return resp
class GlareFaultWrapperFilter(wsgi.Middleware):
"""Replace error body with something the client can parse."""
error_map = {
'BadRequest': webob.exc.HTTPBadRequest,
'Unauthorized': webob.exc.HTTPUnauthorized,
'Forbidden': webob.exc.HTTPForbidden,
'NotFound': webob.exc.HTTPNotFound,
'RequestTimeout': webob.exc.HTTPRequestTimeout,
'Conflict': webob.exc.HTTPConflict,
'Gone': webob.exc.HTTPGone,
'PreconditionFailed': webob.exc.HTTPPreconditionFailed,
'RequestEntityTooLarge': webob.exc.HTTPRequestEntityTooLarge,
'UnsupportedMediaType': webob.exc.HTTPUnsupportedMediaType,
'RequestRangeNotSatisfiable': webob.exc.HTTPRequestRangeNotSatisfiable,
'Locked': webob.exc.HTTPLocked,
'FailedDependency': webob.exc.HTTPFailedDependency,
'NotAcceptable': webob.exc.HTTPNotAcceptable,
}
def _map_exception_to_error(self, class_exception):
if class_exception == exception.GlareException:
return webob.exc.HTTPInternalServerError
if class_exception.__name__ not in self.error_map:
return self._map_exception_to_error(class_exception.__base__)
return self.error_map[class_exception.__name__]
def _error(self, ex):
trace = None
traceback_marker = 'Traceback (most recent call last)'
webob_exc = None
ex_type = reflection.get_class_name(ex, fully_qualified=False)
full_message = six.text_type(ex)
if traceback_marker in full_message:
message, msg_trace = full_message.split(traceback_marker, 1)
message = message.rstrip('\n')
msg_trace = traceback_marker + msg_trace
else:
msg_trace = 'None\n'
if sys.exc_info() != (None, None, None):
msg_trace = traceback.format_exc()
message = full_message
if isinstance(ex, exception.GlareException):
message = ex.message
if cfg.CONF.debug and not trace:
trace = msg_trace
if not webob_exc:
webob_exc = self._map_exception_to_error(ex.__class__)
error = {
'code': webob_exc.code,
'title': webob_exc.title,
'explanation': webob_exc.explanation,
'error': {
'message': message,
'type': ex_type,
'traceback': trace,
}
}
# add microversion header is this is not acceptable request
if isinstance(ex, exception.InvalidGlobalAPIVersion):
error['min_version'] = ex.kwargs['min_ver']
error['max_version'] = ex.kwargs['max_ver']
return error
def process_request(self, req):
try:
return req.get_response(self.application)
except Exception as exc:
LOG.exception(exc)
return req.get_response(Fault(self._error(exc)))

@ -0,0 +1,131 @@
# Copyright 2011-2016 OpenStack Foundation
# All Rights Reserved.
#
# 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 webob
from oslo_config import cfg
from oslo_context import context
from oslo_log import log as logging
from oslo_middleware import request_id
from oslo_serialization import jsonutils
from glare.common import policy
from glare.common import wsgi
from glare.i18n import _
context_opts = [
cfg.BoolOpt('allow_anonymous_access', default=False,
help=_('Allow unauthenticated users to access the API with '
'read-only privileges. This only applies when using '
'ContextMiddleware.'))
]
CONF = cfg.CONF
CONF.register_opts(context_opts)
LOG = logging.getLogger(__name__)
class RequestContext(context.RequestContext):
"""Stores information about the security context for Glare.
Stores how the user accesses the system, as well as additional request
information.
"""
def __init__(self, service_catalog=None, **kwargs):
super(RequestContext, self).__init__(**kwargs)
self.service_catalog = service_catalog
# check if user is admin using policy file
if kwargs.get('is_admin') is None:
self.is_admin = policy.check_is_admin(self)
def to_dict(self):
d = super(RequestContext, self).to_dict()
d.update({
'service_catalog': self.service_catalog,
})
return d
@classmethod
def from_dict(cls, values):
return cls(**values)
def to_policy_values(self):
values = super(RequestContext, self).to_policy_values()
values['is_admin'] = self.is_admin
values['read_only'] = self.read_only
return values
class ContextMiddleware(wsgi.Middleware):
def __init__(self, app):
super(ContextMiddleware, self).__init__(app)
def process_request(self, req):
"""Convert authentication information into a request context
Generate a RequestContext object from the available
authentication headers and store on the 'context' attribute
of the req object.
:param req: wsgi request object that will be given the context object
:raises: webob.exc.HTTPUnauthorized: when value of the
X-Identity-Status header is not
'Confirmed' and anonymous access
is disallowed
"""
if req.headers.get('X-Identity-Status') == 'Confirmed':
req.context = self._get_authenticated_context(req)
elif CONF.allow_anonymous_access:
req.context = self._get_anonymous_context()
else:
raise webob.exc.HTTPUnauthorized()
@staticmethod
def _get_anonymous_context():
"""Anonymous user has only Read-Only grants"""
return RequestContext(read_only=True, is_admin=False)
@staticmethod
def _get_authenticated_context(req):
headers = req.headers
service_catalog = None
if headers.get('X-Service-Catalog') is not None:
catalog_header = headers.get('X-Service-Catalog')
try:
service_catalog = jsonutils.loads(catalog_header)
except ValueError:
raise webob.exc.HTTPInternalServerError(
_('Invalid service catalog json.'))
kwargs = {
'service_catalog': service_catalog,
'request_id': req.environ.get(request_id.ENV_REQUEST_ID),
}
return RequestContext.from_environ(req.environ, **kwargs)
class UnauthenticatedContextMiddleware(wsgi.Middleware):
"""Process requests and responses when auth is turned off at all."""
def process_request(self, req):
"""Create a context without an authorized user.
When glare deployed as public repo everybody is admin
without any credentials.
"""
req.context = RequestContext(is_admin=True)

@ -0,0 +1,134 @@
# Copyright 2011 OpenStack Foundation
# All Rights Reserved.
#
# 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.
"""
A filter middleware that inspects the requested URI for a version string
and/or Accept headers and attempts to negotiate an API controller to
return
"""
import microversion_parse
from oslo_config import cfg
from oslo_log import log as logging
from glare.api.v1 import api_version_request as api_version
from glare.api import versions as artifacts_versions
from glare.common import exception
from glare.common import wsgi
CONF = cfg.CONF
LOG = logging.getLogger(__name__)
def get_version_from_accept(accept_header, vnd_mime_type):
"""Try to parse accept header to extract api version
:param accept_header: accept header
:return: version string in the request or None if not specified
"""
accept = str(accept_header)
if accept.startswith(vnd_mime_type):
LOG.debug("Using media-type versioning")
token_loc = len(vnd_mime_type)
return accept[token_loc:]
else:
return None
class GlareVersionNegotiationFilter(wsgi.Middleware):
"""Middleware that defines API version in request and redirects it
to correct Router.
"""
SERVICE_TYPE = 'artifact'
def __init__(self, app):
super(GlareVersionNegotiationFilter, self).__init__(app)
self.vnd_mime_type = 'application/vnd.openstack.artifacts-'
def process_request(self, req):
"""Process api request:
1. Define if this is request for available versions or not
2. If it is not version request check extract version
3. Validate available version and add version info to request
"""
args = {'method': req.method, 'path': req.path, 'accept': req.accept}
LOG.debug("Determining version of request: %(method)s %(path)s "
"Accept: %(accept)s", args)
# determine if this is request for versions
if req.path_info in ('/versions', '/'):
is_multi = req.path_info == '/'
return artifacts_versions.Controller.index(
req, is_multi=is_multi)
# determine api version from request
req_version = get_version_from_accept(req.accept, self.vnd_mime_type)
if req_version is None:
# determine api version for v0.1 from url
if req.path_info_peek() == 'v0.1':
req_version = 'v0.1'
else:
# determine api version from microversion header
LOG.debug("Determine version from microversion header.")
req_version = microversion_parse.get_version(
req.headers, service_type=self.SERVICE_TYPE)
# validate versions and add version info to request
if req_version == 'v0.1':
req.environ['api.version'] = 0.1
else:
# validate microversions header
req.api_version_request = self._get_api_version_request(
req_version)
req_version = req.api_version_request.get_string()
LOG.debug("Matched version: %s", req_version)
LOG.debug('new path %s', req.path_info)
@staticmethod
def _get_api_version_request(req_version):
"""Set API version for request based on the version header string."""
if req_version is None:
LOG.debug("No API version in request header. Use default version.")
cur_ver = api_version.APIVersionRequest.default_version()
elif req_version == 'latest':
# 'latest' is a special keyword which is equivalent to
# requesting the maximum version of the API supported
cur_ver = api_version.APIVersionRequest.max_version()
else:
cur_ver = api_version.APIVersionRequest(req_version)
# Check that the version requested is within the global
# minimum/maximum of supported API versions
if not cur_ver.matches(cur_ver.min_version(), cur_ver.max_version()):
raise exception.InvalidGlobalAPIVersion(
req_ver=cur_ver.get_string(),
min_ver=cur_ver.min_version().get_string(),
max_ver=cur_ver.max_version().get_string())
return cur_ver
def process_response(self, response):
if hasattr(response, 'headers'):
request = response.request
if hasattr(request, 'api_version_request'):
api_header_name = microversion_parse.STANDARD_HEADER
response.headers[api_header_name] = (
self.SERVICE_TYPE + ' ' +
request.api_version_request.get_string())
response.headers.add('Vary', api_header_name)
return response

@ -0,0 +1,123 @@
# Copyright 2016 Openstack Foundation.
#
# 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 glare.common import exception
from glare.i18n import _
REST_API_VERSION_HISTORY = """REST API Version History:
* 1.0 - First stable API version that supports microversion. If API version
is not specified in the request then API v1.0 is used as default API
version.
"""
class APIVersionRequest(object):
"""This class represents an API Version Request with convenience
methods for manipulation and comparison of version
numbers that we need to do to implement microversions.
"""
_MIN_API_VERSION = "1.0"
_MAX_API_VERSION = "1.0"
_DEFAULT_API_VERSION = "1.0"
def __init__(self, version_string):
"""Create an API version request object.
:param version_string: String representation of APIVersionRequest.
Correct format is 'X.Y', where 'X' and 'Y' are int values.
"""
match = re.match(r"^([1-9]\d*)\.([1-9]\d*|0)$", version_string)
if match:
self.ver_major = int(match.group(1))
self.ver_minor = int(match.group(2))
else:
msg = _("API version string %s is not valid. "
"Cannot determine API version.") % version_string
raise exception.BadRequest(msg)
def __str__(self):
"""Debug/Logging representation of object."""
return ("API Version Request Major: %s, Minor: %s"
% (self.ver_major, self.ver_minor))
def _format_type_error(self, other):
return TypeError(_("'%(other)s' should be an instance of '%(cls)s'") %
{"other": other, "cls": self.__class__})
def __lt__(self, other):
if not isinstance(other, APIVersionRequest):
raise self._format_type_error(other)
return ((self.ver_major, self.ver_minor) <
(other.ver_major, other.ver_minor))
def __eq__(self, other):
if not isinstance(other, APIVersionRequest):
raise self._format_type_error(other)
return ((self.ver_major, self.ver_minor) ==
(other.ver_major, other.ver_minor))
def __gt__(self, other):
if not isinstance(other, APIVersionRequest):
raise self._format_type_error(other)
return ((self.ver_major, self.ver_minor) >
(other.ver_major, other.ver_minor))
def __le__(self, other):
return self < other or self == other
def __ne__(self, other):
return not self.__eq__(other)
def __ge__(self, other):
return self > other or self == other
def matches(self, min_version, max_version):
"""Returns whether the version object represents a version
greater than or equal to the minimum version and less than
or equal to the maximum version.
@param min_version: Minimum acceptable version.
@param max_version: Maximum acceptable version.
@returns: boolean
"""
return min_version <= self <= max_version
def get_string(self):
"""Converts object to string representation which is used to create
an APIVersionRequest object results in the same version request.
"""
return "%s.%s" % (self.ver_major, self.ver_minor)
@classmethod
def min_version(cls):
"""Minimal allowed api version"""
return APIVersionRequest(cls._MIN_API_VERSION)
@classmethod
def max_version(cls):
"""Maximal allowed api version"""
return APIVersionRequest(cls._MAX_API_VERSION)
@classmethod
def default_version(cls):
"""Default api version if no version in request"""
return APIVersionRequest(cls._DEFAULT_API_VERSION)

@ -0,0 +1,169 @@
# Copyright (c) 2016 Mirantis, 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 functools
from glare.api.v1 import api_version_request as api_version
from glare.common import exception as exc
from glare.i18n import _
class VersionedMethod(object):
def __init__(self, name, start_version, end_version, func):
"""Versioning information for a single method
:param name: Name of the method
:param start_version: Minimum acceptable version
:param end_version: Maximum acceptable_version
:param func: Method to call
Minimum and maximums are inclusive
"""
self.name = name
self.start_version = start_version
self.end_version = end_version
self.func = func
def __str__(self):
return ("Version Method %s: min: %s, max: %s"
% (self.name, self.start_version, self.end_version))
class VersionedResource(object):
"""Versioned mixin that provides ability to define versioned methods and
return appropriate methods based on user request
"""
# prefix for all versioned methods in class
VER_METHODS_ATTR_PREFIX = 'versioned_methods_'
@staticmethod
def check_for_versions_intersection(func_list):
"""Determines whether function list contains version intervals
intersections or not. General algorithm:
https://en.wikipedia.org/wiki/Intersection_algorithm
:param func_list: list of VersionedMethod objects
:return: boolean
"""
pairs = []
counter = 0
for f in func_list:
pairs.append((f.start_version, 1, f))
pairs.append((f.end_version, -1, f))
def compare(x):
return x[0]
pairs.sort(key=compare)
for p in pairs:
counter += p[1]
if counter > 1:
return True
return False
@classmethod
def supported_versions(cls, min_ver, max_ver=None):
"""Decorator for versioning api methods.
Add the decorator to any method which takes a request object
as the first parameter and belongs to a class which inherits from
wsgi.Controller. The implementation inspired by Nova.
:param min_ver: string representing minimum version
:param max_ver: optional string representing maximum version
"""
def decorator(f):
obj_min_ver = api_version.APIVersionRequest(min_ver)
if max_ver:
obj_max_ver = api_version.APIVersionRequest(max_ver)
else:
obj_max_ver = api_version.APIVersionRequest.max_version()
# Add to list of versioned methods registered
func_name = f.__name__
new_func = VersionedMethod(func_name, obj_min_ver, obj_max_ver, f)
versioned_attr = cls.VER_METHODS_ATTR_PREFIX + cls.__name__
func_dict = getattr(cls, versioned_attr, {})
if not func_dict:
setattr(cls, versioned_attr, func_dict)
func_list = func_dict.get(func_name, [])
if not func_list:
func_dict[func_name] = func_list
func_list.append(new_func)
# Ensure the list is sorted by minimum version (reversed)
# so later when we work through the list in order we find
# the method which has the latest version which supports
# the version requested.
is_intersect = cls.check_for_versions_intersection(
func_list)
if is_intersect:
raise exc.ApiVersionsIntersect(
name=new_func.name,
min_ver=new_func.start_version,
max_ver=new_func.end_version,
)
func_list.sort(key=lambda vf: vf.start_version, reverse=True)
return f
return decorator
def __getattribute__(self, key):
def version_select(*args, **kwargs):
"""Look for the method which matches the name supplied and version
constraints and calls it with the supplied arguments.
:returns: Returns the result of the method called
:raises: VersionNotFoundForAPIMethod if there is no method which
matches the name and version constraints
"""
# versioning is used in 3 classes: request deserializer and
# controller have request as first argument
# response serializer has response as first argument
# we must respect all three cases
if hasattr(args[0], 'api_version_request'):
ver = args[0].api_version_request
elif hasattr(args[0], 'request'):
ver = args[0].request.api_version_request
else:
raise exc.VersionNotFoundForAPIMethod(
message=_("Api version not found in the request."))
func_list = self.versioned_methods[key]
for func in func_list:
if ver.matches(func.start_version, func.end_version):
# Update the version_select wrapper function so
# other decorator attributes like wsgi.response
# are still respected.
functools.update_wrapper(version_select, func.func)
return func.func(self, *args, **kwargs)
# No version match
raise exc.VersionNotFoundForAPIMethod(version=ver)
class_obj = object.__getattribute__(self, '__class__')
prefix = object.__getattribute__(self, 'VER_METHODS_ATTR_PREFIX')
attr_name = prefix + object.__getattribute__(class_obj, '__name__')
try:
if key in object.__getattribute__(self, attr_name):
return version_select
except AttributeError:
# No versioning on this class
pass
return object.__getattribute__(self, key)

@ -0,0 +1,484 @@
# Copyright (c) 2016 Mirantis, 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.
"""WSGI Resource definition for Glare. Defines Glare API and serialization/
deserialization of incoming requests."""
import json
import jsonpatch
from oslo_config import cfg
from oslo_log import log as logging
import six
from six.moves import http_client
import six.moves.urllib.parse as urlparse
from glare.api.v1 import api_versioning
from glare.common import exception as exc
from glare.common import wsgi
from glare import engine
from glare.i18n import _, _LI
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
list_configs = [
cfg.IntOpt('default_api_limit', default=25,
help=_('Default value for the number of items returned by a '
'request if not specified explicitly in the request')),
cfg.IntOpt('max_api_limit', default=1000,
help=_('Maximum permissible number of items that could be '
'returned by a request')),
]
CONF.register_opts(list_configs)
supported_versions = api_versioning.VersionedResource.supported_versions
class RequestDeserializer(api_versioning.VersionedResource,
wsgi.JSONRequestDeserializer):
"""Glare deserializer for incoming webop Requests.
Deserializer converts incoming request into bunch of python primitives.
So other components doesn't work with requests at all. Deserializer also
executes primary API validation without any knowledge about Artifact
structure.
"""
@staticmethod
def _get_content_type(req, expected=None):
"""Determine content type of the request body."""
if "Content-Type" not in req.headers:
msg = _("Content-Type must be specified.")
LOG.error(msg)
raise exc.BadRequest(msg)
content_type = req.content_type
if expected is not None and content_type not in expected:
msg = (_('Invalid content type: %(ct)s. Expected: %(exp)s') %
{'ct': content_type, 'exp': ', '.join(expected)})
raise exc.UnsupportedMediaType(message=msg)
return content_type
def _get_request_body(self, req):
return self.from_json(req.body)
@supported_versions(min_ver='1.0')
def create(self, req):
self._get_content_type(req, expected=['application/json'])
body = self._get_request_body(req)
if not isinstance(body, dict):
msg = _("Dictionary expected as body value. Got %s.") % type(body)
raise exc.BadRequest(msg)
return {'values': body}
@supported_versions(min_ver='1.0')
def list(self, req):
params = req.params.copy()
marker = params.pop('marker', None)
query_params = {}
# step 1 - apply marker to query if exists
if marker is not None:
query_params['marker'] = marker
# step 2 - apply limit (if exists OR setup default limit)
limit = params.pop('limit', CONF.default_api_limit)
try:
limit = int(limit)
except ValueError:
msg = _("Limit param must be an integer.")
raise exc.BadRequest(message=msg)
if limit < 0:
msg = _("Limit param must be positive.")
raise exc.BadRequest(message=msg)
query_params['limit'] = min(CONF.max_api_limit, limit)
# step 3 - parse sort parameters
if 'sort' in params:
sort = []
for sort_param in params.pop('sort').strip().split(','):
key, _sep, direction = sort_param.partition(':')
if direction and direction not in ('asc', 'desc'):
raise exc.BadRequest('Sort direction must be one of '
'["asc", "desc"]. Got %s direction'
% direction)
sort.append((key, direction or 'desc'))
query_params['sort'] = sort
query_params['filters'] = params
return query_params
@supported_versions(min_ver='1.0')
def update(self, req):
self._get_content_type(
req, expected=['application/json-patch+json'])
body = self._get_request_body(req)
patch = jsonpatch.JsonPatch(body)
try:
# Initially patch object doesn't validate input. It's only checked
# we call get operation on each method
map(patch._get_operation, patch.patch)
except (jsonpatch.InvalidJsonPatch, TypeError):
msg = _("Json Patch body is malformed")
raise exc.BadRequest(msg)
for patch_item in body:
if patch_item['path'] == '/tags':
msg = _("Cannot modify artifact tags with PATCH "
"request. Use special Tag API for that.")
raise exc.BadRequest(msg)
return {'patch': patch}
def _deserialize_blob(self, req):
content_type = self._get_content_type(req)
if content_type == ('application/vnd+openstack.glare-custom-location'
'+json'):
data = self._get_request_body(req)['url']
else:
data = req.body_file
return {'data': data, 'content_type': content_type}
@supported_versions(min_ver='1.0')
def upload_blob(self, req):
return self._deserialize_blob(req)
@supported_versions(min_ver='1.0')
def upload_blob_dict(self, req):
return self._deserialize_blob(req)
@supported_versions(min_ver='1.0')
def set_tags(self, req):
self._get_content_type(req, expected=['application/json'])
body = self._get_request_body(req)
if 'tags' not in body:
msg = _("Tag list must be in the body of request.")
raise exc.BadRequest(msg)
return {'tag_list': body['tags']}
def log_request_progress(f):
def log_decorator(self, req, *args, **kwargs):
LOG.debug("Request %(request_id)s for %(api_method)s successfully "
"deserialized. Pass request parameters to Engine",
{'request_id': req.context.request_id,
'api_method': f.__name__})
result = f(self, req, *args, **kwargs)
LOG.info(_LI(
"Request %(request_id)s for artifact %(api_method)s "
"successfully executed."), {'request_id': req.context.request_id,
'api_method': f.__name__})
return result
return log_decorator
class ArtifactsController(api_versioning.VersionedResource):
"""API controller for Glare Artifacts.
Artifact Controller prepares incoming data for Glare Engine and redirects
data to appropriate engine method (so only controller is working with
Engine. Once the data returned from Engine Controller returns data
in appropriate format for Response Serializer.
"""
def __init__(self):
self.engine = engine.Engine()
@supported_versions(min_ver='1.0')
@log_request_progress
def list_type_schemas(self, req):
type_schemas = self.engine.list_type_schemas(req.context)
return type_schemas
@supported_versions(min_ver='1.0')
@log_request_progress
def show_type_schema(self, req, type_name):
type_schema = self.engine.show_type_schema(req.context, type_name)
return {type_name: type_schema}
@supported_versions(min_ver='1.0')
@log_request_progress
def create(self, req, type_name, values):
"""Create artifact record in Glare.
:param req: User request
:param type_name: Artifact type name
:param values: dict with artifact fields {field_name: field_value}
:return definition of created artifact
"""
return self.engine.create(req.context, type_name, values)
@supported_versions(min_ver='1.0')
@log_request_progress
def update(self, req, type_name, artifact_id, patch):
"""Update artifact record in Glare.
:param req: User request
:param type_name: Artifact type name
:param artifact_id: id of artifact to update
:param patch: json patch with artifact changes
:return definition of updated artifact
"""
return self.engine.update(req.context, type_name, artifact_id, patch)
@supported_versions(min_ver='1.0')
@log_request_progress
def delete(self, req, type_name, artifact_id):
"""Delete artifact from Glare
:param req: User request
:param type_name: Artifact type name
:param artifact_id: id of artifact to delete
"""
return self.engine.delete(req.context, type_name, artifact_id)
@supported_versions(min_ver='1.0')
@log_request_progress
def show(self, req, type_name, artifact_id):
"""Show detailed artifact info
:param req: User request
:param type_name: Artifact type name
:param artifact_id: id of artifact to show
:return: definition of requested artifact
"""
return self.engine.get(req.context, type_name, artifact_id)
@supported_versions(min_ver='1.0')
@log_request_progress
def list(self, req, type_name, filters, marker=None, limit=None,
sort=None):
"""List available artifacts
:param req: User request
:param type_name: Artifact type name
:param filters: filters that need to be applied to artifact
:param marker: the artifact that considered as begin of the list
so all artifacts before marker (including marker itself) will not be
added to artifact list
:param limit: maximum number of items in list
:param sort: sorting options
:return: list of artifacts
"""
artifacts = self.engine.list(req.context, type_name, filters, marker,
limit, sort)
result = {'artifacts': artifacts,
'type_name': type_name}
if len(artifacts) != 0 and len(artifacts) == limit:
result['next_marker'] = artifacts[-1]['id']
return result
@supported_versions(min_ver='1.0')
@log_request_progress
def upload_blob(self, req, type_name, artifact_id, field_name, data,
content_type):
"""Upload blob into Glare repo
:param req: User request
:param type_name: Artifact type name
:param artifact_id: id of Artifact to reactivate
:param field_name: name of blob field in artifact
:param data: Artifact payload
:param content_type: data content-type
"""
if content_type == ('application/vnd+openstack.glare-custom-location'
'+json'):
return self.engine.add_blob_location(
req.context, type_name, artifact_id, field_name, data)
else:
return self.engine.upload_blob(req.context, type_name, artifact_id,
field_name, data, content_type)
@supported_versions(min_ver='1.0')
@log_request_progress
def upload_blob_dict(self, req, type_name, artifact_id, field_name, data,
blob_key, content_type):
"""Upload blob into Glare repo
:param req: User request
:param type_name: Artifact type name
:param artifact_id: id of Artifact to reactivate
:param field_name: name of blob field in artifact
:param data: Artifact payload
:param content_type: data content-type
:param blob_key: blob key in dict
"""
if content_type == ('application/vnd+openstack.glare-custom-location'
'+json'):
return self.engine.add_blob_dict_location(
req.context, type_name, artifact_id,
field_name, blob_key, str(data))
else:
return self.engine.upload_blob_dict(
req.context, type_name, artifact_id,
field_name, blob_key, data, content_type)
@supported_versions(min_ver='1.0')
@log_request_progress
def download_blob(self, req, type_name, artifact_id, field_name):
"""Download blob data from Artifact
:param req: User request
:param type_name: Artifact type name
:param artifact_id: id of Artifact to reactivate
:param field_name: name of blob field in artifact
:return: iterator that returns blob data
"""
data, meta = self.engine.download_blob(req.context, type_name,
artifact_id, field_name)
result = {'data': data, 'meta': meta}
return result
@supported_versions(min_ver='1.0')
@log_request_progress
def download_blob_dict(self, req, type_name, artifact_id,
field_name, blob_key):
"""Download blob data from Artifact
:param req: User request
:param type_name: Artifact type name
:param artifact_id: id of Artifact to reactivate
:param field_name: name of blob field in artifact
:param blob_key: name of Dict of blobs (optional)
:return: iterator that returns blob data
"""
data, meta = self.engine.download_blob_dict(
req.context, type_name, artifact_id, field_name, blob_key)
result = {'data': data, 'meta': meta}
return result
@staticmethod
def _tag_body_resp(af):
return {'tags': af['tags']}
@supported_versions(min_ver='1.0')
@log_request_progress
def get_tags(self, req, type_name, artifact_id):
return self._tag_body_resp(self.engine.get(
req.context, type_name, artifact_id))
@supported_versions(min_ver='1.0')
@log_request_progress
def set_tags(self, req, type_name, artifact_id, tag_list):
patch = [{'op': 'replace', 'path': '/tags', 'value': tag_list}]
patch = jsonpatch.JsonPatch(patch)
return self._tag_body_resp(self.engine.update(
req.context, type_name, artifact_id, patch))
@supported_versions(min_ver='1.0')
@log_request_progress
def delete_tags(self, req, type_name, artifact_id):
patch = [{'op': 'replace', 'path': '/tags', 'value': []}]
patch = jsonpatch.JsonPatch(patch)
self.engine.update(req.context, type_name, artifact_id, patch)
class ResponseSerializer(api_versioning.VersionedResource,
wsgi.JSONResponseSerializer):
"""Glare Response Serializer converts data received from Glare Engine
(it consists from plain data types - dict, int, string, file descriptors,
etc) to WSGI Requests. It also specifies proper response status and
content type as specified by API design.
"""
@staticmethod
def _prepare_json_response(response, result,
content_type='application/json'):
body = json.dumps(result, ensure_ascii=False)
response.unicode_body = six.text_type(body)
response.content_type = content_type
def list_type_schemas(self, response, type_schemas):
self._prepare_json_response(response,
{'schemas': type_schemas},
content_type='application/schema+json')
def show_type_schema(self, response, type_schema):
self._prepare_json_response(response,
{'schemas': type_schema},
content_type='application/schema+json')
@supported_versions(min_ver='1.0')
def list_schemas(self, response, type_list):
self._prepare_json_response(response, {'types': type_list})
@supported_versions(min_ver='1.0')
def create(self, response, artifact):
self._prepare_json_response(response, artifact)
response.status_int = http_client.CREATED
@supported_versions(min_ver='1.0')
def show(self, response, artifact):
self._prepare_json_response(response, artifact)
@supported_versions(min_ver='1.0')
def update(self, response, artifact):
self._prepare_json_response(response, artifact)
@supported_versions(min_ver='1.0')
def list(self, response, af_list):
params = dict(response.request.params)
params.pop('marker', None)
query = urlparse.urlencode(params)
type_name = af_list['type_name']
body = {
type_name: af_list['artifacts'],
'first': '/artifacts/%s' % type_name,
'schema': '/schemas/%s' % type_name,
}
if query:
body['first'] = '%s?%s' % (body['first'], query)
if 'next_marker' in af_list:
params['marker'] = af_list['next_marker']
next_query = urlparse.urlencode(params)
body['next'] = '/artifacts/%s?%s' % (type_name, next_query)
response.unicode_body = six.text_type(json.dumps(body,
ensure_ascii=False))
response.content_type = 'application/json'
@supported_versions(min_ver='1.0')
def delete(self, response, result):