Merge "Gluon RBAC using keystone and oslo.policy"
This commit is contained in:
42
gluon/_i18n.py
Normal file
42
gluon/_i18n.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# 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 oslo_i18n
|
||||
|
||||
DOMAIN = "gluon"
|
||||
|
||||
_translators = oslo_i18n.TranslatorFactory(domain=DOMAIN)
|
||||
|
||||
# The primary translation function using the well-known name "_"
|
||||
_ = _translators.primary
|
||||
|
||||
# The contextual translation function using the name "_C"
|
||||
_C = _translators.contextual_form
|
||||
|
||||
# The plural translation function using the name "_P"
|
||||
_P = _translators.plural_form
|
||||
|
||||
# Translators for log levels.
|
||||
#
|
||||
# The abbreviated names are meant to reflect the usual use of a short
|
||||
# name like '_'. The "L" is for "log" and the other letter comes from
|
||||
# the level.
|
||||
_LI = _translators.log_info
|
||||
_LW = _translators.log_warning
|
||||
_LE = _translators.log_error
|
||||
_LC = _translators.log_critical
|
||||
|
||||
|
||||
def get_available_languages():
|
||||
return oslo_i18n.get_available_languages(DOMAIN)
|
||||
@@ -1,4 +1,4 @@
|
||||
# Copyright 2015, Ericsson AB
|
||||
# Copyright 2016, Ericsson AB
|
||||
#
|
||||
# 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
|
||||
@@ -12,11 +12,25 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
|
||||
import pecan
|
||||
|
||||
from keystonemiddleware import auth_token
|
||||
from oslo_config import cfg
|
||||
from oslo_log import log as logging
|
||||
|
||||
from oslo_middleware import cors
|
||||
from oslo_middleware import http_proxy_to_wsgi
|
||||
from oslo_middleware import request_id
|
||||
|
||||
from gluon.common import exception as g_exc
|
||||
|
||||
# TODO(enikher)
|
||||
# from gluon.api import middleware
|
||||
|
||||
CONF = cfg.CONF
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
app_dic = {'root': 'gluon.api.root.RootController',
|
||||
'modules': ['gluon.api'],
|
||||
'debug': True,
|
||||
@@ -30,10 +44,16 @@ app_dic = {'root': 'gluon.api.root.RootController',
|
||||
|
||||
|
||||
def setup_app(config=None):
|
||||
# app_hooks = [
|
||||
# hooks.PolicyHook(),
|
||||
# hooks.ContextHook()
|
||||
# ]
|
||||
|
||||
app = pecan.make_app(
|
||||
app_dic.pop('root'),
|
||||
logging=getattr(config, 'logging', {}),
|
||||
# wrap_app=_wrap_app,
|
||||
# hooks=app_hooks,
|
||||
# TODO(enikher)
|
||||
# wrap_app=middleware.ParsableErrorMiddleware,
|
||||
**app_dic
|
||||
@@ -47,3 +67,42 @@ def setup_app(config=None):
|
||||
# TODO(enikher) add authentication
|
||||
# return auth.install(app, CONF, config.app.acl_public_routes)
|
||||
return app
|
||||
|
||||
|
||||
# adapted from Neutron code
|
||||
def _wrap_app(app):
|
||||
app = request_id.RequestId(app)
|
||||
|
||||
if CONF.auth_strategy == 'noauth':
|
||||
pass
|
||||
elif CONF.auth_strategy == 'keystone':
|
||||
app = auth_token.AuthProtocol(app, {})
|
||||
LOG.info("Keystone authentication is enabled")
|
||||
else:
|
||||
raise g_exc.InvalidConfigurationOption(
|
||||
opt_name='auth_strategy', opt_value=CONF.auth_strategy)
|
||||
|
||||
# dont bother authenticating version
|
||||
# app = versions.Versions(app)
|
||||
|
||||
# gluon server is behind the proxy
|
||||
app = http_proxy_to_wsgi.HTTPProxyToWSGI(app)
|
||||
|
||||
# This should be the last middleware in the list (which results in
|
||||
# it being the first in the middleware chain). This is to ensure
|
||||
# that any errors thrown by other middleware, such as an auth
|
||||
# middleware - are annotated with CORS headers, and thus accessible
|
||||
# by the browser.
|
||||
app = cors.CORS(app, CONF)
|
||||
app.set_latent(
|
||||
allow_headers=['X-Auth-Token', 'X-Identity-Status', 'X-Roles',
|
||||
'X-Service-Catalog', 'X-User-Id', 'X-Tenant-Id',
|
||||
'X-OpenStack-Request-ID',
|
||||
'X-Trace-Info', 'X-Trace-HMAC'],
|
||||
allow_methods=['GET', 'PUT', 'POST', 'DELETE', 'PATCH'],
|
||||
expose_headers=['X-Auth-Token', 'X-Subject-Token', 'X-Service-Token',
|
||||
'X-OpenStack-Request-ID',
|
||||
'X-Trace-Info', 'X-Trace-HMAC']
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
64
gluon/api/attributes.py
Normal file
64
gluon/api/attributes.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# Copyright (c) 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.
|
||||
|
||||
|
||||
# Defining a constant to avoid repeating string literal in several modules
|
||||
SHARED = 'shared'
|
||||
|
||||
NAME_MAX_LEN = 255
|
||||
TENANT_ID_MAX_LEN = 255
|
||||
DESCRIPTION_MAX_LEN = 255
|
||||
LONG_DESCRIPTION_MAX_LEN = 1024
|
||||
DEVICE_ID_MAX_LEN = 255
|
||||
DEVICE_OWNER_MAX_LEN = 255
|
||||
|
||||
# Define constants for base resource name
|
||||
BASEPORT = 'baseport'
|
||||
BASEPORTS = '%ss' % BASEPORT
|
||||
|
||||
# TODO(kh) RBAC at attribute level is enforced
|
||||
# Note: a default of ATTR_NOT_SPECIFIED indicates that an
|
||||
# attribute is not required, but will be generated by the plugin
|
||||
# if it is not specified. Particularly, a value of ATTR_NOT_SPECIFIED
|
||||
# is different from an attribute that has been specified with a value of
|
||||
# None. For example, if 'gateway_ip' is omitted in a request to
|
||||
# create a subnet, the plugin will receive ATTR_NOT_SPECIFIED
|
||||
# and the default gateway_ip will be generated.
|
||||
# However, if gateway_ip is specified as None, this means that
|
||||
# the subnet does not have a gateway IP.
|
||||
# The following is a short reference for understanding attribute info:
|
||||
# default: default value of the attribute (if missing, the attribute
|
||||
# becomes mandatory.
|
||||
# allow_post: the attribute can be used on POST requests.
|
||||
# allow_put: the attribute can be used on PUT requests.
|
||||
# validate: specifies rules for validating data in the attribute.
|
||||
# convert_to: transformation to apply to the value before it is returned
|
||||
# is_visible: the attribute is returned in GET responses.
|
||||
# required_by_policy: the attribute is required by the policy engine and
|
||||
# should therefore be filled by the API layer even if not present in
|
||||
# request body.
|
||||
# enforce_policy: the attribute is actively part of the policy enforcing
|
||||
# mechanism, ie: there might be rules which refer to this attribute.
|
||||
|
||||
RESOURCE_ATTRIBUTE_MAP = {
|
||||
BASEPORTS: {
|
||||
}
|
||||
}
|
||||
|
||||
# Identify the attribute used by a resource to reference another resource
|
||||
|
||||
RESOURCE_FOREIGN_KEYS = {
|
||||
BASEPORT: 'id'
|
||||
}
|
||||
21
gluon/api/hooks/__init__.py
Normal file
21
gluon/api/hooks/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Copyright (c) 2016 Nokia
|
||||
# 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.
|
||||
|
||||
from gluon.api.hooks import context
|
||||
from gluon.api.hooks import policy_enforcement
|
||||
|
||||
|
||||
ContextHook = context.ContextHook
|
||||
PolicyHook = policy_enforcement.PolicyHook
|
||||
55
gluon/api/hooks/context.py
Normal file
55
gluon/api/hooks/context.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# Copyright 2016 Nokia
|
||||
# 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.
|
||||
|
||||
from oslo_middleware import request_id
|
||||
from pecan import hooks
|
||||
|
||||
from gluon import context
|
||||
|
||||
|
||||
class ContextHook(hooks.PecanHook):
|
||||
"""Configures a request context and attaches it to the request.
|
||||
|
||||
The following HTTP request headers are used:
|
||||
X-User-Id or X-User:
|
||||
Used for context.user_id.
|
||||
X-Project-Id:
|
||||
Used for context.tenant_id.
|
||||
X-Project-Name:
|
||||
Used for context.tenant_name.
|
||||
X-Auth-Token:
|
||||
Used for context.auth_token.
|
||||
X-Roles:
|
||||
Used for setting context.is_admin flag to either True or False.
|
||||
The flag is set to True, if X-Roles contains either an administrator
|
||||
or admin substring. Otherwise it is set to False.
|
||||
|
||||
"""
|
||||
|
||||
priority = 50
|
||||
|
||||
def before(self, state):
|
||||
user_name = state.request.headers.get('X-User-Name', '')
|
||||
tenant_name = state.request.headers.get('X-Project-Name')
|
||||
req_id = state.request.headers.get(request_id.ENV_REQUEST_ID)
|
||||
|
||||
# Create a context with the authentication data
|
||||
ctx = context.Context.from_environ(state.request.environ,
|
||||
user_name=user_name,
|
||||
tenant_name=tenant_name,
|
||||
request_id=req_id)
|
||||
|
||||
# Inject the context...
|
||||
state.request.context['gluon_context'] = ctx
|
||||
167
gluon/api/hooks/policy_enforcement.py
Normal file
167
gluon/api/hooks/policy_enforcement.py
Normal file
@@ -0,0 +1,167 @@
|
||||
# Copyright 2016 Nokia
|
||||
# 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.
|
||||
|
||||
from oslo_policy import policy as oslo_policy
|
||||
from oslo_utils import excutils
|
||||
from pecan import hooks
|
||||
import webob
|
||||
|
||||
from gluon._i18n import _
|
||||
from gluon import constants
|
||||
from gluon import policy
|
||||
|
||||
|
||||
class PolicyHook(hooks.PecanHook):
|
||||
priority = 100
|
||||
|
||||
def before(self, state):
|
||||
|
||||
# This hook should be run only for PUT,POST and DELETE methods
|
||||
resources = state.request.context.get('resources', [])
|
||||
|
||||
if state.request.method not in ('POST', 'PUT', 'DELETE'):
|
||||
return
|
||||
|
||||
# As this routine will likely alter the resources, do a shallow copy
|
||||
resources_copy = resources[:]
|
||||
gluon_context = state.request.context.get('gluon_context')
|
||||
resource = state.request.context.get('resource')
|
||||
|
||||
# If there is no resource for this request, don't bother running authZ
|
||||
# policies
|
||||
if not resource:
|
||||
return
|
||||
|
||||
# controller = utils.get_controller(state)
|
||||
controller = state.arguments.args[0]
|
||||
|
||||
# if not controller or utils.is_member_action(controller):
|
||||
# return
|
||||
|
||||
collection = state.request.context.get('collection')
|
||||
needs_prefetch = (state.request.method == 'PUT' or
|
||||
state.request.method == 'DELETE')
|
||||
policy.init()
|
||||
|
||||
action = controller.plugin_handlers[
|
||||
constants.ACTION_MAP[state.request.method]]
|
||||
|
||||
for item in resources_copy:
|
||||
try:
|
||||
policy.enforce(
|
||||
gluon_context, action, item,
|
||||
pluralized=collection)
|
||||
except oslo_policy.PolicyNotAuthorized:
|
||||
with excutils.save_and_reraise_exception() as ctxt:
|
||||
# If a tenant is modifying it's own object, it's safe to
|
||||
# return a 403. Otherwise, pretend that it doesn't exist
|
||||
# to avoid giving away information.
|
||||
orig_item_tenant_id = item.get('tenant_id')
|
||||
if (needs_prefetch and
|
||||
(gluon_context.tenant_id != orig_item_tenant_id or
|
||||
orig_item_tenant_id is None)):
|
||||
ctxt.reraise = False
|
||||
msg = _('The resource could not be found.')
|
||||
raise webob.exc.HTTPNotFound(msg)
|
||||
|
||||
def after(self, state):
|
||||
gluon_context = state.request.context.get('gluon_context')
|
||||
resource = state.request.context.get('resource')
|
||||
collection = state.request.context.get('collection')
|
||||
# = utils.get_controller(state)
|
||||
controller = state.arguments.args[0]
|
||||
|
||||
if not resource:
|
||||
# can't filter a resource we don't recognize
|
||||
return
|
||||
|
||||
if resource == 'extension':
|
||||
return
|
||||
try:
|
||||
data = state.response.json
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
if state.request.method not in constants.ACTION_MAP:
|
||||
return
|
||||
|
||||
action = '%s_%s' % (constants.ACTION_MAP[state.request.method],
|
||||
resource)
|
||||
|
||||
if not data or (resource not in data and collection not in data):
|
||||
return
|
||||
|
||||
is_single = resource in data
|
||||
key = resource if is_single else collection
|
||||
to_process = [data[resource]] if is_single else data[collection]
|
||||
|
||||
# in the single case, we enforce which raises on violation
|
||||
# in the plural case, we just check so violating items are hidden
|
||||
policy_method = policy.enforce if is_single else policy.check
|
||||
|
||||
try:
|
||||
resp = [self._get_filtered_item(state.request, controller,
|
||||
resource, collection, item)
|
||||
for item in to_process
|
||||
if (state.request.method != 'GET' or
|
||||
policy_method(gluon_context, action, item,
|
||||
pluralized=collection))]
|
||||
except oslo_policy.PolicyNotAuthorized as e:
|
||||
# This exception must be explicitly caught as the exception
|
||||
# translation hook won't be called if an error occurs in the
|
||||
# 'after' handler.
|
||||
raise webob.exc.HTTPForbidden(str(e))
|
||||
|
||||
if is_single:
|
||||
resp = resp[0]
|
||||
state.response.json = {key: resp}
|
||||
|
||||
def _get_filtered_item(self, request, controller, resource, collection,
|
||||
data):
|
||||
gluon_context = request.context.get('gluon_context')
|
||||
to_exclude = self._exclude_attributes_by_policy(
|
||||
gluon_context, controller, resource, collection, data)
|
||||
return self._filter_attributes(request, data, to_exclude)
|
||||
|
||||
def _filter_attributes(self, request, data, fields_to_strip):
|
||||
# This routine will remove the fields that were requested to the
|
||||
# plugin for policy evaluation but were not specified in the
|
||||
# API request
|
||||
user_fields = request.params.getall('fields')
|
||||
return dict(item for item in data.items()
|
||||
if (item[0] not in fields_to_strip and
|
||||
(not user_fields or item[0] in user_fields)))
|
||||
|
||||
def _exclude_attributes_by_policy(self, context, controller, resource,
|
||||
collection, data):
|
||||
"""Identifies attributes to exclude according to authZ policies.
|
||||
|
||||
Return a list of attribute names which should be stripped from the
|
||||
response returned to the user because the user is not authorized
|
||||
to see them.
|
||||
"""
|
||||
attributes_to_exclude = []
|
||||
for attr_name in data.keys():
|
||||
attr_data = controller.resource_info.get(attr_name)
|
||||
if attr_data and attr_data['is_visible']:
|
||||
if policy.check(context, 'get_%s:%s' % (resource, attr_name),
|
||||
data, might_not_exist=True,
|
||||
pluralized=collection):
|
||||
# this attribute is visible, check next one
|
||||
continue
|
||||
# if the code reaches this point then either the policy check
|
||||
# failed or the attribute was not visible in the first place
|
||||
attributes_to_exclude.append(attr_name)
|
||||
return attributes_to_exclude
|
||||
0
gluon/api/views/__init__.py
Normal file
0
gluon/api/views/__init__.py
Normal file
58
gluon/api/views/versions.py
Normal file
58
gluon/api/views/versions.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# 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.
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def get_view_builder(req):
|
||||
base_url = req.application_url
|
||||
return ViewBuilder(base_url)
|
||||
|
||||
|
||||
class ViewBuilder(object):
|
||||
|
||||
def __init__(self, base_url):
|
||||
"""Object initialization.
|
||||
|
||||
:param base_url: url of the root wsgi application
|
||||
"""
|
||||
self.base_url = base_url
|
||||
|
||||
def build(self, version_data):
|
||||
"""Generic method used to generate a version entity."""
|
||||
version = {
|
||||
"id": version_data["id"],
|
||||
"status": version_data["status"],
|
||||
"links": self._build_links(version_data),
|
||||
}
|
||||
|
||||
return version
|
||||
|
||||
def _build_links(self, version_data):
|
||||
"""Generate a container of links that refer to the provided version."""
|
||||
href = self.generate_href(version_data["id"])
|
||||
|
||||
links = [
|
||||
{
|
||||
"rel": "self",
|
||||
"href": href,
|
||||
},
|
||||
]
|
||||
|
||||
return links
|
||||
|
||||
def generate_href(self, version_number):
|
||||
"""Create an url that refers to a specific version_number."""
|
||||
return os.path.join(self.base_url, version_number, '')
|
||||
@@ -21,9 +21,11 @@ Includes decorator for re-raising Cloudpulse-type exceptions.
|
||||
import six
|
||||
|
||||
from oslo_config import cfg
|
||||
|
||||
from oslo_log import log as logging
|
||||
|
||||
from oslo_log._i18n import _
|
||||
from oslo_log._i18n import _LE
|
||||
from oslo_log import log as logging
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
CONF = cfg.CONF
|
||||
@@ -157,3 +159,22 @@ class MalformedResponseBody(GluonClientException):
|
||||
|
||||
class InvalidContentType(GluonClientException):
|
||||
message = _("Invalid content type %(content_type)s.")
|
||||
|
||||
|
||||
class InvalidConfigurationOption(GluonClientException):
|
||||
"""An error due to an invalid configuration option value."""
|
||||
|
||||
message = _("An invalid value for configuration option %(opt_name): "
|
||||
"%(opt_value)")
|
||||
|
||||
|
||||
class PolicyInitError(GluonClientException):
|
||||
"""An error due to policy initialization failure."""
|
||||
|
||||
message = _("Failed to initialize policy %(policy)s because %(reason)s.")
|
||||
|
||||
|
||||
class PolicyCheckError(GluonClientException):
|
||||
"""An error due to a policy check failure."""
|
||||
|
||||
message = _("Failed to check policy %(policy)s because %(reason)s.")
|
||||
|
||||
35
gluon/constants.py
Normal file
35
gluon/constants.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# Copyright (c) 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.
|
||||
|
||||
|
||||
class Sentinel(object):
|
||||
"""A constant object that does not change even when copied."""
|
||||
def __deepcopy__(self, memo):
|
||||
# Always return the same object because this is essentially a constant.
|
||||
return self
|
||||
|
||||
def __copy__(self):
|
||||
# called via copy.copy(x)
|
||||
return self
|
||||
|
||||
|
||||
ATTR_NOT_SPECIFIED = Sentinel()
|
||||
|
||||
ATTRIBUTES_TO_UPDATE = 'attributes_to_update'
|
||||
|
||||
ACTION_MAP = {'POST': 'create',
|
||||
'PUT': 'update',
|
||||
'GET': 'get',
|
||||
'DELETE': 'delete'}
|
||||
141
gluon/context.py
Normal file
141
gluon/context.py
Normal file
@@ -0,0 +1,141 @@
|
||||
# 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.
|
||||
|
||||
"""Context: context for security/db session."""
|
||||
|
||||
import copy
|
||||
import datetime
|
||||
|
||||
from oslo_context import context as oslo_context
|
||||
from oslo_db.sqlalchemy import enginefacade
|
||||
|
||||
from gluon import policy
|
||||
|
||||
|
||||
class ContextBase(oslo_context.RequestContext):
|
||||
"""Security context and request information.
|
||||
|
||||
Represents the user taking a given action within the system.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, user_id=None, tenant_id=None, is_admin=None,
|
||||
timestamp=None, tenant_name=None, user_name=None,
|
||||
is_advsvc=None, **kwargs):
|
||||
"""Object initialization.
|
||||
|
||||
:param overwrite: Set to False to ensure that the greenthread local
|
||||
copy of the index is not overwritten.
|
||||
"""
|
||||
# NOTE(jamielennox): We maintain these arguments in order for tests
|
||||
# that pass arguments positionally.
|
||||
kwargs.setdefault('user', user_id)
|
||||
kwargs.setdefault('tenant', tenant_id)
|
||||
super(ContextBase, self).__init__(is_admin=is_admin, **kwargs)
|
||||
|
||||
self.user_name = user_name
|
||||
self.tenant_name = tenant_name
|
||||
|
||||
if not timestamp:
|
||||
timestamp = datetime.datetime.utcnow()
|
||||
self.timestamp = timestamp
|
||||
self.is_advsvc = is_advsvc
|
||||
if self.is_advsvc is None:
|
||||
self.is_advsvc = self.is_admin or policy.check_is_advsvc(self)
|
||||
if self.is_admin is None:
|
||||
self.is_admin = policy.check_is_admin(self)
|
||||
|
||||
@property
|
||||
def project_id(self):
|
||||
return self.tenant
|
||||
|
||||
@property
|
||||
def tenant_id(self):
|
||||
return self.tenant
|
||||
|
||||
@tenant_id.setter
|
||||
def tenant_id(self, tenant_id):
|
||||
self.tenant = tenant_id
|
||||
|
||||
@property
|
||||
def user_id(self):
|
||||
return self.user
|
||||
|
||||
@user_id.setter
|
||||
def user_id(self, user_id):
|
||||
self.user = user_id
|
||||
|
||||
def to_dict(self):
|
||||
context = super(ContextBase, self).to_dict()
|
||||
context.update({
|
||||
'user_id': self.user_id,
|
||||
'tenant_id': self.tenant_id,
|
||||
'project_id': self.project_id,
|
||||
'timestamp': str(self.timestamp),
|
||||
'tenant_name': self.tenant_name,
|
||||
'project_name': self.tenant_name,
|
||||
'user_name': self.user_name,
|
||||
})
|
||||
return context
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, values):
|
||||
return cls(user_id=values.get('user_id', values.get('user')),
|
||||
tenant_id=values.get('tenant_id', values.get('project_id')),
|
||||
is_admin=values.get('is_admin'),
|
||||
roles=values.get('roles'),
|
||||
timestamp=values.get('timestamp'),
|
||||
request_id=values.get('request_id'),
|
||||
tenant_name=values.get('tenant_name'),
|
||||
user_name=values.get('user_name'),
|
||||
auth_token=values.get('auth_token'))
|
||||
|
||||
def elevated(self):
|
||||
"""Return a version of this context with admin flag set."""
|
||||
context = copy.copy(self)
|
||||
context.is_admin = True
|
||||
|
||||
if 'admin' not in [x.lower() for x in context.roles]:
|
||||
context.roles = context.roles + ["admin"]
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@enginefacade.transaction_context_provider
|
||||
class ContextBaseWithSession(ContextBase):
|
||||
pass
|
||||
|
||||
|
||||
class Context(ContextBaseWithSession):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(Context, self).__init__(*args, **kwargs)
|
||||
self._session = None
|
||||
|
||||
@property
|
||||
def session(self):
|
||||
return self._session
|
||||
|
||||
|
||||
def get_admin_context():
|
||||
return Context(user_id=None,
|
||||
tenant_id=None,
|
||||
is_admin=True,
|
||||
overwrite=False)
|
||||
|
||||
|
||||
def get_admin_context_without_session():
|
||||
return ContextBase(user_id=None,
|
||||
tenant_id=None,
|
||||
is_admin=True)
|
||||
431
gluon/policy.py
Normal file
431
gluon/policy.py
Normal file
@@ -0,0 +1,431 @@
|
||||
# Copyright (c) 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 collections
|
||||
import re
|
||||
import six
|
||||
|
||||
from oslo_config import cfg
|
||||
from oslo_db import exception as db_exc
|
||||
from oslo_log import log as logging
|
||||
from oslo_policy import policy
|
||||
from oslo_utils import excutils
|
||||
from oslo_utils import importutils
|
||||
|
||||
from gluon import constants
|
||||
|
||||
from gluon._i18n import _
|
||||
from gluon._i18n import _LE
|
||||
from gluon._i18n import _LW
|
||||
|
||||
from gluon.api import attributes
|
||||
from gluon.common import exception as g_exc
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
_ENFORCER = None
|
||||
ADMIN_CTX_POLICY = 'context_is_admin'
|
||||
ADVSVC_CTX_POLICY = 'context_is_advsvc'
|
||||
|
||||
|
||||
def reset():
|
||||
global _ENFORCER
|
||||
if _ENFORCER:
|
||||
_ENFORCER.clear()
|
||||
_ENFORCER = None
|
||||
|
||||
|
||||
def init(conf=cfg.CONF, policy_file=None):
|
||||
"""Init an instance of the Enforcer class."""
|
||||
|
||||
global _ENFORCER
|
||||
if not _ENFORCER:
|
||||
_ENFORCER = policy.Enforcer(conf, policy_file=policy_file)
|
||||
_ENFORCER.load_rules(True)
|
||||
|
||||
|
||||
def refresh(policy_file=None):
|
||||
"""Reset policy and init a new instance of Enforcer."""
|
||||
reset()
|
||||
init(policy_file=policy_file)
|
||||
|
||||
|
||||
def get_resource_and_action(action, pluralized=None):
|
||||
"""Return resource and enforce_attr_based_check(boolean) per
|
||||
|
||||
resource and action extracted from api operation.
|
||||
|
||||
"""
|
||||
|
||||
data = action.split(':', 1)[0].split('_', 1)
|
||||
resource = pluralized or ("%ss" % data[-1])
|
||||
enforce_attr_based_check = data[0] not in ('get', 'delete')
|
||||
return resource, enforce_attr_based_check
|
||||
|
||||
|
||||
def set_rules(policies, overwrite=True):
|
||||
"""Set rules based on the provided dict of rules.
|
||||
|
||||
:param policies: New policies to use. It should be an instance of dict.
|
||||
:param overwrite: Whether to overwrite current rules or update them
|
||||
with the new rules.
|
||||
"""
|
||||
|
||||
LOG.debug("Loading policies from file: %s", _ENFORCER.policy_path)
|
||||
init()
|
||||
_ENFORCER.set_rules(policies, overwrite)
|
||||
|
||||
|
||||
def _is_attribute_explicitly_set(attribute_name, resource, target, action):
|
||||
"""Verify that an attribute is present and is explicitly set."""
|
||||
if 'update' in action:
|
||||
# In the case of update, the function should not pay attention to a
|
||||
# default value of an attribute, but check whether it was explicitly
|
||||
# marked as being updated instead.
|
||||
return (attribute_name in target[constants.ATTRIBUTES_TO_UPDATE] and
|
||||
target[attribute_name] is not constants.ATTR_NOT_SPECIFIED)
|
||||
result = (attribute_name in target and
|
||||
target[attribute_name] is not constants.ATTR_NOT_SPECIFIED)
|
||||
if result and 'default' in resource[attribute_name]:
|
||||
return target[attribute_name] != resource[attribute_name]['default']
|
||||
return result
|
||||
|
||||
|
||||
def _should_validate_sub_attributes(attribute, sub_attr):
|
||||
"""Verify that sub-attributes are iterable and should be validated."""
|
||||
validate = attribute.get('validate')
|
||||
return (validate and isinstance(sub_attr, collections.Iterable) and
|
||||
any([k.startswith('type:dict') and
|
||||
v for (k, v) in six.iteritems(validate)]))
|
||||
|
||||
|
||||
def _build_subattr_match_rule(attr_name, attr, action, target):
|
||||
"""Create the rule to match for sub-attribute policy checks."""
|
||||
# TODO(salv-orlando): Instead of relying on validator info, introduce
|
||||
# typing for API attributes
|
||||
# Expect a dict as type descriptor
|
||||
validate = attr['validate']
|
||||
key = [k for k in validate.keys() if k.startswith('type:dict')]
|
||||
if not key:
|
||||
LOG.warning(_LW("Unable to find data type descriptor "
|
||||
"for attribute %s"),
|
||||
attr_name)
|
||||
return
|
||||
data = validate[key[0]]
|
||||
if not isinstance(data, dict):
|
||||
LOG.debug("Attribute type descriptor is not a dict. Unable to "
|
||||
"generate any sub-attr policy rule for %s.",
|
||||
attr_name)
|
||||
return
|
||||
sub_attr_rules = [policy.RuleCheck('rule', '%s:%s:%s' %
|
||||
(action, attr_name,
|
||||
sub_attr_name)) for
|
||||
sub_attr_name in data if sub_attr_name in
|
||||
target[attr_name]]
|
||||
return policy.AndCheck(sub_attr_rules)
|
||||
|
||||
|
||||
def _process_rules_list(rules, match_rule):
|
||||
"""Recursively walk a policy rule to extract a list of match entries."""
|
||||
if isinstance(match_rule, policy.RuleCheck):
|
||||
rules.append(match_rule.match)
|
||||
elif isinstance(match_rule, policy.AndCheck):
|
||||
for rule in match_rule.rules:
|
||||
_process_rules_list(rules, rule)
|
||||
return rules
|
||||
|
||||
|
||||
def _build_match_rule(action, target, pluralized):
|
||||
"""Create the rule to match for a given action.
|
||||
|
||||
The policy rule to be matched is built in the following way:
|
||||
1) add entries for matching permission on objects
|
||||
2) add an entry for the specific action (e.g.: create_network)
|
||||
3) add an entry for attributes of a resource for which the action
|
||||
is being executed (e.g.: create_network:shared)
|
||||
4) add an entry for sub-attributes of a resource for which the
|
||||
action is being executed
|
||||
(e.g.: create_router:external_gateway_info:network_id)
|
||||
"""
|
||||
match_rule = policy.RuleCheck('rule', action)
|
||||
resource, enforce_attr_based_check = get_resource_and_action(
|
||||
action, pluralized)
|
||||
if enforce_attr_based_check:
|
||||
# assigning to variable with short name for improving readability
|
||||
res_map = attributes.RESOURCE_ATTRIBUTE_MAP
|
||||
if resource in res_map:
|
||||
for attribute_name in res_map[resource]:
|
||||
if _is_attribute_explicitly_set(attribute_name,
|
||||
res_map[resource],
|
||||
target, action):
|
||||
attribute = res_map[resource][attribute_name]
|
||||
if 'enforce_policy' in attribute:
|
||||
attr_rule = policy.RuleCheck('rule', '%s:%s' %
|
||||
(action, attribute_name))
|
||||
# Build match entries for sub-attributes
|
||||
if _should_validate_sub_attributes(
|
||||
attribute, target[attribute_name]):
|
||||
attr_rule = policy.AndCheck(
|
||||
[attr_rule, _build_subattr_match_rule(
|
||||
attribute_name, attribute,
|
||||
action, target)])
|
||||
match_rule = policy.AndCheck([match_rule, attr_rule])
|
||||
return match_rule
|
||||
|
||||
|
||||
# This check is registered as 'tenant_id' so that it can override
|
||||
# GenericCheck which was used for validating parent resource ownership.
|
||||
# This will prevent us from having to handling backward compatibility
|
||||
# for policy.json
|
||||
# TODO(salv-orlando): Reinstate GenericCheck for simple tenant_id checks
|
||||
@policy.register('tenant_id')
|
||||
class OwnerCheck(policy.Check):
|
||||
"""Resource ownership check.
|
||||
|
||||
This check verifies the owner of the current resource, or of another
|
||||
resource referenced by the one under analysis.
|
||||
In the former case it falls back to a regular GenericCheck, whereas
|
||||
in the latter case it leverages the plugin to load the referenced
|
||||
resource and perform the check.
|
||||
"""
|
||||
def __init__(self, kind, match):
|
||||
# Process the match
|
||||
try:
|
||||
self.target_field = re.findall(r'^%\((.*)\)s$',
|
||||
match)[0]
|
||||
except IndexError:
|
||||
err_reason = (_("Unable to identify a target field from:%s. "
|
||||
"Match should be in the form %%(<field_name>)s") %
|
||||
match)
|
||||
LOG.exception(err_reason)
|
||||
raise g_exc.PolicyInitError(
|
||||
policy="%s:%s" % (kind, match),
|
||||
reason=err_reason)
|
||||
super(OwnerCheck, self).__init__(kind, match)
|
||||
|
||||
def __call__(self, target, creds, enforcer):
|
||||
if self.target_field not in target:
|
||||
# policy needs a plugin check
|
||||
# target field is in the form resource:field
|
||||
# however if they're not separated by a colon, use an underscore
|
||||
# as a separator for backward compatibility
|
||||
|
||||
def do_split(separator):
|
||||
parent_res, parent_field = self.target_field.split(
|
||||
separator, 1)
|
||||
return parent_res, parent_field
|
||||
|
||||
for separator in (':', '_'):
|
||||
try:
|
||||
parent_res, parent_field = do_split(separator)
|
||||
break
|
||||
except ValueError:
|
||||
LOG.debug("Unable to find ':' as separator in %s.",
|
||||
self.target_field)
|
||||
else:
|
||||
# If we are here split failed with both separators
|
||||
err_reason = (_("Unable to find resource name in %s") %
|
||||
self.target_field)
|
||||
LOG.error(err_reason)
|
||||
raise g_exc.PolicyCheckError(
|
||||
policy="%s:%s" % (self.kind, self.match),
|
||||
reason=err_reason)
|
||||
parent_foreign_key = attributes.RESOURCE_FOREIGN_KEYS.get(
|
||||
"%ss" % parent_res, None)
|
||||
if not parent_foreign_key:
|
||||
err_reason = (_("Unable to verify match:%(match)s as the "
|
||||
"parent resource: %(res)s was not found") %
|
||||
{'match': self.match, 'res': parent_res})
|
||||
LOG.error(err_reason)
|
||||
raise g_exc.PolicyCheckError(
|
||||
policy="%s:%s" % (self.kind, self.match),
|
||||
reason=err_reason)
|
||||
# NOTE(salv-orlando): This check currently assumes the parent
|
||||
# resource is handled by the core plugin. It might be worth
|
||||
# having a way to map resources to plugins so to make this
|
||||
# check more general
|
||||
# NOTE(ihrachys): if import is put in global, circular
|
||||
# import failure occurs
|
||||
manager = importutils.import_module('neutron.manager')
|
||||
f = getattr(manager.NeutronManager.get_instance().plugin,
|
||||
'get_%s' % parent_res)
|
||||
# f *must* exist, if not found it is better to let neutron
|
||||
# explode. Check will be performed with admin context
|
||||
context = importutils.import_module('neutron.context')
|
||||
try:
|
||||
data = f(context.get_admin_context(),
|
||||
target[parent_foreign_key],
|
||||
fields=[parent_field])
|
||||
target[self.target_field] = data[parent_field]
|
||||
except g_exc.NotFound as e:
|
||||
# NOTE(kevinbenton): a NotFound exception can occur if a
|
||||
# list operation is happening at the same time as one of
|
||||
# the parents and its children being deleted. So we issue
|
||||
# a RetryRequest so the API will redo the lookup and the
|
||||
# problem items will be gone.
|
||||
raise db_exc.RetryRequest(e)
|
||||
except Exception:
|
||||
with excutils.save_and_reraise_exception():
|
||||
LOG.exception(_LE('Policy check error while calling %s!'),
|
||||
f)
|
||||
match = self.match % target
|
||||
if self.kind in creds:
|
||||
return match == six.text_type(creds[self.kind])
|
||||
return False
|
||||
|
||||
|
||||
@policy.register('field')
|
||||
class FieldCheck(policy.Check):
|
||||
def __init__(self, kind, match):
|
||||
# Process the match
|
||||
resource, field_value = match.split(':', 1)
|
||||
field, value = field_value.split('=', 1)
|
||||
|
||||
super(FieldCheck, self).__init__(kind, '%s:%s:%s' %
|
||||
(resource, field, value))
|
||||
|
||||
# Value might need conversion - we need help from the attribute map
|
||||
try:
|
||||
attr = attributes.RESOURCE_ATTRIBUTE_MAP[resource][field]
|
||||
conv_func = attr['convert_to']
|
||||
except KeyError:
|
||||
conv_func = lambda x: x
|
||||
|
||||
self.field = field
|
||||
self.value = conv_func(value)
|
||||
self.regex = re.compile(value[1:]) if value.startswith('~') else None
|
||||
|
||||
def __call__(self, target_dict, cred_dict, enforcer):
|
||||
target_value = target_dict.get(self.field)
|
||||
# target_value might be a boolean, explicitly compare with None
|
||||
if target_value is None:
|
||||
LOG.debug("Unable to find requested field: %(field)s in target: "
|
||||
"%(target_dict)s",
|
||||
{'field': self.field, 'target_dict': target_dict})
|
||||
return False
|
||||
if self.regex:
|
||||
return bool(self.regex.match(target_value))
|
||||
return target_value == self.value
|
||||
|
||||
|
||||
def _prepare_check(context, action, target, pluralized):
|
||||
"""Prepare rule, target, and credentials for the policy engine."""
|
||||
# Compare with None to distinguish case in which target is {}
|
||||
if target is None:
|
||||
target = {}
|
||||
match_rule = _build_match_rule(action, target, pluralized)
|
||||
credentials = context.to_dict()
|
||||
return match_rule, target, credentials
|
||||
|
||||
|
||||
def log_rule_list(match_rule):
|
||||
if LOG.isEnabledFor(logging.DEBUG):
|
||||
rules = _process_rules_list([], match_rule)
|
||||
LOG.debug("Enforcing rules: %s", rules)
|
||||
|
||||
|
||||
def check(context, action, target, plugin=None, might_not_exist=False,
|
||||
pluralized=None):
|
||||
"""Verifies that the action is valid on the target in this context.
|
||||
|
||||
:param context: neutron context
|
||||
:param action: string representing the action to be checked
|
||||
this should be colon separated for clarity.
|
||||
:param target: dictionary representing the object of the action
|
||||
for object creation this should be a dictionary representing the
|
||||
location of the object e.g. ``{'project_id': context.project_id}``
|
||||
:param plugin: currently unused and deprecated.
|
||||
Kept for backward compatibility.
|
||||
:param might_not_exist: If True the policy check is skipped (and the
|
||||
function returns True) if the specified policy does not exist.
|
||||
Defaults to false.
|
||||
:param pluralized: pluralized case of resource
|
||||
e.g. firewall_policy -> pluralized = "firewall_policies"
|
||||
|
||||
:return: Returns True if access is permitted else False.
|
||||
"""
|
||||
# If we already know the context has admin rights do not perform an
|
||||
# additional check and authorize the operation
|
||||
if context.is_admin:
|
||||
return True
|
||||
if might_not_exist and not (_ENFORCER.rules and action in _ENFORCER.rules):
|
||||
return True
|
||||
match_rule, target, credentials = _prepare_check(context,
|
||||
action,
|
||||
target,
|
||||
pluralized)
|
||||
result = _ENFORCER.enforce(match_rule,
|
||||
target,
|
||||
credentials,
|
||||
pluralized=pluralized)
|
||||
# logging applied rules in case of failure
|
||||
if not result:
|
||||
log_rule_list(match_rule)
|
||||
return result
|
||||
|
||||
|
||||
def enforce(context, action, target, plugin=None, pluralized=None):
|
||||
"""Verifies that the action is valid on the target in this context.
|
||||
|
||||
:param context: neutron context
|
||||
:param action: string representing the action to be checked
|
||||
this should be colon separated for clarity.
|
||||
:param target: dictionary representing the object of the action
|
||||
for object creation this should be a dictionary representing the
|
||||
location of the object e.g. ``{'project_id': context.project_id}``
|
||||
:param plugin: currently unused and deprecated.
|
||||
Kept for backward compatibility.
|
||||
:param pluralized: pluralized case of resource
|
||||
e.g. firewall_policy -> pluralized = "firewall_policies"
|
||||
|
||||
:raises oslo_policy.policy.PolicyNotAuthorized:
|
||||
if verification fails.
|
||||
"""
|
||||
# If we already know the context has admin rights do not perform an
|
||||
# additional check and authorize the operation
|
||||
if context.is_admin:
|
||||
return True
|
||||
rule, target, credentials = _prepare_check(context,
|
||||
action,
|
||||
target,
|
||||
pluralized)
|
||||
try:
|
||||
result = _ENFORCER.enforce(rule, target, credentials, action=action,
|
||||
do_raise=True)
|
||||
except policy.PolicyNotAuthorized:
|
||||
with excutils.save_and_reraise_exception():
|
||||
log_rule_list(rule)
|
||||
LOG.debug("Failed policy check for '%s'", action)
|
||||
return result
|
||||
|
||||
|
||||
def check_is_admin(context):
|
||||
"""Verify context has admin rights according to policy settings."""
|
||||
init()
|
||||
# the target is user-self
|
||||
credentials = context.to_dict()
|
||||
if ADMIN_CTX_POLICY not in _ENFORCER.rules:
|
||||
return False
|
||||
return _ENFORCER.enforce(ADMIN_CTX_POLICY, credentials, credentials)
|
||||
|
||||
|
||||
def check_is_advsvc(context):
|
||||
"""Verify context has advsvc rights according to policy settings."""
|
||||
init()
|
||||
# the target is user-self
|
||||
credentials = context.to_dict()
|
||||
if ADVSVC_CTX_POLICY not in _ENFORCER.rules:
|
||||
return False
|
||||
return _ENFORCER.enforce(ADVSVC_CTX_POLICY, credentials, credentials)
|
||||
@@ -9,9 +9,11 @@ SQLAlchemy<1.1.0,>=1.0.10 # MIT
|
||||
oslo.db>=4.1.0 # Apache-2.0
|
||||
oslo.versionedobjects>=1.5.0 # Apache-2.0
|
||||
oslo.config>=3.7.0 # Apache-2.0
|
||||
oslo.policy>=1.15.0 # Apache-2.0
|
||||
oslo.log>1.14.0 # Apache-2.0
|
||||
oslo.utils>=3.5.0 # Apache-2.0
|
||||
oslo.i18n>=2.1.0 # Apache-2.0
|
||||
oslo.middleware>=3.0.0
|
||||
six>=1.9.0 # MIT
|
||||
WSME>=0.8 # MIT
|
||||
pecan>=1.0.0 # BSD
|
||||
@@ -20,3 +22,5 @@ PyYAML>=3.1.0 # MIT
|
||||
pytz>=2013.6 # MIT
|
||||
click>=6.6
|
||||
python-etcd>=0.4.3
|
||||
keystonemiddleware!=4.5.0,>=4.2.0
|
||||
keystoneauth1>=2.14.0 # Apache-2.0
|
||||
Reference in New Issue
Block a user