diff --git a/congress/auth.py b/congress/auth.py new file mode 100644 index 000000000..db08afc8f --- /dev/null +++ b/congress/auth.py @@ -0,0 +1,72 @@ +# Copyright 2012 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo.config import cfg +import webob.dec +import webob.exc + +from congress.common import wsgi +from congress import context +from congress.openstack.common.gettextutils import _ +from congress.openstack.common import log as logging +from congress.openstack.common.middleware import request_id + +LOG = logging.getLogger(__name__) + + +class CongressKeystoneContext(wsgi.Middleware): + """Make a request context from keystone headers.""" + + @webob.dec.wsgify + def __call__(self, req): + # Determine the user ID + user_id = req.headers.get('X_USER_ID') + if not user_id: + LOG.debug(_("X_USER_ID is not found in request")) + return webob.exc.HTTPUnauthorized() + + # Determine the tenant + tenant_id = req.headers.get('X_PROJECT_ID') + + # Suck out the roles + roles = [r.strip() for r in req.headers.get('X_ROLES', '').split(',')] + + # Human-friendly names + tenant_name = req.headers.get('X_PROJECT_NAME') + user_name = req.headers.get('X_USER_NAME') + + # Use request_id if already set + req_id = req.environ.get(request_id.ENV_REQUEST_ID) + + # Create a context with the authentication data + ctx = context.Context(user_id, tenant_id, roles=roles, + user_name=user_name, tenant_name=tenant_name, + request_id=req_id) + + # Inject the context... + req.environ['congress.context'] = ctx + + return self.application + + +def pipeline_factory(loader, global_conf, **local_conf): + """Create a paste pipeline based on the 'auth_strategy' config option.""" + pipeline = local_conf[cfg.CONF.auth_strategy] + pipeline = pipeline.split() + filters = [loader.get_filter(n) for n in pipeline[:-1]] + app = loader.get_app(pipeline[-1]) + filters.reverse() + for filter in filters: + app = filter(app) + return app diff --git a/congress/common/config.py b/congress/common/config.py index ef21a3228..a039faa1d 100644 --- a/congress/common/config.py +++ b/congress/common/config.py @@ -12,10 +12,14 @@ # License for the specific language governing permissions and limitations # under the License. +import os from oslo.config import cfg -from congress.openstack.common import log +from congress.openstack.common.gettextutils import _ +from congress.openstack.common import log as logging + +LOG = logging.getLogger(__name__) core_opts = [ cfg.StrOpt('bind_host', default='0.0.0.0', @@ -32,11 +36,15 @@ core_opts = [ help='Sets the value of TCP_KEEPIDLE in seconds for each ' 'server socket. Only applies if tcp_keepalive is ' 'true. Not supported on OS X.'), - cfg.IntOpt('api_workers', default=1, - help='The number of worker processes to server the congress ' - 'WSGI application.'), cfg.StrOpt('policy_path', default=None, help="The path to the latest policy dump"), + cfg.IntOpt('api_workers', default=1, + help='The number of worker processes to serve the congress ' + 'API application.'), + cfg.StrOpt('api_paste_config', default="api-paste.ini", + help=_("The API paste config file to use")), + cfg.StrOpt('auth_strategy', default='keystone', + help=_("The type of authentication to use")), ] # Register the configuration options @@ -49,4 +57,14 @@ def init(args, **kwargs): def setup_logging(): """Sets up logging for the congress package.""" - log.setup('congress') + logging.setup('congress') + + +def find_paste_config(): + config_path = cfg.CONF.find_file(cfg.CONF.api_paste_config) + if not config_path: + raise cfg.ConfigFilesNotFoundError( + config_files=[cfg.CONF.api_paste_config]) + config_path = os.path.abspath(config_path) + LOG.info(_("Config paste file: %s"), config_path) + return config_path diff --git a/congress/common/wsgi.py b/congress/common/wsgi.py new file mode 100644 index 000000000..cfdf5d0d3 --- /dev/null +++ b/congress/common/wsgi.py @@ -0,0 +1,256 @@ +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# Copyright 2010 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. + +"""Utility methods for working with WSGI servers.""" + +from __future__ import print_function + +import sys + +import routes.middleware +import webob.dec +import webob.exc + +from congress.openstack.common.gettextutils import _ +from congress.openstack.common import log as logging + +LOG = logging.getLogger(__name__) + + +class Request(webob.Request): + pass + + +class Application(object): + """Base WSGI application wrapper. Subclasses need to implement __call__.""" + + @classmethod + def factory(cls, global_config, **local_config): + """Used for paste app factories in paste.deploy config files. + + Any local configuration (that is, values under the [app:APPNAME] + section of the paste config) will be passed into the `__init__` method + as kwargs. + + A hypothetical configuration would look like: + + [app:wadl] + latest_version = 1.3 + paste.app_factory = nova.api.fancy_api:Wadl.factory + + which would result in a call to the `Wadl` class as + + import nova.api.fancy_api + fancy_api.Wadl(latest_version='1.3') + + You could of course re-implement the `factory` method in subclasses, + but using the kwarg passing it shouldn't be necessary. + + """ + return cls(**local_config) + + def __call__(self, environ, start_response): + r"""Subclasses will probably want to implement __call__ like this: + + @webob.dec.wsgify(RequestClass=Request) + def __call__(self, req): + # Any of the following objects work as responses: + + # Option 1: simple string + res = 'message\n' + + # Option 2: a nicely formatted HTTP exception page + res = exc.HTTPForbidden(explanation='Nice try') + + # Option 3: a webob Response object (in case you need to play with + # headers, or you want to be treated like an iterable, or or or) + res = Response(); + res.app_iter = open('somefile') + + # Option 4: any wsgi app to be run next + res = self.application + + # Option 5: you can get a Response object for a wsgi app, too, to + # play with headers etc + res = req.get_response(self.application) + + # You can then just return your response... + return res + # ... or set req.response and return None. + req.response = res + + See the end of http://pythonpaste.org/webob/modules/dec.html + for more info. + + """ + raise NotImplementedError(_('You must implement __call__')) + + +class Middleware(Application): + """Base WSGI middleware. + + These classes require an application to be + initialized that will be called next. By default the middleware will + simply call its wrapped app, or you can override __call__ to customize its + behavior. + + """ + + @classmethod + def factory(cls, global_config, **local_config): + """Used for paste app factories in paste.deploy config files. + + Any local configuration (that is, values under the [filter:APPNAME] + section of the paste config) will be passed into the `__init__` method + as kwargs. + + A hypothetical configuration would look like: + + [filter:analytics] + redis_host = 127.0.0.1 + paste.filter_factory = nova.api.analytics:Analytics.factory + + which would result in a call to the `Analytics` class as + + import nova.api.analytics + analytics.Analytics(app_from_paste, redis_host='127.0.0.1') + + You could of course re-implement the `factory` method in subclasses, + but using the kwarg passing it shouldn't be necessary. + + """ + def _factory(app): + return cls(app, **local_config) + return _factory + + def __init__(self, application): + self.application = application + + def process_request(self, req): + """Called on each request. + + If this returns None, the next application down the stack will be + executed. If it returns a response then that response will be returned + and execution will stop here. + + """ + return None + + def process_response(self, response): + """Do whatever you'd like to the response.""" + return response + + @webob.dec.wsgify(RequestClass=Request) + def __call__(self, req): + response = self.process_request(req) + if response: + return response + response = req.get_response(self.application) + return self.process_response(response) + + +class Debug(Middleware): + """Helper class for debugging a WSGI application. + + Can be inserted into any WSGI application chain to get information + about the request and response. + + """ + + @webob.dec.wsgify(RequestClass=Request) + def __call__(self, req): + print(('*' * 40) + ' REQUEST ENVIRON') + for key, value in req.environ.items(): + print(key, '=', value) + print() + resp = req.get_response(self.application) + + print(('*' * 40) + ' RESPONSE HEADERS') + for (key, value) in resp.headers.iteritems(): + print(key, '=', value) + print() + + resp.app_iter = self.print_generator(resp.app_iter) + + return resp + + @staticmethod + def print_generator(app_iter): + """Iterator that prints the contents of a wrapper string.""" + print(('*' * 40) + ' BODY') + for part in app_iter: + sys.stdout.write(part) + sys.stdout.flush() + yield part + print() + + +class Router(object): + """WSGI middleware that maps incoming requests to WSGI apps.""" + + def __init__(self, mapper): + """Create a router for the given routes.Mapper. + + Each route in `mapper` must specify a 'controller', which is a + WSGI app to call. You'll probably want to specify an 'action' as + well and have your controller be an object that can route + the request to the action-specific method. + + Examples: + mapper = routes.Mapper() + sc = ServerController() + + # Explicit mapping of one route to a controller+action + mapper.connect(None, '/svrlist', controller=sc, action='list') + + # Actions are all implicitly defined + mapper.resource('server', 'servers', controller=sc) + + # Pointing to an arbitrary WSGI app. You can specify the + # {path_info:.*} parameter so the target app can be handed just that + # section of the URL. + mapper.connect(None, '/v1.0/{path_info:.*}', controller=BlogApp()) + + """ + self.map = mapper + self._router = routes.middleware.RoutesMiddleware(self._dispatch, + self.map) + + @webob.dec.wsgify(RequestClass=Request) + def __call__(self, req): + """Route the incoming request to a controller based on self.map. + + If no match, return a 404. + + """ + return self._router + + @staticmethod + @webob.dec.wsgify(RequestClass=Request) + def _dispatch(req): + """Dispatch the request to the appropriate controller. + + Called by self._router after matching the incoming request to a route + and putting the information into req.environ. Either returns 404 + or the routed WSGI app's response. + + """ + match = req.environ['wsgiorg.routing_args'][1] + if not match: + return webob.exc.HTTPNotFound() + app = match['controller'] + return app diff --git a/congress/context.py b/congress/context.py new file mode 100644 index 000000000..79329ff9f --- /dev/null +++ b/congress/context.py @@ -0,0 +1,178 @@ +# Copyright 2012 OpenStack Foundation. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Context: context for security/db session.""" + +import copy + +import datetime + +from congress.openstack.common import context as common_context +from congress.openstack.common import local +from congress.openstack.common import log as logging + + +LOG = logging.getLogger(__name__) + + +class ContextBase(common_context.RequestContext): + """Security context and request information. + + Represents the user taking a given action within the system. + + """ + + def __init__(self, user_id, tenant_id, is_admin=None, read_deleted="no", + roles=None, timestamp=None, load_admin_roles=True, + request_id=None, tenant_name=None, user_name=None, + overwrite=True, **kwargs): + """Object initialization. + + :param read_deleted: 'no' indicates deleted records are hidden, 'yes' + indicates deleted records are visible, 'only' indicates that + *only* deleted records are visible. + + :param overwrite: Set to False to ensure that the greenthread local + copy of the index is not overwritten. + + :param kwargs: Extra arguments that might be present, but we ignore + because they possibly came in from older rpc messages. + """ + super(ContextBase, self).__init__(user=user_id, tenant=tenant_id, + is_admin=is_admin, + request_id=request_id) + self.user_name = user_name + self.tenant_name = tenant_name + + self.read_deleted = read_deleted + if not timestamp: + timestamp = datetime.datetime.utcnow() + self.timestamp = timestamp + self._session = None + self.roles = roles or [] + if self.is_admin is None: + # FIXME(arosen) we need to add openstack policy support here + # self.is_admin = policy.check_is_admin(self) + # TODAY assume everyone is an admin i guess.... + self.is_admin = True + elif self.is_admin and load_admin_roles: + pass + #FIXME(arosen) add policy support here + # Ensure context is populated with admin roles + #admin_roles = policy.get_admin_roles() + #if admin_roles: + # self.roles = list(set(self.roles) | set(admin_roles)) + # Allow openstack.common.log to access the context + if overwrite or not hasattr(local.store, 'context'): + local.store.context = self + + # Log only once the context has been configured to prevent + # format errors. + if kwargs: + LOG.debug(_('Arguments dropped when creating ' + 'context: %s'), kwargs) + + @property + def project_id(self): + return self.tenant + + @property + def tenant_id(self): + return self.tenant + + @tenant_id.setter + def tenant_id(self, tenant_id): + self.tenant = tenant_id + + @property + def user_id(self): + return self.user + + @user_id.setter + def user_id(self, user_id): + self.user = user_id + + def _get_read_deleted(self): + return self._read_deleted + + def _set_read_deleted(self, read_deleted): + if read_deleted not in ('no', 'yes', 'only'): + raise ValueError(_("read_deleted can only be one of 'no', " + "'yes' or 'only', not %r") % read_deleted) + self._read_deleted = read_deleted + + def _del_read_deleted(self): + del self._read_deleted + + read_deleted = property(_get_read_deleted, _set_read_deleted, + _del_read_deleted) + + def to_dict(self): + return {'user_id': self.user_id, + 'tenant_id': self.tenant_id, + 'project_id': self.project_id, + 'is_admin': self.is_admin, + 'read_deleted': self.read_deleted, + 'roles': self.roles, + 'timestamp': str(self.timestamp), + 'request_id': self.request_id, + 'tenant': self.tenant, + 'user': self.user, + 'tenant_name': self.tenant_name, + 'project_name': self.tenant_name, + 'user_name': self.user_name, + } + + @classmethod + def from_dict(cls, values): + return cls(**values) + + def elevated(self, read_deleted=None): + """Return a version of this context with admin flag set.""" + context = copy.copy(self) + context.is_admin = True + + if 'admin' not in [x.lower() for x in context.roles]: + context.roles.append('admin') + + if read_deleted is not None: + context.read_deleted = read_deleted + + return context + + +class Context(ContextBase): + @property + def session(self): + if self._session is None: + pass + #self._session = db_api.get_session() + return self._session + + +def get_admin_context(read_deleted="no", load_admin_roles=True): + return Context(user_id=None, + tenant_id=None, + is_admin=True, + read_deleted=read_deleted, + load_admin_roles=load_admin_roles, + overwrite=False) + + +def get_admin_context_without_session(read_deleted="no"): + return ContextBase(user_id=None, + tenant_id=None, + is_admin=True, + read_deleted=read_deleted) diff --git a/congress/openstack/common/context.py b/congress/openstack/common/context.py new file mode 100644 index 000000000..b612db714 --- /dev/null +++ b/congress/openstack/common/context.py @@ -0,0 +1,126 @@ +# Copyright 2011 OpenStack Foundation. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Simple class that stores security context information in the web request. + +Projects should subclass this class if they wish to enhance the request +context or provide additional information in their specific WSGI pipeline. +""" + +import itertools +import uuid + + +def generate_request_id(): + return b'req-' + str(uuid.uuid4()).encode('ascii') + + +class RequestContext(object): + + """Helper class to represent useful information about a request context. + + Stores information about the security context under which the user + accesses the system, as well as additional request information. + """ + + user_idt_format = '{user} {tenant} {domain} {user_domain} {p_domain}' + + def __init__(self, auth_token=None, user=None, tenant=None, domain=None, + user_domain=None, project_domain=None, is_admin=False, + read_only=False, show_deleted=False, request_id=None, + instance_uuid=None): + self.auth_token = auth_token + self.user = user + self.tenant = tenant + self.domain = domain + self.user_domain = user_domain + self.project_domain = project_domain + self.is_admin = is_admin + self.read_only = read_only + self.show_deleted = show_deleted + self.instance_uuid = instance_uuid + if not request_id: + request_id = generate_request_id() + self.request_id = request_id + + def to_dict(self): + user_idt = ( + self.user_idt_format.format(user=self.user or '-', + tenant=self.tenant or '-', + domain=self.domain or '-', + user_domain=self.user_domain or '-', + p_domain=self.project_domain or '-')) + + return {'user': self.user, + 'tenant': self.tenant, + 'domain': self.domain, + 'user_domain': self.user_domain, + 'project_domain': self.project_domain, + 'is_admin': self.is_admin, + 'read_only': self.read_only, + 'show_deleted': self.show_deleted, + 'auth_token': self.auth_token, + 'request_id': self.request_id, + 'instance_uuid': self.instance_uuid, + 'user_identity': user_idt} + + @classmethod + def from_dict(cls, ctx): + return cls( + auth_token=ctx.get("auth_token"), + user=ctx.get("user"), + tenant=ctx.get("tenant"), + domain=ctx.get("domain"), + user_domain=ctx.get("user_domain"), + project_domain=ctx.get("project_domain"), + is_admin=ctx.get("is_admin", False), + read_only=ctx.get("read_only", False), + show_deleted=ctx.get("show_deleted", False), + request_id=ctx.get("request_id"), + instance_uuid=ctx.get("instance_uuid")) + + +def get_admin_context(show_deleted=False): + context = RequestContext(None, + tenant=None, + is_admin=True, + show_deleted=show_deleted) + return context + + +def get_context_from_function_and_args(function, args, kwargs): + """Find an arg of type RequestContext and return it. + + This is useful in a couple of decorators where we don't + know much about the function we're wrapping. + """ + + for arg in itertools.chain(kwargs.values(), args): + if isinstance(arg, RequestContext): + return arg + + return None + + +def is_user_context(context): + """Indicates if the request context is a normal user.""" + if not context: + return False + if context.is_admin: + return False + if not context.user_id or not context.project_id: + return False + return True diff --git a/congress/openstack/common/middleware/__init__.py b/congress/openstack/common/middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/congress/openstack/common/middleware/audit.py b/congress/openstack/common/middleware/audit.py new file mode 100644 index 000000000..a8a129c2d --- /dev/null +++ b/congress/openstack/common/middleware/audit.py @@ -0,0 +1,44 @@ +# Copyright (c) 2013 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. + +""" +Attach open standard audit information to request.environ + +AuditMiddleware filter should be place after Keystone's auth_token middleware +in the pipeline so that it can utilise the information Keystone provides. + +""" +from pycadf.audit import api as cadf_api + +from congress.openstack.common.middleware import notifier + + +class AuditMiddleware(notifier.RequestNotifier): + + def __init__(self, app, **conf): + super(AuditMiddleware, self).__init__(app, **conf) + self.cadf_audit = cadf_api.OpenStackAuditApi() + + @notifier.log_and_ignore_error + def process_request(self, request): + self.cadf_audit.append_audit_event(request) + super(AuditMiddleware, self).process_request(request) + + @notifier.log_and_ignore_error + def process_response(self, request, response, + exception=None, traceback=None): + self.cadf_audit.mod_audit_event(request, response) + super(AuditMiddleware, self).process_response(request, response, + exception, traceback) diff --git a/congress/openstack/common/middleware/base.py b/congress/openstack/common/middleware/base.py new file mode 100644 index 000000000..464a1ccd7 --- /dev/null +++ b/congress/openstack/common/middleware/base.py @@ -0,0 +1,56 @@ +# Copyright 2011 OpenStack Foundation. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Base class(es) for WSGI Middleware.""" + +import webob.dec + + +class Middleware(object): + """Base WSGI middleware wrapper. + + These classes require an application to be initialized that will be called + next. By default the middleware will simply call its wrapped app, or you + can override __call__ to customize its behavior. + """ + + @classmethod + def factory(cls, global_conf, **local_conf): + """Factory method for paste.deploy.""" + return cls + + def __init__(self, application): + self.application = application + + def process_request(self, req): + """Called on each request. + + If this returns None, the next application down the stack will be + executed. If it returns a response then that response will be returned + and execution will stop here. + """ + return None + + def process_response(self, response): + """Do whatever you'd like to the response.""" + return response + + @webob.dec.wsgify + def __call__(self, req): + response = self.process_request(req) + if response: + return response + response = req.get_response(self.application) + return self.process_response(response) diff --git a/congress/openstack/common/middleware/catch_errors.py b/congress/openstack/common/middleware/catch_errors.py new file mode 100644 index 000000000..9bc94bb4c --- /dev/null +++ b/congress/openstack/common/middleware/catch_errors.py @@ -0,0 +1,43 @@ +# Copyright (c) 2013 NEC Corporation +# 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. + +"""Middleware that provides high-level error handling. + +It catches all exceptions from subsequent applications in WSGI pipeline +to hide internal errors from API response. +""" +import logging + +import webob.dec +import webob.exc + +from congress.openstack.common.gettextutils import _LE +from congress.openstack.common.middleware import base + + +LOG = logging.getLogger(__name__) + + +class CatchErrorsMiddleware(base.Middleware): + + @webob.dec.wsgify + def __call__(self, req): + try: + response = req.get_response(self.application) + except Exception: + LOG.exception(_LE('An error occurred during ' + 'processing the request: %s')) + response = webob.exc.HTTPInternalServerError() + return response diff --git a/congress/openstack/common/middleware/correlation_id.py b/congress/openstack/common/middleware/correlation_id.py new file mode 100644 index 000000000..14d9d194f --- /dev/null +++ b/congress/openstack/common/middleware/correlation_id.py @@ -0,0 +1,28 @@ +# Copyright (c) 2013 Rackspace Hosting +# 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. + +"""Middleware that attaches a correlation id to WSGI request""" + +import uuid + +from congress.openstack.common.middleware import base + + +class CorrelationIdMiddleware(base.Middleware): + + def process_request(self, req): + correlation_id = (req.headers.get("X_CORRELATION_ID") or + str(uuid.uuid4())) + req.headers['X_CORRELATION_ID'] = correlation_id diff --git a/congress/openstack/common/middleware/debug.py b/congress/openstack/common/middleware/debug.py new file mode 100644 index 000000000..5622393c0 --- /dev/null +++ b/congress/openstack/common/middleware/debug.py @@ -0,0 +1,60 @@ +# Copyright 2011 OpenStack Foundation. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Debug middleware""" + +from __future__ import print_function + +import sys + +import six +import webob.dec + +from congress.openstack.common.middleware import base + + +class Debug(base.Middleware): + """Helper class that returns debug information. + + Can be inserted into any WSGI application chain to get information about + the request and response. + """ + + @webob.dec.wsgify + def __call__(self, req): + print(("*" * 40) + " REQUEST ENVIRON") + for key, value in req.environ.items(): + print(key, "=", value) + print() + resp = req.get_response(self.application) + + print(("*" * 40) + " RESPONSE HEADERS") + for (key, value) in six.iteritems(resp.headers): + print(key, "=", value) + print() + + resp.app_iter = self.print_generator(resp.app_iter) + + return resp + + @staticmethod + def print_generator(app_iter): + """Prints the contents of a wrapper string iterator when iterated.""" + print(("*" * 40) + " BODY") + for part in app_iter: + sys.stdout.write(part) + sys.stdout.flush() + yield part + print() diff --git a/congress/openstack/common/middleware/notifier.py b/congress/openstack/common/middleware/notifier.py new file mode 100644 index 000000000..e91efc6a2 --- /dev/null +++ b/congress/openstack/common/middleware/notifier.py @@ -0,0 +1,126 @@ +# Copyright (c) 2013 eNovance +# +# 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. + +""" +Send notifications on request + +""" +import logging +import os.path +import sys +import traceback as tb + +import six +import webob.dec + +from congress.openstack.common import context +from congress.openstack.common.gettextutils import _LE +from congress.openstack.common.middleware import base +from congress.openstack.common.notifier import api + +LOG = logging.getLogger(__name__) + + +def log_and_ignore_error(fn): + def wrapped(*args, **kwargs): + try: + return fn(*args, **kwargs) + except Exception as e: + LOG.exception(_LE('An exception occurred processing ' + 'the API call: %s ') % e) + return wrapped + + +class RequestNotifier(base.Middleware): + """Send notification on request.""" + + @classmethod + def factory(cls, global_conf, **local_conf): + """Factory method for paste.deploy.""" + conf = global_conf.copy() + conf.update(local_conf) + + def _factory(app): + return cls(app, **conf) + return _factory + + def __init__(self, app, **conf): + self.service_name = conf.get('service_name') + self.ignore_req_list = [x.upper().strip() for x in + conf.get('ignore_req_list', '').split(',')] + super(RequestNotifier, self).__init__(app) + + @staticmethod + def environ_to_dict(environ): + """Following PEP 333, server variables are lower case, so don't + include them. + + """ + return dict((k, v) for k, v in six.iteritems(environ) + if k.isupper() and k != 'HTTP_X_AUTH_TOKEN') + + @log_and_ignore_error + def process_request(self, request): + request.environ['HTTP_X_SERVICE_NAME'] = \ + self.service_name or request.host + payload = { + 'request': self.environ_to_dict(request.environ), + } + + api.notify(context.get_admin_context(), + api.publisher_id(os.path.basename(sys.argv[0])), + 'http.request', + api.INFO, + payload) + + @log_and_ignore_error + def process_response(self, request, response, + exception=None, traceback=None): + payload = { + 'request': self.environ_to_dict(request.environ), + } + + if response: + payload['response'] = { + 'status': response.status, + 'headers': response.headers, + } + + if exception: + payload['exception'] = { + 'value': repr(exception), + 'traceback': tb.format_tb(traceback) + } + + api.notify(context.get_admin_context(), + api.publisher_id(os.path.basename(sys.argv[0])), + 'http.response', + api.INFO, + payload) + + @webob.dec.wsgify + def __call__(self, req): + if req.method in self.ignore_req_list: + return req.get_response(self.application) + else: + self.process_request(req) + try: + response = req.get_response(self.application) + except Exception: + exc_type, value, traceback = sys.exc_info() + self.process_response(req, None, value, traceback) + raise + else: + self.process_response(req, response) + return response diff --git a/congress/openstack/common/middleware/request_id.py b/congress/openstack/common/middleware/request_id.py new file mode 100644 index 000000000..43d1c0dd8 --- /dev/null +++ b/congress/openstack/common/middleware/request_id.py @@ -0,0 +1,41 @@ +# Copyright (c) 2013 NEC Corporation +# 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. + +"""Middleware that ensures request ID. + +It ensures to assign request ID for each API request and set it to +request environment. The request ID is also added to API response. +""" + +import webob.dec + +from congress.openstack.common import context +from congress.openstack.common.middleware import base + + +ENV_REQUEST_ID = 'openstack.request_id' +HTTP_RESP_HEADER_REQUEST_ID = 'x-openstack-request-id' + + +class RequestIdMiddleware(base.Middleware): + + @webob.dec.wsgify + def __call__(self, req): + req_id = context.generate_request_id() + req.environ[ENV_REQUEST_ID] = req_id + response = req.get_response(self.application) + if HTTP_RESP_HEADER_REQUEST_ID not in response.headers: + response.headers.add(HTTP_RESP_HEADER_REQUEST_ID, req_id) + return response diff --git a/congress/openstack/common/middleware/sizelimit.py b/congress/openstack/common/middleware/sizelimit.py new file mode 100644 index 000000000..dc2b894f5 --- /dev/null +++ b/congress/openstack/common/middleware/sizelimit.py @@ -0,0 +1,82 @@ +# Copyright (c) 2012 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Request Body limiting middleware. + +""" + +from oslo.config import cfg +import webob.dec +import webob.exc + +from congress.openstack.common.gettextutils import _ +from congress.openstack.common.middleware import base + + +# default request size is 112k +max_req_body_size = cfg.IntOpt('max_request_body_size', + deprecated_name='osapi_max_request_body_size', + default=114688, + help='The maximum body size for each ' + ' request, in bytes.') + +CONF = cfg.CONF +CONF.register_opt(max_req_body_size) + + +class LimitingReader(object): + """Reader to limit the size of an incoming request.""" + def __init__(self, data, limit): + """Initiates LimitingReader object. + + :param data: Underlying data object + :param limit: maximum number of bytes the reader should allow + """ + self.data = data + self.limit = limit + self.bytes_read = 0 + + def __iter__(self): + for chunk in self.data: + self.bytes_read += len(chunk) + if self.bytes_read > self.limit: + msg = _("Request is too large.") + raise webob.exc.HTTPRequestEntityTooLarge(explanation=msg) + else: + yield chunk + + def read(self, i=None): + result = self.data.read(i) + self.bytes_read += len(result) + if self.bytes_read > self.limit: + msg = _("Request is too large.") + raise webob.exc.HTTPRequestEntityTooLarge(explanation=msg) + return result + + +class RequestBodySizeLimiter(base.Middleware): + """Limit the size of incoming requests.""" + + @webob.dec.wsgify + def __call__(self, req): + if (req.content_length is not None and + req.content_length > CONF.max_request_body_size): + msg = _("Request is too large.") + raise webob.exc.HTTPRequestEntityTooLarge(explanation=msg) + if req.content_length is None and req.is_body_readable: + limiter = LimitingReader(req.body_file, + CONF.max_request_body_size) + req.body_file = limiter + return self.application diff --git a/congress/server/congress_server.py b/congress/server/congress_server.py index f0f079f21..2f528d62e 100755 --- a/congress/server/congress_server.py +++ b/congress/server/congress_server.py @@ -21,9 +21,9 @@ import socket import os.path from oslo.config import cfg +from paste import deploy import sys -from congress.api import application from congress.api.webservice import CollectionHandler from congress.api.webservice import ElementHandler from congress.common import config @@ -55,24 +55,13 @@ class ServerWrapper(object): launcher.launch_service(self.server) -def create_api_server(conf, name, host, port, workers, - src_path=None, policy_path=None): - if src_path is None: - fpath = os.path.dirname(os.path.realpath(__file__)) - src_path = os.path.dirname(fpath) - src_path = os.path.dirname(fpath) - if policy_path is None: - policy_path = src_path - cage = harness.create(src_path, policy_path) - api_resource_mgr = application.ResourceManager() - initialize_resources(api_resource_mgr, cage) - api_webapp = application.ApiApplication(api_resource_mgr) +def create_api_server(conf, name, host, port, workers): + app = deploy.loadapp('config:%s' % conf, name=name) congress_api_server = eventlet_server.Server( - api_webapp, host=host, port=port, + app, host=host, port=port, keepalive=cfg.CONF.tcp_keepalive, keepidle=cfg.CONF.tcp_keepidle) - #TODO(arosen) - add ssl support here. return name, ServerWrapper(congress_api_server, workers) @@ -212,15 +201,13 @@ def main(): # API resource runtime encapsulation: # event loop -> wsgi server -> webapp -> resource manager - #TODO(arosen): find api-paste.conf for keystonemiddleware - paste_config = None + paste_config = config.find_paste_config() servers = [] servers.append(create_api_server(paste_config, - "congress-api-server", + "congress", cfg.CONF.bind_host, cfg.CONF.bind_port, - cfg.CONF.api_workers, - policy_path=cfg.CONF.policy_path)) + cfg.CONF.api_workers)) serve(*servers) diff --git a/congress/service.py b/congress/service.py new file mode 100644 index 000000000..3340ddd26 --- /dev/null +++ b/congress/service.py @@ -0,0 +1,56 @@ +# 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 sys + +import functools +from oslo.config import cfg + +from congress.api import application +from congress import harness +from congress.openstack.common import log +from congress.server import congress_server + +LOG = log.getLogger(__name__) + + +def fail_gracefully(f): + """Logs exceptions and aborts.""" + @functools.wraps(f) + def wrapper(*args, **kw): + try: + return f(*args, **kw) + except Exception as e: + LOG.debug(e, exc_info=True) + + # exception message is printed to all logs + LOG.critical(e) + sys.exit(1) + + return wrapper + + +@fail_gracefully +def congress_app_factory(global_conf, **local_conf): + #TODO(arosen): refactor this code so we don't need to rely on paths. + fpath = os.path.dirname(os.path.realpath(__file__) + "/server") + src_path = os.path.dirname(fpath) + src_path = os.path.dirname(fpath) + policy_path = cfg.CONF.policy_path + if policy_path is None: + policy_path = src_path + cage = harness.create(src_path, policy_path) + + api_resource_mgr = application.ResourceManager() + congress_server.initialize_resources(api_resource_mgr, cage) + return application.ApiApplication(api_resource_mgr) diff --git a/congress/tests/test_auth.py b/congress/tests/test_auth.py new file mode 100644 index 000000000..b1fba524b --- /dev/null +++ b/congress/tests/test_auth.py @@ -0,0 +1,101 @@ +# Copyright 2012 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 testtools +import webob + +from congress import auth +from congress.common import config +from congress.openstack.common.middleware import request_id + + +class CongressKeystoneContextTestCase(testtools.TestCase): + def setUp(self): + super(CongressKeystoneContextTestCase, self).setUp() + config.setup_logging() + + @webob.dec.wsgify + def fake_app(req): + self.context = req.environ['congress.context'] + return webob.Response() + + self.context = None + self.middleware = auth.CongressKeystoneContext(fake_app) + self.request = webob.Request.blank('/') + self.request.headers['X_AUTH_TOKEN'] = 'testauthtoken' + + def test_no_user_id(self): + self.request.headers['X_PROJECT_ID'] = 'testtenantid' + response = self.request.get_response(self.middleware) + self.assertEqual(response.status, '401 Unauthorized') + + def test_with_user_id(self): + self.request.headers['X_PROJECT_ID'] = 'testtenantid' + self.request.headers['X_USER_ID'] = 'testuserid' + response = self.request.get_response(self.middleware) + self.assertEqual(response.status, '200 OK') + self.assertEqual(self.context.user_id, 'testuserid') + self.assertEqual(self.context.user, 'testuserid') + + def test_with_tenant_id(self): + self.request.headers['X_PROJECT_ID'] = 'testtenantid' + self.request.headers['X_USER_ID'] = 'test_user_id' + response = self.request.get_response(self.middleware) + self.assertEqual(response.status, '200 OK') + self.assertEqual(self.context.tenant_id, 'testtenantid') + self.assertEqual(self.context.tenant, 'testtenantid') + + def test_roles_no_admin(self): + self.request.headers['X_PROJECT_ID'] = 'testtenantid' + self.request.headers['X_USER_ID'] = 'testuserid' + self.request.headers['X_ROLES'] = 'role1, role2 , role3,role4,role5' + response = self.request.get_response(self.middleware) + self.assertEqual(response.status, '200 OK') + self.assertEqual(self.context.roles, ['role1', 'role2', 'role3', + 'role4', 'role5']) + #FIXME(arosen): today everyone is considered an admin until + # we implement the openstack policy frame work in congress. + self.assertEqual(self.context.is_admin, True) + + def test_roles_with_admin(self): + self.request.headers['X_PROJECT_ID'] = 'testtenantid' + self.request.headers['X_USER_ID'] = 'testuserid' + self.request.headers['X_ROLES'] = ('role1, role2 , role3,role4,role5,' + 'AdMiN') + response = self.request.get_response(self.middleware) + self.assertEqual(response.status, '200 OK') + self.assertEqual(self.context.roles, ['role1', 'role2', 'role3', + 'role4', 'role5', 'AdMiN']) + self.assertEqual(self.context.is_admin, True) + + def test_with_user_tenant_name(self): + self.request.headers['X_PROJECT_ID'] = 'testtenantid' + self.request.headers['X_USER_ID'] = 'testuserid' + self.request.headers['X_PROJECT_NAME'] = 'testtenantname' + self.request.headers['X_USER_NAME'] = 'testusername' + response = self.request.get_response(self.middleware) + self.assertEqual(response.status, '200 OK') + self.assertEqual(self.context.user_id, 'testuserid') + self.assertEqual(self.context.user_name, 'testusername') + self.assertEqual(self.context.tenant_id, 'testtenantid') + self.assertEqual(self.context.tenant_name, 'testtenantname') + + def test_request_id_extracted_from_env(self): + req_id = 'dummy-request-id' + self.request.headers['X_PROJECT_ID'] = 'testtenantid' + self.request.headers['X_USER_ID'] = 'testuserid' + self.request.environ[request_id.ENV_REQUEST_ID] = req_id + self.request.get_response(self.middleware) + self.assertEqual(req_id, self.context.request_id) diff --git a/congress/tests/test_config.py b/congress/tests/test_config.py index ce020a8bf..be9bfd07d 100644 --- a/congress/tests/test_config.py +++ b/congress/tests/test_config.py @@ -31,3 +31,5 @@ class ConfigurationTest(testtools.TestCase): self.assertEqual(False, cfg.CONF.tcp_keepalive) self.assertEqual(600, cfg.CONF.tcp_keepidle) self.assertEqual(1, cfg.CONF.api_workers) + self.assertEqual('api-paste.ini', cfg.CONF.api_paste_config) + self.assertEqual('keystone', cfg.CONF.auth_strategy) diff --git a/etc/api-paste.ini b/etc/api-paste.ini new file mode 100644 index 000000000..13be8dd7b --- /dev/null +++ b/etc/api-paste.ini @@ -0,0 +1,25 @@ +[composite:congress] +use = egg:Paste#urlmap +# FIXME(arosen) congress doesn't have version in uri right now. +#/v1: congressversions +/: congress_api_v1 + +[composite:congress_api_v1] +use = call:congress.auth:pipeline_factory +keystone = request_id catch_errors authtoken keystonecontext congress_api +noauth = request_id catch_errors congress_api + +[app:congress_api] +paste.app_factory = congress.service:congress_app_factory + +[filter:request_id] +paste.filter_factory = congress.openstack.common.middleware.request_id:RequestIdMiddleware.factory + +[filter:catch_errors] +paste.filter_factory = congress.openstack.common.middleware.catch_errors:CatchErrorsMiddleware.factory + +[filter:keystonecontext] +paste.filter_factory = congress.auth:CongressKeystoneContext.factory + +[filter:authtoken] +paste.filter_factory = keystonemiddleware.auth_token:filter_factory diff --git a/etc/congress.conf.sample b/etc/congress.conf.sample index f41a35651..f9f1fc2cf 100644 --- a/etc/congress.conf.sample +++ b/etc/congress.conf.sample @@ -1,9 +1,9 @@ [DEFAULT] # Print more verbose output (set logging level to INFO instead of default WARNING level). -# verbose = False +verbose = True # Print debugging output (set logging level to DEBUG instead of default WARNING level). -# debug = False +debug = True # log_format = %(asctime)s %(levelname)8s [%(name)s] %(message)s # log_date_format = %Y-%m-%d %H:%M:%S @@ -32,3 +32,17 @@ policy_path = '/Users/thinrichs/congress/congress/tests/snapshot' +# Paste configuration file +# api_paste_config = api-paste.ini + +# The strategy to be used for auth. +# Supported values are 'keystone'(default), 'noauth'. +# auth_strategy = keystone + +[keystone_authtoken] +auth_host = 127.0.0.1 +auth_port = 35357 +auth_protocol = http +admin_tenant_name = %SERVICE_TENANT_NAME% +admin_user = %SERVICE_USER% +admin_password = %SERVICE_PASSWORD% diff --git a/requirements.txt b/requirements.txt index 97ca0643f..ca58a80cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,15 @@ argparse Babel>=1.3 eventlet>=0.13.0 +keystonemiddleware mox<1.0 +Paste +PasteDeploy>=1.5.0 pbr>=0.6,!=0.7,<1.0 posix_ipc python-novaclient>=2.17.0 python-neutronclient>=2.3.5,<3 +Routes>=1.12.3,!=2.0 six>=1.7.0 oslo.config>=1.2.1 WebOb>=1.2.3