From 0397d6f1236e17eea9bb8492ed7b9df8a21c4002 Mon Sep 17 00:00:00 2001 From: rl001m Date: Sun, 17 Dec 2017 09:28:53 -0500 Subject: Added api directory to the repository Added the HAS-API module in ONAP Change-Id: Ie4a3b3d2f478abbf4878a4c618a568d7f07c4fa3 Issue-ID: OPTFRA-15 Signed-off-by: rl001m --- conductor/conductor/api/__init__.py | 19 ++ conductor/conductor/api/app.py | 137 +++++++++++ conductor/conductor/api/app.wsgi | 9 + conductor/conductor/api/controllers/__init__.py | 54 +++++ conductor/conductor/api/controllers/errors.py | 149 ++++++++++++ conductor/conductor/api/controllers/root.py | 64 +++++ conductor/conductor/api/controllers/v1/__init__.py | 19 ++ conductor/conductor/api/controllers/v1/plans.py | 261 +++++++++++++++++++++ conductor/conductor/api/controllers/v1/root.py | 47 ++++ conductor/conductor/api/controllers/validator.py | 63 +++++ conductor/conductor/api/hooks.py | 137 +++++++++++ conductor/conductor/api/middleware.py | 132 +++++++++++ conductor/conductor/api/rbac.py | 106 +++++++++ 13 files changed, 1197 insertions(+) create mode 100644 conductor/conductor/api/__init__.py create mode 100644 conductor/conductor/api/app.py create mode 100644 conductor/conductor/api/app.wsgi create mode 100644 conductor/conductor/api/controllers/__init__.py create mode 100644 conductor/conductor/api/controllers/errors.py create mode 100644 conductor/conductor/api/controllers/root.py create mode 100644 conductor/conductor/api/controllers/v1/__init__.py create mode 100644 conductor/conductor/api/controllers/v1/plans.py create mode 100644 conductor/conductor/api/controllers/v1/root.py create mode 100644 conductor/conductor/api/controllers/validator.py create mode 100644 conductor/conductor/api/hooks.py create mode 100644 conductor/conductor/api/middleware.py create mode 100644 conductor/conductor/api/rbac.py diff --git a/conductor/conductor/api/__init__.py b/conductor/conductor/api/__init__.py new file mode 100644 index 0000000..f2bbdfd --- /dev/null +++ b/conductor/conductor/api/__init__.py @@ -0,0 +1,19 @@ +# +# ------------------------------------------------------------------------- +# Copyright (c) 2015-2017 AT&T Intellectual Property +# +# 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. +# +# ------------------------------------------------------------------------- +# + diff --git a/conductor/conductor/api/app.py b/conductor/conductor/api/app.py new file mode 100644 index 0000000..70d54b5 --- /dev/null +++ b/conductor/conductor/api/app.py @@ -0,0 +1,137 @@ +# +# ------------------------------------------------------------------------- +# Copyright (c) 2015-2017 AT&T Intellectual Property +# +# 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 +import uuid + +from oslo_config import cfg +from oslo_log import log +from paste import deploy +import pecan + +from conductor.api import hooks +from conductor.api import middleware +from conductor import service + +LOG = log.getLogger(__name__) + +CONF = cfg.CONF + +OPTS = [ + cfg.StrOpt('api_paste_config', + default="api_paste.ini", + help="Configuration file for WSGI definition of API." + ), +] + +API_OPTS = [ + cfg.BoolOpt('pecan_debug', + default=False, + help='Toggle Pecan Debug Middleware.'), + cfg.IntOpt('default_api_return_limit', + min=1, + default=100, + help='Default maximum number of items returned by API request.' + ), +] + +CONF.register_opts(OPTS) +CONF.register_opts(API_OPTS, group='api') + +# Pull in service opts. We use them here. +OPTS = service.OPTS +CONF.register_opts(OPTS) + +# Can call like so to force a particular config: +# conductor-api --port=8091 -- --config-file=my_config +# +# For api command-line options: +# conductor-api -- --help + + +def setup_app(pecan_config=None, conf=None): + if conf is None: + raise RuntimeError("No configuration passed") + + app_hooks = [ + hooks.ConfigHook(conf), + hooks.MessagingHook(conf), + ] + + pecan_config = pecan_config or { + "app": { + 'root': 'conductor.api.controllers.root.RootController', + 'modules': ['conductor.api'], + } + } + + pecan.configuration.set_config(dict(pecan_config), overwrite=True) + + app = pecan.make_app( + pecan_config['app']['root'], + debug=conf.api.pecan_debug, + hooks=app_hooks, + wrap_app=middleware.ParsableErrorMiddleware, + guess_content_type_from_ext=False, + default_renderer='json', + force_canonical=False, + ) + + return app + + +# pastedeploy uses ConfigParser to handle global_conf, since Python 3's +# ConfigParser doesn't allow storing objects as config values. Only strings +# are permitted. Thus, to be able to pass an object created before paste +# loads the app, we store them in a global variable. Then each loaded app +# stores it's configuration using a unique key to be concurrency safe. +global APPCONFIGS +APPCONFIGS = {} + + +def load_app(conf): + global APPCONFIGS + + # Build the WSGI app + cfg_file = None + cfg_path = conf.api_paste_config + if not os.path.isabs(cfg_path): + cfg_file = conf.find_file(cfg_path) + elif os.path.exists(cfg_path): + cfg_file = cfg_path + + if not cfg_file: + raise cfg.ConfigFilesNotFoundError([conf.api_paste_config]) + + configkey = str(uuid.uuid4()) + APPCONFIGS[configkey] = conf + + LOG.info("Full WSGI config used: %s" % cfg_file) + return deploy.loadapp("config:" + cfg_file, + global_conf={'configkey': configkey}) + + +def app_factory(global_config, **local_conf): + global APPCONFIGS + conf = APPCONFIGS.get(global_config.get('configkey')) + return setup_app(conf=conf) + + +def build_wsgi_app(argv=None): + return load_app(service.prepare_service(argv=argv)) diff --git a/conductor/conductor/api/app.wsgi b/conductor/conductor/api/app.wsgi new file mode 100644 index 0000000..573d3d2 --- /dev/null +++ b/conductor/conductor/api/app.wsgi @@ -0,0 +1,9 @@ +"""Use this file for deploying the API under mod_wsgi. +See http://pecan.readthedocs.org/en/latest/deployment.html for details. +""" +from conductor import service +from conductor.api import app + +# Initialize the oslo configuration library and logging +conf = service.prepare_service([]) +application = app.load_app(conf) \ No newline at end of file diff --git a/conductor/conductor/api/controllers/__init__.py b/conductor/conductor/api/controllers/__init__.py new file mode 100644 index 0000000..4f46681 --- /dev/null +++ b/conductor/conductor/api/controllers/__init__.py @@ -0,0 +1,54 @@ +# +# ------------------------------------------------------------------------- +# Copyright (c) 2015-2017 AT&T Intellectual Property +# +# 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 os import path + +from notario import exceptions +from notario.utils import forced_leaf_validator +import pecan +import six + + +# +# Error Handler +# +def error(url, msg=None, **kwargs): + """Error handler""" + if msg: + pecan.request.context['error_message'] = msg + if kwargs: + pecan.request.context['kwargs'] = kwargs + url = path.join(url, '?error_message=%s' % msg) + pecan.redirect(url, internal=True) + + +# +# Notario Custom Validators +# +@forced_leaf_validator +def string_or_dict(_object, *args): + """Validator - Must be Basestring or Dictionary""" + error_msg = 'not of type dictionary or string' + + if isinstance(_object, six.string_types): + return + if isinstance(_object, dict): + return + raise exceptions.Invalid('dict or basestring type', pair='value', + msg=None, reason=error_msg, *args) diff --git a/conductor/conductor/api/controllers/errors.py b/conductor/conductor/api/controllers/errors.py new file mode 100644 index 0000000..6216721 --- /dev/null +++ b/conductor/conductor/api/controllers/errors.py @@ -0,0 +1,149 @@ +# +# ------------------------------------------------------------------------- +# Copyright (c) 2015-2017 AT&T Intellectual Property +# +# 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 traceback + +from oslo_log import log +import pecan +from webob.exc import status_map + +from conductor.i18n import _ + +LOG = log.getLogger(__name__) + + +def error_wrapper(func): + """Error decorator.""" + def func_wrapper(self, **kw): + """Wrapper.""" + + kwargs = func(self, **kw) + status = status_map.get(pecan.response.status_code) + message = getattr(status, 'explanation', '') + explanation = \ + pecan.request.context.get('error_message', message) + error_type = status.__name__ + title = status.title + traceback = getattr(kwargs, 'traceback', None) + + LOG.error(explanation) + + # Modeled after Heat's format + error = { + "explanation": explanation, + "code": pecan.response.status_code, + "error": { + "message": message, + "type": error_type, + }, + "title": title, + } + if traceback: + error['error']['traceback'] = traceback + return error + return func_wrapper + + +class ErrorsController(object): + """Errors Controller /errors/{error_name}""" + + @pecan.expose('json') + @error_wrapper + def schema(self, **kw): + """400""" + pecan.request.context['error_message'] = \ + str(pecan.request.validation_error) + pecan.response.status = 400 + return pecan.request.context.get('kwargs') + + @pecan.expose('json') + @error_wrapper + def invalid(self, **kw): + """400""" + pecan.response.status = 400 + return pecan.request.context.get('kwargs') + + @pecan.expose() + def unauthorized(self, **kw): + """401""" + # This error is terse and opaque on purpose. + # Don't give any clues to help AuthN along. + pecan.response.status = 401 + pecan.response.content_type = 'text/plain' + LOG.error('unauthorized') + traceback.print_stack() + LOG.error(self.__class__) + LOG.error(kw) + pecan.response.body = _('Authentication required') + LOG.error(pecan.response.body) + return pecan.response + + @pecan.expose('json') + @error_wrapper + def forbidden(self, **kw): + """403""" + pecan.response.status = 403 + return pecan.request.context.get('kwargs') + + @pecan.expose('json') + @error_wrapper + def not_found(self, **kw): + """404""" + pecan.response.status = 404 + return pecan.request.context.get('kwargs') + + @pecan.expose('json') + @error_wrapper + def not_allowed(self, **kw): + """405""" + kwargs = pecan.request.context.get('kwargs') + if kwargs: + allow = kwargs.get('allow', None) + if allow: + pecan.response.headers['Allow'] = allow + pecan.response.status = 405 + return kwargs + + @pecan.expose('json') + @error_wrapper + def conflict(self, **kw): + """409""" + pecan.response.status = 409 + return pecan.request.context.get('kwargs') + + @pecan.expose('json') + @error_wrapper + def server_error(self, **kw): + """500""" + pecan.response.status = 500 + return pecan.request.context.get('kwargs') + + @pecan.expose('json') + @error_wrapper + def unimplemented(self, **kw): + """501""" + pecan.response.status = 501 + return pecan.request.context.get('kwargs') + + @pecan.expose('json') + @error_wrapper + def unavailable(self, **kw): + """503""" + pecan.response.status = 503 + return pecan.request.context.get('kwargs') diff --git a/conductor/conductor/api/controllers/root.py b/conductor/conductor/api/controllers/root.py new file mode 100644 index 0000000..d7c4a7e --- /dev/null +++ b/conductor/conductor/api/controllers/root.py @@ -0,0 +1,64 @@ +# +# ------------------------------------------------------------------------- +# Copyright (c) 2015-2017 AT&T Intellectual Property +# +# 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 pecan + +from conductor.api.controllers import errors +from conductor.api.controllers.v1 import root as v1 + +MEDIA_TYPE_JSON = 'application/vnd.onap.has-%s+json' + + +class RootController(object): + """Root Controller /""" + + def __init__(self): + self.errors = errors.ErrorsController() + self.v1 = v1.V1Controller() + + @pecan.expose(generic=True, template='json') + def index(self): + """Catchall for all methods""" + base_url = pecan.request.application_url + available = [{'tag': 'v1', 'date': '2016-11-01T00:00:00Z', }] + collected = [version_descriptor(base_url, v['tag'], v['date']) + for v in available] + versions = {'versions': collected} + return versions + + +def version_descriptor(base_url, version, released_on): + """Version Descriptor""" + url = version_url(base_url, version) + return { + 'id': version, + 'links': [ + {'href': url, 'rel': 'self', }, + {'href': 'https://wiki.onap.org/pages/viewpage.action?pageId=16005528', + 'rel': 'describedby', 'type': 'text/html', }], + 'media-types': [ + {'base': 'application/json', 'type': MEDIA_TYPE_JSON % version, }], + 'status': 'EXPERIMENTAL', + 'updated': released_on, + } + + +def version_url(base_url, version_number): + """Version URL""" + return '%s/%s' % (base_url, version_number) diff --git a/conductor/conductor/api/controllers/v1/__init__.py b/conductor/conductor/api/controllers/v1/__init__.py new file mode 100644 index 0000000..f2bbdfd --- /dev/null +++ b/conductor/conductor/api/controllers/v1/__init__.py @@ -0,0 +1,19 @@ +# +# ------------------------------------------------------------------------- +# Copyright (c) 2015-2017 AT&T Intellectual Property +# +# 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. +# +# ------------------------------------------------------------------------- +# + diff --git a/conductor/conductor/api/controllers/v1/plans.py b/conductor/conductor/api/controllers/v1/plans.py new file mode 100644 index 0000000..fa635f7 --- /dev/null +++ b/conductor/conductor/api/controllers/v1/plans.py @@ -0,0 +1,261 @@ +# +# ------------------------------------------------------------------------- +# Copyright (c) 2015-2017 AT&T Intellectual Property +# +# 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 six +import yaml +from yaml.constructor import ConstructorError + +from notario import decorators +from notario.validators import types +from oslo_log import log +import pecan +from pecan_notario import validate + +from conductor.api.controllers import error +from conductor.api.controllers import string_or_dict +from conductor.api.controllers import validator +from conductor.i18n import _, _LI + +LOG = log.getLogger(__name__) + +CREATE_SCHEMA = ( + (decorators.optional('files'), types.dictionary), + (decorators.optional('id'), types.string), + (decorators.optional('limit'), types.integer), + (decorators.optional('name'), types.string), + ('template', string_or_dict), + (decorators.optional('template_url'), types.string), + (decorators.optional('timeout'), types.integer), +) + + +class PlansBaseController(object): + """Plans Base Controller - Common Methods""" + + def plan_link(self, plan_id): + return [ + { + "href": "%(url)s/v1/%(endpoint)s/%(id)s" % + { + 'url': pecan.request.application_url, + 'endpoint': 'plans', + 'id': plan_id, + }, + "rel": "self" + } + ] + + def plans_get(self, plan_id=None): + ctx = {} + method = 'plans_get' + if plan_id: + args = {'plan_id': plan_id} + LOG.debug('Plan {} requested.'.format(plan_id)) + else: + args = {} + LOG.debug('All plans requested.') + + plans_list = [] + + client = pecan.request.controller + result = client.call(ctx, method, args) + plans = result and result.get('plans') + + for the_plan in plans: + the_plan_id = the_plan.get('id') + the_plan['links'] = [self.plan_link(the_plan_id)] + plans_list.append(the_plan) + + if plan_id: + if len(plans_list) == 1: + return plans_list[0] + else: + # For a single plan, we return None if not found + return None + else: + # For all plans, it's ok to return an empty list + return plans_list + + def plan_create(self, args): + ctx = {} + method = 'plan_create' + + # TODO(jdandrea): Enhance notario errors to use similar syntax + # valid_keys = ['files', 'id', 'limit', 'name', + # 'template', 'template_url', 'timeout'] + # if not set(args.keys()).issubset(valid_keys): + # invalid = [name for name in args if name not in valid_keys] + # invalid_str = ', '.join(invalid) + # error('/errors/invalid', + # _('Invalid keys found: {}').format(invalid_str)) + # required_keys = ['template'] + # if not set(required_keys).issubset(args): + # required = [name for name in required_keys if name not in args] + # required_str = ', '.join(required) + # error('/errors/invalid', + # _('Missing required keys: {}').format(required_str)) + + LOG.debug('Plan creation requested (name "{}").'.format( + args.get('name'))) + + client = pecan.request.controller + result = client.call(ctx, method, args) + plan = result and result.get('plan') + if plan: + plan_name = plan.get('name') + plan_id = plan.get('id') + plan['links'] = [self.plan_link(plan_id)] + LOG.info(_LI('Plan {} (name "{}") created.').format( + plan_id, plan_name)) + return plan + + def plan_delete(self, plan): + ctx = {} + method = 'plans_delete' + + plan_name = plan.get('name') + plan_id = plan.get('id') + LOG.debug('Plan {} (name "{}") deletion requested.'.format( + plan_id, plan_name)) + + args = {'plan_id': plan_id} + client = pecan.request.controller + client.call(ctx, method, args) + LOG.info(_LI('Plan {} (name "{}") deleted.').format( + plan_id, plan_name)) + + +class PlansItemController(PlansBaseController): + """Plans Item Controller /v1/plans/{plan_id}""" + + def __init__(self, uuid4): + """Initializer.""" + self.uuid = uuid4 + self.plan = self.plans_get(plan_id=self.uuid) + + if not self.plan: + error('/errors/not_found', + _('Plan {} not found').format(self.uuid)) + pecan.request.context['plan_id'] = self.uuid + + @classmethod + def allow(cls): + """Allowed methods""" + return 'GET,DELETE' + + @pecan.expose(generic=True, template='json') + def index(self): + """Catchall for unallowed methods""" + message = _('The {} method is not allowed.').format( + pecan.request.method) + kwargs = {'allow': self.allow()} + error('/errors/not_allowed', message, **kwargs) + + @index.when(method='OPTIONS', template='json') + def index_options(self): + """Options""" + pecan.response.headers['Allow'] = self.allow() + pecan.response.status = 204 + + @index.when(method='GET', template='json') + def index_get(self): + """Get plan""" + return {"plans": [self.plan]} + + @index.when(method='DELETE', template='json') + def index_delete(self): + """Delete a Plan""" + self.plan_delete(self.plan) + pecan.response.status = 204 + + +class PlansController(PlansBaseController): + """Plans Controller /v1/plans""" + + @classmethod + def allow(cls): + """Allowed methods""" + return 'GET,POST' + + @pecan.expose(generic=True, template='json') + def index(self): + """Catchall for unallowed methods""" + message = _('The {} method is not allowed.').format( + pecan.request.method) + kwargs = {'allow': self.allow()} + error('/errors/not_allowed', message, **kwargs) + + @index.when(method='OPTIONS', template='json') + def index_options(self): + """Options""" + pecan.response.headers['Allow'] = self.allow() + pecan.response.status = 204 + + @index.when(method='GET', template='json') + def index_get(self): + """Get all the plans""" + plans = self.plans_get() + return {"plans": plans} + + @index.when(method='POST', template='json') + @validate(CREATE_SCHEMA, '/errors/schema') + def index_post(self): + """Create a Plan""" + + # Look for duplicate keys in the YAML/JSON, first in the + # entire request, and then again if the template parameter + # value is itself an embedded JSON/YAML string. + where = "API Request" + try: + parsed = yaml.load(pecan.request.text, validator.UniqueKeyLoader) + if 'template' in parsed: + where = "Template" + template = parsed['template'] + if isinstance(template, six.string_types): + yaml.load(template, validator.UniqueKeyLoader) + except ConstructorError as exc: + # Only bail on the duplicate key problem (problem and problem_mark + # attributes are available in ConstructorError): + if exc.problem is \ + validator.UniqueKeyLoader.DUPLICATE_KEY_PROBLEM_MARK: + # ConstructorError messages have a two line snippet. + # Grab it, get rid of the second line, and strip any + # remaining whitespace so we can fashion a one line msg. + snippet = exc.problem_mark.get_snippet() + snippet = snippet.split('\n')[0].strip() + msg = _('{} has a duplicate key on line {}: {}') + error('/errors/invalid', + msg.format(where, exc.problem_mark.line + 1, snippet)) + except Exception as exc: + # Let all others pass through for now. + pass + + args = pecan.request.json + plan = self.plan_create(args) + + if not plan: + error('/errors/server_error', _('Unable to create Plan.')) + else: + pecan.response.status = 201 + return plan + + @pecan.expose() + def _lookup(self, uuid4, *remainder): + """Pecan subcontroller routing callback""" + return PlansItemController(uuid4), remainder diff --git a/conductor/conductor/api/controllers/v1/root.py b/conductor/conductor/api/controllers/v1/root.py new file mode 100644 index 0000000..87b4a35 --- /dev/null +++ b/conductor/conductor/api/controllers/v1/root.py @@ -0,0 +1,47 @@ +# +# ------------------------------------------------------------------------- +# Copyright (c) 2015-2017 AT&T Intellectual Property +# +# 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_log import log +import pecan +from pecan import secure + +from conductor.api.controllers import error +from conductor.api.controllers.v1 import plans +from conductor.i18n import _ + +LOG = log.getLogger(__name__) + + +class V1Controller(secure.SecureController): + """Version 1 API controller root.""" + + plans = plans.PlansController() + + @classmethod + def check_permissions(cls): + """SecureController permission check callback""" + return True + # error('/errors/unauthorized', msg) + + @pecan.expose(generic=True, template='json') + def index(self): + """Catchall for unallowed methods""" + message = _('The %s method is not allowed.') % pecan.request.method + kwargs = {} + error('/errors/not_allowed', message, **kwargs) diff --git a/conductor/conductor/api/controllers/validator.py b/conductor/conductor/api/controllers/validator.py new file mode 100644 index 0000000..f9bff3f --- /dev/null +++ b/conductor/conductor/api/controllers/validator.py @@ -0,0 +1,63 @@ +# +# ------------------------------------------------------------------------- +# Copyright (c) 2015-2017 AT&T Intellectual Property +# +# 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 yaml.constructor import ConstructorError +from yaml.nodes import MappingNode + +try: + from yaml import CLoader as Loader +except ImportError: + from yaml import Loader + + +class UniqueKeyLoader(Loader): + """Unique Key Loader for PyYAML + + Ensures no duplicate keys on any given level. + + https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-2084028 + """ + + DUPLICATE_KEY_PROBLEM_MARK = "found duplicate key" + + def construct_mapping(self, node, deep=False): + """Check for duplicate keys while constructing a mapping.""" + if not isinstance(node, MappingNode): + raise ConstructorError( + None, None, "expected a mapping node, but found %s" % node.id, + node.start_mark) + mapping = {} + for key_node, value_node in node.value: + key = self.construct_object(key_node, deep=deep) + try: + hash(key) + except (TypeError) as exc: + raise ConstructorError("while constructing a mapping", + node.start_mark, + "found unacceptable key (%s)" % exc, + key_node.start_mark) + # check for duplicate keys + if key in mapping: + raise ConstructorError("while constructing a mapping", + node.start_mark, + self.DUPLICATE_KEY_PROBLEM_MARK, + key_node.start_mark) + value = self.construct_object(value_node, deep=deep) + mapping[key] = value + return mapping diff --git a/conductor/conductor/api/hooks.py b/conductor/conductor/api/hooks.py new file mode 100644 index 0000000..08677cc --- /dev/null +++ b/conductor/conductor/api/hooks.py @@ -0,0 +1,137 @@ +# +# ------------------------------------------------------------------------- +# Copyright (c) 2015-2017 AT&T Intellectual Property +# +# 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_log import log +from pecan import hooks + +# from conductor.common.models import plan +# from conductor.common.music import api +from conductor.common.music import messaging as music_messaging +# from conductor.common.music.model import base +from conductor import messaging + +LOG = log.getLogger(__name__) + + +class ConfigHook(hooks.PecanHook): + """Attach the configuration object to the request. + + That allows controllers to get it. + """ + + def __init__(self, conf): + super(ConfigHook, self).__init__() + self.conf = conf + + def on_route(self, state): + state.request.cfg = self.conf + + +class MessagingHook(hooks.PecanHook): + """Create and attach a controller RPC client to the request.""" + + def __init__(self, conf): + super(MessagingHook, self).__init__() + topic = "controller" + transport = messaging.get_transport(conf=conf) + target = music_messaging.Target(topic=topic) + self.controller = \ + music_messaging.RPCClient(conf=conf, + transport=transport, + target=target) + + def on_route(self, state): + state.request.controller = self.controller + + +# NOTE: We no longer use ModelHook, since the API should be asking +# the controller (via RPC) for info about plans, not requesting them directly. + +# class ModelHook(hooks.PecanHook): +# """Create and attach dynamic model classes to the request.""" +# +# def __init__(self, conf): +# super(ModelHook, self).__init__() +# +# # TODO(jdandrea) Move this to DBHook? +# music = api.API() +# music.keyspace_create(keyspace=conf.keyspace) +# +# # Dynamically create a plan class for the specified keyspace +# self.Plan = base.create_dynamic_model( +# keyspace=conf.keyspace, baseclass=plan.Plan, classname="Plan") +# +# def before(self, state): +# state.request.models = { +# "Plan": self.Plan, +# } + + +# class DBHook(hooks.PecanHook): +# +# def __init__(self): +# self.storage_connection = DBHook.get_connection('metering') +# self.event_storage_connection = DBHook.get_connection('event') +# +# if (not self.storage_connection +# and not self.event_storage_connection): +# raise Exception("API failed to start. Failed to connect to " +# "databases, purpose: %s" % +# ', '.join(['metering', 'event'])) +# +# def before(self, state): +# state.request.storage_conn = self.storage_connection +# state.request.event_storage_conn = self.event_storage_connection +# +# @staticmethod +# def get_connection(purpose): +# try: +# return storage.get_connection_from_config(cfg.CONF, purpose) +# except Exception as err: +# params = {"purpose": purpose, "err": err} +# LOG.exception(_LE("Failed to connect to db, purpose %(purpose)s " +# "retry later: %(err)s") % params) +# +# +# class NotifierHook(hooks.PecanHook): +# """Create and attach a notifier to the request. +# Usually, samples will be push to notification bus by notifier when they +# are posted via /v2/meters/ API. +# """ +# +# def __init__(self): +# transport = messaging.get_transport() +# self.notifier = oslo_messaging.Notifier( +# transport, driver=cfg.CONF.publisher_notifier.homing_driver, +# publisher_id="conductor.api") +# +# def before(self, state): +# state.request.notifier = self.notifier +# +# +# class TranslationHook(hooks.PecanHook): +# +# def after(self, state): +# # After a request has been done, we need to see if +# # ClientSideError has added an error onto the response. +# # If it has we need to get it info the thread-safe WSGI +# # environ to be used by the ParsableErrorMiddleware. +# if hasattr(state.response, 'translatable_error'): +# state.request.environ['translatable_error'] = ( +# state.response.translatable_error) diff --git a/conductor/conductor/api/middleware.py b/conductor/conductor/api/middleware.py new file mode 100644 index 0000000..dc0664a --- /dev/null +++ b/conductor/conductor/api/middleware.py @@ -0,0 +1,132 @@ +# +# ------------------------------------------------------------------------- +# Copyright (c) 2015-2017 AT&T Intellectual Property +# +# 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. +# +# ------------------------------------------------------------------------- +# + +"""Middleware to replace the plain text message body of an error +response with one formatted so the client can parse it. + +Based on pecan.middleware.errordocument +""" + +import json + +from lxml import etree +from oslo_log import log +import six +import webob + +from conductor import i18n +from conductor.i18n import _ + +LOG = log.getLogger(__name__) + + +class ParsableErrorMiddleware(object): + """Replace error body with something the client can parse.""" + + @staticmethod + def best_match_language(accept_language): + """Determines best available locale from the Accept-Language header. + + :returns: the best language match or None if the 'Accept-Language' + header was not available in the request. + """ + if not accept_language: + return None + all_languages = i18n.get_available_languages() + return accept_language.best_match(all_languages) + + def __init__(self, app): + self.app = app + + def __call__(self, environ, start_response): + # Request for this state, modified by replace_start_response() + # and used when an error is being reported. + state = {} + + def replacement_start_response(status, headers, exc_info=None): + """Overrides the default response to make errors parsable.""" + try: + status_code = int(status.split(' ')[0]) + state['status_code'] = status_code + except (ValueError, TypeError): # pragma: nocover + raise Exception(( + 'ErrorDocumentMiddleware received an invalid ' + 'status %s' % status + )) + else: + if (state['status_code'] // 100) not in (2, 3): + # Remove some headers so we can replace them later + # when we have the full error message and can + # compute the length. + headers = [(h, v) + for (h, v) in headers + if h not in ('Content-Length', 'Content-Type') + ] + # Save the headers in case we need to modify them. + state['headers'] = headers + return start_response(status, headers, exc_info) + + app_iter = self.app(environ, replacement_start_response) + if (state['status_code'] // 100) not in (2, 3): + req = webob.Request(environ) + error = environ.get('translatable_error') + user_locale = self.best_match_language(req.accept_language) + if (req.accept.best_match(['application/json', 'application/xml']) + == 'application/xml'): + content_type = 'application/xml' + try: + # simple check xml is valid + fault = etree.fromstring(b'\n'.join(app_iter)) + # Add the translated error to the xml data + if error is not None: + for fault_string in fault.findall('faultstring'): + fault_string.text = i18n.translate(error, + user_locale) + error_message = etree.tostring(fault) + body = b''.join((b'', + error_message, + b'')) + except etree.XMLSyntaxError as err: + LOG.error(_('Error parsing HTTP response: %s'), err) + error_message = state['status_code'] + body = '%s' % error_message + if six.PY3: + body = body.encode('utf-8') + else: + content_type = 'application/json' + app_data = b'\n'.join(app_iter) + if six.PY3: + app_data = app_data.decode('utf-8') + try: + fault = json.loads(app_data) + if error is not None and 'faultstring' in fault: + fault['faultstring'] = i18n.translate(error, + user_locale) + except ValueError as err: + fault = app_data + body = json.dumps({'error_message': fault}) + if six.PY3: + body = body.encode('utf-8') + + state['headers'].append(('Content-Length', str(len(body)))) + state['headers'].append(('Content-Type', content_type)) + body = [body] + else: + body = app_iter + return body diff --git a/conductor/conductor/api/rbac.py b/conductor/conductor/api/rbac.py new file mode 100644 index 0000000..6caaad3 --- /dev/null +++ b/conductor/conductor/api/rbac.py @@ -0,0 +1,106 @@ +# +# ------------------------------------------------------------------------- +# Copyright (c) 2015-2017 AT&T Intellectual Property +# +# 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. +# +# ------------------------------------------------------------------------- +# + +"""Access Control Lists (ACL's) control access the API server.""" + +from oslo_config import cfg +from oslo_policy import policy +import pecan + +_ENFORCER = None + +CONF = cfg.CONF + + +def reset(): + global _ENFORCER + if _ENFORCER: + _ENFORCER.clear() + _ENFORCER = None + + +def _has_rule(name): + return name in _ENFORCER.rules.keys() + + +def enforce(policy_name, request): + """Return the user and project the request should be limited to. + + :param request: HTTP request + :param policy_name: the policy name to validate AuthZ against. + """ + global _ENFORCER + if not _ENFORCER: + _ENFORCER = policy.Enforcer(CONF) + _ENFORCER.load_rules() + + rule_method = "homing:" + policy_name + headers = request.headers + + policy_dict = dict() + policy_dict['roles'] = headers.get('X-Roles', "").split(",") + policy_dict['user_id'] = (headers.get('X-User-Id')) + policy_dict['project_id'] = (headers.get('X-Project-Id')) + + # maintain backward compat with Juno and previous by allowing the action if + # there is no rule defined for it + if ((_has_rule('default') or _has_rule(rule_method)) and + not _ENFORCER.enforce(rule_method, {}, policy_dict)): + pecan.core.abort(status_code=403, detail='RBAC Authorization Failed') + + +# TODO(fabiog): these methods are still used because the scoping part is really +# convoluted and difficult to separate out. + +def get_limited_to(headers): + """Return the user and project the request should be limited to. + + :param headers: HTTP headers dictionary + :return: A tuple of (user, project), set to None if there's no limit on + one of these. + """ + global _ENFORCER + if not _ENFORCER: + _ENFORCER = policy.Enforcer(CONF) + _ENFORCER.load_rules() + + policy_dict = dict() + policy_dict['roles'] = headers.get('X-Roles', "").split(",") + policy_dict['user_id'] = (headers.get('X-User-Id')) + policy_dict['project_id'] = (headers.get('X-Project-Id')) + + # maintain backward compat with Juno and previous by using context_is_admin + # rule if the segregation rule (added in Kilo) is not defined + rule_name = 'segregation' if _has_rule( + 'segregation') else 'context_is_admin' + if not _ENFORCER.enforce(rule_name, + {}, + policy_dict): + return headers.get('X-User-Id'), headers.get('X-Project-Id') + + return None, None + + +def get_limited_to_project(headers): + """Return the project the request should be limited to. + + :param headers: HTTP headers dictionary + :return: A project, or None if there's no limit on it. + """ + return get_limited_to(headers)[1] -- cgit 1.2.3-korg