Browse Source

Flask to WSGI and security

This adds a lot of stuff related changing from API from Flask to
WSGI and adding security.

This is mostly api side. There will more changes to add testing
and engine side changes. API parameter validation can also be
done later.

story: 2004882
Task: #29163
story: 2003844
Task: #26635

Change-Id: Id373440affd2d625933da2a6dbeb6354acd75e1e
Signed-off-by: Tomi Juvonen <tomi.juvonen@nokia.com>
changes/00/667300/1
Tomi Juvonen 2 years ago
parent
commit
eb02de1ac9
  1. 11
      devstack/plugin.sh
  2. 52
      fenix/api/__init__.py
  3. 53
      fenix/api/context.py
  4. 44
      fenix/api/root.py
  5. 26
      fenix/api/v1/__init__.py
  6. 81
      fenix/api/v1/app.py
  7. 52
      fenix/api/v1/base.py
  8. 64
      fenix/api/v1/controllers/__init__.py
  9. 52
      fenix/api/v1/controllers/base.py
  10. 128
      fenix/api/v1/controllers/maintenance.py
  11. 132
      fenix/api/v1/controllers/types.py
  12. 68
      fenix/api/v1/hooks.py
  13. 103
      fenix/api/v1/maintenance.py
  14. 137
      fenix/api/v1/middleware/__init__.py
  15. 19
      fenix/cmd/api.py
  16. 27
      fenix/context.py
  17. 19
      fenix/exceptions.py
  18. 27
      fenix/policies/__init__.py
  19. 32
      fenix/policies/base.py
  20. 49
      fenix/policies/maintenance.py
  21. 50
      fenix/policies/project.py
  22. 57
      fenix/policies/session.py
  23. 110
      fenix/policy.py
  24. 17
      fenix/utils/service.py
  25. 18
      fenix/workflow/workflows/default.py

11
devstack/plugin.sh

@ -19,6 +19,7 @@ function configure_fenix {
create_service_user "$FENIX_USER_NAME" "admin"
init_fenix_service_user_conf
init_fenix_keystone
iniset $FENIX_CONF_FILE DEFAULT host "$FENIX_SERVICE_HOST"
iniset $FENIX_API_CONF_FILE DEFAULT host "$FENIX_SERVICE_HOST"
@ -37,6 +38,16 @@ function init_fenix_service_user_conf {
iniset $FENIX_CONF_FILE service_user os_user_domain_name "$SERVICE_DOMAIN_NAME"
iniset $FENIX_CONF_FILE service_user os_project_name "$SERVICE_TENANT_NAME"
iniset $FENIX_CONF_FILE service_user os_project_domain_name "$SERVICE_DOMAIN_NAME"
function init_fenix_keystone {
iniset $FENIX_API_CONF keystone_authtoken cafile "$SSL_BUNDLE_FILE"
iniset $FENIX_API_CONF keystone_authtoken project_domain_name "$SERVICE_DOMAIN_NAME"
iniset $FENIX_API_CONF keystone_authtoken project_name "$SERVICE_TENANT_NAME"
iniset $FENIX_API_CONF keystone_authtoken user_domain_name "$SERVICE_DOMAIN_NAME"
iniset $FENIX_API_CONF keystone_authtoken password "$SERVICE_PASSWORD"
iniset $FENIX_API_CONF keystone_authtoken username "$FENIX_USER_NAME"
iniset $FENIX_API_CONF keystone_authtoken auth_url "$KEYSTONE_SERVICE_URI"
iniset $FENIX_API_CONF keystone_authtoken auth_type = password
}
function create_fenix_accounts {

52
fenix/api/__init__.py

@ -1,52 +0,0 @@
# Copyright (c) 2018 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.
from flask import Flask
from oslo_config import cfg
from oslo_log import log as logging
import sys
from fenix.api import v1
LOG = logging.getLogger(__name__)
api_opts = [
cfg.StrOpt('api_config',
default="api.conf",
help="Configuration file for API service."),
cfg.StrOpt('host',
default="127.0.0.1",
help="API host IP"),
cfg.IntOpt('port',
default=5000,
help="API port to use."),
]
CONF = cfg.CONF
CONF.register_opts(api_opts)
logging.register_options(cfg.CONF)
cfg.CONF(sys.argv[1:], project='fenix', prog='fenix-api')
def create_app(global_config, **local_config):
return setup_app()
def setup_app(config=None):
app = Flask(__name__, static_folder=None)
app.config.update(PROPAGATE_EXCEPTIONS=True)
app.register_blueprint(v1.bp, url_prefix='/v1')
return app

53
fenix/api/context.py

@ -0,0 +1,53 @@
# Copyright (c) 2019 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 six
from fenix import context
def ctx_from_headers(headers):
if 'X-User-Id' in headers:
user_id = headers['X-User-Id']
else:
user_id = None
if 'X-Project-Id' in headers:
project_id = headers['X-Project-Id']
else:
project_id = None
if 'X-Auth-Token' in headers:
auth_token = headers['X-Auth-Token']
else:
auth_token = None
if 'X-User-Name' in headers:
user_name = headers['X-User-Name']
else:
user_name = None
if 'X-Project-Name' in headers:
project_name = headers['X-Project-Name']
else:
project_name = None
if 'X-Roles' in headers:
roles = list(map(six.text_type.strip, headers['X-Roles'].split(',')))
else:
roles = None
return context.FenixContext(
user_id=user_id,
project_id=project_id,
auth_token=auth_token,
user_name=user_name,
project_name=project_name,
roles=roles,
)

44
fenix/api/root.py

@ -0,0 +1,44 @@
# Copyright (c) 2019 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.
from oslo_serialization import jsonutils
import pecan
from fenix.api.v1 import controllers
class RootController(object):
v1 = controllers.V1Controller()
def _append_versions_from_controller(self, versions, controller, path):
for version in getattr(controller, 'versions', None):
version['links'] = [{
"href": "{0}/{1}".format(pecan.request.host_url, path),
"rel": "self"}]
versions.append(version)
@pecan.expose(content_type='application/json')
def index(self):
pecan.response.status_code = 300
pecan.response.content_type = 'application/json'
versions = {"versions": []}
self._append_versions_from_controller(versions['versions'],
self.v1, 'v1')
return jsonutils.dump_as_bytes(versions)
@pecan.expose(content_type='application/json')
def versions(self):
return self.index()

26
fenix/api/v1/__init__.py

@ -1,26 +0,0 @@
# Copyright (c) 2018 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.
from flask import Blueprint
import flask_restful as restful
from fenix.api.v1.routes import routes
bp = Blueprint('v1', __name__)
api = restful.Api(bp, catch_all_404s=True)
for route in routes:
api.add_resource(route.pop('resource'), *route.pop('urls'), **route)

81
fenix/api/v1/app.py

@ -0,0 +1,81 @@
# Copyright (c) 2018 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 sys
from keystonemiddleware import auth_token
from oslo_config import cfg
from oslo_log import log as logging
import pecan
from fenix.api.v1 import hooks
from fenix.api.v1 import middleware
LOG = logging.getLogger(__name__)
api_opts = [
cfg.StrOpt('api_config',
default="api.conf",
help="Configuration file for API service."),
cfg.StrOpt('host',
default="127.0.0.1",
help="API host IP"),
cfg.IntOpt('port',
default=5000,
help="API port to use."),
]
CONF = cfg.CONF
CONF.register_opts(api_opts)
logging.register_options(cfg.CONF)
cfg.CONF(sys.argv[1:], project='fenix', prog='fenix-api')
def setup_app(pecan_config=None, debug=False, argv=None):
app_hooks = [hooks.ConfigHook(),
hooks.DBHook(),
hooks.ContextHook(),
hooks.RPCHook()]
app = pecan.make_app(pecan_config.app.root,
debug=CONF.debug,
hooks=app_hooks,
wrap_app=middleware.ParsableErrorMiddleware,
guess_content_type_from_ext=False)
# WSGI middleware for debugging
# if CONF.log_exchange:
# app = debug.Debug.factory(pecan_config)(app)
# WSGI middleware for Keystone auth
# NOTE(sbauza): ACLs are always active unless for unittesting where
# enable_acl could be set to False
if pecan_config.app.enable_acl:
app = auth_token.AuthProtocol(app, {})
return app
def make_app():
config = {
'app': {
'modules': ['fenix.api.v1'],
'root': 'fenix.api.root.RootController',
'enable_acl': True,
}
}
app = pecan.load_app(config)
return app

52
fenix/api/v1/base.py

@ -1,52 +0,0 @@
# Copyright (c) 2018 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 decorator
import flask
import flask_restful as restful
import inspect
import json
import re
SORT_KEY_SPLITTER = re.compile('[ ,]')
class Resource(restful.Resource):
method_decorators = []
def error_response(self, status_code, message):
body = json.dumps(
{
'status': status_code,
'message': message
},
)
resp = flask.make_response("{body}\n".format(body=body))
resp.status_code = status_code
return resp
@decorator.decorator
def http_codes(f, *args, **kwargs):
try:
return f(*args, **kwargs)
except Exception as err:
try:
inspect.getmodule(f).LOG.error(
'Error during %s: %s' % (f.__qualname__, err))
except AttributeError:
inspect.getmodule(f).LOG.error(
'Error during %s: %s' % (f.__name__, err))
return args[0].error_response(500, 'Unknown Error')

64
fenix/api/v1/controllers/__init__.py

@ -0,0 +1,64 @@
# Copyright (c) 2019 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.
"""Version 1 of the API.
"""
from oslo_log import log as logging
import pecan
from pecan import rest
from fenix.api.v1.controllers import maintenance as controller
LOG = logging.getLogger(__name__)
class V1Controller(rest.RestController):
versions = [{"id": "v1", "status": "CURRENT"}]
_routes = {}
# maps to v1/maintenance
maintenance = controller.MaintenanceController()
# maps to v1/maintenance/{session_id}
session = controller.SessionController()
# maps to v1/maintenance/{session_id}/{project_id}
project = controller.ProjectController()
@pecan.expose()
def _route(self, args):
"""Overrides the default routing behavior.
It allows to map controller URL with correct controller instance.
By default, it maps with the same name.
"""
try:
route = self._routes.get(args[0], args[0])
depth = len(args)
if route is None:
# NOTE(sbauza): Route must map to a non-existing controller
args[0] = 'http404-nonexistingcontroller'
elif depth == 1:
args[0] = route
elif depth == 2 and route == "maintenance":
args[0] = "session"
elif depth == 3 and route == "maintenance":
args[0] = "project"
else:
args[0] = 'http404-nonexistingcontroller'
except IndexError:
LOG.error("No args found on V1 controller")
return super(V1Controller, self)._route(args)

52
fenix/api/v1/controllers/base.py

@ -0,0 +1,52 @@
# Copyright (c) 2014 Bull.
#
# 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 wsme
from wsme import types as wtypes
from fenix.api.v1.controllers import types
class _Base(wtypes.DynamicBase):
# NOTE(sbauza): That does respect ISO8601 but with a different sep (' ')
created_at = types.Datetime('%Y-%m-%d %H:%M:%S.%f')
"The time in UTC at which the object is created"
updated_at = types.Datetime('%Y-%m-%d %H:%M:%S.%f')
"The time in UTC at which the object is updated"
def as_dict(self):
cls = type(self)
valid_keys = [item for item in dir(cls)
if item not in dir(_Base)
and wtypes.iswsattr(getattr(cls, item))]
if 'self' in valid_keys:
valid_keys.remove('self')
return self.as_dict_from_keys(valid_keys)
def as_dict_from_keys(self, keys):
res = {}
for key in keys:
value = getattr(self, key, wsme.Unset)
if value != wsme.Unset:
res[key] = value
return res
@classmethod
def convert(cls, rpc_obj):
obj = cls(**rpc_obj)
return obj

128
fenix/api/v1/controllers/maintenance.py

@ -0,0 +1,128 @@
# Copyright (c) 2019 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 json
from pecan import abort
from pecan import expose
from pecan import request
from pecan import response
from pecan import rest
from oslo_log import log
from oslo_serialization import jsonutils
from fenix.api.v1 import maintenance
from fenix import policy
LOG = log.getLogger(__name__)
class ProjectController(rest.RestController):
name = 'project'
def __init__(self):
self.engine_rpcapi = maintenance.EngineRPCAPI()
# GET /v1/maintenance/<session_id>/<project_id>
@policy.authorize('maintenance:session:project', 'get')
@expose(content_type='application/json')
def get(self, session_id, project_id):
if request.body:
LOG.error("Unexpected data")
abort(400)
engine_data = self.engine_rpcapi.project_get_session(session_id,
project_id)
response.body = jsonutils.dumps(engine_data)
# PUT /v1/maintenance/<session_id>/<project_id>
@policy.authorize('maintenance:session:project', 'put')
@expose(content_type='application/json')
def put(self, session_id, project_id):
data = json.loads(request.body.decode('utf8'))
engine_data = self.engine_rpcapi.project_update_session(session_id,
project_id,
data)
response.body = jsonutils.dumps(engine_data)
class SessionController(rest.RestController):
name = 'session'
def __init__(self):
self.engine_rpcapi = maintenance.EngineRPCAPI()
# GET /v1/maintenance/<session_id>
@policy.authorize('maintenance:session', 'get')
@expose(content_type='application/json')
def get(self, session_id):
if request.body:
LOG.error("Unexpected data")
abort(400)
session = self.engine_rpcapi.admin_get_session(session_id)
if session is None:
response.status = 404
return {"error": "Invalid session"}
response.body = jsonutils.dumps(session)
# PUT /v1/maintenance/<session_id>
@policy.authorize('maintenance:session', 'put')
@expose(content_type='application/json')
def put(self, session_id):
data = json.loads(request.body.decode('utf8'))
engine_data = self.engine_rpcapi.admin_update_session(session_id, data)
response.body = jsonutils.dumps(engine_data)
# DELETE /v1/maintenance/<session_id>
@policy.authorize('maintenance:session', 'delete')
@expose(content_type='application/json')
def delete(self, session_id):
if request.body:
LOG.error("Unexpected data")
abort(400)
engine_data = self.engine_rpcapi.admin_delete_session(session_id)
response.body = jsonutils.dumps(engine_data)
class MaintenanceController(rest.RestController):
name = 'maintenance'
def __init__(self):
self.engine_rpcapi = maintenance.EngineRPCAPI()
# GET /v1/maintenance
@policy.authorize('maintenance', 'get')
@expose(content_type='application/json')
def get(self):
if request.body:
LOG.error("Unexpected data")
abort(400)
sessions = self.engine_rpcapi.admin_get()
response.body = jsonutils.dumps(sessions)
# POST /v1/maintenance
@policy.authorize('maintenance', 'post')
@expose(content_type='application/json')
def post(self):
LOG.debug("POST /v1/maintenance")
data = json.loads(request.body.decode('utf8'))
LOG.debug("type: %s" % type(data))
session = self.engine_rpcapi.admin_create_session(data)
if session is None:
response.status = 509
return {"error": "Too many sessions"}
response.body = jsonutils.dumps(session)

132
fenix/api/v1/controllers/types.py

@ -0,0 +1,132 @@
# Copyright (c) 2014 Bull.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import datetime
import uuid
from oslo_serialization import jsonutils
import six
from wsme import types as wtypes
from wsme import utils as wutils
from fenix import exceptions
class UuidType(wtypes.UserType):
"""A simple UUID type."""
basetype = wtypes.text
name = 'uuid'
# FIXME(sbauza): When used with wsexpose decorator WSME will try
# to get the name of the type by accessing it's __name__ attribute.
# Remove this __name__ attribute once it's fixed in WSME.
# https://bugs.launchpad.net/wsme/+bug/1265590
__name__ = name
def __init__(self, without_dashes=False):
self.without_dashes = without_dashes
def validate(self, value):
try:
valid_uuid = six.text_type(uuid.UUID(value))
if self.without_dashes:
valid_uuid = valid_uuid.replace('-', '')
return valid_uuid
except (TypeError, ValueError, AttributeError):
error = 'Value should be UUID format'
raise ValueError(error)
class IntegerType(wtypes.IntegerType):
"""A simple integer type. Can validate a value range.
:param minimum: Possible minimum value
:param maximum: Possible maximum value
Example::
Price = IntegerType(minimum=1)
"""
name = 'integer'
# FIXME(sbauza): When used with wsexpose decorator WSME will try
# to get the name of the type by accessing it's __name__ attribute.
# Remove this __name__ attribute once it's fixed in WSME.
# https://bugs.launchpad.net/wsme/+bug/1265590
__name__ = name
class CPUInfo(wtypes.UserType):
"""A type for matching CPU info from hypervisors."""
basetype = wtypes.text
name = 'cpuinfo as JSON formated str'
@staticmethod
def validate(value):
# NOTE(sbauza): cpu_info can be very different from one Nova driver to
# another. We need to keep this method as generic as
# possible, ie. we accept JSONified dict.
try:
cpu_info = jsonutils.loads(value)
except TypeError:
raise exceptions.InvalidInput(cls=CPUInfo.name, value=value)
if not isinstance(cpu_info, dict):
raise exceptions.InvalidInput(cls=CPUInfo.name, value=value)
return value
class TextOrInteger(wtypes.UserType):
"""A type for matching either text or integer."""
basetype = wtypes.text
name = 'textorinteger'
@staticmethod
def validate(value):
# NOTE(sbauza): We need to accept non-unicoded Python2 strings
if (isinstance(value, six.text_type) or isinstance(value, str)
or isinstance(value, int)):
return value
else:
raise exceptions.InvalidInput(cls=TextOrInteger.name, value=value)
class Datetime(wtypes.UserType):
"""A type for matching unicoded datetime."""
basetype = wtypes.text
name = 'datetime'
# Format must be ISO8601 as default
format = '%Y-%m-%dT%H:%M:%S.%f'
def __init__(self, format=None):
if format:
self.format = format
def validate(self, value):
try:
datetime.datetime.strptime(value, self.format)
except ValueError:
# FIXME(sbauza): Start_date and end_date are given using a specific
# format but are shown in default ISO8601, we must
# fail back to it for verification
try:
wutils.parse_isodatetime(value)
except ValueError:
raise exceptions.InvalidInput(cls=Datetime.name, value=value)
return value

68
fenix/api/v1/hooks.py

@ -0,0 +1,68 @@
# Copyright (c) 2019 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.
from oslo_config import cfg
from oslo_log import log as logging
from pecan import hooks
from fenix.api import context
from fenix.api.v1 import maintenance
from fenix.db import api as dbapi
LOG = logging.getLogger(__name__)
class ConfigHook(hooks.PecanHook):
"""ConfigHook
Attach the configuration object to the request
so controllers can get to it.
"""
def before(self, state):
state.request.cfg = cfg.CONF
class DBHook(hooks.PecanHook):
"""Attach the dbapi object to the request so controllers can get to it."""
def before(self, state):
state.request.dbapi = dbapi.get_instance()
class ContextHook(hooks.PecanHook):
"""Configures a request context and attaches it to the request."""
def before(self, state):
state.request.context = context.ctx_from_headers(state.request.headers)
state.request.context.__enter__()
# NOTE(sbauza): on_error() can be fired before after() if the original
# exception is not catched by WSME. That's necessary to not
# handle context.__exit__() within on_error() as it could
# lead to pop the stack twice for the same request
def after(self, state):
# If no API extensions are loaded, context is empty
if state.request.context:
state.request.context.__exit__(None, None, None)
class RPCHook(hooks.PecanHook):
"""Attach the rpcapi object to the request so controllers can get to it."""
def before(self, state):
state.request.rpcapi = maintenance.EngineRPCAPI()

103
fenix/api/v1/maintenance.py

@ -1,4 +1,4 @@
# Copyright (c) 2018 OpenStack Foundation.
# Copyright (c) 2019 OpenStack Foundation.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
@ -13,12 +13,8 @@
# License for the specific language governing permissions and limitations
# under the License.
from flask import request
import json
from oslo_log import log
from oslo_serialization import jsonutils
from fenix.api.v1 import base
from fenix import engine
from fenix.utils import service
@ -48,9 +44,10 @@ class EngineRPCAPI(service.RPCClient):
"""Delete maintenance workflow session thread"""
return self.call('admin_delete_session', session_id=session_id)
def admin_update_session(self, session_id):
def admin_update_session(self, session_id, data):
"""Update maintenance workflow session"""
return self.call('admin_update_session', session_id=session_id)
return self.call('admin_update_session', session_id=session_id,
data=data)
def project_get_session(self, session_id, project_id):
"""Get maintenance workflow session project specific details"""
@ -61,95 +58,3 @@ class EngineRPCAPI(service.RPCClient):
"""Update maintenance workflow session project state"""
return self.call('project_update_session', session_id=session_id,
project_id=project_id, data=data)
class Maintenance(base.Resource):
def __init__(self):
self.engine_rpcapi = EngineRPCAPI()
@base.http_codes
def get(self):
if request.data:
LOG.error("Unexpected data")
return {}, 400, None
LOG.info("admin get")
LOG.info("self.engine_rpcapi.admin_get")
sessions = self.engine_rpcapi.admin_get()
LOG.info("return: %s" % sessions)
return jsonutils.to_primitive(sessions), 200, None
@base.http_codes
def post(self):
LOG.info("admin post: first")
data = json.loads(request.data.decode('utf8'))
LOG.info("admin post: %s" % data)
session = self.engine_rpcapi.admin_create_session(data)
if session is None:
return {"error": "Too many sessions"}, 509, None
LOG.info("return: %s" % session)
return jsonutils.to_primitive(session), 200, None
class MaintenanceSession(base.Resource):
def __init__(self):
self.engine_rpcapi = EngineRPCAPI()
@base.http_codes
def get(self, session_id=None):
if request.data:
LOG.error("Unexpected data")
return {}, 400, None
LOG.info("admin session_id get")
session = self.engine_rpcapi.admin_get_session(session_id)
if session is None:
return {"error": "Invalid session"}, 404, None
return jsonutils.to_primitive(session), 200, None
@base.http_codes
def put(self, session_id=None):
data = json.loads(request.data.decode('utf8'))
LOG.info("admin session_id put: %s" % data)
engine_data = self.engine_rpcapi.admin_update_session(session_id)
LOG.info("engine_data: %s" % engine_data)
response_body = {'maintenance': request.base_url,
'session_id': session_id}
return jsonutils.to_primitive(response_body), 200, None
@base.http_codes
def delete(self, session_id=None):
if request.data:
LOG.error("Unexpected data")
return {}, 400, None
LOG.info("admin session_id delete")
ret = self.engine_rpcapi.admin_delete_session(session_id)
LOG.info("return: %s" % ret)
return jsonutils.to_primitive(ret), 200, None
class MaintenanceSessionProject(base.Resource):
def __init__(self):
self.engine_rpcapi = EngineRPCAPI()
@base.http_codes
def get(self, session_id=None, project_id=None):
if request.data:
LOG.error("Unexpected data")
return {}, 400, None
LOG.info("%s_get" % project_id)
engine_data = self.engine_rpcapi.project_get_session(session_id,
project_id)
LOG.info("engine_data: %s" % engine_data)
return jsonutils.to_primitive(engine_data), 200, None
@base.http_codes
def put(self, session_id=None, project_id=None):
data = json.loads(request.data.decode('utf8'))
LOG.info("%s_put: %s" % (project_id, data))
engine_data = self.engine_rpcapi.project_update_session(session_id,
project_id,
data)
LOG.info("engine_data: %s" % engine_data)
return jsonutils.to_primitive(engine_data), 200, None

137
fenix/api/v1/middleware/__init__.py

@ -0,0 +1,137 @@
# Copyright (c) 2014 Bull.
#
# 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 as logging
from oslo_serialization import jsonutils
import webob
from fenix.db import exceptions as db_exceptions
from fenix import exceptions
LOG = logging.getLogger(__name__)
class ParsableErrorMiddleware(object):
"""Middleware which prepared body to the client
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
"""
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 = {}
faultstring = None
def replacement_start_response(status, headers, exc_info=None):
"""Overrides the default response to make errors parsable."""
try:
status_code = int(status.split(' ')[0])
except (ValueError, TypeError): # pragma: nocover
raise exceptions.FenixException(_(
'Status {0} was unexpected').format(status))
else:
if status_code >= 400:
# 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.lower() != 'content-length'
]
# Save the headers as we need to modify them.
state['status_code'] = status_code
state['headers'] = headers
state['exc_info'] = exc_info
return start_response(status, headers, exc_info)
# NOTE(sbauza): As agreed, XML is not supported with API v2, but can
# still work if no errors are raised
try:
app_iter = self.app(environ, replacement_start_response)
except exceptions.FenixException as e:
faultstring = "{0} {1}".format(e.__class__.__name__, str(e))
replacement_start_response(
webob.response.Response(status=str(e.code)).status,
[('Content-Type', 'application/json; charset=UTF-8')]
)
else:
if not state:
return app_iter
try:
res_dct = jsonutils.loads(app_iter[0])
except ValueError:
return app_iter
else:
try:
faultstring = res_dct['faultstring']
except KeyError:
return app_iter
traceback_marker = 'Traceback (most recent call last):'
remote_marker = 'Remote error: '
if not faultstring:
return app_iter
if traceback_marker in faultstring:
# Cut-off traceback.
faultstring = faultstring.split(traceback_marker, 1)[0]
faultstring = faultstring.split('[u\'', 1)[0]
if remote_marker in faultstring:
# RPC calls put that string on
try:
faultstring = faultstring.split(
remote_marker, 1)[1]
except IndexError:
pass
faultstring = faultstring.rstrip()
try:
(exc_name, exc_value) = faultstring.split(' ', 1)
except (ValueError, AttributeError):
LOG.warning('Incorrect Remote error {0}'.format(faultstring))
else:
cls = getattr(exceptions, exc_name, None)
if cls is not None:
faultstring = str(cls(exc_value))
state['status_code'] = cls.code
else:
# Get the exception from db exceptions and hide
# the message because could contain table/column
# information
cls = getattr(db_exceptions, exc_name, None)
if cls is not None:
faultstring = '{0}: A database error occurred'.format(
cls.__name__)
state['status_code'] = cls.code
# NOTE(sbauza): Client expects a JSON encoded dict
body = [jsonutils.dump_as_bytes(
{'error_code': state['status_code'],
'error_message': faultstring,
'error_name': state['status_code']}
)]
start_response(
webob.response.Response(status=state['status_code']).status,
state['headers'],
state['exc_info']
)
return body

19
fenix/cmd/api.py

@ -13,24 +13,27 @@
# License for the specific language governing permissions and limitations
# under the License.
from wsgiref import simple_server
import eventlet
eventlet.monkey_patch(
os=True, select=True, socket=True, thread=True, time=True)
from eventlet import wsgi
import gettext
from oslo_config import cfg
from oslo_log import log as logging
from fenix import api
gettext.install('fenix')
from fenix.api.v1 import app as v1_app
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
def main():
app = api.setup_app()
host, port = cfg.CONF.host, cfg.CONF.port
LOG.info("host %s port %s" % (host, port))
srv = simple_server.make_server(host, port, app)
LOG.info("fenix-api started")
srv.serve_forever()
logging.setup(cfg.CONF, 'fenix')
app = v1_app.make_app()
wsgi.server(eventlet.listen((CONF.host, CONF.port), backlog=500), app)
if __name__ == "__main__":

27
fenix/context.py

@ -1,17 +1,17 @@
# Copyright (c) 2018 OpenStack Foundation.
# All Rights Reserved.
# Copyright (c) 2013 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
# 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
# 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.
# 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 threading
@ -82,7 +82,6 @@ class FenixContext(BaseContext):
"user_id",
"project_id",
"auth_token",
"service_catalog",
"user_name",
"project_name",
"roles",
@ -100,3 +99,7 @@ class FenixContext(BaseContext):
def current():
return FenixContext.current()
def elevated():
return FenixContext.elevated()

19
fenix/exceptions.py

@ -47,3 +47,22 @@ class FenixException(Exception):
message = self.msg_fmt
super(FenixException, self).__init__(message)
class NotFound(FenixException):
"""Object not found exception."""
msg_fmt = "Object with %(object)s not found"
code = 404
class NotAuthorized(FenixException):
msg_fmt = "Not authorized"
code = 403
class PolicyNotAuthorized(NotAuthorized):
msg_fmt = "Policy doesn't allow %(action)s to be performed"
class ServiceCatalogNotFound(NotFound):
msg_fmt = "Could not find service catalog"

27
fenix/policies/__init__.py

@ -0,0 +1,27 @@
# 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 itertools
from fenix.policies import base
from fenix.policies import maintenance
from fenix.policies import project
from fenix.policies import session
def list_rules():
return itertools.chain(
base.list_rules(),
maintenance.list_rules(),
session.list_rules(),
project.list_rules(),
)

32
fenix/api/v1/routes.py → fenix/policies/base.py

@ -1,6 +1,3 @@
# Copyright (c) 2018 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
@ -13,16 +10,23 @@
# License for the specific language governing permissions and limitations
# under the License.
from fenix.api.v1 import maintenance
from oslo_policy import policy
RULE_ADMIN = 'rule:admin'
RULE_ADMIN_OR_OWNER = 'rule:admin_or_owner'
RULE_ANY = '@'
routes = [
dict(resource=maintenance.Maintenance,
urls=['/maintenance'],
endpoint='maintenance'),
dict(resource=maintenance.MaintenanceSession,
urls=['/maintenance/<session_id>'],
endpoint='maintenance/<session_id>'),
dict(resource=maintenance.MaintenanceSessionProject,
urls=['/maintenance/<session_id>/<project_id>'],
endpoint='maintenance/<session_id>/<project_id>'),
rules = [
policy.RuleDefault(
name="admin",
check_str="is_admin:True or role:admin",
description="Default rule for most Admin APIs."),
policy.RuleDefault(
name="admin_or_owner",
check_str="rule:admin or project_id:%(project_id)s",
description="Default rule for most non-Admin APIs.")
]
def list_rules():
return rules

49
fenix/policies/maintenance.py

@ -0,0 +1,49 @@
# Copyright (c) 2019 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.
from oslo_policy import policy
from fenix.policies import base
POLICY_ROOT = 'fenix:maintenance:%s'
maintenance_policies = [
policy.DocumentedRuleDefault(
name=POLICY_ROOT % 'get',
check_str=base.RULE_ADMIN,
description='Policy rule for listing maintenance sessions API',
operations=[
{
'path': '/{api_version}/maintenance',
'method': 'GET'
}
]
),
policy.DocumentedRuleDefault(
name=POLICY_ROOT % 'post',
check_str=base.RULE_ADMIN,
description='Policy rule for creating maintenance session API',
operations=[
{
'path': '/{api_version}/maintenance',
'method': 'POST'
}
]
)
]
def list_rules():
return maintenance_policies

50
fenix/policies/project.py

@ -0,0 +1,50 @@
# 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
from fenix.policies import base
POLICY_ROOT = 'fenix:maintenance:session:project:%s'
project_policies = [
policy.DocumentedRuleDefault(
name=POLICY_ROOT % 'get',
check_str=base.RULE_ADMIN_OR_OWNER,
description='Policy rule for showing project session affected '
'instances API.',
operations=[
{
'path': '/{api_version}/maintenance/{seission_id}/'
'{project_id}',
'method': 'GET'
}
]
),
policy.DocumentedRuleDefault(
name=POLICY_ROOT % 'put',
check_str=base.RULE_ADMIN_OR_OWNER,
description='Policy rule for setting project session affected '
'instances actions API.',
operations=[
{
'path': '/{api_version}/maintenance/{seission_id}/'
'{project_id}',
'method': 'PUT'
}
]
)
]
def list_rules():
return project_policies

57
fenix/policies/session.py

@ -0,0 +1,57 @@
# 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
from fenix.policies import base
POLICY_ROOT = 'fenix:maintenance:session:%s'
session_policies = [
policy.DocumentedRuleDefault(
name=POLICY_ROOT % 'get',
check_str=base.RULE_ADMIN,
description='Policy rule for showing maintenance session API',
operations=[
{
'path': '/{api_version}/maintenance/{seission_id}',
'method': 'GET'
}
]
),
policy.DocumentedRuleDefault(
name=POLICY_ROOT % 'put',
check_str=base.RULE_ADMIN,
description='Policy rule for updating maintenance session API',
operations=[
{
'path': '/{api_version}/maintenance/{seission_id}',
'method': 'PUT'
}
]
),
policy.DocumentedRuleDefault(
name=POLICY_ROOT % 'delete',
check_str=base.RULE_ADMIN,
description='Policy rule for deleting maintenance session API',
operations=[
{
'path': '/{api_version}/maintenance/{seission_id}',
'method': 'DELETE'
}
]
)
]
def list_rules():
return session_policies

110
fenix/policy.py

@ -0,0 +1,110 @@
# Copyright (c) 2013 Bull.
#
# 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.
"""Policy Engine For Fenix."""
import functools
from oslo_config import cfg
from oslo_log import log as logging
from oslo_policy import opts
from oslo_policy import policy
from fenix import context
from fenix import exceptions
from fenix import policies
CONF = cfg.CONF
opts.set_defaults(CONF)
LOG = logging.getLogger(__name__)
_ENFORCER = None
def reset():
global _ENFORCER
if _ENFORCER:
_ENFORCER.clear()
_ENFORCER = None
def init():
global _ENFORCER
if not _ENFORCER:
_ENFORCER = policy.Enforcer(CONF)
_ENFORCER.register_defaults(policies.list_rules())
def set_rules(data, default_rule=None):
default_rule = default_rule or CONF.policy_default_rule
if not _ENFORCER:
init()
if default_rule:
_ENFORCER.default_rule = default_rule
_ENFORCER.set_rules(policy.Rules.load(data, default_rule))
def enforce(context, action, target, do_raise=True):
"""Verifies that the action is valid on the target in this context.
:param context: fenix context
:param action: string representing the action to be checked
: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 do_raise: if True (the default), raises PolicyNotAuthorized;
if False, returns False
:raises fenix.exceptions.PolicyNotAuthorized: if verification fails
and do_raise is True.
:return: returns a non-False value (not necessarily "True") if
authorized, and the exact value False if not authorized and
do_raise is False.
"""
init()
credentials = context.to_dict()
LOG.debug("enforce %s" % credentials)
# Add the exceptions arguments if asked to do a raise
extra = {}
if do_raise:
extra.update(exc=exceptions.PolicyNotAuthorized, action=action)
return _ENFORCER.enforce(action, target, credentials, do_raise=do_raise,
**extra)
def authorize(extension, action=None, api='fenix', ctx=None,
target=None):
def decorator(func):
@functools.wraps(func)
def wrapped(self, *args, **kwargs):
cur_ctx = ctx or context.current()
tgt = target or {'project_id': cur_ctx.project_id,
'user_id': cur_ctx.user_id}
LOG.debug("authorize target: %s" % tgt)
if action is None:
act = '%s:%s' % (api, extension)
else:
act = '%s:%s:%s' % (api, extension, action)
LOG.debug("authorize policy: %s" % act)
enforce(cur_ctx, act, tgt)
return func(self, *args, **kwargs)
return wrapped
return decorator

17
fenix/utils/service.py

@ -102,7 +102,7 @@ class EngineEndpoint(object):
def admin_get(self, ctx):
"""Get maintenance workflow sessions"""
LOG.info("EngineEndpoint: admin_get")
return {'sessions': self.workflow_sessions.keys()}
return {"sessions": self.workflow_sessions.keys()}
def admin_create_session(self, ctx, data):
"""Create maintenance workflow session thread"""
@ -145,14 +145,14 @@ class EngineEndpoint(object):
(self.session_id, data["workflow"]))
self.workflow_sessions[session_id].start()
return {'session_id': session_id}
return {"session_id": session_id}
def admin_get_session(self, ctx, session_id):
"""Get maintenance workflow session details"""
if not self._validate_session(session_id):
return None
LOG.info("EngineEndpoint: admin_get_session")
return ({'session_id': session_id, 'state':
return ({"session_id": session_id, "state":
self.workflow_sessions[session_id].session.state})
def admin_delete_session(self, ctx, session_id):
@ -165,10 +165,11 @@ class EngineEndpoint(object):
rmtree(session_dir)
return {}
def admin_update_session(self, ctx, session_id):
def admin_update_session(self, ctx, session_id, data):
"""Update maintenance workflow session"""
LOG.info("EngineEndpoint: admin_update_session")
return {'session_id': session_id}
# TBD Update data to workflow and return updated data
return data
def project_get_session(self, ctx, session_id, project_id):
"""Get maintenance workflow session project specific details"""
@ -177,7 +178,7 @@ class EngineEndpoint(object):
LOG.info("EngineEndpoint: project_get_session")
instance_ids = (self.workflow_sessions[session_id].
state_instance_ids(project_id))
return {'instance_ids': instance_ids}
return {"instance_ids": instance_ids}
def project_update_session(self, ctx, session_id, project_id, data):
"""Update maintenance workflow session project state"""
@ -185,9 +186,9 @@ class EngineEndpoint(object):
session_obj = self.workflow_sessions[session_id]
project = session_obj.project(project_id)
project.state = data["state"]
if 'instance_actions' in data:
if "instance_actions" in data:
session_obj.proj_instance_actions[project_id] = (
data['instance_actions'].copy())
data["instance_actions"].copy())
return data