"Fossies" - the Fresh Open Source Software Archive

Member "keystone-17.0.0/keystone/resource/core.py" (13 May 2020, 71352 Bytes) of package /linux/misc/openstack/keystone-17.0.0.tar.gz:


As a special service "Fossies" has tried to format the requested source page into HTML format using (guessed) Python source code syntax highlighting (style: standard) with prefixed line numbers. Alternatively you can here view or download the uninterpreted source code file. For more information about "core.py" see the Fossies "Dox" file reference documentation and the latest Fossies "Diffs" side-by-side code changes report: 16.0.1_vs_17.0.0.

    1 # Licensed under the Apache License, Version 2.0 (the "License"); you may
    2 # not use this file except in compliance with the License. You may obtain
    3 # a copy of the License at
    4 #
    5 #      http://www.apache.org/licenses/LICENSE-2.0
    6 #
    7 # Unless required by applicable law or agreed to in writing, software
    8 # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
    9 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
   10 # License for the specific language governing permissions and limitations
   11 # under the License.
   12 
   13 """Main entry point into the Resource service."""
   14 
   15 from oslo_log import log
   16 
   17 from keystone import assignment
   18 from keystone.common import cache
   19 from keystone.common import driver_hints
   20 from keystone.common import manager
   21 from keystone.common import provider_api
   22 from keystone.common.resource_options import options as ro_opt
   23 from keystone.common import utils
   24 import keystone.conf
   25 from keystone import exception
   26 from keystone.i18n import _
   27 from keystone import notifications
   28 from keystone.resource.backends import base
   29 from keystone.token import provider as token_provider
   30 
   31 CONF = keystone.conf.CONF
   32 LOG = log.getLogger(__name__)
   33 MEMOIZE = cache.get_memoization_decorator(group='resource')
   34 PROVIDERS = provider_api.ProviderAPIs
   35 
   36 
   37 TAG_SEARCH_FILTERS = ('tags', 'tags-any', 'not-tags', 'not-tags-any')
   38 
   39 
   40 class Manager(manager.Manager):
   41     """Default pivot point for the Resource backend.
   42 
   43     See :mod:`keystone.common.manager.Manager` for more details on how this
   44     dynamically calls the backend.
   45 
   46     """
   47 
   48     driver_namespace = 'keystone.resource'
   49     _provides_api = 'resource_api'
   50 
   51     _DOMAIN = 'domain'
   52     _PROJECT = 'project'
   53     _PROJECT_TAG = 'project tag'
   54 
   55     def __init__(self):
   56         resource_driver = CONF.resource.driver
   57         super(Manager, self).__init__(resource_driver)
   58 
   59     def _get_hierarchy_depth(self, parents_list):
   60         return len(parents_list) + 1
   61 
   62     def _assert_max_hierarchy_depth(self, project_id, parents_list=None):
   63         if parents_list is None:
   64             parents_list = self.list_project_parents(project_id)
   65         # NOTE(henry-nash): In upgrading to a scenario where domains are
   66         # represented as projects acting as domains, we will effectively
   67         # increase the depth of any existing project hierarchy by one. To avoid
   68         # pushing any existing hierarchies over the limit, we add one to the
   69         # maximum depth allowed, as specified in the configuration file.
   70         max_depth = CONF.max_project_tree_depth + 1
   71 
   72         # NOTE(wxy): If the hierarchical limit enforcement model is used, the
   73         # project depth should be not greater than the model's limit as well.
   74         #
   75         # TODO(wxy): Deprecate and remove CONF.max_project_tree_depth, let the
   76         # depth check only based on the limit enforcement model.
   77         limit_model = PROVIDERS.unified_limit_api.enforcement_model
   78         if limit_model.MAX_PROJECT_TREE_DEPTH is not None:
   79             max_depth = min(max_depth, limit_model.MAX_PROJECT_TREE_DEPTH + 1)
   80         if self._get_hierarchy_depth(parents_list) > max_depth:
   81             raise exception.ForbiddenNotSecurity(
   82                 _('Max hierarchy depth reached for %s branch.') % project_id)
   83 
   84     def _assert_is_domain_project_constraints(self, project_ref):
   85         """Enforce specific constraints of projects that act as domains.
   86 
   87         Called when is_domain is true, this method ensures that:
   88 
   89         * multiple domains are enabled
   90         * the project name is not the reserved name for a federated domain
   91         * the project is a root project
   92 
   93         :raises keystone.exception.ValidationError: If one of the constraints
   94             was not satisfied.
   95         """
   96         if (not PROVIDERS.identity_api.multiple_domains_supported and
   97                 project_ref['id'] != CONF.identity.default_domain_id and
   98                 project_ref['id'] != base.NULL_DOMAIN_ID):
   99             raise exception.ValidationError(
  100                 message=_('Multiple domains are not supported'))
  101 
  102         self.assert_domain_not_federated(project_ref['id'], project_ref)
  103 
  104         if project_ref['parent_id']:
  105             raise exception.ValidationError(
  106                 message=_('only root projects are allowed to act as '
  107                           'domains.'))
  108 
  109     def _assert_regular_project_constraints(self, project_ref):
  110         """Enforce regular project hierarchy constraints.
  111 
  112         Called when is_domain is false. The project must contain a valid
  113         domain_id and parent_id. The goal of this method is to check
  114         that the domain_id specified is consistent with the domain of its
  115         parent.
  116 
  117         :raises keystone.exception.ValidationError: If one of the constraints
  118             was not satisfied.
  119         :raises keystone.exception.DomainNotFound: In case the domain is not
  120             found.
  121         """
  122         # Ensure domain_id is valid, and by inference will not be None.
  123         domain = self.get_domain(project_ref['domain_id'])
  124         parent_ref = self.get_project(project_ref['parent_id'])
  125 
  126         if parent_ref['is_domain']:
  127             if parent_ref['id'] != domain['id']:
  128                 raise exception.ValidationError(
  129                     message=_('Cannot create project, the parent '
  130                               '(%(parent_id)s) is acting as a domain, '
  131                               'but this project\'s domain id (%(domain_id)s) '
  132                               'does not match the parent\'s id.')
  133                     % {'parent_id': parent_ref['id'],
  134                        'domain_id': domain['id']})
  135         else:
  136             parent_domain_id = parent_ref.get('domain_id')
  137             if parent_domain_id != domain['id']:
  138                 raise exception.ValidationError(
  139                     message=_('Cannot create project, since it specifies '
  140                               'its domain_id %(domain_id)s, but '
  141                               'specifies a parent in a different domain '
  142                               '(%(parent_domain_id)s).')
  143                     % {'domain_id': domain['id'],
  144                        'parent_domain_id': parent_domain_id})
  145 
  146     def _enforce_project_constraints(self, project_ref):
  147         if project_ref.get('is_domain'):
  148             self._assert_is_domain_project_constraints(project_ref)
  149         else:
  150             self._assert_regular_project_constraints(project_ref)
  151             # The whole hierarchy (upwards) must be enabled
  152             parent_id = project_ref['parent_id']
  153             parents_list = self.list_project_parents(parent_id)
  154             parent_ref = self.get_project(parent_id)
  155             parents_list.append(parent_ref)
  156             for ref in parents_list:
  157                 if not ref.get('enabled', True):
  158                     raise exception.ValidationError(
  159                         message=_('cannot create a project in a '
  160                                   'branch containing a disabled '
  161                                   'project: %s') % ref['id'])
  162 
  163             self._assert_max_hierarchy_depth(project_ref.get('parent_id'),
  164                                              parents_list)
  165 
  166     def _raise_reserved_character_exception(self, entity_type, name):
  167         msg = _('%(entity)s name cannot contain the following reserved '
  168                 'characters: %(chars)s')
  169         raise exception.ValidationError(
  170             message=msg % {
  171                 'entity': entity_type,
  172                 'chars': utils.list_url_unsafe_chars(name)
  173             })
  174 
  175     def _generate_project_name_conflict_msg(self, project):
  176         if project['is_domain']:
  177             return _('it is not permitted to have two projects '
  178                      'acting as domains with the same name: %s'
  179                      ) % project['name']
  180         else:
  181             return _('it is not permitted to have two projects '
  182                      'with either the same name or same id in '
  183                      'the same domain: '
  184                      'name is %(name)s, project id %(id)s'
  185                      ) % project
  186 
  187     def create_project(self, project_id, project, initiator=None):
  188         project = project.copy()
  189 
  190         if (CONF.resource.project_name_url_safe != 'off' and
  191                 utils.is_not_url_safe(project['name'])):
  192             self._raise_reserved_character_exception('Project',
  193                                                      project['name'])
  194 
  195         project.setdefault('enabled', True)
  196         project['name'] = project['name'].strip()
  197         project.setdefault('description', '')
  198 
  199         # For regular projects, the controller will ensure we have a valid
  200         # domain_id. For projects acting as a domain, the project_id
  201         # is, effectively, the domain_id - and for such projects we don't
  202         # bother to store a copy of it in the domain_id attribute.
  203         project.setdefault('domain_id', None)
  204         project.setdefault('parent_id', None)
  205         if not project['parent_id']:
  206             project['parent_id'] = project['domain_id']
  207         project.setdefault('is_domain', False)
  208 
  209         self._enforce_project_constraints(project)
  210 
  211         # We leave enforcing name uniqueness to the underlying driver (instead
  212         # of doing it in code in the project_constraints above), so as to allow
  213         # this check to be done at the storage level, avoiding race conditions
  214         # in multi-process keystone configurations.
  215         try:
  216             ret = self.driver.create_project(project_id, project)
  217         except exception.Conflict:
  218             raise exception.Conflict(
  219                 type='project',
  220                 details=self._generate_project_name_conflict_msg(project))
  221 
  222         if project.get('is_domain'):
  223             notifications.Audit.created(self._DOMAIN, project_id, initiator)
  224         else:
  225             notifications.Audit.created(self._PROJECT, project_id, initiator)
  226         if MEMOIZE.should_cache(ret):
  227             self.get_project.set(ret, self, project_id)
  228             self.get_project_by_name.set(ret, self, ret['name'],
  229                                          ret['domain_id'])
  230 
  231         assignment.COMPUTED_ASSIGNMENTS_REGION.invalidate()
  232 
  233         return ret
  234 
  235     def assert_domain_enabled(self, domain_id, domain=None):
  236         """Assert the Domain is enabled.
  237 
  238         :raise AssertionError: if domain is disabled.
  239         """
  240         if domain is None:
  241             domain = self.get_domain(domain_id)
  242         if not domain.get('enabled', True):
  243             raise AssertionError(_('Domain is disabled: %s') % domain_id)
  244 
  245     def assert_domain_not_federated(self, domain_id, domain):
  246         """Assert the Domain's name and id do not match the reserved keyword.
  247 
  248         Note that the reserved keyword is defined in the configuration file,
  249         by default, it is 'Federated', it is also case insensitive.
  250         If config's option is empty the default hardcoded value 'Federated'
  251         will be used.
  252 
  253         :raise AssertionError: if domain named match the value in the config.
  254 
  255         """
  256         # NOTE(marek-denis): We cannot create this attribute in the __init__ as
  257         # config values are always initialized to default value.
  258         federated_domain = CONF.federation.federated_domain_name.lower()
  259         if (domain.get('name') and domain['name'].lower() == federated_domain):
  260             raise AssertionError(_('Domain cannot be named %s')
  261                                  % domain['name'])
  262         if (domain_id.lower() == federated_domain):
  263             raise AssertionError(_('Domain cannot have ID %s')
  264                                  % domain_id)
  265 
  266     def assert_project_enabled(self, project_id, project=None):
  267         """Assert the project is enabled and its associated domain is enabled.
  268 
  269         :raise AssertionError: if the project or domain is disabled.
  270         """
  271         if project is None:
  272             project = self.get_project(project_id)
  273         # If it's a regular project (i.e. it has a domain_id), we need to make
  274         # sure the domain itself is not disabled
  275         if project['domain_id']:
  276             self.assert_domain_enabled(domain_id=project['domain_id'])
  277         if not project.get('enabled', True):
  278             raise AssertionError(_('Project is disabled: %s') % project_id)
  279 
  280     def _assert_all_parents_are_enabled(self, project_id):
  281         parents_list = self.list_project_parents(project_id)
  282         for project in parents_list:
  283             if not project.get('enabled', True):
  284                 raise exception.ForbiddenNotSecurity(
  285                     _('Cannot enable project %s since it has disabled '
  286                       'parents') % project_id)
  287 
  288     def _is_immutable(self, project_ref):
  289         return project_ref['options'].get(
  290             ro_opt.IMMUTABLE_OPT.option_name, False)
  291 
  292     def _check_whole_subtree_is_disabled(self, project_id, subtree_list=None):
  293         if not subtree_list:
  294             subtree_list = self.list_projects_in_subtree(project_id)
  295         subtree_enabled = [ref.get('enabled', True) for ref in subtree_list]
  296         return (not any(subtree_enabled))
  297 
  298     def _update_project(self, project_id, project, initiator=None,
  299                         cascade=False):
  300         # Use the driver directly to prevent using old cached value.
  301         original_project = self.driver.get_project(project_id)
  302         project = project.copy()
  303         self._require_matching_domain_id(project, original_project)
  304 
  305         if original_project['is_domain']:
  306             # prevent updates to immutable domains
  307             ro_opt.check_immutable_update(
  308                 original_resource_ref=original_project,
  309                 new_resource_ref=project,
  310                 type='domain',
  311                 resource_id=project_id)
  312             domain = self._get_domain_from_project(original_project)
  313             self.assert_domain_not_federated(project_id, domain)
  314             url_safe_option = CONF.resource.domain_name_url_safe
  315             exception_entity = 'Domain'
  316         else:
  317             # prevent updates to immutable projects
  318             ro_opt.check_immutable_update(
  319                 original_resource_ref=original_project,
  320                 new_resource_ref=project,
  321                 type='project',
  322                 resource_id=project_id)
  323             url_safe_option = CONF.resource.project_name_url_safe
  324             exception_entity = 'Project'
  325 
  326         project_name_changed = ('name' in project and project['name'] !=
  327                                 original_project['name'])
  328         if (url_safe_option != 'off' and project_name_changed and
  329                 utils.is_not_url_safe(project['name'])):
  330             self._raise_reserved_character_exception(exception_entity,
  331                                                      project['name'])
  332         elif project_name_changed:
  333             project['name'] = project['name'].strip()
  334         parent_id = original_project.get('parent_id')
  335         if 'parent_id' in project and project.get('parent_id') != parent_id:
  336             raise exception.ForbiddenNotSecurity(
  337                 _('Update of `parent_id` is not allowed.'))
  338 
  339         if ('is_domain' in project and
  340                 project['is_domain'] != original_project['is_domain']):
  341             raise exception.ValidationError(
  342                 message=_('Update of `is_domain` is not allowed.'))
  343 
  344         original_project_enabled = original_project.get('enabled', True)
  345         project_enabled = project.get('enabled', True)
  346         if not original_project_enabled and project_enabled:
  347             self._assert_all_parents_are_enabled(project_id)
  348         if original_project_enabled and not project_enabled:
  349             # NOTE(htruta): In order to disable a regular project, all its
  350             # children must already be disabled. However, to keep
  351             # compatibility with the existing domain behaviour, we allow a
  352             # project acting as a domain to be disabled irrespective of the
  353             # state of its children. Disabling a project acting as domain
  354             # effectively disables its children.
  355             if (not original_project.get('is_domain') and not cascade and not
  356                     self._check_whole_subtree_is_disabled(project_id)):
  357                 raise exception.ForbiddenNotSecurity(
  358                     _('Cannot disable project %(project_id)s since its '
  359                       'subtree contains enabled projects.')
  360                     % {'project_id': project_id})
  361 
  362             notifications.Audit.disabled(self._PROJECT, project_id,
  363                                          public=False)
  364             # Drop the computed assignments if the project is being disabled.
  365             # This ensures an accurate list of projects is returned when
  366             # listing projects/domains for a user based on role assignments.
  367             assignment.COMPUTED_ASSIGNMENTS_REGION.invalidate()
  368 
  369         if cascade:
  370             self._only_allow_enabled_to_update_cascade(project,
  371                                                        original_project)
  372             self._update_project_enabled_cascade(project_id, project_enabled)
  373 
  374         try:
  375             project['is_domain'] = (project.get('is_domain') or
  376                                     original_project['is_domain'])
  377             ret = self.driver.update_project(project_id, project)
  378         except exception.Conflict:
  379             raise exception.Conflict(
  380                 type='project',
  381                 details=self._generate_project_name_conflict_msg(project))
  382 
  383         try:
  384             self.get_project.invalidate(self, project_id)
  385             self.get_project_by_name.invalidate(self, original_project['name'],
  386                                                 original_project['domain_id'])
  387             if ('domain_id' in project and
  388                project['domain_id'] != original_project['domain_id']):
  389                 # If the project's domain_id has been updated, invalidate user
  390                 # role assignments cache region, as it may be caching inherited
  391                 # assignments from the old domain to the specified project
  392                 assignment.COMPUTED_ASSIGNMENTS_REGION.invalidate()
  393         finally:
  394             # attempt to send audit event even if the cache invalidation raises
  395             notifications.Audit.updated(self._PROJECT, project_id, initiator)
  396             if original_project['is_domain']:
  397                 notifications.Audit.updated(self._DOMAIN, project_id,
  398                                             initiator)
  399                 # If the domain is being disabled, issue the disable
  400                 # notification as well
  401                 if original_project_enabled and not project_enabled:
  402                     # NOTE(lbragstad): When a domain is disabled, we have to
  403                     # invalidate the entire token cache. With persistent
  404                     # tokens, we did something similar where all tokens for a
  405                     # specific domain were deleted when that domain was
  406                     # disabled. This effectively offers the same behavior for
  407                     # non-persistent tokens by removing them from the cache and
  408                     # requiring the authorization context to be rebuilt the
  409                     # next time they're validated.
  410                     token_provider.TOKENS_REGION.invalidate()
  411                     notifications.Audit.disabled(self._DOMAIN, project_id,
  412                                                  public=False)
  413 
  414         return ret
  415 
  416     def _only_allow_enabled_to_update_cascade(self, project, original_project):
  417         for attr in project:
  418             if attr != 'enabled':
  419                 if project.get(attr) != original_project.get(attr):
  420                     raise exception.ValidationError(
  421                         message=_('Cascade update is only allowed for '
  422                                   'enabled attribute.'))
  423 
  424     def _update_project_enabled_cascade(self, project_id, enabled):
  425         subtree = self.list_projects_in_subtree(project_id)
  426         # Update enabled only if different from original value
  427         subtree_to_update = [child for child in subtree
  428                              if child['enabled'] != enabled]
  429         for child in subtree_to_update:
  430             child['enabled'] = enabled
  431 
  432             if not enabled:
  433                 # Does not in fact disable the project, only emits a
  434                 # notification that it was disabled. The actual disablement
  435                 # is done in the next line.
  436                 notifications.Audit.disabled(self._PROJECT, child['id'],
  437                                              public=False)
  438 
  439             self.driver.update_project(child['id'], child)
  440 
  441     def update_project(self, project_id, project, initiator=None,
  442                        cascade=False):
  443         ret = self._update_project(project_id, project, initiator, cascade)
  444         if ret['is_domain']:
  445             self.get_domain.invalidate(self, project_id)
  446             self.get_domain_by_name.invalidate(self, ret['name'])
  447 
  448         return ret
  449 
  450     def _post_delete_cleanup_project(self, project_id, project,
  451                                      initiator=None):
  452         try:
  453             self.get_project.invalidate(self, project_id)
  454             self.get_project_by_name.invalidate(self, project['name'],
  455                                                 project['domain_id'])
  456             PROVIDERS.assignment_api.delete_project_assignments(project_id)
  457             # Invalidate user role assignments cache region, as it may
  458             # be caching role assignments where the target is
  459             # the specified project
  460             assignment.COMPUTED_ASSIGNMENTS_REGION.invalidate()
  461             PROVIDERS.credential_api.delete_credentials_for_project(project_id)
  462             PROVIDERS.trust_api.delete_trusts_for_project(project_id)
  463             PROVIDERS.unified_limit_api.delete_limits_for_project(project_id)
  464         finally:
  465             # attempt to send audit event even if the cache invalidation raises
  466             notifications.Audit.deleted(self._PROJECT, project_id, initiator)
  467 
  468     def delete_project(self, project_id, initiator=None, cascade=False):
  469         """Delete one project or a subtree.
  470 
  471         :param cascade: If true, the specified project and all its
  472                         sub-projects are deleted. Otherwise, only the specified
  473                         project is deleted.
  474         :type cascade: boolean
  475         :raises keystone.exception.ValidationError: if project is a domain
  476         :raises keystone.exception.Forbidden: if project is not a leaf
  477         """
  478         project = self.driver.get_project(project_id)
  479         if project.get('is_domain'):
  480             self._delete_domain(project, initiator)
  481         else:
  482             self._delete_project(project, initiator, cascade)
  483 
  484     def _delete_project(self, project, initiator=None, cascade=False):
  485         # Prevent deletion of immutable projects
  486         ro_opt.check_immutable_delete(
  487             resource_ref=project,
  488             resource_type='project',
  489             resource_id=project['id'])
  490         project_id = project['id']
  491         if project['is_domain'] and project['enabled']:
  492             raise exception.ValidationError(
  493                 message=_('cannot delete an enabled project acting as a '
  494                           'domain. Please disable the project %s first.')
  495                 % project.get('id'))
  496 
  497         if not self.is_leaf_project(project_id) and not cascade:
  498             raise exception.ForbiddenNotSecurity(
  499                 _('Cannot delete the project %s since it is not a leaf in the '
  500                   'hierarchy. Use the cascade option if you want to delete a '
  501                   'whole subtree.')
  502                 % project_id)
  503 
  504         if cascade:
  505             # Getting reversed project's subtrees list, i.e. from the leaves
  506             # to the root, so we do not break parent_id FK.
  507             subtree_list = self.list_projects_in_subtree(project_id)
  508             subtree_list.reverse()
  509             if not self._check_whole_subtree_is_disabled(
  510                     project_id, subtree_list=subtree_list):
  511                 raise exception.ForbiddenNotSecurity(
  512                     _('Cannot delete project %(project_id)s since its subtree '
  513                       'contains enabled projects.')
  514                     % {'project_id': project_id})
  515 
  516             project_list = subtree_list + [project]
  517             projects_ids = [x['id'] for x in project_list]
  518 
  519             ret = self.driver.delete_projects_from_ids(projects_ids)
  520             for prj in project_list:
  521                 self._post_delete_cleanup_project(prj['id'], prj, initiator)
  522         else:
  523             ret = self.driver.delete_project(project_id)
  524             self._post_delete_cleanup_project(project_id, project, initiator)
  525 
  526         reason = (
  527             'The token cache is being invalidate because project '
  528             '%(project_id)s was deleted. Authorization will be recalculated '
  529             'and enforced accordingly the next time users authenticate or '
  530             'validate a token.' % {'project_id': project_id}
  531         )
  532         notifications.invalidate_token_cache_notification(reason)
  533         return ret
  534 
  535     def _filter_projects_list(self, projects_list, user_id):
  536         user_projects = PROVIDERS.assignment_api.list_projects_for_user(
  537             user_id
  538         )
  539         user_projects_ids = set([proj['id'] for proj in user_projects])
  540         # Keep only the projects present in user_projects
  541         return [proj for proj in projects_list
  542                 if proj['id'] in user_projects_ids]
  543 
  544     def _assert_valid_project_id(self, project_id):
  545         if project_id is None:
  546             msg = _('Project field is required and cannot be empty.')
  547             raise exception.ValidationError(message=msg)
  548         # Check if project_id exists
  549         self.get_project(project_id)
  550 
  551     def _include_limits(self, projects):
  552         """Modify a list of projects to include limit information.
  553 
  554         :param projects: a list of project references including an `id`
  555         :type projects: list of dictionaries
  556         """
  557         for project in projects:
  558             hints = driver_hints.Hints()
  559             hints.add_filter('project_id', project['id'])
  560             limits = PROVIDERS.unified_limit_api.list_limits(hints)
  561             project['limits'] = limits
  562 
  563     def list_project_parents(self, project_id, user_id=None,
  564                              include_limits=False):
  565         self._assert_valid_project_id(project_id)
  566         parents = self.driver.list_project_parents(project_id)
  567         # If a user_id was provided, the returned list should be filtered
  568         # against the projects this user has access to.
  569         if user_id:
  570             parents = self._filter_projects_list(parents, user_id)
  571         if include_limits:
  572             self._include_limits(parents)
  573         return parents
  574 
  575     def _build_parents_as_ids_dict(self, project, parents_by_id):
  576         # NOTE(rodrigods): we don't rely in the order of the projects returned
  577         # by the list_project_parents() method. Thus, we create a project cache
  578         # (parents_by_id) in order to access each parent in constant time and
  579         # traverse up the hierarchy.
  580         def traverse_parents_hierarchy(project):
  581             parent_id = project.get('parent_id')
  582             if not parent_id:
  583                 return None
  584 
  585             parent = parents_by_id[parent_id]
  586             return {parent_id: traverse_parents_hierarchy(parent)}
  587 
  588         return traverse_parents_hierarchy(project)
  589 
  590     def get_project_parents_as_ids(self, project):
  591         """Get the IDs from the parents from a given project.
  592 
  593         The project IDs are returned as a structured dictionary traversing up
  594         the hierarchy to the top level project. For example, considering the
  595         following project hierarchy::
  596 
  597                                     A
  598                                     |
  599                                   +-B-+
  600                                   |   |
  601                                   C   D
  602 
  603         If we query for project C parents, the expected return is the following
  604         dictionary::
  605 
  606             'parents': {
  607                 B['id']: {
  608                     A['id']: None
  609                 }
  610             }
  611 
  612         """
  613         parents_list = self.list_project_parents(project['id'])
  614         parents_as_ids = self._build_parents_as_ids_dict(
  615             project, {proj['id']: proj for proj in parents_list})
  616         return parents_as_ids
  617 
  618     def list_projects_in_subtree(self, project_id, user_id=None,
  619                                  include_limits=False):
  620         self._assert_valid_project_id(project_id)
  621         subtree = self.driver.list_projects_in_subtree(project_id)
  622         # If a user_id was provided, the returned list should be filtered
  623         # against the projects this user has access to.
  624         if user_id:
  625             subtree = self._filter_projects_list(subtree, user_id)
  626         if include_limits:
  627             self._include_limits(subtree)
  628         return subtree
  629 
  630     def _build_subtree_as_ids_dict(self, project_id, subtree_by_parent):
  631         # NOTE(rodrigods): we perform a depth first search to construct the
  632         # dictionaries representing each level of the subtree hierarchy. In
  633         # order to improve this traversal performance, we create a cache of
  634         # projects (subtree_py_parent) that accesses in constant time the
  635         # direct children of a given project.
  636         def traverse_subtree_hierarchy(project_id):
  637             children = subtree_by_parent.get(project_id)
  638             if not children:
  639                 return None
  640 
  641             children_ids = {}
  642             for child in children:
  643                 children_ids[child['id']] = traverse_subtree_hierarchy(
  644                     child['id'])
  645             return children_ids
  646 
  647         return traverse_subtree_hierarchy(project_id)
  648 
  649     def get_projects_in_subtree_as_ids(self, project_id):
  650         """Get the IDs from the projects in the subtree from a given project.
  651 
  652         The project IDs are returned as a structured dictionary representing
  653         their hierarchy. For example, considering the following project
  654         hierarchy::
  655 
  656                                     A
  657                                     |
  658                                   +-B-+
  659                                   |   |
  660                                   C   D
  661 
  662         If we query for project A subtree, the expected return is the following
  663         dictionary::
  664 
  665             'subtree': {
  666                 B['id']: {
  667                     C['id']: None,
  668                     D['id']: None
  669                 }
  670             }
  671 
  672         """
  673         def _projects_indexed_by_parent(projects_list):
  674             projects_by_parent = {}
  675             for proj in projects_list:
  676                 parent_id = proj.get('parent_id')
  677                 if parent_id:
  678                     if parent_id in projects_by_parent:
  679                         projects_by_parent[parent_id].append(proj)
  680                     else:
  681                         projects_by_parent[parent_id] = [proj]
  682             return projects_by_parent
  683 
  684         subtree_list = self.list_projects_in_subtree(project_id)
  685         subtree_as_ids = self._build_subtree_as_ids_dict(
  686             project_id, _projects_indexed_by_parent(subtree_list))
  687         return subtree_as_ids
  688 
  689     def list_domains_from_ids(self, domain_ids):
  690         """List domains for the provided list of ids.
  691 
  692         :param domain_ids: list of ids
  693 
  694         :returns: a list of domain_refs.
  695 
  696         This method is used internally by the assignment manager to bulk read
  697         a set of domains given their ids.
  698 
  699         """
  700         # Retrieve the projects acting as domains get their correspondent
  701         # domains
  702         projects = self.list_projects_from_ids(domain_ids)
  703         domains = [self._get_domain_from_project(project)
  704                    for project in projects]
  705 
  706         return domains
  707 
  708     @MEMOIZE
  709     def get_domain(self, domain_id):
  710         try:
  711             # Retrieve the corresponding project that acts as a domain
  712             project = self.driver.get_project(domain_id)
  713             # the DB backend might not operate in case sensitive mode,
  714             # therefore verify for exact match of IDs
  715             if domain_id != project['id']:
  716                 raise exception.DomainNotFound(domain_id=domain_id)
  717         except exception.ProjectNotFound:
  718             raise exception.DomainNotFound(domain_id=domain_id)
  719 
  720         # Return its correspondent domain
  721         return self._get_domain_from_project(project)
  722 
  723     @MEMOIZE
  724     def get_domain_by_name(self, domain_name):
  725         try:
  726             # Retrieve the corresponding project that acts as a domain
  727             project = self.driver.get_project_by_name(domain_name,
  728                                                       domain_id=None)
  729         except exception.ProjectNotFound:
  730             raise exception.DomainNotFound(domain_id=domain_name)
  731 
  732         # Return its correspondent domain
  733         return self._get_domain_from_project(project)
  734 
  735     def _get_domain_from_project(self, project_ref):
  736         """Create a domain ref from a project ref.
  737 
  738         Based on the provided project ref, create a domain ref, so that the
  739         result can be returned in response to a domain API call.
  740         """
  741         if not project_ref['is_domain']:
  742             LOG.error('Asked to convert a non-domain project into a '
  743                       'domain - Domain: %(domain_id)s, Project ID: '
  744                       '%(id)s, Project Name: %(project_name)s',
  745                       {'domain_id': project_ref['domain_id'],
  746                        'id': project_ref['id'],
  747                        'project_name': project_ref['name']})
  748             raise exception.DomainNotFound(domain_id=project_ref['id'])
  749 
  750         domain_ref = project_ref.copy()
  751         # As well as the project specific attributes that we need to remove,
  752         # there is an old compatibility issue in that update project (as well
  753         # as extracting an extra attributes), also includes a copy of the
  754         # actual extra dict as well - something that update domain does not do.
  755         for k in ['parent_id', 'domain_id', 'is_domain', 'extra']:
  756             domain_ref.pop(k, None)
  757 
  758         return domain_ref
  759 
  760     def create_domain(self, domain_id, domain, initiator=None):
  761         if (CONF.resource.domain_name_url_safe != 'off' and
  762                 utils.is_not_url_safe(domain['name'])):
  763             self._raise_reserved_character_exception('Domain', domain['name'])
  764         project_from_domain = base.get_project_from_domain(domain)
  765         is_domain_project = self.create_project(
  766             domain_id, project_from_domain, initiator)
  767 
  768         return self._get_domain_from_project(is_domain_project)
  769 
  770     @manager.response_truncated
  771     def list_domains(self, hints=None):
  772         projects = self.list_projects_acting_as_domain(hints)
  773         domains = [self._get_domain_from_project(project)
  774                    for project in projects]
  775         return domains
  776 
  777     def update_domain(self, domain_id, domain, initiator=None):
  778         # TODO(henry-nash): We shouldn't have to check for the federated domain
  779         # here as well as _update_project, but currently our tests assume the
  780         # checks are done in a specific order. The tests should be refactored.
  781         self.assert_domain_not_federated(domain_id, domain)
  782         project = base.get_project_from_domain(domain)
  783         try:
  784             original_domain = self.driver.get_project(domain_id)
  785             project = self._update_project(domain_id, project, initiator)
  786         except exception.ProjectNotFound:
  787             raise exception.DomainNotFound(domain_id=domain_id)
  788 
  789         domain_from_project = self._get_domain_from_project(project)
  790         self.get_domain.invalidate(self, domain_id)
  791         self.get_domain_by_name.invalidate(self, original_domain['name'])
  792 
  793         return domain_from_project
  794 
  795     def delete_domain(self, domain_id, initiator=None):
  796         # Use the driver directly to get the project that acts as a domain and
  797         # prevent using old cached value.
  798         try:
  799             domain = self.driver.get_project(domain_id)
  800         except exception.ProjectNotFound:
  801             raise exception.DomainNotFound(domain_id=domain_id)
  802         self._delete_domain(domain, initiator)
  803 
  804     def _delete_domain(self, domain, initiator=None):
  805         # Disallow deletion of immutable domains
  806         ro_opt.check_immutable_delete(
  807             resource_ref=domain,
  808             resource_type='domain',
  809             resource_id=domain['id'])
  810         # To help avoid inadvertent deletes, we insist that the domain
  811         # has been previously disabled.  This also prevents a user deleting
  812         # their own domain since, once it is disabled, they won't be able
  813         # to get a valid token to issue this delete.
  814         if domain['enabled']:
  815             raise exception.ForbiddenNotSecurity(
  816                 _('Cannot delete a domain that is enabled, please disable it '
  817                   'first.'))
  818 
  819         domain_id = domain['id']
  820         self._delete_domain_contents(domain_id)
  821         notifications.Audit.internal(
  822             notifications.DOMAIN_DELETED, domain_id
  823         )
  824         self._delete_project(domain, initiator)
  825         try:
  826             self.get_domain.invalidate(self, domain_id)
  827             self.get_domain_by_name.invalidate(self, domain['name'])
  828             # Delete any database stored domain config
  829             PROVIDERS.domain_config_api.delete_config_options(domain_id)
  830             PROVIDERS.domain_config_api.release_registration(domain_id)
  831         finally:
  832             # attempt to send audit event even if the cache invalidation raises
  833             notifications.Audit.deleted(self._DOMAIN, domain_id, initiator)
  834 
  835     def _delete_domain_contents(self, domain_id):
  836         """Delete the contents of a domain.
  837 
  838         Before we delete a domain, we need to remove all the entities
  839         that are owned by it, i.e. Projects. To do this we
  840         call the delete function for these entities, which are
  841         themselves responsible for deleting any credentials and role grants
  842         associated with them as well as revoking any relevant tokens.
  843 
  844         """
  845         def _delete_projects(project, projects, examined):
  846             if project['id'] in examined:
  847                 msg = ('Circular reference or a repeated entry found '
  848                        'projects hierarchy - %(project_id)s.')
  849                 LOG.error(msg, {'project_id': project['id']})
  850                 return
  851 
  852             examined.add(project['id'])
  853             children = [proj for proj in projects
  854                         if proj.get('parent_id') == project['id']]
  855             for proj in children:
  856                 _delete_projects(proj, projects, examined)
  857 
  858             try:
  859                 self._delete_project(project, initiator=None)
  860             except exception.ProjectNotFound:
  861                 LOG.debug(('Project %(projectid)s not found when '
  862                            'deleting domain contents for %(domainid)s, '
  863                            'continuing with cleanup.'),
  864                           {'projectid': project['id'],
  865                            'domainid': domain_id})
  866 
  867         proj_refs = self.list_projects_in_domain(domain_id)
  868 
  869         # Deleting projects recursively
  870         roots = [x for x in proj_refs if x.get('parent_id') == domain_id]
  871         examined = set()
  872         for project in roots:
  873             _delete_projects(project, proj_refs, examined)
  874 
  875     @manager.response_truncated
  876     def list_projects(self, hints=None):
  877         if hints:
  878             tag_filters = {}
  879             # Handle project tag filters separately
  880             for f in list(hints.filters):
  881                 if f['name'] in TAG_SEARCH_FILTERS:
  882                     tag_filters[f['name']] = f['value']
  883                     hints.filters.remove(f)
  884             if tag_filters:
  885                 tag_refs = self.driver.list_projects_by_tags(tag_filters)
  886                 project_refs = self.driver.list_projects(hints)
  887                 ref_ids = [ref['id'] for ref in tag_refs]
  888                 return [ref for ref in project_refs if ref['id'] in ref_ids]
  889         return self.driver.list_projects(hints or driver_hints.Hints())
  890 
  891     # NOTE(henry-nash): list_projects_in_domain is actually an internal method
  892     # and not exposed via the API.  Therefore there is no need to support
  893     # driver hints for it.
  894     def list_projects_in_domain(self, domain_id):
  895         return self.driver.list_projects_in_domain(domain_id)
  896 
  897     def list_projects_acting_as_domain(self, hints=None):
  898         return self.driver.list_projects_acting_as_domain(
  899             hints or driver_hints.Hints())
  900 
  901     @MEMOIZE
  902     def get_project(self, project_id):
  903         return self.driver.get_project(project_id)
  904 
  905     @MEMOIZE
  906     def get_project_by_name(self, project_name, domain_id):
  907         return self.driver.get_project_by_name(project_name, domain_id)
  908 
  909     def _require_matching_domain_id(self, new_ref, orig_ref):
  910         """Ensure the current domain ID matches the reference one, if any.
  911 
  912         Provided we want domain IDs to be immutable, check whether any
  913         domain_id specified in the ref dictionary matches the existing
  914         domain_id for this entity.
  915 
  916         :param new_ref: the dictionary of new values proposed for this entity
  917         :param orig_ref: the dictionary of original values proposed for this
  918                          entity
  919         :raises: :class:`keystone.exception.ValidationError`
  920         """
  921         if 'domain_id' in new_ref:
  922             if new_ref['domain_id'] != orig_ref['domain_id']:
  923                 raise exception.ValidationError(_('Cannot change Domain ID'))
  924 
  925     def create_project_tag(self, project_id, tag, initiator=None):
  926         """Create a new tag on project.
  927 
  928         :param project_id: ID of a project to create a tag for
  929         :param tag: The string value of a tag to add
  930 
  931         :returns: The value of the created tag
  932         """
  933         project = self.driver.get_project(project_id)
  934         if ro_opt.check_resource_immutable(resource_ref=project):
  935             raise exception.ResourceUpdateForbidden(
  936                 message=_(
  937                     'Cannot create project tags for %(project_id)s, project '
  938                     'is immutable. Set "immutable" option to false before '
  939                     'creating project tags.') % {'project_id': project_id})
  940         tag_name = tag.strip()
  941         project['tags'].append(tag_name)
  942         self.update_project(project_id, {'tags': project['tags']})
  943 
  944         notifications.Audit.created(
  945             self._PROJECT_TAG, tag_name, initiator)
  946         return tag_name
  947 
  948     def get_project_tag(self, project_id, tag_name):
  949         """Return information for a single tag on a project.
  950 
  951         :param project_id: ID of a project to retrive a tag from
  952         :param tag_name: Name of a tag to return
  953 
  954         :raises keystone.exception.ProjectTagNotFound: If the tag name
  955             does not exist on the project
  956         :returns: The tag value
  957         """
  958         project = self.driver.get_project(project_id)
  959         if tag_name not in project.get('tags'):
  960             raise exception.ProjectTagNotFound(project_tag=tag_name)
  961         return tag_name
  962 
  963     def list_project_tags(self, project_id):
  964         """List all tags on project.
  965 
  966         :param project_id: The ID of a project
  967 
  968         :returns: A list of tags from a project
  969         """
  970         project = self.driver.get_project(project_id)
  971         return project.get('tags', [])
  972 
  973     def update_project_tags(self, project_id, tags, initiator=None):
  974         """Update all tags on a project.
  975 
  976         :param project_id: The ID of the project to update
  977         :param tags: A list of tags to update on the project
  978 
  979         :returns: A list of tags
  980         """
  981         project = self.driver.get_project(project_id)
  982         if ro_opt.check_resource_immutable(resource_ref=project):
  983             raise exception.ResourceUpdateForbidden(
  984                 message=_(
  985                     'Cannot update project tags for %(project_id)s, project '
  986                     'is immutable. Set "immutable" option to false before '
  987                     'creating project tags.') % {'project_id': project_id})
  988         tag_list = [t.strip() for t in tags]
  989         project = {'tags': tag_list}
  990         self.update_project(project_id, project)
  991         return tag_list
  992 
  993     def delete_project_tag(self, project_id, tag):
  994         """Delete single tag from project.
  995 
  996         :param project_id: The ID of the project
  997         :param tag: The tag value to delete
  998 
  999         :raises keystone.exception.ProjectTagNotFound: If the tag name
 1000             does not exist on the project
 1001         """
 1002         project = self.driver.get_project(project_id)
 1003         if ro_opt.check_resource_immutable(resource_ref=project):
 1004             raise exception.ResourceUpdateForbidden(
 1005                 message=_(
 1006                     'Cannot delete project tags for %(project_id)s, project '
 1007                     'is immutable. Set "immutable" option to false before '
 1008                     'creating project tags.') % {'project_id': project_id})
 1009         try:
 1010             project['tags'].remove(tag)
 1011         except ValueError:
 1012             raise exception.ProjectTagNotFound(project_tag=tag)
 1013         self.update_project(project_id, project)
 1014         notifications.Audit.deleted(self._PROJECT_TAG, tag)
 1015 
 1016     def check_project_depth(self, max_depth=None):
 1017         """Check project depth whether greater than input or not."""
 1018         if max_depth:
 1019             exceeded_project_ids = self.driver.check_project_depth(max_depth)
 1020             if exceeded_project_ids:
 1021                 raise exception.LimitTreeExceedError(exceeded_project_ids,
 1022                                                      max_depth)
 1023 
 1024 
 1025 MEMOIZE_CONFIG = cache.get_memoization_decorator(group='domain_config')
 1026 
 1027 
 1028 class DomainConfigManager(manager.Manager):
 1029     """Default pivot point for the Domain Config backend."""
 1030 
 1031     # NOTE(henry-nash): In order for a config option to be stored in the
 1032     # standard table, it must be explicitly whitelisted. Options marked as
 1033     # sensitive are stored in a separate table. Attempting to store options
 1034     # that are not listed as either whitelisted or sensitive will raise an
 1035     # exception.
 1036     #
 1037     # Only those options that affect the domain-specific driver support in
 1038     # the identity manager are supported.
 1039 
 1040     driver_namespace = 'keystone.resource.domain_config'
 1041     _provides_api = 'domain_config_api'
 1042 
 1043     # We explicitly state each whitelisted option instead of pulling all ldap
 1044     # options from CONF and selectively pruning them to prevent a security
 1045     # lapse. That way if a new ldap CONF key/value were to be added it wouldn't
 1046     # automatically be added to the whitelisted options unless that is what was
 1047     # intended. In which case, we explicitly add it to the list ourselves.
 1048     whitelisted_options = {
 1049         'identity': ['driver', 'list_limit'],
 1050         'ldap': [
 1051             'url', 'user', 'suffix', 'query_scope', 'page_size',
 1052             'alias_dereferencing', 'debug_level', 'chase_referrals',
 1053             'user_tree_dn', 'user_filter', 'user_objectclass',
 1054             'user_id_attribute', 'user_name_attribute', 'user_mail_attribute',
 1055             'user_description_attribute', 'user_pass_attribute',
 1056             'user_enabled_attribute', 'user_enabled_invert',
 1057             'user_enabled_mask', 'user_enabled_default',
 1058             'user_attribute_ignore', 'user_default_project_id_attribute',
 1059             'user_enabled_emulation', 'user_enabled_emulation_dn',
 1060             'user_enabled_emulation_use_group_config',
 1061             'user_additional_attribute_mapping', 'group_tree_dn',
 1062             'group_filter', 'group_objectclass', 'group_id_attribute',
 1063             'group_name_attribute', 'group_members_are_ids',
 1064             'group_member_attribute', 'group_desc_attribute',
 1065             'group_attribute_ignore', 'group_additional_attribute_mapping',
 1066             'tls_cacertfile', 'tls_cacertdir', 'use_tls', 'tls_req_cert',
 1067             'use_pool', 'pool_size', 'pool_retry_max', 'pool_retry_delay',
 1068             'pool_connection_timeout', 'pool_connection_lifetime',
 1069             'use_auth_pool', 'auth_pool_size', 'auth_pool_connection_lifetime'
 1070         ]
 1071     }
 1072     sensitive_options = {
 1073         'identity': [],
 1074         'ldap': ['password']
 1075     }
 1076 
 1077     def __init__(self):
 1078         super(DomainConfigManager, self).__init__(CONF.domain_config.driver)
 1079 
 1080     def _assert_valid_config(self, config):
 1081         """Ensure the options in the config are valid.
 1082 
 1083         This method is called to validate the request config in create and
 1084         update manager calls.
 1085 
 1086         :param config: config structure being created or updated
 1087 
 1088         """
 1089         # Something must be defined in the request
 1090         if not config:
 1091             raise exception.InvalidDomainConfig(
 1092                 reason=_('No options specified'))
 1093 
 1094         # Make sure the groups/options defined in config itself are valid
 1095         for group in config:
 1096             if (not config[group] or not
 1097                     isinstance(config[group], dict)):
 1098                 msg = _('The value of group %(group)s specified in the '
 1099                         'config should be a dictionary of options') % {
 1100                             'group': group}
 1101                 raise exception.InvalidDomainConfig(reason=msg)
 1102             for option in config[group]:
 1103                 self._assert_valid_group_and_option(group, option)
 1104 
 1105     def _assert_valid_group_and_option(self, group, option):
 1106         """Ensure the combination of group and option is valid.
 1107 
 1108         :param group: optional group name, if specified it must be one
 1109                       we support
 1110         :param option: optional option name, if specified it must be one
 1111                        we support and a group must also be specified
 1112 
 1113         """
 1114         if not group and not option:
 1115             # For all calls, it's OK for neither to be defined, it means you
 1116             # are operating on all config options for that domain.
 1117             return
 1118 
 1119         if not group and option:
 1120             # Our API structure should prevent this from ever happening, so if
 1121             # it does, then this is coding error.
 1122             msg = _('Option %(option)s found with no group specified while '
 1123                     'checking domain configuration request') % {
 1124                         'option': option}
 1125             raise exception.UnexpectedError(exception=msg)
 1126 
 1127         if (group and group not in self.whitelisted_options and
 1128                 group not in self.sensitive_options):
 1129             msg = _('Group %(group)s is not supported '
 1130                     'for domain specific configurations') % {'group': group}
 1131             raise exception.InvalidDomainConfig(reason=msg)
 1132 
 1133         if option:
 1134             if (option not in self.whitelisted_options[group] and option not in
 1135                     self.sensitive_options[group]):
 1136                 msg = _('Option %(option)s in group %(group)s is not '
 1137                         'supported for domain specific configurations') % {
 1138                             'group': group, 'option': option}
 1139                 raise exception.InvalidDomainConfig(reason=msg)
 1140 
 1141     def _is_sensitive(self, group, option):
 1142         return option in self.sensitive_options[group]
 1143 
 1144     def _config_to_list(self, config):
 1145         """Build list of options for use by backend drivers."""
 1146         option_list = []
 1147         for group in config:
 1148             for option in config[group]:
 1149                 option_list.append({
 1150                     'group': group, 'option': option,
 1151                     'value': config[group][option],
 1152                     'sensitive': self._is_sensitive(group, option)})
 1153 
 1154         return option_list
 1155 
 1156     def _option_dict(self, group, option):
 1157         group_attr = getattr(CONF, group)
 1158         return {'group': group, 'option': option,
 1159                 'value': getattr(group_attr, option)}
 1160 
 1161     def _list_to_config(self, whitelisted, sensitive=None, req_option=None):
 1162         """Build config dict from a list of option dicts.
 1163 
 1164         :param whitelisted: list of dicts containing options and their groups,
 1165                             this has already been filtered to only contain
 1166                             those options to include in the output.
 1167         :param sensitive: list of dicts containing sensitive options and their
 1168                           groups, this has already been filtered to only
 1169                           contain those options to include in the output.
 1170         :param req_option: the individual option requested
 1171 
 1172         :returns: a config dict, including sensitive if specified
 1173 
 1174         """
 1175         the_list = whitelisted + (sensitive or [])
 1176         if not the_list:
 1177             return {}
 1178 
 1179         if req_option:
 1180             # The request was specific to an individual option, so
 1181             # no need to include the group in the output. We first check that
 1182             # there is only one option in the answer (and that it's the right
 1183             # one) - if not, something has gone wrong and we raise an error
 1184             if len(the_list) > 1 or the_list[0]['option'] != req_option:
 1185                 LOG.error('Unexpected results in response for domain '
 1186                           'config - %(count)s responses, first option is '
 1187                           '%(option)s, expected option %(expected)s',
 1188                           {'count': len(the_list), 'option': list[0]['option'],
 1189                            'expected': req_option})
 1190                 raise exception.UnexpectedError(
 1191                     _('An unexpected error occurred when retrieving domain '
 1192                       'configs'))
 1193             return {the_list[0]['option']: the_list[0]['value']}
 1194 
 1195         config = {}
 1196         for option in the_list:
 1197             config.setdefault(option['group'], {})
 1198             config[option['group']][option['option']] = option['value']
 1199 
 1200         return config
 1201 
 1202     def create_config(self, domain_id, config):
 1203         """Create config for a domain.
 1204 
 1205         :param domain_id: the domain in question
 1206         :param config: the dict of config groups/options to assign to the
 1207                        domain
 1208 
 1209         Creates a new config, overwriting any previous config (no Conflict
 1210         error will be generated).
 1211 
 1212         :returns: a dict of group dicts containing the options, with any that
 1213                   are sensitive removed
 1214         :raises keystone.exception.InvalidDomainConfig: when the config
 1215                 contains options we do not support
 1216 
 1217         """
 1218         self._assert_valid_config(config)
 1219         option_list = self._config_to_list(config)
 1220         self.create_config_options(domain_id, option_list)
 1221         # Since we are caching on the full substituted config, we just
 1222         # invalidate here, rather than try and create the right result to
 1223         # cache.
 1224         self.get_config_with_sensitive_info.invalidate(self, domain_id)
 1225         return self._list_to_config(self.list_config_options(domain_id))
 1226 
 1227     def get_config(self, domain_id, group=None, option=None):
 1228         """Get config, or partial config, for a domain.
 1229 
 1230         :param domain_id: the domain in question
 1231         :param group: an optional specific group of options
 1232         :param option: an optional specific option within the group
 1233 
 1234         :returns: a dict of group dicts containing the whitelisted options,
 1235                   filtered by group and option specified
 1236         :raises keystone.exception.DomainConfigNotFound: when no config found
 1237                 that matches domain_id, group and option specified
 1238         :raises keystone.exception.InvalidDomainConfig: when the config
 1239                 and group/option parameters specify an option we do not
 1240                 support
 1241 
 1242         An example response::
 1243 
 1244             {
 1245                 'ldap': {
 1246                     'url': 'myurl'
 1247                     'user_tree_dn': 'OU=myou'},
 1248                 'identity': {
 1249                     'driver': 'ldap'}
 1250 
 1251             }
 1252 
 1253         """
 1254         self._assert_valid_group_and_option(group, option)
 1255         whitelisted = self.list_config_options(domain_id, group, option)
 1256         if whitelisted:
 1257             return self._list_to_config(whitelisted, req_option=option)
 1258 
 1259         if option:
 1260             msg = _('option %(option)s in group %(group)s') % {
 1261                 'group': group, 'option': option}
 1262         elif group:
 1263             msg = _('group %(group)s') % {'group': group}
 1264         else:
 1265             msg = _('any options')
 1266         raise exception.DomainConfigNotFound(
 1267             domain_id=domain_id, group_or_option=msg)
 1268 
 1269     def get_security_compliance_config(self, domain_id, group, option=None):
 1270         r"""Get full or partial security compliance config from configuration.
 1271 
 1272         :param domain_id: the domain in question
 1273         :param group: a specific group of options
 1274         :param option: an optional specific option within the group
 1275 
 1276         :returns: a dict of group dicts containing the whitelisted options,
 1277                   filtered by group and option specified
 1278         :raises keystone.exception.InvalidDomainConfig: when the config
 1279                 and group/option parameters specify an option we do not
 1280                 support
 1281 
 1282         An example response::
 1283 
 1284             {
 1285                 'security_compliance': {
 1286                     'password_regex': '^(?=.*\d)(?=.*[a-zA-Z]).{7,}$'
 1287                     'password_regex_description':
 1288                         'A password must consist of at least 1 letter, '
 1289                         '1 digit, and have a minimum length of 7 characters'
 1290                     }
 1291             }
 1292 
 1293         """
 1294         if domain_id != CONF.identity.default_domain_id:
 1295             msg = _('Reading security compliance information for any domain '
 1296                     'other than the default domain is not allowed or '
 1297                     'supported.')
 1298             raise exception.InvalidDomainConfig(reason=msg)
 1299 
 1300         config_list = []
 1301         readable_options = ['password_regex', 'password_regex_description']
 1302         if option and option not in readable_options:
 1303             msg = _('Reading security compliance values other than '
 1304                     'password_regex and password_regex_description is not '
 1305                     'allowed.')
 1306             raise exception.InvalidDomainConfig(reason=msg)
 1307         elif option and option in readable_options:
 1308             config_list.append(self._option_dict(group, option))
 1309         elif not option:
 1310             for op in readable_options:
 1311                 config_list.append(self._option_dict(group, op))
 1312         # We already validated that the group is the security_compliance group
 1313         # so we can move along and start validating the options
 1314         return self._list_to_config(config_list, req_option=option)
 1315 
 1316     def update_config(self, domain_id, config, group=None, option=None):
 1317         """Update config, or partial config, for a domain.
 1318 
 1319         :param domain_id: the domain in question
 1320         :param config: the config dict containing and groups/options being
 1321                        updated
 1322         :param group: an optional specific group of options, which if specified
 1323                       must appear in config, with no other groups
 1324         :param option: an optional specific option within the group, which if
 1325                        specified must appear in config, with no other options
 1326 
 1327         The contents of the supplied config will be merged with the existing
 1328         config for this domain, updating or creating new options if these did
 1329         not previously exist. If group or option is specified, then the update
 1330         will be limited to those specified items and the inclusion of other
 1331         options in the supplied config will raise an exception, as will the
 1332         situation when those options do not already exist in the current
 1333         config.
 1334 
 1335         :returns: a dict of groups containing all whitelisted options
 1336         :raises keystone.exception.InvalidDomainConfig: when the config
 1337                 and group/option parameters specify an option we do not
 1338                 support or one that does not exist in the original config
 1339 
 1340         """
 1341         def _assert_valid_update(domain_id, config, group=None, option=None):
 1342             """Ensure the combination of config, group and option is valid."""
 1343             self._assert_valid_config(config)
 1344             self._assert_valid_group_and_option(group, option)
 1345 
 1346             # If a group has been specified, then the request is to
 1347             # explicitly only update the options in that group - so the config
 1348             # must not contain anything else. Further, that group must exist in
 1349             # the original config. Likewise, if an option has been specified,
 1350             # then the group in the config must only contain that option and it
 1351             # also must exist in the original config.
 1352             if group:
 1353                 if len(config) != 1 or (option and len(config[group]) != 1):
 1354                     if option:
 1355                         msg = _('Trying to update option %(option)s in group '
 1356                                 '%(group)s, so that, and only that, option '
 1357                                 'must be specified  in the config') % {
 1358                                     'group': group, 'option': option}
 1359                     else:
 1360                         msg = _('Trying to update group %(group)s, so that, '
 1361                                 'and only that, group must be specified in '
 1362                                 'the config') % {'group': group}
 1363                     raise exception.InvalidDomainConfig(reason=msg)
 1364 
 1365                 # So we now know we have the right number of entries in the
 1366                 # config that align with a group/option being specified, but we
 1367                 # must also make sure they match.
 1368                 if group not in config:
 1369                     msg = _('request to update group %(group)s, but config '
 1370                             'provided contains group %(group_other)s '
 1371                             'instead') % {
 1372                                 'group': group,
 1373                                 'group_other': list(config.keys())[0]}
 1374                     raise exception.InvalidDomainConfig(reason=msg)
 1375                 if option and option not in config[group]:
 1376                     msg = _('Trying to update option %(option)s in group '
 1377                             '%(group)s, but config provided contains option '
 1378                             '%(option_other)s instead') % {
 1379                                 'group': group, 'option': option,
 1380                                 'option_other': list(config[group].keys())[0]}
 1381                     raise exception.InvalidDomainConfig(reason=msg)
 1382 
 1383                 # Finally, we need to check if the group/option specified
 1384                 # already exists in the original config - since if not, to keep
 1385                 # with the semantics of an update, we need to fail with
 1386                 # a DomainConfigNotFound
 1387                 if not self._get_config_with_sensitive_info(domain_id,
 1388                                                             group, option):
 1389                     if option:
 1390                         msg = _('option %(option)s in group %(group)s') % {
 1391                             'group': group, 'option': option}
 1392                         raise exception.DomainConfigNotFound(
 1393                             domain_id=domain_id, group_or_option=msg)
 1394                     else:
 1395                         msg = _('group %(group)s') % {'group': group}
 1396                         raise exception.DomainConfigNotFound(
 1397                             domain_id=domain_id, group_or_option=msg)
 1398 
 1399         update_config = config
 1400         if group and option:
 1401             # The config will just be a dict containing the option and
 1402             # its value, so make it look like a single option under the
 1403             # group in question
 1404             update_config = {group: config}
 1405 
 1406         _assert_valid_update(domain_id, update_config, group, option)
 1407 
 1408         option_list = self._config_to_list(update_config)
 1409         self.update_config_options(domain_id, option_list)
 1410 
 1411         self.get_config_with_sensitive_info.invalidate(self, domain_id)
 1412         return self.get_config(domain_id)
 1413 
 1414     def delete_config(self, domain_id, group=None, option=None):
 1415         """Delete config, or partial config, for the domain.
 1416 
 1417         :param domain_id: the domain in question
 1418         :param group: an optional specific group of options
 1419         :param option: an optional specific option within the group
 1420 
 1421         If group and option are None, then the entire config for the domain
 1422         is deleted. If group is not None, then just that group of options will
 1423         be deleted. If group and option are both specified, then just that
 1424         option is deleted.
 1425 
 1426         :raises keystone.exception.InvalidDomainConfig: when group/option
 1427                 parameters specify an option we do not support or one that
 1428                 does not exist in the original config.
 1429 
 1430         """
 1431         self._assert_valid_group_and_option(group, option)
 1432         if group:
 1433             # As this is a partial delete, then make sure the items requested
 1434             # are valid and exist in the current config
 1435             current_config = self._get_config_with_sensitive_info(domain_id)
 1436             # Raise an exception if the group/options specified don't exist in
 1437             # the current config so that the delete method provides the
 1438             # correct error semantics.
 1439             current_group = current_config.get(group)
 1440             if not current_group:
 1441                 msg = _('group %(group)s') % {'group': group}
 1442                 raise exception.DomainConfigNotFound(
 1443                     domain_id=domain_id, group_or_option=msg)
 1444             if option and not current_group.get(option):
 1445                 msg = _('option %(option)s in group %(group)s') % {
 1446                     'group': group, 'option': option}
 1447                 raise exception.DomainConfigNotFound(
 1448                     domain_id=domain_id, group_or_option=msg)
 1449 
 1450         self.delete_config_options(domain_id, group, option)
 1451         self.get_config_with_sensitive_info.invalidate(self, domain_id)
 1452 
 1453     def _get_config_with_sensitive_info(self, domain_id, group=None,
 1454                                         option=None):
 1455         """Get config for a domain/group/option with sensitive info included.
 1456 
 1457         This is only used by the methods within this class, which may need to
 1458         check individual groups or options.
 1459 
 1460         """
 1461         whitelisted = self.list_config_options(domain_id, group, option)
 1462         sensitive = self.list_config_options(domain_id, group, option,
 1463                                              sensitive=True)
 1464 
 1465         # Check if there are any sensitive substitutions needed. We first try
 1466         # and simply ensure any sensitive options that have valid substitution
 1467         # references in the whitelisted options are substituted. We then check
 1468         # the resulting whitelisted option and raise a warning if there
 1469         # appears to be an unmatched or incorrectly constructed substitution
 1470         # reference. To avoid the risk of logging any sensitive options that
 1471         # have already been substituted, we first take a copy of the
 1472         # whitelisted option.
 1473 
 1474         # Build a dict of the sensitive options ready to try substitution
 1475         sensitive_dict = {s['option']: s['value'] for s in sensitive}
 1476 
 1477         for each_whitelisted in whitelisted:
 1478             if not isinstance(each_whitelisted['value'], str):
 1479                 # We only support substitutions into string types, if its an
 1480                 # integer, list etc. then just continue onto the next one
 1481                 continue
 1482 
 1483             # Store away the original value in case we need to raise a warning
 1484             # after substitution.
 1485             original_value = each_whitelisted['value']
 1486             warning_msg = ''
 1487             try:
 1488                 each_whitelisted['value'] = (
 1489                     each_whitelisted['value'] % sensitive_dict)
 1490             except KeyError:
 1491                 warning_msg = (
 1492                     'Found what looks like an unmatched config option '
 1493                     'substitution reference - domain: %(domain)s, group: '
 1494                     '%(group)s, option: %(option)s, value: %(value)s. Perhaps '
 1495                     'the config option to which it refers has yet to be '
 1496                     'added?')
 1497             except (ValueError, TypeError):
 1498                 warning_msg = (
 1499                     'Found what looks like an incorrectly constructed '
 1500                     'config option substitution reference - domain: '
 1501                     '%(domain)s, group: %(group)s, option: %(option)s, '
 1502                     'value: %(value)s.')
 1503 
 1504             if warning_msg:
 1505                 LOG.warning(warning_msg, {
 1506                     'domain': domain_id,
 1507                     'group': each_whitelisted['group'],
 1508                     'option': each_whitelisted['option'],
 1509                     'value': original_value})
 1510 
 1511         return self._list_to_config(whitelisted, sensitive)
 1512 
 1513     @MEMOIZE_CONFIG
 1514     def get_config_with_sensitive_info(self, domain_id):
 1515         """Get config for a domain with sensitive info included.
 1516 
 1517         This method is not exposed via the public API, but is used by the
 1518         identity manager to initialize a domain with the fully formed config
 1519         options.
 1520 
 1521         """
 1522         return self._get_config_with_sensitive_info(domain_id)
 1523 
 1524     def get_config_default(self, group=None, option=None):
 1525         """Get default config, or partial default config.
 1526 
 1527         :param group: an optional specific group of options
 1528         :param option: an optional specific option within the group
 1529 
 1530         :returns: a dict of group dicts containing the default options,
 1531                   filtered by group and option if specified
 1532         :raises keystone.exception.InvalidDomainConfig: when the config
 1533                 and group/option parameters specify an option we do not
 1534                 support (or one that is not whitelisted).
 1535 
 1536         An example response::
 1537 
 1538             {
 1539                 'ldap': {
 1540                     'url': 'myurl',
 1541                     'user_tree_dn': 'OU=myou',
 1542                     ....},
 1543                 'identity': {
 1544                     'driver': 'ldap'}
 1545 
 1546             }
 1547 
 1548         """
 1549         self._assert_valid_group_and_option(group, option)
 1550         config_list = []
 1551         if group:
 1552             if option:
 1553                 if option not in self.whitelisted_options[group]:
 1554                     msg = _('Reading the default for option %(option)s in '
 1555                             'group %(group)s is not supported') % {
 1556                                 'option': option, 'group': group}
 1557                     raise exception.InvalidDomainConfig(reason=msg)
 1558                 config_list.append(self._option_dict(group, option))
 1559             else:
 1560                 for each_option in self.whitelisted_options[group]:
 1561                     config_list.append(self._option_dict(group, each_option))
 1562         else:
 1563             for each_group in self.whitelisted_options:
 1564                 for each_option in self.whitelisted_options[each_group]:
 1565                     config_list.append(
 1566                         self._option_dict(each_group, each_option)
 1567                     )
 1568 
 1569         return self._list_to_config(config_list, req_option=option)