From 184c84ae76b1e4dc6b9907caf0cfa0d65bf8ecc5 Mon Sep 17 00:00:00 2001 From: Morgan Fainberg Date: Thu, 11 Oct 2018 15:16:02 -0700 Subject: [PATCH] Remove pre-flask legacy code This removes common.controller, common.extension, common.router, and common.wsgi. Relevant code from common.wsgi (used by AuthContext) was moved into keystone.server.flask.request_processing.middleware.auth_context. keystone.api.discovery now uses keystone.flask.base_url test_middleware and test_exception were modified to reflect the changes to the remaining code from keystone.common.wsgi keystone.common.authorization only holds a couple constants for auth work now. Routes is removed from requirements.txt Release-Note for migration to flask added. Change-Id: I81563b6a49c8f12ecade058a9483f3b6f070dc72 Closes-Bug: #1776504 --- keystone/api/discovery.py | 14 +- keystone/common/authorization.py | 150 +--- keystone/common/controller.py | 623 ---------------- keystone/common/dependency.py | 59 -- keystone/common/extension.py | 44 -- keystone/common/request.py | 142 ---- keystone/common/router.py | 85 --- keystone/common/wsgi.py | 703 ------------------ .../middleware/auth_context.py | 210 +++++- keystone/tests/unit/test_exception.py | 9 +- keystone/tests/unit/test_middleware.py | 3 +- ...-conversion-to-flask-372a5654a55675c6.yaml | 26 + requirements.txt | 1 - 13 files changed, 240 insertions(+), 1829 deletions(-) delete mode 100644 keystone/common/controller.py delete mode 100644 keystone/common/dependency.py delete mode 100644 keystone/common/extension.py delete mode 100644 keystone/common/request.py delete mode 100644 keystone/common/router.py delete mode 100644 keystone/common/wsgi.py create mode 100644 releasenotes/notes/bug-1776504-keystone-conversion-to-flask-372a5654a55675c6.yaml diff --git a/keystone/api/discovery.py b/keystone/api/discovery.py index 2495c473ec..5550903f1f 100644 --- a/keystone/api/discovery.py +++ b/keystone/api/discovery.py @@ -16,9 +16,9 @@ from oslo_serialization import jsonutils from six.moves import http_client from keystone.common import json_home -from keystone.common import wsgi import keystone.conf from keystone import exception +from keystone.server import flask as ks_flask CONF = keystone.conf.CONF @@ -87,11 +87,7 @@ def get_versions(): return flask.Response(response=jsonutils.dumps(v3_json_home), mimetype=MimeTypes.JSON_HOME) else: - # NOTE(morgan): wsgi.Application.base_url will eventually need to - # be moved to a better "common" location. For now, we'll just lean - # on it for the sake of leaning on common code where possible. - identity_url = '%s/v3/' % wsgi.Application.base_url( - context={'environment': request.environ}) + identity_url = '%s/' % ks_flask.base_url() versions = _get_versions_list(identity_url) return flask.Response( response=jsonutils.dumps( @@ -113,11 +109,7 @@ def get_version_v3(): return flask.Response(response=jsonutils.dumps(content), mimetype=MimeTypes.JSON_HOME) else: - # NOTE(morgan): wsgi.Application.base_url will eventually need to - # be moved to a better "common" location. For now, we'll just lean - # on it for the sake of leaning on common code where possible. - identity_url = '%s/v3/' % wsgi.Application.base_url( - context={'environment': request.environ}) + identity_url = '%s/' % ks_flask.base_url() versions = _get_versions_list(identity_url) return flask.Response( response=jsonutils.dumps({'version': versions['v3']}), diff --git a/keystone/common/authorization.py b/keystone/common/authorization.py index 6daf258462..1afe47f700 100644 --- a/keystone/common/authorization.py +++ b/keystone/common/authorization.py @@ -15,163 +15,15 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. -from oslo_log import log -from oslo_utils import strutils - -from keystone.common.policies import base as pol_base -from keystone.common import utils -from keystone import conf -from keystone import exception +# A couple common constants for Auth data # Header used to transmit the auth token AUTH_TOKEN_HEADER = 'X-Auth-Token' - # Header used to transmit the subject token SUBJECT_TOKEN_HEADER = 'X-Subject-Token' - -CONF = conf.CONF - # Environment variable used to convey the Keystone auth context, # the user credential used for policy enforcement. AUTH_CONTEXT_ENV = 'KEYSTONE_AUTH_CONTEXT' - -LOG = log.getLogger(__name__) - - -def assert_admin(app, request): - """Ensure the user is an admin. - - :raises keystone.exception.Unauthorized: if a token could not be - found/authorized, a user is invalid, or a tenant is - invalid/not scoped. - :raises keystone.exception.Forbidden: if the user is not an admin and - does not have the admin role - - """ - check_policy(app, request, 'admin_required', input_attr={}) - - -def _build_policy_check_credentials(action, context, kwargs): - kwargs_str = ', '.join(['%s=%s' % (k, kwargs[k]) for k in kwargs]) - kwargs_str = strutils.mask_password(kwargs_str) - msg = 'RBAC: Authorizing %(action)s(%(kwargs)s)' - LOG.debug(msg, {'action': action, 'kwargs': kwargs_str}) - - return context['environment'].get(AUTH_CONTEXT_ENV, {}) - - -def _handle_member_from_driver(self, policy_dict, **kwargs): - # Check to see if we need to include the target entity in our - # policy checks. We deduce this by seeing if the class has - # specified a get_member() method and that kwargs contains the - # appropriate entity id. - if (hasattr(self, 'get_member_from_driver') and - self.get_member_from_driver is not None): - key = '%s_id' % self.member_name - if key in kwargs: - ref = self.get_member_from_driver(kwargs[key]) - policy_dict['target'] = {self.member_name: ref} - - -def token_validation_window(request): - # NOTE(jamielennox): it's dumb that i have to put this here. We should - # only validate subject token in one place. - - allow_expired = request.params.get('allow_expired') - allow_expired = strutils.bool_from_string(allow_expired, default=False) - return CONF.token.allow_expired_window if allow_expired else 0 - - -def _handle_subject_token_id(self, request, policy_dict): - if request.subject_token is not None: - window_seconds = token_validation_window(request) - - token = self.token_provider_api.validate_token( - request.subject_token, window_seconds=window_seconds - ) - policy_dict.setdefault('target', {}) - policy_dict['target'].setdefault(self.member_name, {}) - policy_dict['target'][self.member_name]['user_id'] = (token.user_id) - try: - user_domain_id = token.user_domain['id'] - except exception.UnexpectedError: - user_domain_id = None - if user_domain_id: - policy_dict['target'][self.member_name].setdefault( - 'user', {}) - policy_dict['target'][self.member_name][ - 'user'].setdefault('domain', {}) - policy_dict['target'][self.member_name]['user'][ - 'domain']['id'] = ( - user_domain_id) - - -def check_protection(controller, request, prep_info, target_attr=None, - *args, **kwargs): - """Provide call protection for complex target attributes. - - As well as including the standard parameters from the original API - call (which is passed in prep_info), this call will add in any - additional entities or attributes (passed in target_attr), so that - they can be referenced by policy rules. - - """ - check_policy(controller, request, - pol_base.IDENTITY % prep_info['f_name'], - prep_info.get('filter_attr'), - prep_info.get('input_attr'), - target_attr, - *args, **kwargs) - - -def check_policy(controller, request, action, - filter_attr=None, input_attr=None, target_attr=None, - *args, **kwargs): - # Makes the arguments from check protection explicit. - request.assert_authenticated() - if request.context.is_admin: - LOG.warning('RBAC: Bypassing authorization') - return - - # TODO(henry-nash) need to log the target attributes as well - creds = _build_policy_check_credentials( - action, request.context_dict, input_attr) - # Build the dict the policy engine will check against from both the - # parameters passed into the call we are protecting plus the target - # attributes provided. - policy_dict = {} - _handle_member_from_driver(controller, policy_dict, **kwargs) - _handle_subject_token_id(controller, request, policy_dict) - - if target_attr: - policy_dict = {'target': target_attr} - if input_attr: - policy_dict.update(input_attr) - if filter_attr: - policy_dict.update(filter_attr) - - for key in kwargs: - policy_dict[key] = kwargs[key] - controller.policy_api.enforce(creds, - action, - utils.flatten_dict(policy_dict)) - LOG.debug('RBAC: Authorization granted') - - -def get_token_ref(context): - """Retrieve TokenModel object from the auth context and returns it. - - :param dict context: The request context. - :raises keystone.exception.Unauthorized: If auth context cannot be found. - :returns: The TokenModel object. - """ - try: - # Retrieve the auth context that was prepared by AuthContextMiddleware. - auth_context = (context['environment'][AUTH_CONTEXT_ENV]) - return auth_context['token'] - except KeyError: - LOG.warning("Couldn't find the auth context.") - raise exception.Unauthorized() diff --git a/keystone/common/controller.py b/keystone/common/controller.py deleted file mode 100644 index 63e4cc56ec..0000000000 --- a/keystone/common/controller.py +++ /dev/null @@ -1,623 +0,0 @@ -# Copyright 2013 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. - -import functools -import uuid - -from oslo_log import log -from oslo_log import versionutils -import six - -from keystone.common import authorization -from keystone.common import driver_hints -from keystone.common import provider_api -from keystone.common import utils -from keystone.common import wsgi -import keystone.conf -from keystone import exception -from keystone.i18n import _ - - -LOG = log.getLogger(__name__) -CONF = keystone.conf.CONF -PROVIDERS = provider_api.ProviderAPIs - - -def protected(callback=None): - """Wrap API calls with role based access controls (RBAC). - - This handles both the protection of the API parameters as well as any - target entities for single-entity API calls. - - More complex API calls (for example that deal with several different - entities) should pass in a callback function, that will be subsequently - called to check protection for these multiple entities. This callback - function should gather the appropriate entities needed and then call - check_protection() in the V3Controller class. - - """ - def wrapper(f): - @functools.wraps(f) - def inner(self, request, *args, **kwargs): - check_function = authorization.check_protection - if callback is not None: - check_function = callback - - protected_wrapper( - self, f, check_function, request, None, *args, **kwargs) - return f(self, request, *args, **kwargs) - return inner - return wrapper - - -def filterprotected(*filters, **callback): - """Wrap API list calls with role based access controls (RBAC). - - This handles both the protection of the API parameters as well as any - filters supplied. - - More complex API list calls (for example that need to examine the contents - of an entity referenced by one of the filters) should pass in a callback - function, that will be subsequently called to check protection for these - multiple entities. This callback function should gather the appropriate - entities needed and then call check_protection() in the V3Controller class. - - """ - def _handle_filters(filters, request): - target = dict() - if filters: - for item in filters: - if item in request.params: - target[item] = request.params[item] - - LOG.debug('RBAC: Adding query filter params (%s)', ( - ', '.join(['%s=%s' % (item, target[item]) - for item in target]))) - return target - - def _filterprotected(f): - @functools.wraps(f) - def wrapper(self, request, **kwargs): - filter_attr = _handle_filters(filters, request) - check_function = authorization.check_protection - if 'callback' in callback and callback['callback'] is not None: - # A callback has been specified to load additional target - # data, so pass it the formal url params as well as the - # list of filters, so it can augment these and then call - # the check_protection() method. - check_function = callback['callback'] - - protected_wrapper( - self, f, check_function, request, filter_attr, **kwargs) - return f(self, request, filters, **kwargs) - return wrapper - return _filterprotected - - -# Unified calls for the decorators above. -# TODO(ayoung): Continue the refactoring. Always call check_protection -# explicitly, by removing the calls to check protection from the callbacks. -# Instead, have a call to the callbacks inserted prior to the call to -# `check_protection`. -def protected_wrapper(self, f, check_function, request, filter_attr, - *args, **kwargs): - request.assert_authenticated() - if request.context.is_admin: - LOG.warning('RBAC: Bypassing authorization') - return - prep_info = {'f_name': f.__name__, - 'input_attr': kwargs} - if (filter_attr): - prep_info['filter_attr'] = filter_attr - check_function(self, request, prep_info, *args, **kwargs) - - -# FIXME(lbragstad): Find a better home for this... I put there here since it's -# needed across a couple different controller (keystone/auth/controllers.py and -# keystone/contrib/ec2/controllers.py both need it). This is technically an -# opinion of how a token should look according to the V3 API contract. My -# thought was to try and work this into a view of a token associated to the V3 -# controller logic somewhere. -def render_token_response_from_model(token, include_catalog=True): - token_reference = { - 'token': { - 'methods': token.methods, - 'user': { - 'domain': { - 'id': token.user_domain['id'], - 'name': token.user_domain['name'] - }, - 'id': token.user_id, - 'name': token.user['name'], - 'password_expires_at': token.user[ - 'password_expires_at' - ] - }, - 'audit_ids': token.audit_ids, - 'expires_at': token.expires_at, - 'issued_at': token.issued_at, - } - } - if token.system_scoped: - token_reference['token']['roles'] = token.roles - token_reference['token']['system'] = {'all': True} - elif token.domain_scoped: - token_reference['token']['domain'] = { - 'id': token.domain['id'], - 'name': token.domain['name'] - } - token_reference['token']['roles'] = token.roles - elif token.trust_scoped: - token_reference['token']['OS-TRUST:trust'] = { - 'id': token.trust_id, - 'trustor_user': {'id': token.trustor['id']}, - 'trustee_user': {'id': token.trustee['id']}, - 'impersonation': token.trust['impersonation'] - } - token_reference['token']['project'] = { - 'domain': { - 'id': token.project_domain['id'], - 'name': token.project_domain['name'] - }, - 'id': token.trust_project['id'], - 'name': token.trust_project['name'] - } - if token.trust.get('impersonation'): - trustor_domain = PROVIDERS.resource_api.get_domain( - token.trustor['domain_id'] - ) - token_reference['token']['user'] = { - 'domain': { - 'id': trustor_domain['id'], - 'name': trustor_domain['name'] - }, - 'id': token.trustor['id'], - 'name': token.trustor['name'], - 'password_expires_at': token.trustor[ - 'password_expires_at' - ] - } - token_reference['token']['roles'] = token.roles - elif token.project_scoped: - token_reference['token']['project'] = { - 'domain': { - 'id': token.project_domain['id'], - 'name': token.project_domain['name'] - }, - 'id': token.project['id'], - 'name': token.project['name'] - } - token_reference['token']['is_domain'] = token.project.get( - 'is_domain', False - ) - token_reference['token']['roles'] = token.roles - ap_name = CONF.resource.admin_project_name - ap_domain_name = CONF.resource.admin_project_domain_name - if ap_name and ap_domain_name: - is_ap = ( - token.project['name'] == ap_name and - ap_domain_name == token.project_domain['name'] - ) - token_reference['token']['is_admin_project'] = is_ap - if include_catalog and not token.unscoped: - user_id = token.user_id - if token.trust_id: - user_id = token.trust['trustor_user_id'] - catalog = PROVIDERS.catalog_api.get_v3_catalog( - user_id, token.project_id - ) - token_reference['token']['catalog'] = catalog - sps = PROVIDERS.federation_api.get_enabled_service_providers() - if sps: - token_reference['token']['service_providers'] = sps - if token.is_federated: - PROVIDERS.federation_api.get_idp(token.identity_provider_id) - federated_dict = {} - federated_dict['groups'] = token.federated_groups - federated_dict['identity_provider'] = { - 'id': token.identity_provider_id - } - federated_dict['protocol'] = {'id': token.protocol_id} - token_reference['token']['user']['OS-FEDERATION'] = ( - federated_dict - ) - token_reference['token']['user']['domain'] = { - 'id': 'Federated', 'name': 'Federated' - } - del token_reference['token']['user']['password_expires_at'] - if token.access_token_id: - token_reference['token']['OS-OAUTH1'] = { - 'access_token_id': token.access_token_id, - 'consumer_id': token.access_token['consumer_id'] - } - if token.application_credential_id: - key = 'application_credential' - token_reference['token'][key] = {} - token_reference['token'][key]['id'] = ( - token.application_credential['id'] - ) - token_reference['token'][key]['name'] = ( - token.application_credential['name'] - ) - restricted = not token.application_credential['unrestricted'] - token_reference['token'][key]['restricted'] = restricted - - return token_reference - - -class V3Controller(provider_api.ProviderAPIMixin, wsgi.Application): - """Base controller class for Identity API v3. - - Child classes should set the ``collection_name`` and ``member_name`` class - attributes, representing the collection of entities they are exposing to - the API. This is required for supporting self-referential links, - pagination, etc. - - Class parameters: - - * `_public_parameters` - set of parameters that are exposed to the user. - Usually used by cls.filter_params() - - """ - - collection_name = 'entities' - member_name = 'entity' - get_member_from_driver = None - - @classmethod - def base_url(cls, context, path=None): - endpoint = super(V3Controller, cls).base_url(context, 'public') - if not path: - path = cls.collection_name - - return '%s/%s/%s' % (endpoint, 'v3', path.lstrip('/')) - - @classmethod - def full_url(cls, context, path=None): - url = cls.base_url(context, path) - if context['environment'].get('QUERY_STRING'): - url = '%s?%s' % (url, context['environment']['QUERY_STRING']) - - return url - - @classmethod - def query_filter_is_true(cls, filter_value): - """Determine if bool query param is 'True'. - - We treat this the same way as we do for policy - enforcement: - - {bool_param}=0 is treated as False - - Any other value is considered to be equivalent to - True, including the absence of a value - - """ - if (isinstance(filter_value, six.string_types) and - filter_value == '0'): - val = False - else: - val = True - return val - - @classmethod - def _add_self_referential_link(cls, context, ref): - ref.setdefault('links', {}) - ref['links']['self'] = cls.base_url(context) + '/' + ref['id'] - - @classmethod - def wrap_member(cls, context, ref): - cls._add_self_referential_link(context, ref) - return {cls.member_name: ref} - - @classmethod - def wrap_collection(cls, context, refs, hints=None): - """Wrap a collection, checking for filtering and pagination. - - Returns the wrapped collection, which includes: - - Executing any filtering not already carried out - - Truncate to a set limit if necessary - - Adds 'self' links in every member - - Adds 'next', 'self' and 'prev' links for the whole collection. - - :param context: the current context, containing the original url path - and query string - :param refs: the list of members of the collection - :param hints: list hints, containing any relevant filters and limit. - Any filters already satisfied by managers will have been - removed - """ - # Check if there are any filters in hints that were not - # handled by the drivers. The driver will not have paginated or - # limited the output if it found there were filters it was unable to - # handle. - - if hints is not None: - refs = cls.filter_by_attributes(refs, hints) - - list_limited, refs = cls.limit(refs, hints) - - for ref in refs: - cls.wrap_member(context, ref) - - container = {cls.collection_name: refs} - container['links'] = { - 'next': None, - 'self': cls.full_url(context, path=context['path']), - 'previous': None} - - if list_limited: - container['truncated'] = True - - return container - - @classmethod - def limit(cls, refs, hints): - """Limit a list of entities. - - The underlying driver layer may have already truncated the collection - for us, but in case it was unable to handle truncation we check here. - - :param refs: the list of members of the collection - :param hints: hints, containing, among other things, the limit - requested - - :returns: boolean indicating whether the list was truncated, as well - as the list of (truncated if necessary) entities. - - """ - NOT_LIMITED = False - LIMITED = True - - if hints is None or hints.limit is None: - # No truncation was requested - return NOT_LIMITED, refs - - if hints.limit.get('truncated', False): - # The driver did truncate the list - return LIMITED, refs - - if len(refs) > hints.limit['limit']: - # The driver layer wasn't able to truncate it for us, so we must - # do it here - return LIMITED, refs[:hints.limit['limit']] - - return NOT_LIMITED, refs - - @classmethod - def filter_by_attributes(cls, refs, hints): - """Filter a list of references by filter values.""" - def _attr_match(ref_attr, val_attr): - """Matche attributes allowing for booleans as strings. - - We test explicitly for a value that defines it as 'False', - which also means that the existence of the attribute with - no value implies 'True' - - """ - if type(ref_attr) is bool: - return ref_attr == utils.attr_as_boolean(val_attr) - else: - return ref_attr == val_attr - - def _inexact_attr_match(filter, ref): - """Apply an inexact filter to a result dict. - - :param filter: the filter in question - :param ref: the dict to check - - :returns: True if there is a match - - """ - comparator = filter['comparator'] - key = filter['name'] - - if key in ref: - filter_value = filter['value'] - target_value = ref[key] - if not filter['case_sensitive']: - # We only support inexact filters on strings so - # it's OK to use lower() - filter_value = filter_value.lower() - target_value = target_value.lower() - - if comparator == 'contains': - return (filter_value in target_value) - elif comparator == 'startswith': - return target_value.startswith(filter_value) - elif comparator == 'endswith': - return target_value.endswith(filter_value) - else: - # We silently ignore unsupported filters - return True - - return False - - for filter in hints.filters: - if filter['comparator'] == 'equals': - attr = filter['name'] - value = filter['value'] - refs = [r for r in refs if _attr_match( - utils.flatten_dict(r).get(attr), value)] - else: - # It might be an inexact filter - refs = [r for r in refs if _inexact_attr_match( - filter, r)] - - return refs - - @classmethod - def build_driver_hints(cls, request, supported_filters): - """Build list hints based on the context query string. - - :param request: the current request - :param supported_filters: list of filters supported, so ignore any - keys in query_dict that are not in this list. - - """ - hints = driver_hints.Hints() - - if not request.params: - return hints - - for key, value in request.params.items(): - # Check if this is an exact filter - if supported_filters is None or key in supported_filters: - hints.add_filter(key, value) - continue - - # Check if it is an inexact filter - for valid_key in supported_filters: - # See if this entry in query_dict matches a known key with an - # inexact suffix added. If it doesn't match, then that just - # means that there is no inexact filter for that key in this - # query. - if not key.startswith(valid_key + '__'): - continue - - base_key, comparator = key.split('__', 1) - - # We map the query-style inexact of, for example: - # - # {'email__contains', 'myISP'} - # - # into a list directive add filter call parameters of: - # - # name = 'email' - # value = 'myISP' - # comparator = 'contains' - # case_sensitive = True - - case_sensitive = True - if comparator.startswith('i'): - case_sensitive = False - comparator = comparator[1:] - hints.add_filter(base_key, value, - comparator=comparator, - case_sensitive=case_sensitive) - - # NOTE(henry-nash): If we were to support pagination, we would pull any - # pagination directives out of the query_dict here, and add them into - # the hints list. - return hints - - def _require_matching_id(self, value, ref): - """Ensure the value matches the reference's ID, if any.""" - if 'id' in ref and ref['id'] != value: - raise exception.ValidationError('Cannot change ID') - - def _assign_unique_id(self, ref): - """Generate and assigns a unique identifier to a reference.""" - ref = ref.copy() - ref['id'] = uuid.uuid4().hex - return ref - - def _get_domain_id_for_list_request(self, request): - """Get the domain_id for a v3 list call. - - If we running with multiple domain drivers, then the caller must - specify a domain_id either as a filter or as part of the token scope. - - """ - if not CONF.identity.domain_specific_drivers_enabled: - # We don't need to specify a domain ID in this case - return - - domain_id = request.params.get('domain_id') - if domain_id: - return domain_id - - token = authorization.get_token_ref(request.context_dict) - - if token.domain_scoped: - return token.domain_id - elif token.project_scoped: - return token.project_domain['id'] - else: - msg = 'No domain information specified as part of list request' - tr_msg = _('No domain information specified as part of list ' - 'request') - LOG.warning(msg) - raise exception.Unauthorized(tr_msg) - - def _get_domain_id_from_token(self, request): - """Get the domain_id for a v3 create call. - - In the case of a v3 create entity call that does not specify a domain - ID, the spec says that we should use the domain scoping from the token - being used. - - """ - # return if domain scoped - if request.context.domain_id: - return request.context.domain_id - - if request.context.is_admin: - raise exception.ValidationError( - _('You have tried to create a resource using the admin ' - 'token. As this token is not within a domain you must ' - 'explicitly include a domain for this resource to ' - 'belong to.')) - - # TODO(henry-nash): We should issue an exception here since if - # a v3 call does not explicitly specify the domain_id in the - # entity, it should be using a domain scoped token. However, - # the current tempest heat tests issue a v3 call without this. - # This is raised as bug #1283539. Once this is fixed, we - # should remove the line below and replace it with an error. - # - # Ahead of actually changing the code to raise an exception, we - # issue a deprecation warning. - versionutils.report_deprecated_feature( - LOG, - 'Not specifying a domain during a create user, group or ' - 'project call, and relying on falling back to the ' - 'default domain, is deprecated as of Liberty. There is no ' - 'plan to remove this compatibility, however, future API ' - 'versions may remove this, so please specify the domain ' - 'explicitly or use a domain-scoped token.') - return CONF.identity.default_domain_id - - def _normalize_domain_id(self, request, ref): - """Fill in domain_id if not specified in a v3 call.""" - if not ref.get('domain_id'): - ref['domain_id'] = self._get_domain_id_from_token(request) - return ref - - def check_protection(self, request, prep_info, target_attr=None): - """Provide call protection for complex target attributes. - - As well as including the standard parameters from the original API - call (which is passed in prep_info), this call will add in any - additional entities or attributes (passed in target_attr), so that - they can be referenced by policy rules. - - """ - authorization.check_protection(self, request, prep_info, target_attr) - - @classmethod - def filter_params(cls, ref): - """Remove unspecified parameters from the dictionary. - - This function removes unspecified parameters from the dictionary. - This method checks only root-level keys from a ref dictionary. - - :param ref: a dictionary representing deserialized response to be - serialized - """ - ref_keys = set(ref.keys()) - blocked_keys = ref_keys - cls._public_parameters - for blocked_param in blocked_keys: - del ref[blocked_param] - return ref diff --git a/keystone/common/dependency.py b/keystone/common/dependency.py deleted file mode 100644 index 1469d7c270..0000000000 --- a/keystone/common/dependency.py +++ /dev/null @@ -1,59 +0,0 @@ -# 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. - -"""This module provides support for dependency injection. - -WARNING: Use the ``keystone.common.provider_api`` module instead. This module -is going away in favor of an implementation that is better about following the -dependency injection model: - - https://en.wikipedia.org/wiki/Dependency_injection - -""" - -from keystone.common import provider_api -from keystone.i18n import _ - - -REGISTRY = provider_api.ProviderAPIs - - -GET_REQUIRED = object() -GET_OPTIONAL = object() - - -def get_provider(name, optional=GET_REQUIRED): - return None - - -class UnresolvableDependencyException(Exception): - """Raised when a required dependency is not resolvable. - - See ``resolve_future_dependencies()`` for more details. - - """ - - def __init__(self, name, targets): - msg = _('Unregistered dependency: %(name)s for %(targets)s') % { - 'name': name, 'targets': targets} - super(UnresolvableDependencyException, self).__init__(msg) - - -def resolve_future_dependencies(__provider_name=None): - """Deprecated, does nothing.""" - return {} - - -def reset(): - """Deprecated, does nothing.""" diff --git a/keystone/common/extension.py b/keystone/common/extension.py deleted file mode 100644 index be5de631de..0000000000 --- a/keystone/common/extension.py +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright 2013 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. - - -ADMIN_EXTENSIONS = {} -PUBLIC_EXTENSIONS = {} - - -def register_admin_extension(url_prefix, extension_data): - """Register extension with collection of admin extensions. - - Extensions register the information here that will show - up in the /extensions page as a way to indicate that the extension is - active. - - url_prefix: unique key for the extension that will appear in the - urls generated by the extension. - - extension_data is a dictionary. The expected fields are: - 'name': short, human readable name of the extension - 'namespace': xml namespace - 'alias': identifier for the extension - 'updated': date the extension was last updated - 'description': text description of the extension - 'links': hyperlinks to documents describing the extension - - """ - ADMIN_EXTENSIONS[url_prefix] = extension_data - - -def register_public_extension(url_prefix, extension_data): - """Same as register_admin_extension but for public extensions.""" - PUBLIC_EXTENSIONS[url_prefix] = extension_data diff --git a/keystone/common/request.py b/keystone/common/request.py deleted file mode 100644 index f5be61be9b..0000000000 --- a/keystone/common/request.py +++ /dev/null @@ -1,142 +0,0 @@ -# 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 pycadf import cadftaxonomy as taxonomy -from pycadf import host -from pycadf import resource -import webob -from webob.descriptors import environ_getter - -from keystone.common import authorization -from keystone.common import context -from keystone.common import utils -import keystone.conf -from keystone import exception -from keystone.i18n import _ - - -# Environment variable used to pass the request context -CONTEXT_ENV = 'openstack.context' - -CONF = keystone.conf.CONF -LOG = logging.getLogger(__name__) - - -class Request(webob.Request): - - _context_dict = None - - def _get_context_dict(self): - # allow middleware up the stack to provide context, params and headers. - context = self.environ.get(CONTEXT_ENV, {}) - - # NOTE(jamielennox): The webob package throws UnicodeError when a - # param cannot be decoded. If we make webob iterate them now we can - # catch this and throw an error early rather than on access. - try: - self.params.items() - except UnicodeDecodeError: - msg = _('Query string is not UTF-8 encoded') - raise exception.ValidationError(msg) - - context['path'] = self.environ['PATH_INFO'] - scheme = self.environ.get(CONF.secure_proxy_ssl_header) - if scheme: - # NOTE(andrey-mp): "wsgi.url_scheme" contains the protocol used - # before the proxy removed it ('https' usually). So if - # the webob.Request instance is modified in order to use this - # scheme instead of the one defined by API, the call to - # webob.Request.relative_url() will return a URL with the correct - # scheme. - self.environ['wsgi.url_scheme'] = scheme - context['host_url'] = self.host_url - # authentication and authorization attributes are set as environment - # values by the container and processed by the pipeline. The complete - # set is not yet known. - context['environment'] = self.environ - - if self.context: - context['is_admin_project'] = self.context.is_admin_project - - context.setdefault('is_admin', False) - context['token_id'] = self.auth_token - if self.subject_token: - context['subject_token_id'] = self.subject_token - - return context - - @property - def context_dict(self): - if not self._context_dict: - self._context_dict = self._get_context_dict() - - return self._context_dict - - @property - def auth_context(self): - return self.environ.get(authorization.AUTH_CONTEXT_ENV, {}) - - def assert_authenticated(self): - """Ensure that the current request has been authenticated.""" - if not self.context: - msg = ('An authenticated call was made and there is ' - 'no request.context. This means the ' - 'auth_context middleware is not in place. You ' - 'must have this middleware in your pipeline ' - 'to perform authenticated calls') - tr_msg = _('An authenticated call was made and there is ' - 'no request.context. This means the ' - 'auth_context middleware is not in place. You ' - 'must have this middleware in your pipeline ' - 'to perform authenticated calls') - LOG.warning(msg) - raise exception.Unauthorized(tr_msg) - - if not self.context.authenticated: - # auth_context didn't decode anything we can use - raise exception.Unauthorized( - _('auth_context did not decode anything useful')) - - @property - def audit_initiator(self): - """A pyCADF initiator describing the current authenticated context.""" - pycadf_host = host.Host(address=self.remote_addr, - agent=self.user_agent) - initiator = resource.Resource(typeURI=taxonomy.ACCOUNT_USER, - host=pycadf_host) - - if self.context.user_id: - initiator.id = utils.resource_uuid(self.context.user_id) - initiator.user_id = self.context.user_id - - if self.context.project_id: - initiator.project_id = self.context.project_id - - if self.context.domain_id: - initiator.domain_id = self.context.domain_id - - return initiator - - @property - def auth_token(self): - return self.headers.get(authorization.AUTH_TOKEN_HEADER, None) - - @property - def subject_token(self): - return self.headers.get(authorization.SUBJECT_TOKEN_HEADER, None) - - auth_type = environ_getter('AUTH_TYPE', None) - remote_domain = environ_getter('REMOTE_DOMAIN', None) - context = environ_getter(context.REQUEST_CONTEXT_ENV, None) - token_auth = environ_getter('keystone.token_auth', None) diff --git a/keystone/common/router.py b/keystone/common/router.py deleted file mode 100644 index fe50568042..0000000000 --- a/keystone/common/router.py +++ /dev/null @@ -1,85 +0,0 @@ -# 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 keystone.common import json_home -from keystone.common import wsgi - - -class Router(wsgi.ComposableRouter): - def __init__(self, controller, collection_key, key, - resource_descriptions=None, - is_entity_implemented=True, - method_template=None): - self.controller = controller - self.key = key - self.collection_key = collection_key - self._resource_descriptions = resource_descriptions - self._is_entity_implemented = is_entity_implemented - self.method_template = method_template or '%s' - - def add_routes(self, mapper): - collection_path = '/%(collection_key)s' % { - 'collection_key': self.collection_key} - entity_path = '/%(collection_key)s/{%(key)s_id}' % { - 'collection_key': self.collection_key, - 'key': self.key} - - mapper.connect( - collection_path, - controller=self.controller, - action=self.method_template % 'create_%s' % self.key, - conditions=dict(method=['POST'])) - mapper.connect( - collection_path, - controller=self.controller, - action=self.method_template % 'list_%s' % self.collection_key, - conditions=dict(method=['GET', 'HEAD'])) - mapper.connect( - entity_path, - controller=self.controller, - action=self.method_template % 'get_%s' % self.key, - conditions=dict(method=['GET', 'HEAD'])) - mapper.connect( - entity_path, - controller=self.controller, - action=self.method_template % 'update_%s' % self.key, - conditions=dict(method=['PATCH'])) - mapper.connect( - entity_path, - controller=self.controller, - action=self.method_template % 'delete_%s' % self.key, - conditions=dict(method=['DELETE'])) - - # Add the collection resource and entity resource to the resource - # descriptions. - - collection_rel = json_home.build_v3_resource_relation( - self.collection_key) - rel_data = {'href': collection_path, } - self._resource_descriptions.append((collection_rel, rel_data)) - json_home.JsonHomeResources.append_resource(collection_rel, rel_data) - - if self._is_entity_implemented: - entity_rel = json_home.build_v3_resource_relation(self.key) - id_str = '%s_id' % self.key - id_param_rel = json_home.build_v3_parameter_relation(id_str) - entity_rel_data = { - 'href-template': entity_path, - 'href-vars': { - id_str: id_param_rel, - }, - } - self._resource_descriptions.append((entity_rel, entity_rel_data)) - json_home.JsonHomeResources.append_resource( - entity_rel, entity_rel_data) diff --git a/keystone/common/wsgi.py b/keystone/common/wsgi.py deleted file mode 100644 index 36c41955b5..0000000000 --- a/keystone/common/wsgi.py +++ /dev/null @@ -1,703 +0,0 @@ -# Copyright 2012 OpenStack Foundation -# 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.""" - -import functools -import itertools -import re -import wsgiref.util - -import oslo_i18n -from oslo_log import log -from oslo_serialization import jsonutils -from oslo_utils import importutils -from oslo_utils import strutils -import routes.middleware -import six -from six.moves import http_client -import webob.dec -import webob.exc - -from keystone.common import authorization -from keystone.common import json_home -from keystone.common import request as request_mod -from keystone.common import utils -import keystone.conf -from keystone import exception -from keystone.i18n import _ - - -CONF = keystone.conf.CONF -LOG = log.getLogger(__name__) - -# Environment variable used to pass the request context -CONTEXT_ENV = 'openstack.context' - -# Environment variable used to pass the request params -PARAMS_ENV = 'openstack.params' - -JSON_ENCODE_CONTENT_TYPES = set(['application/json', - 'application/json-home']) - - -def best_match_language(req): - """Determine the best available locale. - - This returns best available locale based on the Accept-Language HTTP - header passed in the request. - """ - if not req.accept_language: - return None - return req.accept_language.best_match( - oslo_i18n.get_available_languages('keystone')) - - -class BaseApplication(object): - """Base WSGI application wrapper. Subclasses need to implement __call__.""" - - @classmethod - def factory(cls, global_config, **local_config): - """Used for loading in middleware (holdover from paste.deploy).""" - return cls(**local_config) - - def __call__(self, environ, start_response): - r"""Provide subclasses on how to implement __call__. - - Probably like this: - - @webob.dec.wsgify() - 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. - - NOTE: this is now strictly used in conversion from old wsgi - implementation to flask. Once the flask implementation is complete, - the __call__ will not be needed as the flask app will handle - dispatching and __call__. - - """ - raise NotImplementedError('You must implement __call__') - - -class Application(BaseApplication): - - @webob.dec.wsgify(RequestClass=request_mod.Request) - def __call__(self, req): - arg_dict = req.environ['wsgiorg.routing_args'][1] - action = arg_dict.pop('action') - del arg_dict['controller'] - - params = req.environ.get(PARAMS_ENV, {}) - params.update(arg_dict) - - # TODO(termie): do some basic normalization on methods - method = getattr(self, action) - - # NOTE(morganfainberg): use the request method to normalize the - # response code between GET and HEAD requests. The HTTP status should - # be the same. - LOG.info('%(req_method)s %(uri)s', { - 'req_method': req.method.upper(), - 'uri': wsgiref.util.request_uri(req.environ), - }) - - params = self._normalize_dict(params) - - try: - result = method(req, **params) - except exception.Unauthorized as e: - LOG.warning( - "Authorization failed. %(exception)s from " - "%(remote_addr)s", - {'exception': e, 'remote_addr': req.environ['REMOTE_ADDR']}) - return render_exception(e, - context=req.context_dict, - user_locale=best_match_language(req)) - except exception.Error as e: - if isinstance(e, exception.UnexpectedError): - LOG.exception(six.text_type(e)) - else: - LOG.warning(six.text_type(e)) - return render_exception(e, - context=req.context_dict, - user_locale=best_match_language(req)) - except TypeError as e: - LOG.exception(six.text_type(e)) - return render_exception(exception.ValidationError(e), - context=req.context_dict, - user_locale=best_match_language(req)) - except Exception as e: - LOG.exception(six.text_type(e)) - return render_exception(exception.UnexpectedError(exception=e), - context=req.context_dict, - user_locale=best_match_language(req)) - - if result is None: - return render_response( - status=(http_client.NO_CONTENT, - http_client.responses[http_client.NO_CONTENT])) - elif isinstance(result, six.string_types): - return result - elif isinstance(result, webob.Response): - return result - elif isinstance(result, webob.exc.WSGIHTTPException): - return result - - response_code = self._get_response_code(req) - return render_response(body=result, - status=response_code, - method=req.method) - - def _get_response_code(self, req): - req_method = req.environ['REQUEST_METHOD'] - controller = importutils.import_class('keystone.common.controller') - code = None - if isinstance(self, controller.V3Controller) and req_method == 'POST': - code = (http_client.CREATED, - http_client.responses[http_client.CREATED]) - return code - - def _normalize_arg(self, arg): - return arg.replace(':', '_').replace('-', '_') - - def _normalize_dict(self, d): - return {self._normalize_arg(k): v for (k, v) in d.items()} - - def assert_admin(self, request): - """Ensure the user is an admin. - - :raises keystone.exception.Unauthorized: if a token could not be - found/authorized, a user is invalid, or a tenant is - invalid/not scoped. - :raises keystone.exception.Forbidden: if the user is not an admin and - does not have the admin role - - """ - authorization.assert_admin(self, request) - - def _attribute_is_empty(self, ref, attribute): - """Determine if the attribute in ref is empty or None.""" - return ref.get(attribute) is None or ref.get(attribute) == '' - - def _require_attribute(self, ref, attribute): - """Ensure the reference contains the specified attribute. - - Raise a ValidationError if the given attribute is not present - """ - if self._attribute_is_empty(ref, attribute): - msg = _('%s field is required and cannot be empty') % attribute - raise exception.ValidationError(message=msg) - - def _require_attributes(self, ref, attrs): - """Ensure the reference contains the specified attributes. - - Raise a ValidationError if any of the given attributes is not present - """ - missing_attrs = [attribute for attribute in attrs - if self._attribute_is_empty(ref, attribute)] - - if missing_attrs: - msg = _('%s field(s) cannot be empty') % ', '.join(missing_attrs) - raise exception.ValidationError(message=msg) - - @classmethod - def base_url(cls, context, endpoint_type=None): - url = CONF['public_endpoint'] - - if url: - substitutions = dict( - itertools.chain(CONF.items(), CONF.eventlet_server.items())) - - url = url % substitutions - elif 'environment' in context: - url = wsgiref.util.application_uri(context['environment']) - # remove version from the URL as it may be part of SCRIPT_NAME but - # it should not be part of base URL - url = re.sub(r'/v(3|(2\.0))/*$', '', url) - - # now remove the standard port - url = utils.remove_standard_port(url) - else: - # if we don't have enough information to come up with a base URL, - # then fall back to localhost. This should never happen in - # production environment. - url = 'http://localhost:%d' % CONF.eventlet_server.public_port - - return url.rstrip('/') - - -def middleware_exceptions(method): - - @functools.wraps(method) - def _inner(self, request): - try: - return method(self, request) - except exception.Error as e: - LOG.warning(six.text_type(e)) - return render_exception(e, request=request, - user_locale=best_match_language(request)) - except TypeError as e: - LOG.exception(six.text_type(e)) - return render_exception(exception.ValidationError(e), - request=request, - user_locale=best_match_language(request)) - except Exception as e: - LOG.exception(six.text_type(e)) - return render_exception(exception.UnexpectedError(exception=e), - request=request, - user_locale=best_match_language(request)) - - return _inner - - -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): - """Used for paste app factories in paste.deploy config files.""" - def _factory(app): - return cls(app) - return _factory - - def __init__(self, application, conf=None): - super(Middleware, self).__init__() - self.application = application - - def process_request(self, request): - """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, request, response): - """Do whatever you'd like to the response, based on the request.""" - return response - - @webob.dec.wsgify(RequestClass=request_mod.Request) - @middleware_exceptions - def __call__(self, request): - response = self.process_request(request) - if response: - return response - response = request.get_response(self.application) - return self.process_response(request, 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_mod.Request) - def __call__(self, req): - if not hasattr(LOG, 'isEnabledFor') or LOG.isEnabledFor(LOG.debug): - LOG.debug('%s %s %s', ('*' * 20), 'REQUEST ENVIRON', ('*' * 20)) - for key, value in req.environ.items(): - LOG.debug('%s = %s', key, - strutils.mask_password(value)) - LOG.debug('') - LOG.debug('%s %s %s', ('*' * 20), 'REQUEST BODY', ('*' * 20)) - for line in req.body_file: - LOG.debug('%s', strutils.mask_password(line)) - LOG.debug('') - - resp = req.get_response(self.application) - if not hasattr(LOG, 'isEnabledFor') or LOG.isEnabledFor(LOG.debug): - LOG.debug('%s %s %s', ('*' * 20), 'RESPONSE HEADERS', ('*' * 20)) - for (key, value) in resp.headers.items(): - LOG.debug('%s = %s', key, value) - LOG.debug('') - - 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.""" - LOG.debug('%s %s %s', ('*' * 20), 'RESPONSE BODY', ('*' * 20)) - for part in app_iter: - LOG.debug(part) - yield part - - -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_mod.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_mod.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: - msg = (_('(%(url)s): The resource could not be found.') % - {'url': req.url}) - return render_exception(exception.NotFound(msg), - request=req, - user_locale=best_match_language(req)) - app = match['controller'] - return app - - -class ComposingRouter(Router): - def __init__(self, mapper=None, routers=None): - if mapper is None: - mapper = routes.Mapper() - if routers is None: - routers = [] - for router in routers: - router.add_routes(mapper) - super(ComposingRouter, self).__init__(mapper) - - -class ComposableRouter(Router): - """Router that supports use by ComposingRouter.""" - - def __init__(self, mapper=None): - if mapper is None: - mapper = routes.Mapper() - self.add_routes(mapper) - super(ComposableRouter, self).__init__(mapper) - - def add_routes(self, mapper): - """Add routes to given mapper.""" - pass - - -class ExtensionRouter(Router): - """A router that allows extensions to supplement or overwrite routes. - - Expects to be subclassed. - """ - - def __init__(self, application, mapper=None): - if mapper is None: - mapper = routes.Mapper() - self.application = application - self.add_routes(mapper) - mapper.connect('/{path_info:.*}', controller=self.application) - super(ExtensionRouter, self).__init__(mapper) - - def add_routes(self, mapper): - pass - - @classmethod - def factory(cls, global_config, **local_config): - """Used for loading in middleware (holdover from paste.deploy).""" - def _factory(app): - conf = global_config.copy() - conf.update(local_config) - return cls(app, **local_config) - return _factory - - -class RoutersBase(object): - """Base class for Routers.""" - - def __init__(self): - self.v3_resources = [] - - def append_v3_routers(self, mapper, routers): - """Append v3 routers. - - Subclasses should override this method to map its routes. - - Use self._add_resource() to map routes for a resource. - """ - - def _add_resource(self, mapper, controller, path, rel, - get_action=None, head_action=None, get_head_action=None, - put_action=None, post_action=None, patch_action=None, - delete_action=None, get_post_action=None, - path_vars=None, status=json_home.Status.STABLE, - new_path=None): - if get_head_action: - getattr(controller, get_head_action) # ensure the attribute exists - mapper.connect(path, controller=controller, action=get_head_action, - conditions=dict(method=['GET', 'HEAD'])) - if get_action: - getattr(controller, get_action) # ensure the attribute exists - mapper.connect(path, controller=controller, action=get_action, - conditions=dict(method=['GET'])) - if head_action: - getattr(controller, head_action) # ensure the attribute exists - mapper.connect(path, controller=controller, action=head_action, - conditions=dict(method=['HEAD'])) - if put_action: - getattr(controller, put_action) # ensure the attribute exists - mapper.connect(path, controller=controller, action=put_action, - conditions=dict(method=['PUT'])) - if post_action: - getattr(controller, post_action) # ensure the attribute exists - mapper.connect(path, controller=controller, action=post_action, - conditions=dict(method=['POST'])) - if patch_action: - getattr(controller, patch_action) # ensure the attribute exists - mapper.connect(path, controller=controller, action=patch_action, - conditions=dict(method=['PATCH'])) - if delete_action: - getattr(controller, delete_action) # ensure the attribute exists - mapper.connect(path, controller=controller, action=delete_action, - conditions=dict(method=['DELETE'])) - if get_post_action: - getattr(controller, get_post_action) # ensure the attribute exists - mapper.connect(path, controller=controller, action=get_post_action, - conditions=dict(method=['GET', 'POST'])) - - resource_data = dict() - - if path_vars: - resource_data['href-template'] = new_path or path - resource_data['href-vars'] = path_vars - else: - resource_data['href'] = new_path or path - - json_home.Status.update_resource_data(resource_data, status) - - self.v3_resources.append((rel, resource_data)) - json_home.JsonHomeResources.append_resource(rel, resource_data) - - -class V3ExtensionRouter(ExtensionRouter, RoutersBase): - """Base class for V3 extension router.""" - - def __init__(self, application, mapper=None): - self.v3_resources = list() - super(V3ExtensionRouter, self).__init__(application, mapper) - - def _update_version_response(self, response_data): - response_data['resources'].update(self.v3_resources) - - @webob.dec.wsgify(RequestClass=request_mod.Request) - def __call__(self, request): - if request.path_info != '/': - # Not a request for version info so forward to super. - return super(V3ExtensionRouter, self).__call__(request) - - response = request.get_response(self.application) - - if response.status_code != http_client.OK: - # The request failed, so don't update the response. - return response - - if response.headers['Content-Type'] != 'application/json-home': - # Not a request for JSON Home document, so don't update the - # response. - return response - - response_data = jsonutils.loads(response.body) - self._update_version_response(response_data) - response.body = jsonutils.dump_as_bytes(response_data, - cls=utils.SmarterEncoder) - return response - - -def render_response(body=None, status=None, headers=None, method=None): - """Form a WSGI response.""" - if headers is None: - headers = [] - else: - headers = list(headers) - headers.append(('Vary', 'X-Auth-Token')) - - if body is None: - body = b'' - status = status or (http_client.NO_CONTENT, - http_client.responses[http_client.NO_CONTENT]) - else: - content_types = [v for h, v in headers if h == 'Content-Type'] - if content_types: - content_type = content_types[0] - else: - content_type = None - - if content_type is None or content_type in JSON_ENCODE_CONTENT_TYPES: - body = jsonutils.dump_as_bytes(body, cls=utils.SmarterEncoder) - if content_type is None: - headers.append(('Content-Type', 'application/json')) - status = status or (http_client.OK, - http_client.responses[http_client.OK]) - - # NOTE(davechen): `mod_wsgi` follows the standards from pep-3333 and - # requires the value in response header to be binary type(str) on python2, - # unicode based string(str) on python3, or else keystone will not work - # under apache with `mod_wsgi`. - # keystone needs to check the data type of each header and convert the - # type if needed. - # see bug: - # https://bugs.launchpad.net/keystone/+bug/1528981 - # see pep-3333: - # https://www.python.org/dev/peps/pep-3333/#a-note-on-string-types - # see source from mod_wsgi: - # https://github.com/GrahamDumpleton/mod_wsgi(methods: - # wsgi_convert_headers_to_bytes(...), wsgi_convert_string_to_bytes(...) - # and wsgi_validate_header_value(...)). - def _convert_to_str(headers): - str_headers = [] - for header in headers: - str_header = [] - for value in header: - if not isinstance(value, str): - str_header.append(str(value)) - else: - str_header.append(value) - # convert the list to the immutable tuple to build the headers. - # header's key/value will be guaranteed to be str type. - str_headers.append(tuple(str_header)) - return str_headers - - headers = _convert_to_str(headers) - - resp = webob.Response(body=body, - status='%d %s' % status, - headerlist=headers, - charset='utf-8') - - if method and method.upper() == 'HEAD': - # NOTE(morganfainberg): HEAD requests should return the same status - # as a GET request and same headers (including content-type and - # content-length). The webob.Response object automatically changes - # content-length (and other headers) if the body is set to b''. Capture - # all headers and reset them on the response object after clearing the - # body. The body can only be set to a binary-type (not TextType or - # NoneType), so b'' is used here and should be compatible with - # both py2x and py3x. - stored_headers = resp.headers.copy() - resp.body = b'' - for header, value in stored_headers.items(): - resp.headers[header] = value - - return resp - - -def render_exception(error, context=None, request=None, user_locale=None): - """Form a WSGI response based on the current error.""" - error_message = error.args[0] - message = oslo_i18n.translate(error_message, desired_locale=user_locale) - if message is error_message: - # translate() didn't do anything because it wasn't a Message, - # convert to a string. - message = six.text_type(message) - - body = {'error': { - 'code': error.code, - 'title': error.title, - 'message': message, - }} - headers = [] - if isinstance(error, exception.AuthPluginException): - body['error']['identity'] = error.authentication - elif isinstance(error, exception.Unauthorized): - # NOTE(gyee): we only care about the request environment in the - # context. Also, its OK to pass the environment as it is read-only in - # Application.base_url() - local_context = {} - if request: - local_context = {'environment': request.environ} - elif context and 'environment' in context: - local_context = {'environment': context['environment']} - url = Application.base_url(local_context) - - headers.append(('WWW-Authenticate', 'Keystone uri="%s"' % url)) - return render_response(status=(error.code, error.title), - body=body, - headers=headers) diff --git a/keystone/server/flask/request_processing/middleware/auth_context.py b/keystone/server/flask/request_processing/middleware/auth_context.py index ccd0325bac..b883085ba6 100644 --- a/keystone/server/flask/request_processing/middleware/auth_context.py +++ b/keystone/server/flask/request_processing/middleware/auth_context.py @@ -10,19 +10,31 @@ # License for the specific language governing permissions and limitations # under the License. + +import functools +import itertools +import re +import wsgiref.util + from keystonemiddleware import auth_token +import oslo_i18n from oslo_log import log +from oslo_serialization import jsonutils +import six +from six.moves import http_client +import webob.dec +import webob.exc from keystone.common import authorization from keystone.common import context from keystone.common import provider_api from keystone.common import render_token from keystone.common import tokenless_auth -from keystone.common import wsgi +from keystone.common import utils import keystone.conf from keystone import exception from keystone.federation import constants as federation_constants -from keystone.federation import utils +from keystone.federation import utils as federation_utils from keystone.i18n import _ from keystone.models import token_model @@ -30,9 +42,193 @@ CONF = keystone.conf.CONF LOG = log.getLogger(__name__) PROVIDERS = provider_api.ProviderAPIs +# Environment variable used to pass the request context +CONTEXT_ENV = 'openstack.context' + __all__ = ('AuthContextMiddleware',) +CONF = keystone.conf.CONF +LOG = log.getLogger(__name__) + + +JSON_ENCODE_CONTENT_TYPES = set(['application/json', + 'application/json-home']) + + +def best_match_language(req): + """Determine the best available locale. + + This returns best available locale based on the Accept-Language HTTP + header passed in the request. + """ + if not req.accept_language: + return None + return req.accept_language.best_match( + oslo_i18n.get_available_languages('keystone')) + + +def base_url(context): + url = CONF['public_endpoint'] + + if url: + substitutions = dict( + itertools.chain(CONF.items(), CONF.eventlet_server.items())) + + url = url % substitutions + elif 'environment' in context: + url = wsgiref.util.application_uri(context['environment']) + # remove version from the URL as it may be part of SCRIPT_NAME but + # it should not be part of base URL + url = re.sub(r'/v(3|(2\.0))/*$', '', url) + + # now remove the standard port + url = utils.remove_standard_port(url) + else: + # if we don't have enough information to come up with a base URL, + # then fall back to localhost. This should never happen in + # production environment. + url = 'http://localhost:%d' % CONF.eventlet_server.public_port + + return url.rstrip('/') + + +def middleware_exceptions(method): + + @functools.wraps(method) + def _inner(self, request): + try: + return method(self, request) + except exception.Error as e: + LOG.warning(six.text_type(e)) + return render_exception(e, request=request, + user_locale=best_match_language(request)) + except TypeError as e: + LOG.exception(six.text_type(e)) + return render_exception(exception.ValidationError(e), + request=request, + user_locale=best_match_language(request)) + except Exception as e: + LOG.exception(six.text_type(e)) + return render_exception(exception.UnexpectedError(exception=e), + request=request, + user_locale=best_match_language(request)) + + return _inner + + +def render_response(body=None, status=None, headers=None, method=None): + """Form a WSGI response.""" + if headers is None: + headers = [] + else: + headers = list(headers) + headers.append(('Vary', 'X-Auth-Token')) + + if body is None: + body = b'' + status = status or (http_client.NO_CONTENT, + http_client.responses[http_client.NO_CONTENT]) + else: + content_types = [v for h, v in headers if h == 'Content-Type'] + if content_types: + content_type = content_types[0] + else: + content_type = None + + if content_type is None or content_type in JSON_ENCODE_CONTENT_TYPES: + body = jsonutils.dump_as_bytes(body, cls=utils.SmarterEncoder) + if content_type is None: + headers.append(('Content-Type', 'application/json')) + status = status or (http_client.OK, + http_client.responses[http_client.OK]) + + # NOTE(davechen): `mod_wsgi` follows the standards from pep-3333 and + # requires the value in response header to be binary type(str) on python2, + # unicode based string(str) on python3, or else keystone will not work + # under apache with `mod_wsgi`. + # keystone needs to check the data type of each header and convert the + # type if needed. + # see bug: + # https://bugs.launchpad.net/keystone/+bug/1528981 + # see pep-3333: + # https://www.python.org/dev/peps/pep-3333/#a-note-on-string-types + # see source from mod_wsgi: + # https://github.com/GrahamDumpleton/mod_wsgi(methods: + # wsgi_convert_headers_to_bytes(...), wsgi_convert_string_to_bytes(...) + # and wsgi_validate_header_value(...)). + def _convert_to_str(headers): + str_headers = [] + for header in headers: + str_header = [] + for value in header: + if not isinstance(value, str): + str_header.append(str(value)) + else: + str_header.append(value) + # convert the list to the immutable tuple to build the headers. + # header's key/value will be guaranteed to be str type. + str_headers.append(tuple(str_header)) + return str_headers + + headers = _convert_to_str(headers) + + resp = webob.Response(body=body, + status='%d %s' % status, + headerlist=headers, + charset='utf-8') + + if method and method.upper() == 'HEAD': + # NOTE(morganfainberg): HEAD requests should return the same status + # as a GET request and same headers (including content-type and + # content-length). The webob.Response object automatically changes + # content-length (and other headers) if the body is set to b''. Capture + # all headers and reset them on the response object after clearing the + # body. The body can only be set to a binary-type (not TextType or + # NoneType), so b'' is used here and should be compatible with + # both py2x and py3x. + stored_headers = resp.headers.copy() + resp.body = b'' + for header, value in stored_headers.items(): + resp.headers[header] = value + + return resp + + +def render_exception(error, context=None, request=None, user_locale=None): + """Form a WSGI response based on the current error.""" + error_message = error.args[0] + message = oslo_i18n.translate(error_message, desired_locale=user_locale) + if message is error_message: + # translate() didn't do anything because it wasn't a Message, + # convert to a string. + message = six.text_type(message) + + body = {'error': { + 'code': error.code, + 'title': error.title, + 'message': message, + }} + headers = [] + if isinstance(error, exception.AuthPluginException): + body['error']['identity'] = error.authentication + elif isinstance(error, exception.Unauthorized): + # NOTE(gyee): we only care about the request environment in the + # context. Also, its OK to pass the environment as it is read-only in + # base_url() + local_context = {} + if request: + local_context = {'environment': request.environ} + elif context and 'environment' in context: + local_context = {'environment': context['environment']} + url = base_url(local_context) + + headers.append(('WWW-Authenticate', 'Keystone uri="%s"' % url)) + return render_response(status=(error.code, error.title), + body=body, + headers=headers) + + class AuthContextMiddleware(provider_api.ProviderAPIMixin, auth_token.BaseAuthProtocol): """Build the authentication context from the request auth token.""" @@ -66,7 +262,7 @@ class AuthContextMiddleware(provider_api.ProviderAPIMixin, # NOTE(gyee): if it is an ephemeral user, the # given X.509 SSL client cert does not need to map to # an existing user. - if user_ref['type'] == utils.UserType.EPHEMERAL: + if user_ref['type'] == federation_utils.UserType.EPHEMERAL: auth_context = {} auth_context['group_ids'] = user_ref['group_ids'] auth_context[federation_constants.IDENTITY_PROVIDER] = ( @@ -130,9 +326,9 @@ class AuthContextMiddleware(provider_api.ProviderAPIMixin, return False - @wsgi.middleware_exceptions + @middleware_exceptions def process_request(self, request): - context_env = request.environ.get(wsgi.CONTEXT_ENV, {}) + context_env = request.environ.get(CONTEXT_ENV, {}) # NOTE(notmorgan): This code is merged over from the admin token # middleware and now emits the security warning when the @@ -146,7 +342,7 @@ class AuthContextMiddleware(provider_api.ProviderAPIMixin, "not be set. This option is deprecated in favor of using " "'keystone-manage bootstrap' and will be removed in a " "future release.") - request.environ[wsgi.CONTEXT_ENV] = context_env + request.environ[CONTEXT_ENV] = context_env if not context_env.get('is_admin', False): resp = super(AuthContextMiddleware, self).process_request(request) @@ -210,7 +406,7 @@ class AuthContextMiddleware(provider_api.ProviderAPIMixin, # certificate is effectively disabled if no trusted issuers are # provided. - if request.environ.get(wsgi.CONTEXT_ENV, {}).get('is_admin', False): + if request.environ.get(CONTEXT_ENV, {}).get('is_admin', False): request_context.is_admin = True auth_context = {} diff --git a/keystone/tests/unit/test_exception.py b/keystone/tests/unit/test_exception.py index 7141e7edfd..b2f27cda8e 100644 --- a/keystone/tests/unit/test_exception.py +++ b/keystone/tests/unit/test_exception.py @@ -12,6 +12,9 @@ # License for the specific language governing permissions and limitations # under the License. +# NOTE(morgan): These test cases are used for AuthContextMiddleware exception +# rendering. + import uuid import fixtures @@ -20,9 +23,9 @@ from oslo_log import log from oslo_serialization import jsonutils import six -from keystone.common import wsgi import keystone.conf from keystone import exception +from keystone.server.flask.request_processing.middleware import auth_context from keystone.tests import unit @@ -31,7 +34,7 @@ CONF = keystone.conf.CONF class ExceptionTestCase(unit.BaseTestCase): def assertValidJsonRendering(self, e): - resp = wsgi.render_exception(e) + resp = auth_context.render_exception(e) self.assertEqual(e.code, resp.status_int) self.assertEqual('%s %s' % (e.code, e.title), resp.status) @@ -74,7 +77,7 @@ class ExceptionTestCase(unit.BaseTestCase): def test_forbidden_title(self): e = exception.Forbidden() - resp = wsgi.render_exception(e) + resp = auth_context.render_exception(e) j = jsonutils.loads(resp.body) self.assertEqual('Forbidden', e.title) self.assertEqual('Forbidden', j['error'].get('title')) diff --git a/keystone/tests/unit/test_middleware.py b/keystone/tests/unit/test_middleware.py index 1927ec45bc..2327995d36 100644 --- a/keystone/tests/unit/test_middleware.py +++ b/keystone/tests/unit/test_middleware.py @@ -23,7 +23,6 @@ import webtest from keystone.common import authorization from keystone.common import provider_api from keystone.common import tokenless_auth -from keystone.common import wsgi import keystone.conf from keystone import exception from keystone.federation import constants as federation_constants @@ -667,7 +666,7 @@ class AuthContextMiddlewareTest(test_backend_sql.SqlTests, log_fix = self.useFixture(fixtures.FakeLogger()) headers = {authorization.AUTH_TOKEN_HEADER: 'ADMIN'} req = self._do_middleware_request(headers=headers) - self.assertTrue(req.environ[wsgi.CONTEXT_ENV]['is_admin']) + self.assertTrue(req.environ[auth_context.CONTEXT_ENV]['is_admin']) self.assertNotIn('Invalid user token', log_fix.output) def test_request_non_admin(self): diff --git a/releasenotes/notes/bug-1776504-keystone-conversion-to-flask-372a5654a55675c6.yaml b/releasenotes/notes/bug-1776504-keystone-conversion-to-flask-372a5654a55675c6.yaml new file mode 100644 index 0000000000..35dfed667e --- /dev/null +++ b/releasenotes/notes/bug-1776504-keystone-conversion-to-flask-372a5654a55675c6.yaml @@ -0,0 +1,26 @@ +--- +other: + - | + Keystone has been fully converted to run under flask. All of the APIs are + now natively dispatched under flask. + + Included in this change is a removal of a legacy WSGI environment data + holder calld `openstack.params`. The data holder was used exclusively for + communicating data down the chain under paste-deploy. The data in + `openstack.params` was generally "normalized" in an odd way and + unreferenced in the rest of the openstack code-base. + + Some minor changes to the JSON Home document occured to make it consistent + with the rest of our convensions (Technically an API contract break) but + required for the more strict view the Keystone flask code takes on setting + up the values for JSON Home. Notably "application_credentials" now has + an appropriate entry for listing and creating new app creds. + + JSON Body and URL Normalizing middleware were move to a flask-native + model. + + Any middleware defined in Keystone's tree is no longer loaded via + stevedore, and likewise the entry points were removed. + + Original WSGI Framework (custom, home-rolled, based on WEBOB) has been + removed from the codebase. diff --git a/requirements.txt b/requirements.txt index 8c5042d37e..fb95135596 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,6 @@ Babel!=2.4.0,>=2.3.4 # BSD pbr!=2.1.0,>=2.0.0 # Apache-2.0 WebOb>=1.7.1 # MIT -Routes>=2.3.1 # MIT Flask!=0.11,>=1.0.2 # BSD Flask-RESTful>=0.3.5 # BSD cryptography>=2.1 # BSD/Apache-2.0