From b8c756ecdd7cced1db4300935484e8c83701c82e Mon Sep 17 00:00:00 2001 From: WuKong Date: Tue, 30 Jun 2015 18:47:29 +0200 Subject: migrate moon code from github to opnfv Change-Id: Ice53e368fd1114d56a75271aa9f2e598e3eba604 Signed-off-by: WuKong --- keystone-moon/keystone/resource/core.py | 1324 +++++++++++++++++++++++++++++++ 1 file changed, 1324 insertions(+) create mode 100644 keystone-moon/keystone/resource/core.py (limited to 'keystone-moon/keystone/resource/core.py') diff --git a/keystone-moon/keystone/resource/core.py b/keystone-moon/keystone/resource/core.py new file mode 100644 index 00000000..017eb4e7 --- /dev/null +++ b/keystone-moon/keystone/resource/core.py @@ -0,0 +1,1324 @@ +# 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. + +"""Main entry point into the resource service.""" + +import abc + +from oslo_config import cfg +from oslo_log import log +import six + +from keystone import clean +from keystone.common import cache +from keystone.common import dependency +from keystone.common import driver_hints +from keystone.common import manager +from keystone.contrib import federation +from keystone import exception +from keystone.i18n import _, _LE, _LW +from keystone import notifications + + +CONF = cfg.CONF +LOG = log.getLogger(__name__) +MEMOIZE = cache.get_memoization_decorator(section='resource') + + +def calc_default_domain(): + return {'description': + (u'Owns users and tenants (i.e. projects)' + ' available on Identity API v2.'), + 'enabled': True, + 'id': CONF.identity.default_domain_id, + 'name': u'Default'} + + +@dependency.provider('resource_api') +@dependency.requires('assignment_api', 'credential_api', 'domain_config_api', + 'identity_api', 'revoke_api') +class Manager(manager.Manager): + """Default pivot point for the resource backend. + + See :mod:`keystone.common.manager.Manager` for more details on how this + dynamically calls the backend. + + """ + _DOMAIN = 'domain' + _PROJECT = 'project' + + def __init__(self): + # If there is a specific driver specified for resource, then use it. + # Otherwise retrieve the driver type from the assignment driver. + resource_driver = CONF.resource.driver + + if resource_driver is None: + assignment_driver = ( + dependency.get_provider('assignment_api').driver) + resource_driver = assignment_driver.default_resource_driver() + + super(Manager, self).__init__(resource_driver) + + def _get_hierarchy_depth(self, parents_list): + return len(parents_list) + 1 + + def _assert_max_hierarchy_depth(self, project_id, parents_list=None): + if parents_list is None: + parents_list = self.list_project_parents(project_id) + max_depth = CONF.max_project_tree_depth + if self._get_hierarchy_depth(parents_list) > max_depth: + raise exception.ForbiddenAction( + action=_('max hierarchy depth reached for ' + '%s branch.') % project_id) + + def create_project(self, tenant_id, tenant, initiator=None): + tenant = tenant.copy() + tenant.setdefault('enabled', True) + tenant['enabled'] = clean.project_enabled(tenant['enabled']) + tenant.setdefault('description', '') + tenant.setdefault('parent_id', None) + + if tenant.get('parent_id') is not None: + parent_ref = self.get_project(tenant.get('parent_id')) + parents_list = self.list_project_parents(parent_ref['id']) + parents_list.append(parent_ref) + for ref in parents_list: + if ref.get('domain_id') != tenant.get('domain_id'): + raise exception.ForbiddenAction( + action=_('cannot create a project within a different ' + 'domain than its parents.')) + if not ref.get('enabled', True): + raise exception.ForbiddenAction( + action=_('cannot create a project in a ' + 'branch containing a disabled ' + 'project: %s') % ref['id']) + self._assert_max_hierarchy_depth(tenant.get('parent_id'), + parents_list) + + ret = self.driver.create_project(tenant_id, tenant) + notifications.Audit.created(self._PROJECT, tenant_id, initiator) + if MEMOIZE.should_cache(ret): + self.get_project.set(ret, self, tenant_id) + self.get_project_by_name.set(ret, self, ret['name'], + ret['domain_id']) + return ret + + def assert_domain_enabled(self, domain_id, domain=None): + """Assert the Domain is enabled. + + :raise AssertionError if domain is disabled. + """ + if domain is None: + domain = self.get_domain(domain_id) + if not domain.get('enabled', True): + raise AssertionError(_('Domain is disabled: %s') % domain_id) + + def assert_domain_not_federated(self, domain_id, domain): + """Assert the Domain's name and id do not match the reserved keyword. + + Note that the reserved keyword is defined in the configuration file, + by default, it is 'Federated', it is also case insensitive. + If config's option is empty the default hardcoded value 'Federated' + will be used. + + :raise AssertionError if domain named match the value in the config. + + """ + # NOTE(marek-denis): We cannot create this attribute in the __init__ as + # config values are always initialized to default value. + federated_domain = (CONF.federation.federated_domain_name or + federation.FEDERATED_DOMAIN_KEYWORD).lower() + if (domain.get('name') and domain['name'].lower() == federated_domain): + raise AssertionError(_('Domain cannot be named %s') + % federated_domain) + if (domain_id.lower() == federated_domain): + raise AssertionError(_('Domain cannot have ID %s') + % federated_domain) + + def assert_project_enabled(self, project_id, project=None): + """Assert the project is enabled and its associated domain is enabled. + + :raise AssertionError if the project or domain is disabled. + """ + if project is None: + project = self.get_project(project_id) + self.assert_domain_enabled(domain_id=project['domain_id']) + if not project.get('enabled', True): + raise AssertionError(_('Project is disabled: %s') % project_id) + + @notifications.disabled(_PROJECT, public=False) + def _disable_project(self, project_id): + """Emit a notification to the callback system project is been disabled. + + This method, and associated callback listeners, removes the need for + making direct calls to other managers to take action (e.g. revoking + project scoped tokens) when a project is disabled. + + :param project_id: project identifier + :type project_id: string + """ + pass + + def _assert_all_parents_are_enabled(self, project_id): + parents_list = self.list_project_parents(project_id) + for project in parents_list: + if not project.get('enabled', True): + raise exception.ForbiddenAction( + action=_('cannot enable project %s since it has ' + 'disabled parents') % project_id) + + def _assert_whole_subtree_is_disabled(self, project_id): + subtree_list = self.driver.list_projects_in_subtree(project_id) + for ref in subtree_list: + if ref.get('enabled', True): + raise exception.ForbiddenAction( + action=_('cannot disable project %s since ' + 'its subtree contains enabled ' + 'projects') % project_id) + + def update_project(self, tenant_id, tenant, initiator=None): + original_tenant = self.driver.get_project(tenant_id) + tenant = tenant.copy() + + parent_id = original_tenant.get('parent_id') + if 'parent_id' in tenant and tenant.get('parent_id') != parent_id: + raise exception.ForbiddenAction( + action=_('Update of `parent_id` is not allowed.')) + + if 'enabled' in tenant: + tenant['enabled'] = clean.project_enabled(tenant['enabled']) + + # NOTE(rodrigods): for the current implementation we only allow to + # disable a project if all projects below it in the hierarchy are + # already disabled. This also means that we can not enable a + # project that has disabled parents. + original_tenant_enabled = original_tenant.get('enabled', True) + tenant_enabled = tenant.get('enabled', True) + if not original_tenant_enabled and tenant_enabled: + self._assert_all_parents_are_enabled(tenant_id) + if original_tenant_enabled and not tenant_enabled: + self._assert_whole_subtree_is_disabled(tenant_id) + self._disable_project(tenant_id) + + ret = self.driver.update_project(tenant_id, tenant) + notifications.Audit.updated(self._PROJECT, tenant_id, initiator) + self.get_project.invalidate(self, tenant_id) + self.get_project_by_name.invalidate(self, original_tenant['name'], + original_tenant['domain_id']) + return ret + + def delete_project(self, tenant_id, initiator=None): + if not self.driver.is_leaf_project(tenant_id): + raise exception.ForbiddenAction( + action=_('cannot delete the project %s since it is not ' + 'a leaf in the hierarchy.') % tenant_id) + + project = self.driver.get_project(tenant_id) + project_user_ids = ( + self.assignment_api.list_user_ids_for_project(tenant_id)) + for user_id in project_user_ids: + payload = {'user_id': user_id, 'project_id': tenant_id} + self._emit_invalidate_user_project_tokens_notification(payload) + ret = self.driver.delete_project(tenant_id) + self.assignment_api.delete_project_assignments(tenant_id) + self.get_project.invalidate(self, tenant_id) + self.get_project_by_name.invalidate(self, project['name'], + project['domain_id']) + self.credential_api.delete_credentials_for_project(tenant_id) + notifications.Audit.deleted(self._PROJECT, tenant_id, initiator) + return ret + + def _filter_projects_list(self, projects_list, user_id): + user_projects = self.assignment_api.list_projects_for_user(user_id) + user_projects_ids = set([proj['id'] for proj in user_projects]) + # Keep only the projects present in user_projects + projects_list = [proj for proj in projects_list + if proj['id'] in user_projects_ids] + + def list_project_parents(self, project_id, user_id=None): + parents = self.driver.list_project_parents(project_id) + # If a user_id was provided, the returned list should be filtered + # against the projects this user has access to. + if user_id: + self._filter_projects_list(parents, user_id) + return parents + + def _build_parents_as_ids_dict(self, project, parents_by_id): + # NOTE(rodrigods): we don't rely in the order of the projects returned + # by the list_project_parents() method. Thus, we create a project cache + # (parents_by_id) in order to access each parent in constant time and + # traverse up the hierarchy. + def traverse_parents_hierarchy(project): + parent_id = project.get('parent_id') + if not parent_id: + return None + + parent = parents_by_id[parent_id] + return {parent_id: traverse_parents_hierarchy(parent)} + + return traverse_parents_hierarchy(project) + + def get_project_parents_as_ids(self, project): + """Gets the IDs from the parents from a given project. + + The project IDs are returned as a structured dictionary traversing up + the hierarchy to the top level project. For example, considering the + following project hierarchy:: + + A + | + +-B-+ + | | + C D + + If we query for project C parents, the expected return is the following + dictionary:: + + 'parents': { + B['id']: { + A['id']: None + } + } + + """ + parents_list = self.list_project_parents(project['id']) + parents_as_ids = self._build_parents_as_ids_dict( + project, {proj['id']: proj for proj in parents_list}) + return parents_as_ids + + def list_projects_in_subtree(self, project_id, user_id=None): + subtree = self.driver.list_projects_in_subtree(project_id) + # If a user_id was provided, the returned list should be filtered + # against the projects this user has access to. + if user_id: + self._filter_projects_list(subtree, user_id) + return subtree + + def _build_subtree_as_ids_dict(self, project_id, subtree_by_parent): + # NOTE(rodrigods): we perform a depth first search to construct the + # dictionaries representing each level of the subtree hierarchy. In + # order to improve this traversal performance, we create a cache of + # projects (subtree_py_parent) that accesses in constant time the + # direct children of a given project. + def traverse_subtree_hierarchy(project_id): + children = subtree_by_parent.get(project_id) + if not children: + return None + + children_ids = {} + for child in children: + children_ids[child['id']] = traverse_subtree_hierarchy( + child['id']) + return children_ids + + return traverse_subtree_hierarchy(project_id) + + def get_projects_in_subtree_as_ids(self, project_id): + """Gets the IDs from the projects in the subtree from a given project. + + The project IDs are returned as a structured dictionary representing + their hierarchy. For example, considering the following project + hierarchy:: + + A + | + +-B-+ + | | + C D + + If we query for project A subtree, the expected return is the following + dictionary:: + + 'subtree': { + B['id']: { + C['id']: None, + D['id']: None + } + } + + """ + def _projects_indexed_by_parent(projects_list): + projects_by_parent = {} + for proj in projects_list: + parent_id = proj.get('parent_id') + if parent_id: + if parent_id in projects_by_parent: + projects_by_parent[parent_id].append(proj) + else: + projects_by_parent[parent_id] = [proj] + return projects_by_parent + + subtree_list = self.list_projects_in_subtree(project_id) + subtree_as_ids = self._build_subtree_as_ids_dict( + project_id, _projects_indexed_by_parent(subtree_list)) + return subtree_as_ids + + @MEMOIZE + def get_domain(self, domain_id): + return self.driver.get_domain(domain_id) + + @MEMOIZE + def get_domain_by_name(self, domain_name): + return self.driver.get_domain_by_name(domain_name) + + def create_domain(self, domain_id, domain, initiator=None): + if (not self.identity_api.multiple_domains_supported and + domain_id != CONF.identity.default_domain_id): + raise exception.Forbidden(_('Multiple domains are not supported')) + self.assert_domain_not_federated(domain_id, domain) + domain.setdefault('enabled', True) + domain['enabled'] = clean.domain_enabled(domain['enabled']) + ret = self.driver.create_domain(domain_id, domain) + + notifications.Audit.created(self._DOMAIN, domain_id, initiator) + + if MEMOIZE.should_cache(ret): + self.get_domain.set(ret, self, domain_id) + self.get_domain_by_name.set(ret, self, ret['name']) + return ret + + @manager.response_truncated + def list_domains(self, hints=None): + return self.driver.list_domains(hints or driver_hints.Hints()) + + @notifications.disabled(_DOMAIN, public=False) + def _disable_domain(self, domain_id): + """Emit a notification to the callback system domain is been disabled. + + This method, and associated callback listeners, removes the need for + making direct calls to other managers to take action (e.g. revoking + domain scoped tokens) when a domain is disabled. + + :param domain_id: domain identifier + :type domain_id: string + """ + pass + + def update_domain(self, domain_id, domain, initiator=None): + self.assert_domain_not_federated(domain_id, domain) + original_domain = self.driver.get_domain(domain_id) + if 'enabled' in domain: + domain['enabled'] = clean.domain_enabled(domain['enabled']) + ret = self.driver.update_domain(domain_id, domain) + notifications.Audit.updated(self._DOMAIN, domain_id, initiator) + # disable owned users & projects when the API user specifically set + # enabled=False + if (original_domain.get('enabled', True) and + not domain.get('enabled', True)): + notifications.Audit.disabled(self._DOMAIN, domain_id, initiator, + public=False) + + self.get_domain.invalidate(self, domain_id) + self.get_domain_by_name.invalidate(self, original_domain['name']) + return ret + + def delete_domain(self, domain_id, initiator=None): + # explicitly forbid deleting the default domain (this should be a + # carefully orchestrated manual process involving configuration + # changes, etc) + if domain_id == CONF.identity.default_domain_id: + raise exception.ForbiddenAction(action=_('delete the default ' + 'domain')) + + domain = self.driver.get_domain(domain_id) + + # To help avoid inadvertent deletes, we insist that the domain + # has been previously disabled. This also prevents a user deleting + # their own domain since, once it is disabled, they won't be able + # to get a valid token to issue this delete. + if domain['enabled']: + raise exception.ForbiddenAction( + action=_('cannot delete a domain that is enabled, ' + 'please disable it first.')) + + self._delete_domain_contents(domain_id) + # Delete any database stored domain config + self.domain_config_api.delete_config_options(domain_id) + self.domain_config_api.delete_config_options(domain_id, sensitive=True) + # TODO(henry-nash): Although the controller will ensure deletion of + # all users & groups within the domain (which will cause all + # assignments for those users/groups to also be deleted), there + # could still be assignments on this domain for users/groups in + # other domains - so we should delete these here by making a call + # to the backend to delete all assignments for this domain. + # (see Bug #1277847) + self.driver.delete_domain(domain_id) + notifications.Audit.deleted(self._DOMAIN, domain_id, initiator) + self.get_domain.invalidate(self, domain_id) + self.get_domain_by_name.invalidate(self, domain['name']) + + def _delete_domain_contents(self, domain_id): + """Delete the contents of a domain. + + Before we delete a domain, we need to remove all the entities + that are owned by it, i.e. Projects. To do this we + call the delete function for these entities, which are + themselves responsible for deleting any credentials and role grants + associated with them as well as revoking any relevant tokens. + + """ + + def _delete_projects(project, projects, examined): + if project['id'] in examined: + msg = _LE('Circular reference or a repeated entry found ' + 'projects hierarchy - %(project_id)s.') + LOG.error(msg, {'project_id': project['id']}) + return + + examined.add(project['id']) + children = [proj for proj in projects + if proj.get('parent_id') == project['id']] + for proj in children: + _delete_projects(proj, projects, examined) + + try: + self.delete_project(project['id']) + except exception.ProjectNotFound: + LOG.debug(('Project %(projectid)s not found when ' + 'deleting domain contents for %(domainid)s, ' + 'continuing with cleanup.'), + {'projectid': project['id'], + 'domainid': domain_id}) + + proj_refs = self.list_projects_in_domain(domain_id) + + # Deleting projects recursively + roots = [x for x in proj_refs if x.get('parent_id') is None] + examined = set() + for project in roots: + _delete_projects(project, proj_refs, examined) + + @manager.response_truncated + def list_projects(self, hints=None): + return self.driver.list_projects(hints or driver_hints.Hints()) + + # NOTE(henry-nash): list_projects_in_domain is actually an internal method + # and not exposed via the API. Therefore there is no need to support + # driver hints for it. + def list_projects_in_domain(self, domain_id): + return self.driver.list_projects_in_domain(domain_id) + + @MEMOIZE + def get_project(self, project_id): + return self.driver.get_project(project_id) + + @MEMOIZE + def get_project_by_name(self, tenant_name, domain_id): + return self.driver.get_project_by_name(tenant_name, domain_id) + + @notifications.internal( + notifications.INVALIDATE_USER_PROJECT_TOKEN_PERSISTENCE) + def _emit_invalidate_user_project_tokens_notification(self, payload): + # This notification's payload is a dict of user_id and + # project_id so the token provider can invalidate the tokens + # from persistence if persistence is enabled. + pass + + +@six.add_metaclass(abc.ABCMeta) +class Driver(object): + + def _get_list_limit(self): + return CONF.resource.list_limit or CONF.list_limit + + @abc.abstractmethod + def get_project_by_name(self, tenant_name, domain_id): + """Get a tenant by name. + + :returns: tenant_ref + :raises: keystone.exception.ProjectNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + # domain crud + @abc.abstractmethod + def create_domain(self, domain_id, domain): + """Creates a new domain. + + :raises: keystone.exception.Conflict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_domains(self, hints): + """List domains in the system. + + :param hints: filter hints which the driver should + implement if at all possible. + + :returns: a list of domain_refs or an empty list. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_domains_from_ids(self, domain_ids): + """List domains for the provided list of ids. + + :param domain_ids: list of ids + + :returns: a list of domain_refs. + + This method is used internally by the assignment manager to bulk read + a set of domains given their ids. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def get_domain(self, domain_id): + """Get a domain by ID. + + :returns: domain_ref + :raises: keystone.exception.DomainNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def get_domain_by_name(self, domain_name): + """Get a domain by name. + + :returns: domain_ref + :raises: keystone.exception.DomainNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def update_domain(self, domain_id, domain): + """Updates an existing domain. + + :raises: keystone.exception.DomainNotFound, + keystone.exception.Conflict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def delete_domain(self, domain_id): + """Deletes an existing domain. + + :raises: keystone.exception.DomainNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + # project crud + @abc.abstractmethod + def create_project(self, project_id, project): + """Creates a new project. + + :raises: keystone.exception.Conflict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_projects(self, hints): + """List projects in the system. + + :param hints: filter hints which the driver should + implement if at all possible. + + :returns: a list of project_refs or an empty list. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_projects_from_ids(self, project_ids): + """List projects for the provided list of ids. + + :param project_ids: list of ids + + :returns: a list of project_refs. + + This method is used internally by the assignment manager to bulk read + a set of projects given their ids. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_project_ids_from_domain_ids(self, domain_ids): + """List project ids for the provided list of domain ids. + + :param domain_ids: list of domain ids + + :returns: a list of project ids owned by the specified domain ids. + + This method is used internally by the assignment manager to bulk read + a set of project ids given a list of domain ids. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_projects_in_domain(self, domain_id): + """List projects in the domain. + + :param domain_id: the driver MUST only return projects + within this domain. + + :returns: a list of project_refs or an empty list. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def get_project(self, project_id): + """Get a project by ID. + + :returns: project_ref + :raises: keystone.exception.ProjectNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def update_project(self, project_id, project): + """Updates an existing project. + + :raises: keystone.exception.ProjectNotFound, + keystone.exception.Conflict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def delete_project(self, project_id): + """Deletes an existing project. + + :raises: keystone.exception.ProjectNotFound + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_project_parents(self, project_id): + """List all parents from a project by its ID. + + :param project_id: the driver will list the parents of this + project. + + :returns: a list of project_refs or an empty list. + :raises: keystone.exception.ProjectNotFound + + """ + raise exception.NotImplemented() + + @abc.abstractmethod + def list_projects_in_subtree(self, project_id): + """List all projects in the subtree below the hierarchy of the + given project. + + :param project_id: the driver will get the subtree under + this project. + + :returns: a list of project_refs or an empty list + :raises: keystone.exception.ProjectNotFound + + """ + raise exception.NotImplemented() + + @abc.abstractmethod + def is_leaf_project(self, project_id): + """Checks if a project is a leaf in the hierarchy. + + :param project_id: the driver will check if this project + is a leaf in the hierarchy. + + :raises: keystone.exception.ProjectNotFound + + """ + raise exception.NotImplemented() + + # Domain management functions for backends that only allow a single + # domain. Currently, this is only LDAP, but might be used by other + # backends in the future. + def _set_default_domain(self, ref): + """If the domain ID has not been set, set it to the default.""" + if isinstance(ref, dict): + if 'domain_id' not in ref: + ref = ref.copy() + ref['domain_id'] = CONF.identity.default_domain_id + return ref + elif isinstance(ref, list): + return [self._set_default_domain(x) for x in ref] + else: + raise ValueError(_('Expected dict or list: %s') % type(ref)) + + def _validate_default_domain(self, ref): + """Validate that either the default domain or nothing is specified. + + Also removes the domain from the ref so that LDAP doesn't have to + persist the attribute. + + """ + ref = ref.copy() + domain_id = ref.pop('domain_id', CONF.identity.default_domain_id) + self._validate_default_domain_id(domain_id) + return ref + + def _validate_default_domain_id(self, domain_id): + """Validate that the domain ID specified belongs to the default domain. + + """ + if domain_id != CONF.identity.default_domain_id: + raise exception.DomainNotFound(domain_id=domain_id) + + +@dependency.provider('domain_config_api') +class DomainConfigManager(manager.Manager): + """Default pivot point for the Domain Config backend.""" + + # NOTE(henry-nash): In order for a config option to be stored in the + # standard table, it must be explicitly whitelisted. Options marked as + # sensitive are stored in a separate table. Attempting to store options + # that are not listed as either whitelisted or sensitive will raise an + # exception. + # + # Only those options that affect the domain-specific driver support in + # the identity manager are supported. + + whitelisted_options = { + 'identity': ['driver'], + 'ldap': [ + 'url', 'user', 'suffix', 'use_dumb_member', 'dumb_member', + 'allow_subtree_delete', 'query_scope', 'page_size', + 'alias_dereferencing', 'debug_level', 'chase_referrals', + 'user_tree_dn', 'user_filter', 'user_objectclass', + 'user_id_attribute', 'user_name_attribute', 'user_mail_attribute', + 'user_pass_attribute', 'user_enabled_attribute', + 'user_enabled_invert', 'user_enabled_mask', 'user_enabled_default', + 'user_attribute_ignore', 'user_default_project_id_attribute', + 'user_allow_create', 'user_allow_update', 'user_allow_delete', + 'user_enabled_emulation', 'user_enabled_emulation_dn', + 'user_additional_attribute_mapping', 'group_tree_dn', + 'group_filter', 'group_objectclass', 'group_id_attribute', + 'group_name_attribute', 'group_member_attribute', + 'group_desc_attribute', 'group_attribute_ignore', + 'group_allow_create', 'group_allow_update', 'group_allow_delete', + 'group_additional_attribute_mapping', 'tls_cacertfile', + 'tls_cacertdir', 'use_tls', 'tls_req_cert', 'use_pool', + 'pool_size', 'pool_retry_max', 'pool_retry_delay', + 'pool_connection_timeout', 'pool_connection_lifetime', + 'use_auth_pool', 'auth_pool_size', 'auth_pool_connection_lifetime' + ] + } + sensitive_options = { + 'identity': [], + 'ldap': ['password'] + } + + def __init__(self): + super(DomainConfigManager, self).__init__(CONF.domain_config.driver) + + def _assert_valid_config(self, config): + """Ensure the options in the config are valid. + + This method is called to validate the request config in create and + update manager calls. + + :param config: config structure being created or updated + + """ + # Something must be defined in the request + if not config: + raise exception.InvalidDomainConfig( + reason=_('No options specified')) + + # Make sure the groups/options defined in config itself are valid + for group in config: + if (not config[group] or not + isinstance(config[group], dict)): + msg = _('The value of group %(group)s specified in the ' + 'config should be a dictionary of options') % { + 'group': group} + raise exception.InvalidDomainConfig(reason=msg) + for option in config[group]: + self._assert_valid_group_and_option(group, option) + + def _assert_valid_group_and_option(self, group, option): + """Ensure the combination of group and option is valid. + + :param group: optional group name, if specified it must be one + we support + :param option: optional option name, if specified it must be one + we support and a group must also be specified + + """ + if not group and not option: + # For all calls, it's OK for neither to be defined, it means you + # are operating on all config options for that domain. + return + + if not group and option: + # Our API structure should prevent this from ever happening, so if + # it does, then this is coding error. + msg = _('Option %(option)s found with no group specified while ' + 'checking domain configuration request') % { + 'option': option} + raise exception.UnexpectedError(exception=msg) + + if (group and group not in self.whitelisted_options and + group not in self.sensitive_options): + msg = _('Group %(group)s is not supported ' + 'for domain specific configurations') % {'group': group} + raise exception.InvalidDomainConfig(reason=msg) + + if option: + if (option not in self.whitelisted_options[group] and option not in + self.sensitive_options[group]): + msg = _('Option %(option)s in group %(group)s is not ' + 'supported for domain specific configurations') % { + 'group': group, 'option': option} + raise exception.InvalidDomainConfig(reason=msg) + + def _is_sensitive(self, group, option): + return option in self.sensitive_options[group] + + def _config_to_list(self, config): + """Build whitelisted and sensitive lists for use by backend drivers.""" + + whitelisted = [] + sensitive = [] + for group in config: + for option in config[group]: + the_list = (sensitive if self._is_sensitive(group, option) + else whitelisted) + the_list.append({ + 'group': group, 'option': option, + 'value': config[group][option]}) + + return whitelisted, sensitive + + def _list_to_config(self, whitelisted, sensitive=None, req_option=None): + """Build config dict from a list of option dicts. + + :param whitelisted: list of dicts containing options and their groups, + this has already been filtered to only contain + those options to include in the output. + :param sensitive: list of dicts containing sensitive options and their + groups, this has already been filtered to only + contain those options to include in the output. + :param req_option: the individual option requested + + :returns: a config dict, including sensitive if specified + + """ + the_list = whitelisted + (sensitive or []) + if not the_list: + return {} + + if req_option: + # The request was specific to an individual option, so + # no need to include the group in the output. We first check that + # there is only one option in the answer (and that it's the right + # one) - if not, something has gone wrong and we raise an error + if len(the_list) > 1 or the_list[0]['option'] != req_option: + LOG.error(_LE('Unexpected results in response for domain ' + 'config - %(count)s responses, first option is ' + '%(option)s, expected option %(expected)s'), + {'count': len(the_list), 'option': list[0]['option'], + 'expected': req_option}) + raise exception.UnexpectedError( + _('An unexpected error occurred when retrieving domain ' + 'configs')) + return {the_list[0]['option']: the_list[0]['value']} + + config = {} + for option in the_list: + config.setdefault(option['group'], {}) + config[option['group']][option['option']] = option['value'] + + return config + + def create_config(self, domain_id, config): + """Create config for a domain + + :param domain_id: the domain in question + :param config: the dict of config groups/options to assign to the + domain + + Creates a new config, overwriting any previous config (no Conflict + error will be generated). + + :returns: a dict of group dicts containing the options, with any that + are sensitive removed + :raises keystone.exception.InvalidDomainConfig: when the config + contains options we do not support + + """ + self._assert_valid_config(config) + whitelisted, sensitive = self._config_to_list(config) + # Delete any existing config + self.delete_config_options(domain_id) + self.delete_config_options(domain_id, sensitive=True) + # ...and create the new one + for option in whitelisted: + self.create_config_option( + domain_id, option['group'], option['option'], option['value']) + for option in sensitive: + self.create_config_option( + domain_id, option['group'], option['option'], option['value'], + sensitive=True) + return self._list_to_config(whitelisted) + + def get_config(self, domain_id, group=None, option=None): + """Get config, or partial config, for a domain + + :param domain_id: the domain in question + :param group: an optional specific group of options + :param option: an optional specific option within the group + + :returns: a dict of group dicts containing the whitelisted options, + filtered by group and option specified + :raises keystone.exception.DomainConfigNotFound: when no config found + that matches domain_id, group and option specified + :raises keystone.exception.InvalidDomainConfig: when the config + and group/option parameters specify an option we do not + support + + An example response:: + + { + 'ldap': { + 'url': 'myurl' + 'user_tree_dn': 'OU=myou'}, + 'identity': { + 'driver': 'keystone.identity.backends.ldap.Identity'} + + } + + """ + self._assert_valid_group_and_option(group, option) + whitelisted = self.list_config_options(domain_id, group, option) + if whitelisted: + return self._list_to_config(whitelisted, req_option=option) + + if option: + msg = _('option %(option)s in group %(group)s') % { + 'group': group, 'option': option} + elif group: + msg = _('group %(group)s') % {'group': group} + else: + msg = _('any options') + raise exception.DomainConfigNotFound( + domain_id=domain_id, group_or_option=msg) + + def update_config(self, domain_id, config, group=None, option=None): + """Update config, or partial config, for a domain + + :param domain_id: the domain in question + :param config: the config dict containing and groups/options being + updated + :param group: an optional specific group of options, which if specified + must appear in config, with no other groups + :param option: an optional specific option within the group, which if + specified must appear in config, with no other options + + The contents of the supplied config will be merged with the existing + config for this domain, updating or creating new options if these did + not previously exist. If group or option is specified, then the update + will be limited to those specified items and the inclusion of other + options in the supplied config will raise an exception, as will the + situation when those options do not already exist in the current + config. + + :returns: a dict of groups containing all whitelisted options + :raises keystone.exception.InvalidDomainConfig: when the config + and group/option parameters specify an option we do not + support or one that does not exist in the original config + + """ + def _assert_valid_update(domain_id, config, group=None, option=None): + """Ensure the combination of config, group and option is valid.""" + + self._assert_valid_config(config) + self._assert_valid_group_and_option(group, option) + + # If a group has been specified, then the request is to + # explicitly only update the options in that group - so the config + # must not contain anything else. Further, that group must exist in + # the original config. Likewise, if an option has been specified, + # then the group in the config must only contain that option and it + # also must exist in the original config. + if group: + if len(config) != 1 or (option and len(config[group]) != 1): + if option: + msg = _('Trying to update option %(option)s in group ' + '%(group)s, so that, and only that, option ' + 'must be specified in the config') % { + 'group': group, 'option': option} + else: + msg = _('Trying to update group %(group)s, so that, ' + 'and only that, group must be specified in ' + 'the config') % {'group': group} + raise exception.InvalidDomainConfig(reason=msg) + + # So we now know we have the right number of entries in the + # config that align with a group/option being specified, but we + # must also make sure they match. + if group not in config: + msg = _('request to update group %(group)s, but config ' + 'provided contains group %(group_other)s ' + 'instead') % { + 'group': group, + 'group_other': config.keys()[0]} + raise exception.InvalidDomainConfig(reason=msg) + if option and option not in config[group]: + msg = _('Trying to update option %(option)s in group ' + '%(group)s, but config provided contains option ' + '%(option_other)s instead') % { + 'group': group, 'option': option, + 'option_other': config[group].keys()[0]} + raise exception.InvalidDomainConfig(reason=msg) + + # Finally, we need to check if the group/option specified + # already exists in the original config - since if not, to keep + # with the semantics of an update, we need to fail with + # a DomainConfigNotFound + if not self.get_config_with_sensitive_info(domain_id, + group, option): + if option: + msg = _('option %(option)s in group %(group)s') % { + 'group': group, 'option': option} + raise exception.DomainConfigNotFound( + domain_id=domain_id, group_or_option=msg) + else: + msg = _('group %(group)s') % {'group': group} + raise exception.DomainConfigNotFound( + domain_id=domain_id, group_or_option=msg) + + def _update_or_create(domain_id, option, sensitive): + """Update the option, if it doesn't exist then create it.""" + + try: + self.create_config_option( + domain_id, option['group'], option['option'], + option['value'], sensitive=sensitive) + except exception.Conflict: + self.update_config_option( + domain_id, option['group'], option['option'], + option['value'], sensitive=sensitive) + + update_config = config + if group and option: + # The config will just be a dict containing the option and + # its value, so make it look like a single option under the + # group in question + update_config = {group: config} + + _assert_valid_update(domain_id, update_config, group, option) + + whitelisted, sensitive = self._config_to_list(update_config) + + for new_option in whitelisted: + _update_or_create(domain_id, new_option, sensitive=False) + for new_option in sensitive: + _update_or_create(domain_id, new_option, sensitive=True) + + return self.get_config(domain_id) + + def delete_config(self, domain_id, group=None, option=None): + """Delete config, or partial config, for the domain. + + :param domain_id: the domain in question + :param group: an optional specific group of options + :param option: an optional specific option within the group + + If group and option are None, then the entire config for the domain + is deleted. If group is not None, then just that group of options will + be deleted. If group and option are both specified, then just that + option is deleted. + + :raises keystone.exception.InvalidDomainConfig: when group/option + parameters specify an option we do not support or one that + does not exist in the original config. + + """ + self._assert_valid_group_and_option(group, option) + if group: + # As this is a partial delete, then make sure the items requested + # are valid and exist in the current config + current_config = self.get_config_with_sensitive_info(domain_id) + # Raise an exception if the group/options specified don't exist in + # the current config so that the delete method provides the + # correct error semantics. + current_group = current_config.get(group) + if not current_group: + msg = _('group %(group)s') % {'group': group} + raise exception.DomainConfigNotFound( + domain_id=domain_id, group_or_option=msg) + if option and not current_group.get(option): + msg = _('option %(option)s in group %(group)s') % { + 'group': group, 'option': option} + raise exception.DomainConfigNotFound( + domain_id=domain_id, group_or_option=msg) + + self.delete_config_options(domain_id, group, option) + self.delete_config_options(domain_id, group, option, sensitive=True) + + def get_config_with_sensitive_info(self, domain_id, group=None, + option=None): + """Get config for a domain with sensitive info included. + + This method is not exposed via the public API, but is used by the + identity manager to initialize a domain with the fully formed config + options. + + """ + whitelisted = self.list_config_options(domain_id, group, option) + sensitive = self.list_config_options(domain_id, group, option, + sensitive=True) + + # Check if there are any sensitive substitutions needed. We first try + # and simply ensure any sensitive options that have valid substitution + # references in the whitelisted options are substituted. We then check + # the resulting whitelisted option and raise a warning if there + # appears to be an unmatched or incorrectly constructed substitution + # reference. To avoid the risk of logging any sensitive options that + # have already been substituted, we first take a copy of the + # whitelisted option. + + # Build a dict of the sensitive options ready to try substitution + sensitive_dict = {s['option']: s['value'] for s in sensitive} + + for each_whitelisted in whitelisted: + if not isinstance(each_whitelisted['value'], six.string_types): + # We only support substitutions into string types, if its an + # integer, list etc. then just continue onto the next one + continue + + # Store away the original value in case we need to raise a warning + # after substitution. + original_value = each_whitelisted['value'] + warning_msg = '' + try: + each_whitelisted['value'] = ( + each_whitelisted['value'] % sensitive_dict) + except KeyError: + warning_msg = _LW( + 'Found what looks like an unmatched config option ' + 'substitution reference - domain: %(domain)s, group: ' + '%(group)s, option: %(option)s, value: %(value)s. Perhaps ' + 'the config option to which it refers has yet to be ' + 'added?') + except (ValueError, TypeError): + warning_msg = _LW( + 'Found what looks like an incorrectly constructed ' + 'config option substitution reference - domain: ' + '%(domain)s, group: %(group)s, option: %(option)s, ' + 'value: %(value)s.') + + if warning_msg: + LOG.warn(warning_msg % { + 'domain': domain_id, + 'group': each_whitelisted['group'], + 'option': each_whitelisted['option'], + 'value': original_value}) + + return self._list_to_config(whitelisted, sensitive) + + +@six.add_metaclass(abc.ABCMeta) +class DomainConfigDriver(object): + """Interface description for a Domain Config driver.""" + + @abc.abstractmethod + def create_config_option(self, domain_id, group, option, value, + sensitive=False): + """Creates a config option for a domain. + + :param domain_id: the domain for this option + :param group: the group name + :param option: the option name + :param value: the value to assign to this option + :param sensitive: whether the option is sensitive + + :returns: dict containing group, option and value + :raises: keystone.exception.Conflict + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def get_config_option(self, domain_id, group, option, sensitive=False): + """Gets the config option for a domain. + + :param domain_id: the domain for this option + :param group: the group name + :param option: the option name + :param sensitive: whether the option is sensitive + + :returns: dict containing group, option and value + :raises: keystone.exception.DomainConfigNotFound: the option doesn't + exist. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def list_config_options(self, domain_id, group=None, option=False, + sensitive=False): + """Gets a config options for a domain. + + :param domain_id: the domain for this option + :param group: optional group option name + :param option: optional option name. If group is None, then this + parameter is ignored + :param sensitive: whether the option is sensitive + + :returns: list of dicts containing group, option and value + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def update_config_option(self, domain_id, group, option, value, + sensitive=False): + """Updates a config option for a domain. + + :param domain_id: the domain for this option + :param group: the group option name + :param option: the option name + :param value: the value to assign to this option + :param sensitive: whether the option is sensitive + + :returns: dict containing updated group, option and value + :raises: keystone.exception.DomainConfigNotFound: the option doesn't + exist. + + """ + raise exception.NotImplemented() # pragma: no cover + + @abc.abstractmethod + def delete_config_options(self, domain_id, group=None, option=None, + sensitive=False): + """Deletes config options for a domain. + + Allows deletion of all options for a domain, all options in a group + or a specific option. The driver is silent if there are no options + to delete. + + :param domain_id: the domain for this option + :param group: optional group option name + :param option: optional option name. If group is None, then this + parameter is ignored + :param sensitive: whether the option is sensitive + + """ + raise exception.NotImplemented() # pragma: no cover -- cgit 1.2.3-korg