config/sysinv/sysinv/sysinv/sysinv/api/hooks.py

487 lines
18 KiB
Python

# -*- encoding: utf-8 -*-
#
# Copyright © 2012 New Dream Network, LLC (DreamHost)
#
# Author: Doug Hellmann <doug.hellmann@dreamhost.com>
#
# 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.
#
# Copyright (c) 2013-2018 Wind River Systems, Inc.
#
import re
import time
from six.moves.urllib.parse import urlparse
import webob
from oslo_config import cfg
from oslo_serialization import jsonutils
from oslo_log import log
from oslo_utils import uuidutils
from pecan import hooks
from sysinv._i18n import _
from sysinv.api.policies import base as base_policy
from sysinv.common import context
from sysinv.common import utils
from sysinv.conductor import rpcapi
from sysinv.db import api as dbapi
from sysinv.common import policy
from webob import exc
LOG = log.getLogger(__name__)
NO_SPACE_MSG = "Insufficient space"
audit_log_name = "{}.{}".format(__name__, "auditor")
auditLOG = log.getLogger(audit_log_name)
def generate_request_id():
return 'req-%s' % uuidutils.generate_uuid()
def is_load_import(content_type, url_path):
if (content_type == "multipart/form-data" and
url_path == "/v1/loads/import_load"):
return True
else:
return False
class MultiFormDataHook(hooks.PecanHook):
"""For multipart form-data, check disk space available before
proceeding.
Currently, it is only applying to import_load request, but
it can be extended to cover other multipart form-data requests
"""
def on_route(self, state):
content_type = state.request.content_type
url_path = state.request.path
if is_load_import(content_type, url_path):
content_length = int(state.request.headers.get('Content-Length'))
# Currently, the restriction is 2x the file size:
# 1x from internal webob copy (see before override below)
# 1x from sysinv temporary copy
if not utils.is_space_available("/scratch", 2 * content_length):
msg = _(NO_SPACE_MSG + " on /scratch for request %s"
% url_path)
raise webob.exc.HTTPInternalServerError(explanation=msg)
# Note: webob, for the multipart form-data request, creates 2 internal
# temporary copies, using the before override we can close the second
# temporary request before request goes to sysinv, this saves 1x file
# size required
def before(self, state):
content_type = state.request.content_type
url_path = state.request.path
if is_load_import(content_type, url_path):
state.request.body_file.close()
class ConfigHook(hooks.PecanHook):
"""Attach the config 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.
priority = 120
The following HTTP request headers are used:
X-User-Id or X-User:
Used for context.user_id.
X-Tenant-Id or X-Tenant:
Used for context.tenant.
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.
X-Service_Catalog:
Used for context.service_catalog.
"""
def __init__(self, public_api_routes):
self.public_api_routes = public_api_routes
super(ContextHook, self).__init__()
def before(self, state):
user_id = state.request.headers.get('X-User-Id')
user_id = state.request.headers.get('X-User', user_id)
tenant = state.request.headers.get('X-Tenant-Id')
tenant = state.request.headers.get('X-Tenant', tenant)
project_name = state.request.headers.get('X-Project-Name')
domain_id = state.request.headers.get('X-User-Domain-Id')
domain_name = state.request.headers.get('X-User-Domain-Name')
auth_token = state.request.headers.get('X-Auth-Token', None)
roles = state.request.headers.get('X-Roles', '').split(',')
catalog_header = state.request.headers.get('X-Service-Catalog')
service_catalog = None
if catalog_header:
try:
service_catalog = jsonutils.loads(catalog_header)
except ValueError:
raise webob.exc.HTTPInternalServerError(
_('Invalid service catalog json.'))
credentials = {
'project_name': project_name,
'roles': roles
}
is_admin = policy.authorize(base_policy.ADMIN_IN_SYSTEM_PROJECTS, {},
credentials, do_raise=False)
utils.safe_rstrip(state.request.path, '/')
is_public_api = state.request.environ.get('is_public_api', False)
state.request.context = context.RequestContext(
auth_token=auth_token,
user=user_id,
tenant=tenant,
domain_id=domain_id,
domain_name=domain_name,
is_admin=is_admin,
is_public_api=is_public_api,
project_name=project_name,
roles=roles,
service_catalog=service_catalog
)
class RPCHook(hooks.PecanHook):
"""Attach the rpcapi object to the request so controllers can get to it."""
def before(self, state):
state.request.rpcapi = rpcapi.ConductorAPI()
class AccessPolicyHook(hooks.PecanHook):
"""Verify that the user has the needed credentials to execute the action."""
def before(self, state):
controller = state.controller.__self__
if hasattr(controller, 'enforce_policy'):
try:
controller_method = state.controller.__name__
controller.enforce_policy(controller_method, state.request)
except Exception:
raise exc.HTTPForbidden()
else:
context = state.request.context
is_admin_api = policy.authorize(
base_policy.ADMIN_IN_SYSTEM_PROJECTS, {},
context.to_dict(), do_raise=False)
if not is_admin_api and not context.is_public_api:
raise exc.HTTPForbidden()
class NoExceptionTracebackHook(hooks.PecanHook):
"""Workaround rpc.common: deserialize_remote_exception.
deserialize_remote_exception builds rpc exception traceback into error
message which is then sent to the client. Such behavior is a security
concern so this hook is aimed to cut-off traceback from the error message.
"""
# NOTE(max_lobur): 'after' hook used instead of 'on_error' because
# 'on_error' never fired for wsme+pecan pair. wsme @wsexpose decorator
# catches and handles all the errors, so 'on_error' dedicated for unhandled
# exceptions never fired.
def after(self, state):
# Omit empty body. Some errors may not have body at this level yet.
if not state.response.body:
return
# Do nothing if there is no error.
if 200 <= state.response.status_int < 400:
return
json_body = state.response.json
# Do not remove traceback when server in debug mode (except 'Server'
# errors when 'debuginfo' will be used for traces).
if cfg.CONF.debug and json_body.get('faultcode') != 'Server':
return
faultsting = json_body.get('faultstring')
traceback_marker = 'Traceback (most recent call last):'
if faultsting and (traceback_marker in faultsting):
# Cut-off traceback.
faultsting = faultsting.split(traceback_marker, 1)[0]
# Remove trailing newlines and spaces if any.
json_body['faultstring'] = faultsting.rstrip()
# Replace the whole json. Cannot change original one beacause it's
# generated on the fly.
state.response.json = json_body
class AuditLogging(hooks.PecanHook):
"""Performs audit logging of all sysinv ["POST", "PUT","PATCH","DELETE"] REST requests"""
def __init__(self):
self.log_methods = ["POST", "PUT", "PATCH", "DELETE"]
def before(self, state):
state.request.start_time = time.time()
def __after(self, state):
method = state.request.method
if method not in self.log_methods:
return
now = time.time()
try:
elapsed = now - state.request.start_time
except AttributeError:
LOG.info("Start time is not in request, setting it to 0.")
elapsed = 0
environ = state.request.environ
server_protocol = environ["SERVER_PROTOCOL"]
response_content_length = state.response.content_length
user_id = state.request.headers.get('X-User-Id')
user_name = state.request.headers.get('X-User', user_id)
tenant_id = state.request.headers.get('X-Tenant-Id')
tenant = state.request.headers.get('X-Tenant', tenant_id)
domain_name = state.request.headers.get('X-User-Domain-Name')
try:
request_id = state.request.context.request_id
except AttributeError:
LOG.info("Request id is not in request, setting it to an "
"auto generated id.")
request_id = generate_request_id()
url_path = urlparse(state.request.path_qs).path
def json_post_data(rest_state):
if 'form-data' in rest_state.request.headers.get('Content-Type'):
# rest_state.request.params causes an internal webob copy,
# prevent its call if there is no space available
size = int(rest_state.request.headers.get('Content-Length'))
if utils.is_space_available("/scratch", 2 * size):
return " POST: {}".format(rest_state.request.params)
else:
return " POST: " + NO_SPACE_MSG + " for processing"
try:
if not hasattr(rest_state.request, 'json'):
return ""
except Exception:
return ""
return " POST: {}".format(rest_state.request.json)
# Filter password from log
filtered_json = re.sub(r'{[^{}]*(passwd_hash|community|password)[^{}]*},*',
'',
json_post_data(state))
log_data = "{} \"{} {} {}\" status: {} len: {} time: {}{} host:{}" \
" agent:{} user: {} tenant: {} domain: {}".format(
state.request.remote_addr,
state.request.method,
url_path,
server_protocol,
state.response.status_int,
response_content_length,
elapsed,
filtered_json,
state.request.host,
state.request.user_agent,
user_name,
tenant,
domain_name)
def cleanup(environ):
post_vars, body_file = environ['webob._parsed_post_vars']
# for large post request, the body is also copied to a tempfile by webob
if not isinstance(body_file, bytes):
body_file.close()
for f in post_vars.keys():
item = post_vars[f]
if hasattr(item, 'file'):
item.file.close()
if 'webob._parsed_post_vars' in state.request.environ:
cleanup(state.request.environ)
# The following ctx object will be output in the logger as
# something like this:
# [req-088ed3b6-a2c9-483e-b2ad-f1b2d03e06e6 3d76d3c1376744e8ad9916a6c3be3e5f ca53e70c76d847fd860693f8eb301546]
# When the ctx is defined, the formatter (defined in common/log.py) requires that keys
# request_id, user, tenant be defined within the ctx
ctx = {'request_id': request_id,
'user': user_id,
'tenant': tenant_id}
auditLOG.info("{}".format(log_data), context=ctx)
def after(self, state):
# noinspection PyBroadException
try:
self.__after(state)
except Exception:
# Logging and then swallowing exception to ensure
# rest service does not fail even if audit logging fails
auditLOG.exception("Exception in AuditLogging on event 'after'")
def on_error(self, state, e):
auditLOG.exception("Exception in AuditLogging passed to event 'on_error': " + str(e))
class DBTransactionHook(hooks.PecanHook):
"""Custom hook for SysInv database transactions.
"""
priority = 150
def __init__(self):
self.transactional_methods = ["POST", "PUT", "PATCH", "DELETE"]
LOG.info("DBTransactionHook")
def _cfg(self, f):
if not hasattr(f, '_pecan'):
f._pecan = {}
return f._pecan
def is_transactional(self, state):
'''
Decide if a request should be wrapped in a transaction, based
upon the state of the request. By default, wraps all but ``GET``
and ``HEAD`` requests in a transaction, along with respecting
the ``transactional`` decorator from :mod:pecan.decorators.
:param state: The Pecan state object for the current request.
'''
controller = getattr(state, 'controller', None)
if controller:
force_transactional = self._cfg(controller).get('transactional', False)
else:
force_transactional = False
if state.request.method not in ('GET', 'HEAD') or force_transactional:
return True
return False
def on_route(self, state):
state.request.error = False
if self.is_transactional(state):
state.request.transactional = True
self.start_transaction(state)
else:
state.request.transactional = False
self.start_ro(state)
def on_error(self, state, e):
#
# If we should ignore redirects,
# (e.g., shouldn't consider them rollback-worthy)
# don't set `state.request.error = True`.
#
LOG.error("DBTransaction on_error state=%s e=%s" % (state, e))
trans_ignore_redirects = (
state.request.method not in ('GET', 'HEAD')
)
if state.controller is not None:
trans_ignore_redirects = (
self._cfg(state.controller).get(
'transactional_ignore_redirects',
trans_ignore_redirects
)
)
if type(e) is exc.HTTPFound and trans_ignore_redirects is True:
return
state.request.error = True
def before(self, state):
if self.is_transactional(state) \
and not getattr(state.request, 'transactional', False):
self.clear(state)
state.request.transactional = True
self.start_transaction(state)
# NOTE(max_lobur): 'after' hook used instead of 'on_error' because
# 'on_error' never fired for wsme+pecan pair. wsme @wsexpose decorator
# catches and handles all the errors, so 'on_error' dedicated for unhandled
# exceptions never fired.
def after(self, state):
# Omit empty body. Some errors may not have body at this level yet.
method = state.request.method
if not state.response.body:
if method in self.transactional_methods:
self.commit_transaction(state)
self.clear(state)
return
# Do nothing if there is no error.
if 200 <= state.response.status_int < 400:
if method in self.transactional_methods:
self.commit_transaction(state)
self.clear(state)
return
LOG.warn("ROLLBACK after state.response.status=%s " %
(state.response.status_int))
try:
self.rollback_transaction(state)
except AttributeError:
LOG.error("rollback_transaction Attribute error")
self.clear(state)
def start_transaction(self, state):
# session is attached by context when needed
return
def start_ro(self, state):
# session is attached by context when needed
return
def commit_transaction(self, state):
# The autocommit handles the commit
return
def rollback_transaction(self, state):
if (hasattr(state.request.context, 'session') and
state.request.context.session):
session = state.request.context.session
session.rollback()
LOG.info("rollback_transaction %s" % session)
return
def clear(self, state):
if (hasattr(state.request.context, 'session') and
state.request.context.session):
session = state.request.context.session
session.remove()
return